Skip to content

[Fiber] Force rerender a failed tree #8227

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

Closed
wants to merge 4 commits into from

Conversation

acdlite
Copy link
Collaborator

@acdlite acdlite commented Nov 7, 2016

This lets us preserve the invariant that if a tree contains pending work, its root is scheduled. Previously, a tree with an uncaught error broke this assumption.

Now we can exit early in scheduleUpdate once we reach a node whose priority matches.

Closes #8222

@sebmarkbage
Copy link
Collaborator

Curious to hear what @gaearon thinks about this. I think it is possible to unmount it from React's perspective but avoid removing the child at the root if we want to leave it in.

@gaearon
Copy link
Collaborator

gaearon commented Nov 8, 2016

I think the least surprising behavior is:

  • All unmount lifecycles fire (safely, i.e. errors in them are ignored)
  • isMounted() for components returns false and setState() is a no-op
  • All event handlers are removed, but the DOM tree stays at the same spot, it's just "non-interactive" now
  • Next unmountComponentAtNode() removes the DOM tree
  • Next render() also removes the DOM tree, and mounts a new one

gaearon
gaearon previously requested changes Nov 8, 2016
Copy link
Collaborator

@gaearon gaearon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep the DOM.

@acdlite
Copy link
Collaborator Author

acdlite commented Nov 8, 2016

I assume we should do the same thing for errors caught by boundaries? "Unmount" the tree, without actually removing the DOM nodes, and mount a new one the next time the boundary is re-rendered.

@gaearon
Copy link
Collaborator

gaearon commented Nov 8, 2016

Would the difference be observable if boundaries are processed synchronously?

@acdlite
Copy link
Collaborator Author

acdlite commented Nov 8, 2016

Yes because the entire failed tree below the boundary is unmounted regardless, whereas in the current implementation parts of the failed tree may be reused.

@sebmarkbage
Copy link
Collaborator

I just want to frame the discussion a bit:

The worst case scenario isn't that the user get frustrated that the UI is non-interactive or confusing. That's the best case. We have to weight that against the worst case.

The worst case, would likely be that you get into an inconsistent state so that internal values are not represented on the screen and then an action gets taken based on that. For example, a user spends the wrong amount, pays for the wrong thing, or worse, "Like" your ex's photo instead of your current significant other.

I think it is very easy to get in to an inconsistent state for a subtree that has errored. Given that life-cycles can mutate instances etc. Recovering from that may work in 99% of cases but in the 1% it doesn't, it can be devastating.

So, my preference would be to force an unmount even in the error boundary case. So that the view gets a forced rerender.

However, in the forced rerender case, when do you remove the child node? You don't want to remove it in case the error boundary then fails, because then it is gone. I guess you don't, and just let the regular reconciliation remove it if it succeeds.

@acdlite
Copy link
Collaborator Author

acdlite commented Nov 8, 2016

However, in the forced rerender case, when do you remove the child node? You don't want to remove it in case the error boundary then fails, because then it is gone. I guess you don't, and just let the regular reconciliation remove it if it succeeds.

That makes sense to me.

I'll give this a shot.

@gaearon
Copy link
Collaborator

gaearon commented Nov 8, 2016

So, my preference would be to force an unmount even in the error boundary case. So that the view gets a forced rerender.

👍

@acdlite
Copy link
Collaborator Author

acdlite commented Nov 11, 2016

@gaearon Ready for review!

Now that boundary's are force remounted, I had to tweak some of the unit tests in ReactErrorBoundaries-test.js. I believe the changes I made correctly match the new semantics but they might deserve some extra scrutiny.

Copy link
Collaborator

@gaearon gaearon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to fix duplicate componentWillUnmount calls.

...(ReactDOMFeatureFlags.useFiber ? [
// Fiber unmounts an error boundary's children
'Normal componentWillUnmount',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is another Normal componentWillUnmount below right before the array ends.
Does this means we now call it twice? Doesn't seem right.

...(ReactDOMFeatureFlags.useFiber ? [
// Fiber unmounts an error boundary's children
'Normal componentWillUnmount',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. There's another Normal componentWillUnmount later.

...(ReactDOMFeatureFlags.useFiber ? [
// Fiber unmounts an error boundary's children
'Normal componentWillUnmount',
'BrokenComponentWillReceiveProps componentWillUnmount',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is also duplicated: see BrokenComponentWillReceiveProps componentWillUnmount later in the array.

@@ -193,11 +195,14 @@ module.exports = function<T, P, I, TI, C>(
commitNestedUnmounts(node);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this call (or any commitUnmount() calls it makes) also need to be guarded by !ignoreTop?
I don't fully understand what's happening here but it offers another path to commitUnmount().

@@ -193,11 +195,14 @@ module.exports = function<T, P, I, TI, C>(
commitNestedUnmounts(node);
// After all the children have unmounted, it is now safe to remove the
// node from the tree.
if (parent) {
if (parent && !ignoreHostNode) {
removeChild(parent, node.stateNode);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please bear with me as I don't fully understand this method yet.
Can removeChild() below run more than once?
If it can, ignoreHostNode should be named ignoreHostNodes?

removeChild(parent, node.stateNode);
}
} else {
commitUnmount(node);
// If ignoreTop is true, don't unmount the top fiber
if (!ignoreTop || node !== current) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question here. Why are we sure this is a "top" node? It isn't quite clear to me.
Is there only one "top" node, or can this block run more than twice in a loop?
Is there a better name for ignoreTop? I don't understand which fiber exactly would be called a "top" node.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be easier to understand if the comment emphasized when this happens (e.g. "If node and current are different, it means X. When ignoreTop is set, it means Y. Therefore we do Z to implement this [scenario described from user's point of view].")

// the tree errored, we won't actually remove the host nodes; we'll leave
// them there until the tree is rerendered.
unmountHostComponents(null, current, true, false);
current.child = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to clear progressedChild?

unmountHostComponents(parent, current, true, true);
}

boundary.child = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question about progressedChild here.

root = nextScheduledRoot;
while (root) {
forceUnmountFailedRoot(root);
root.current.pendingWorkPriority = NoWork;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to update root.current.alternate here?

@@ -753,27 +753,31 @@ module.exports = function<T, P, I, TI, C>(config : HostConfig<T, P, I, TI, C>) {
priorityLevel = TaskPriority;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this commit fix? Is there a test that was failing before? Is this a performance optimization?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perf optimization, unobservable

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment where you set shouldContinue to false explaining in which circumstances it will get set to true?

@@ -184,7 +185,7 @@ module.exports = function<T, P, I, TI, C>(
}
}

function unmountHostComponents(parent, current): void {
function unmountHostComponents(parent, current, ignoreHostNode, ignoreTop): void {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can find a way to not use these flags.

A normal unmount won't schedule a removeChild anywhere but the top root where it was deleted. All we have to do is special case the root and then the rest of the traversal can be exactly the same.

Similarly, ignoreTop wouldn't be needed if instead we did the root work outside this function instead of inside it. That way we don't have to branch inside the loop.

@sebmarkbage
Copy link
Collaborator

I worry about us making mutations directly to the tree. It makes it a bit difficult to reason about what will happen and what needs to be done to get the tree into a consistent state.

I think we should strive to reuse as much of the normal reconciliation logic as possible when we deal with errors. So that we know that everything gets processed in the same way.

I wonder if we can just do a normal reconciliation but force ReactChildFiber to not reuse the previous node. That way all the regular processing kicks in just as if children's key differ.

@acdlite
Copy link
Collaborator Author

acdlite commented Nov 11, 2016

Btw I'm aware there are some mistakes in my edits to ReactChildFiber. Fixing it up now (learning more about how it works as I go) but wanted to push this up to get overall feedback on the approach.

@acdlite acdlite force-pushed the fiberunmountonerror branch 6 times, most recently from 6293042 to c7ad324 Compare November 12, 2016 01:20
@acdlite
Copy link
Collaborator Author

acdlite commented Nov 12, 2016

I pushed some more changes. Implemented the failed root case using a flag on the FiberRoot. Added the concept of a root "error phase."

  • The SoftDeletion phase happens when the error is first caught, and indicates that the tree should be unmounted without removing any host nodes.
  • The HardDeletion phase indicates to ReactChildFiber that the next time the container is rendered into, the old nodes should be force removed from the DOM. This uses the normal reconciliation logic, as with the error boundary case.
  • The default phase is NoError.

These phases are only used for the failed root case. They're not necessary for the error boundary case because the nodes are immediately replaced by a new tree.

I'll wait for feedback before I add any more test coverage. Still not sure how to avoid a flag on the class instance (_isFailedErrorBoundary).

@acdlite acdlite force-pushed the fiberunmountonerror branch from f1dceb8 to 7bbe026 Compare November 12, 2016 01:45
@gaearon
Copy link
Collaborator

gaearon commented Nov 12, 2016

Is activeErrorBoundaries.has() an adequate check for the case you implement with a flag? You could expose that to CommitWork too. If not maybe you could have a separate set for boundaries in that phase?

@acdlite
Copy link
Collaborator Author

acdlite commented Nov 12, 2016

@gaearon Yes I could thread a parameter through based on activeErrorBoundaries. It'd be a bit cumbersome because I need in inside ReactChildFiber, which is connected to the scheduler via ReactFiberBeginWork. I held off doing that at first to see if there was some way we could infer a failed error boundary without an extra field, or by explicitly passing an extra parameter. Don't think there is, though.

@acdlite acdlite force-pushed the fiberunmountonerror branch from ad217e3 to 1acd543 Compare November 13, 2016 17:57
// continue from this point.
current.progressedChild = workInProgress.progressedChild;
current.progressedPriority = workInProgress.progressedPriority;
function BeginWork(forceReplace : boolean) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes in this file are minimal; there's a lot of noise because I wrapped everything in this function, which increased the indentation.

The only real change is the addition of a forceReplace parameter.

@acdlite
Copy link
Collaborator Author

acdlite commented Nov 13, 2016

Latest commit removes the temporary _isFailedErrorBoundary hack. Good for another review.

@sebmarkbage I wrapped ReactFiberBeginWork in a wrapper function so that I could branch on whether the work in progress is a failed node, following the same pattern as ReactChildFiber. This seemed like a reasonable solution to me but perhaps I'm overlooking something.

@acdlite acdlite force-pushed the fiberunmountonerror branch from 1acd543 to 2b2c189 Compare November 13, 2016 18:11
@acdlite acdlite changed the title [Fiber] If error is not caught, unmount entire tree [Fiber] Force rerender a failed tree Nov 13, 2016
@gaearon gaearon dismissed their stale review November 13, 2016 18:12

outdated

@acdlite acdlite force-pushed the fiberunmountonerror branch 2 times, most recently from edc7cdb to 1758a0d Compare November 15, 2016 10:22
When an error is thrown and caught by a boundary, we don't want to reuse
the failed work when attempting to recover, because parts of the tree
might be in an inconsistent state. Instead, we force rerender the entire
tree from scratch.

A similar thing happens with failed roots. When a tree fails with an
uncaught error, the entire tree is force unmounted. But we don't want
to actually remove the tree from the host environment, because otherwise
the user would see an empty screen -- unlike the error boundary case,
there's nothing to replace the failed tree with. So we do a "soft"
delete, where componentWillUnmount is called recursively, but the host
nodes remain. The next time something is rendered into the container,
the old nodes are deleted.
This is a performance optimization and is unobservable. However, it
helps protect against regressions on the following invariants on which
it relies:

- The priority of a fiber is greater than or equal to the priority of
all its descendent fibers.
- If a tree has pending work priority, its root is scheduled.
Added a new parameter to ReactChildFiber's wrapper function to force
replace old nodes, effectively as if their keys don't match. The module
exports a new function, replaceChildFibers, where this argument is true.

ReactFiberBeginWork branches to use replaceChildFibers on failed nodes.
To avoid excessive argument passing, I refactored ReactFiberBeginWork
to use a wrapper function, like ReactChildFiber. It now returns beginWork
and beginWorkOnFailedNode.

The final bit of branching happens in the scheduler, which calls
beginWorkOnFailedNode if the work in progress is a failed error boundary
or a host container with an uncaught error.
Copy link
Collaborator

@sebmarkbage sebmarkbage left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like this is waiting for #8304 so I'll flag it as needing changes or abandon.

@gaearon
Copy link
Collaborator

gaearon commented Dec 1, 2016

This was superseded by #8304.

@gaearon gaearon closed this Dec 1, 2016
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants