-
Notifications
You must be signed in to change notification settings - Fork 49.2k
[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
Conversation
9ec7825
to
a7d0672
Compare
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. |
I think the least surprising behavior is:
|
There was a problem hiding this 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.
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. |
Would the difference be observable if boundaries are processed synchronously? |
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. |
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. |
That makes sense to me. I'll give this a shot. |
👍 |
f54a312
to
6834477
Compare
@gaearon Ready for review! Now that boundary's are force remounted, I had to tweak some of the unit tests in |
There was a problem hiding this 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', |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; | |||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perf optimization, unobservable
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
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. |
Btw I'm aware there are some mistakes in my edits to |
6293042
to
c7ad324
Compare
I pushed some more changes. Implemented the failed root case using a flag on the FiberRoot. Added the concept of a root "error phase."
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 ( |
f1dceb8
to
7bbe026
Compare
Is |
@gaearon Yes I could thread a parameter through based on |
ad217e3
to
1acd543
Compare
// continue from this point. | ||
current.progressedChild = workInProgress.progressedChild; | ||
current.progressedPriority = workInProgress.progressedPriority; | ||
function BeginWork(forceReplace : boolean) { |
There was a problem hiding this comment.
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.
Latest commit removes the temporary @sebmarkbage I wrapped |
1acd543
to
2b2c189
Compare
edc7cdb
to
1758a0d
Compare
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.
1758a0d
to
90f29a2
Compare
There was a problem hiding this 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.
This was superseded by #8304. |
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