Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[tracking PR] Render new #112

Closed
wants to merge 3 commits into from
Closed

[tracking PR] Render new #112

wants to merge 3 commits into from

Conversation

emidoots
Copy link
Member

@emidoots emidoots commented May 17, 2017

@pdf I've rebased your branch here (prior PR #107), while I try to decipher the rest of your changes.

I've dropped the prior commit for namespaced elements, in favor of the now-merged #110

I've squashed a number of commits into Refactor rendering for reliability and performance. since they were not individual changes really.

My plan:

  1. Adapt the Unmount change against master.
  2. Make the Mount addition against master.
  3. Try adapting the keyer (logic, anyway) against master.

Things I have doubts about:

  • I am unsure about the Keyer implementation, but need to look further into it. I don't know why it needs to be a Component method instead of just Markup.
  • I'm not convinced that splitting up Restorer into two separate Updater methods is a good idea.
  • I think providing an implicit reflection-based Clone would be a mistake, currently.
  • I have many other concerns in general, but the change is prohibitively large for me to be able to effectively communicate them currently

@emidoots emidoots mentioned this pull request May 17, 2017
@pdf
Copy link
Contributor

pdf commented May 17, 2017

  • I am unsure about the Keyer implementation, but need to look further into it. I don't know why it needs to be a Component method instead of just Markup.

It doesn't need to be a Component method, Markup-only would be fine if you'd prefer not to provide the option, shouldn't change the implementation in any significant way.

The primary limitation of the current Keyer implementation is that all siblings must be keyed for it to work. I think from memory that React has the same limitation though.

  • I'm not convinced that splitting up Restorer into two separate Updater methods is a good idea.
  • I think providing an implicit reflection-based Clone would be a mistake, currently.

Restorer can't work in its current state, for either re-rendering, or re-using components. As far as I can see, the only options to make it work are: require a Clone method; provide cloning via reflection (we could make this more robust than what's in this PR if it's the only path); or introduce some sort of explicit state implementation, where state must be managed via a separate object that can be compared - this takes us down a React-style path. The latter would be a less desirable path IMO, but has some merit.

  • I have many other concerns in general, but the change is prohibitively large for me to be able to effectively communicate them currently

Happy to help however I can, I will say that I've exercised the changes pretty well at this point.

I'm not certain that merging the Mount/Unmount modifications without the later changes is worthwhile, since changes like deferring the lifecycle events reduces surprises significantly for some usage patterns.

@emidoots
Copy link
Member Author

The primary limitation of the current Keyer implementation is that all siblings must be keyed for it to work. I think from memory that React has the same limitation though.

Yes, React has the same limitation: see https://facebook.github.io/react/docs/lists-and-keys.html

emidoots pushed a commit that referenced this pull request May 18, 2017
Needed because any `(*wrappedObject).Call(...)` can produce an `js.Undefined`
which in turn goes to `wrapObject` leading to a `TODO` panic. With this change,
that does not happen.

Also helps reduce #112
@emidoots
Copy link
Member Author

Rebased to account for 5ad2a3e

…nt based on component equality

Refs # 85

Fixes # 75 # 85
@emidoots
Copy link
Member Author

Rebased to account for #113

@emidoots emidoots mentioned this pull request May 18, 2017
@emidoots
Copy link
Member Author

Restorer can't work in its current state, for either re-rendering, or re-using components.

I agree that Restorer as it stands in master today is broken, but only due to a bug not an inherent design flaw of it.

As far as I can see, the only options to make it work are: require a Clone method; provide cloning via reflection (we could make this more robust than what's in this PR if it's the only path); or introduce some sort of explicit state implementation, where state must be managed via a separate object that can be compared - this takes us down a React-style path. The latter would be a less desirable path IMO, but has some merit.

If you could explain in further detail why you believe the Restorer design in place today would not work, I would appreciate hearing about it. As it stands it stands from my perspective, I see no reason why Restorer would be flawed, but it sounds like you have a different viewpoint.

I've also sent #114 just now which I believe would be all that is needed in order to make Restorer work properly today (but there is always a chance I've missed something).

@pdf
Copy link
Contributor

pdf commented May 18, 2017

I'll keep the discussion here, because I don't believe #114 will help.

The problem should be easily demonstrable with the following minimal example:

type MyComponent struct {
        vecty.Core
}

func (c *MyComponent) Restore(old vecty.Component) bool {
        oldComponent, ok := old.(*MyComponent)
        if !ok {
                return false
        }

        if oldComponent == c {
                panic(`Components point to same memory, change comparison impossible`)
        }

        return true
}

func (c *MyComponent) Trigger() {
        vecty.Rerender(c)
}

func (c *MyComponent) Render() *vecty.HTML {
        return elem.Div()
}

What happens when Trigger is called? This will panic in Restore, illustrating the problem - you can't detect a change in component state to return whether Restore1 should occur, because the passed value points to the same memory.

Also, the panic in #114 on unrendered component seems arbitrary - I don't see why you can't just render as normal here, and if you can't it's probably because the old render code has other bugs. I guess maybe it doesn't make any sense to render here since there probably isn't a parent available, but panic seems like the nuclear option.


1 The term 'restore' for what the related code does is still backwards, I do hope we can change that.

@emidoots
Copy link
Member Author

Understood, I see the problem you're talking about now! I very much appreciate the example.

Also, the panic in #114 on unrendered component seems arbitrary - I don't see why you can't just render as normal here, and if you can't it's probably because the old render code has other bugs. I guess maybe it doesn't make any sense to render here since there probably isn't a parent available, but panic seems like the nuclear option.

A couple of things:

  1. If a component isn't rendered previously (hasn't been mounted in the DOM), how could you "render as normal here"? What would that even do? (I think you answered your own statement here with it doesn't make any sense to render here since there probably isn't a parent available)
  2. I originally went with a no-op implementation, but later thought about the fact that a user might be confused by their code invoking Rerender but it doing nothing. So I'd prefer to be very strict. After all, nobody should invoke Rerender under normal circumstances without the component being previously rendered.
  3. I know you say it with good intentions, but probably because the old render code has other bugs doesn't really help me here. As a maintainer, I do not want to merge any code that is not fully understood by me. Unless I can prove that the new render implementation by you is strictly better in some way, I will not merge it. This is why I'm breaking down the PR into smaller pieces one-by-one. Just the same, you can expect that if these changes land I will not blindly merge any PR in the future that is not guaranteed to be better through proper testing. If you have specific examples of where the current render implementation falls short, they would help me in reviewing these changes significantly! 😃

The term 'restore' for what the related code does is still backwards, I do hope we can change that.

I'll circle back here on this at a later point, as I would like to discuss history here & potential better naming.

In the meanwhile, I will update and merge #114 with something close to your Clone addition here and rebase this branch just the same.

pdf added 2 commits May 18, 2017 13:40
NEW:
- `Keyer` interface provides support for efficient list element re-use.
  Components simply need to implement the `Key` function, or the
  `vecty.Key` Markup may be used for HTML.

BREAKING:
- `Restorer` interface is now `Updater`, and its `Restore` method is now
  `ShouldUpdate`. The previous `Restorer` implementation was variously
  broken in any case, and poorly named.

  Additionally, it requires a `Clone` method to support passing a
  previous Component instance to `ShouldUpdate`.  There is a limited
  generic version available on `vecty.Core` using reflection that may
  satisfy for users that do not wish to implement it themselves.

- `Context` method on `Component` interface is no longer public.

- `Restore` method on HTML is no longer public.

Move DOM manipulation to HTML, and fix incompatible elements

Fix for rendering with prevKeyed <-> nextKeyed transition

Style fixes

Avoid unnecessary element replacement on Rerender for compatible tags

Defer lifecycle events until all mutations have been performed.

This allows Mount handlers that depend on sibling or parent elements to
function as expected, and for performing tasks like detecting element
sizes.

On double-mount, perform unmount first.
On double-unmount, skip event.

Add jsUndefined and fix lint

Avoid panic on HTML.node == nil

Skip Rerender for Components that have been removed
Batching makes updates smoother, and dedup avoids unnecessary mutations,
since Component state must be maintained internally.
@pdf
Copy link
Contributor

pdf commented May 18, 2017

  1. I absolutely feel you here. Perhaps I can walk you through the changes somehow to explain each decision?

@emidoots
Copy link
Member Author

I think the best way to do it is to make all changes against master and one-by-one review them. That's what I'm doing now, and I think it's the only path forward. Open to suggestions, though.


While working on the Clone change, I've encountered an issue which I believe is also a problem with your version here. Consider your change to pageview.go:

image

I assume that the reason this change was made is because Restore in this case would be invoked with a prev clone of *PageView from the time it was last rendered, and that means that onNewItemTitleInput would set p.newItemTitle = "foobar" and vecty.Rerender here would trigger p.newItemTitle = old.newItemTitle (effectively p.newItemTitle = "" -- thus preventing the user from typing at all. Do you agree?

If so, there is a lurking problem here which can't be shown by the todomvc code currently but would happen in the real world. In fact, it's the problem that Restore was originally resolving:

  • Consider if PageView in this context was actually a child component of something else (let's call it GlobalView). In this situation, GlobalView.Render returns a &PageView{} as one of its children.
  • If vecty.Rerender(theGlobalView) is invoked, a new &PageView{} will be returned, and newItemTitle will not be set. Thus, when vecty.Rerender(theGlobalView) occurs, the user will see that their existing newItemTitle input is entirely lost.

I have a feeling this is also why you believe Restore is improperly named (it is named as such since it is responsible for 'restoring friendly relations between' two components, like reconcile in React but easier to spell).

@pdf
Copy link
Contributor

pdf commented May 19, 2017

I think the best way to do it is to make all changes against master and one-by-one review them. That's what I'm doing now, and I think it's the only path forward. Open to suggestions, though.

Part of my goal here was to break up the work into units that are easier to reason about, to remove redundant code paths, and remove passing of redundant arguments. I think it may be difficult to do this piecemeal. I'd be happy to try and schedule a VOIP call of some variety to talk things through if you think that may help (scheduling may be slightly challenging, as I expect some time-zone conflict, but I'm sure we could work something out if you're interested).


I assume that the reason this change was made is because Restore in this case would be invoked with a prev clone of *PageView from the time it was last rendered, and that means that onNewItemTitleInput would set p.newItemTitle = "foobar" and vecty.Rerender here would trigger p.newItemTitle = old.newItemTitle (effectively p.newItemTitle = "" -- thus preventing the user from typing at all. Do you agree?

Correct, the internal state is already updated via the input callback, so performing this action inside Restore would reset the state, though I removed it mainly because based on the documentation, it didn't make logical sense based to perform this work here.

The Rerender in onNewItemTitleInput was removed because it's unnecessary.

If so, there is a lurking problem here which can't be shown by the todomvc code currently but would happen in the real world. In fact, it's the problem that Restore was originally resolving:

Consider if PageView in this context was actually a child component of something else (let's call it GlobalView). In this situation, GlobalView.Render returns a &PageView{} as one of its children.
If vecty.Rerender(theGlobalView) is invoked, a new &PageView{} will be returned, and newItemTitle will not be set. Thus, when vecty.Rerender(theGlobalView) occurs, the user will see that their existing newItemTitle input is entirely lost.

I understand the scenario you've described, and based on this - Restore seems to be trying to fill at least two roles, and that's probably the cause of the confusion here - the comments only explicitly describe the scenario supported by my Updater implementation, and I'm not sure that Restore should be trying to do many things at the same time.

To put this interaction in terms of React semantics, we're talking about a couple of things - Props (controlled by the parent Component), and State (maintained internally to the Component), and that for Vecty in the case of children that are newly instantiated every parent render, State in the absence some sort of Restore mechanic will be lost on parent Rerender, and Props will be reset to the parent's view.

I think in React this works because there's essentially transparent state persistence between Components of the same type at the same location, and a child is only ever updated if its Props are changed by the parent or its State changes internally, whereas Vecty requires all children to be re-rendered for every parent render, and if instantiated inline, two Components of the same type, at the same location, are separate entities that need to be manually migrated.

The React model looks more like how Vecty behaves (with the modifications to allow it) if child components are persistent references (ie, a single component pointer is re-used between renders), since then the child component is able to maintain internal state between renders. This is probably why I haven't come across the scenario described here - using my PoC router implementation naturally lends itself to this sort of pattern.

I have a feeling this is also why you believe Restore is improperly named (it is named as such since it is responsible for 'restoring friendly relations between' two components, like reconcile in React but easier to spell).

Whilst this may be part of what Restore is meant to do, this is not what reconciliation does in React, and it's not what doRestore and friends do in Vecty - they're responsible for mutating elements from one version of a DOM representation and the next, based on the current known state (which in React is maintained via separate mechanisms). In React, this is made easier by virtue of Props and State being explicit (and separate) entities, which allows the lifecycle triggers/events to resolve in sane fashion.

Based on the use-case for Restore described above:

In React terms, calling Rerender is closest to calling setState, except that we don't provide any explicit target state mutation. In the case of parent 'rerender' in React, child components only mutate when a change in parent state/props would necessitate it. Restore in Vecty is roughly of a combination of React's shouldComponentUpdate, componentWillReceiveProps/componentWillMount, and requires an implementation of internal state persistence; combined into a single callback.

The problem with this pattern is that it makes it difficult for the user to reason about whether there has been a change of state or not, and there's pretty much no way for a component to determine whether it should actually copy values from the previous component to a new instance, since it can't differentiate between field values passed down by the parent at instantiation time; values that should be inherited from the previous instance (due to Go's zero values); and local state changes.

I can think of a few approaches to handling this, but I'd have to examine them more closely, and some off the top of my head may require reflection, which I get the impression you're strongly against? Though I'm not sure what starting point I'd use at the current stage... also happy to work through ideas here if you have any.

PS - Please excuse the Friday afternoon brain-dump - it's been a long week, and I wanted to get this down so I can come back for a refresher when I have time to consider this more closely.

emidoots pushed a commit that referenced this pull request May 19, 2017
- The `Restorer` interface is now just `Restore(Component)` (previously `Restore(Component) bool`).
- A `RenderSkipper` interface is added, where `SkipRender(Component) bool` replaces the old `Restore` return value behavior.

A notable change here is that the `prev` argument to `SkipRender` is a `Copy` of the `Component` at the time of the last `Render`, while the `prev` argument to `Restore` is not as it needs to copy component state (struct fields) from the old component instance to the new one.

See #112 (comment) for background.
@emidoots
Copy link
Member Author

emidoots commented May 19, 2017

Thanks for the writeup! I'm heading to sleep soon, so I haven't fully comprehended it yet & will say that I need to look more into the React differences you mentioned. But I did skim over it.

I agree with what you said about Restore being convoluted in terms of doing two things at once. I admit that I do not strictly know the React comparison for this situation. One thing Vecty tries to do is be much simpler at a high level than React; where a component can be described in terms of only a few methods and the lifecycle has much fewer callbacks. From my perspective (a non-React developer), it has too many lifecycle methods that end up being quite difficult to comprehend as a new user.

I've spent the last few hours investigating this issue (again, against the master branch) and looking for a correct fix. What I've come up with is in #114 and I think it's the right approach. Essentially, with this change, we keep Restorer but remove the skip bool return value. Then, we add a SkipRender(prev Component) bool method which is essentially your ShouldUpdate here. In this situation Restorer is really for handling the case I described: copying component state over from an old instance; and SkipRender is for skipping a component render by making a comparison against state. SkipRender, of course, acts on a Copy (Clone in your code here) of the previous component at Render time, while Restorer does not.

Hopefully that's a clear enough description for now, as I've gotta get to sleep. Check out #114 for more info or leave questions/comments there. I'll probably move forward with this tomorrow after testing with @shurcooL's Go Package Store application if there are no objections

EDIT: also I return to work Monday, so I'm trying to move as quickly as I can to resolve everything here before then :)

@pdf
Copy link
Contributor

pdf commented May 19, 2017

I've spent the last few hours investigating this issue (again, against the master branch) and looking for a correct fix. What I've come up with is in #114 and I think it's the right approach. Essentially, with this change, we keep Restorer but remove the skip bool return value. Then, we add a SkipRender(prev Component) bool method which is essentially your ShouldUpdate here. In this situation Restorer is really for handling the case I described: copying component state over from an old instance; and SkipRender is for skipping a component render by making a comparison against state. SkipRender, of course, acts on a Copy (Clone in your code here) of the previous component at Render time, while Restorer does not.

This is the first approach that came to mind for me too, but it doesn't quite cover all the usage problems. I'm heading out shortly, but I'll comment on #114 when I have a sec, and will try to set aside some availability over the next couple of days to help with whatever else.

emidoots pushed a commit that referenced this pull request May 19, 2017
- The `Restorer` interface is now just `Restore(Component)` (previously `Restore(Component) bool`).
- A `RenderSkipper` interface is added, where `SkipRender(Component) bool` replaces the old `Restore` return value behavior.

A notable change here is that the `prev` argument to `SkipRender` is a `Copy` of the `Component` at the time of the last `Render`, while the `prev` argument to `Restore` is not as it needs to copy component state (struct fields) from the old component instance to the new one.

See #112 (comment) for background.
@pdf
Copy link
Contributor

pdf commented May 20, 2017

@slimsag just FYI, today is the last day of the weekend for my timezone (GMT+10), so I can make some time available if there's anything you need me to do.

@pdf
Copy link
Contributor

pdf commented Oct 8, 2017

Time to finally put this one to bed 🛌

@emidoots
Copy link
Member Author

emidoots commented Oct 8, 2017

🎉 thanks for confirming that!

And thank you for working with me to land each of these changes individually, I really really appreciate it! Let me know if I can buy you a coffee or something :)

@emidoots emidoots closed this Oct 8, 2017
@emidoots emidoots deleted the render_new branch October 8, 2017 10:31
@pdf
Copy link
Contributor

pdf commented Oct 8, 2017

It was a worthwhile process - I know we've found some cases together that I wouldn't have otherwise. Still more to do, but it's been a good experience.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants