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

[Fiber] Full Error Boundaries #8095

Merged
merged 1 commit into from
Oct 28, 2016

Conversation

gaearon
Copy link
Collaborator

@gaearon gaearon commented Oct 25, 2016

Picking up the work I dropped in #7993.

Now this passes all existing tests, including those that don't work in stack reconciler (setState, didMount, and didUpdate).

function commitUnmount(current : Fiber, safely : boolean) : void {
// Make sure we mark this deletion as completed so that we don't
// attempt to perform it again if one of the deletions fails, and
// error boundary tries to unmount this subtree again.
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 explain how this happens? When we commit, we've already swapped the tree so as far as Fiber is concerned, it is deleted in the "current" state. So any rerender should presumably not try to unmount again.

Copy link
Collaborator Author

@gaearon gaearon Oct 25, 2016

Choose a reason for hiding this comment

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

When we commit, we've already swapped the tree so as far as Fiber is concerned, it is deleted in the "current" state.

Hmm, I think this is something I changed below:

        commitAllWork(workInProgress, isHandlingError);
        // Swap the pointer after committing all work so that if committing fails,
        // we still treat it as a work in progress in case there is an error boundary.
        root.current = workInProgress;

Without changing it, Fiber complained that

Cannot commit the same tree as before. This is probably a bug related to the return field.

whenever I caught an error during commit phase (e.g. DidMount) and tried to get error boundary to re-render the whole thing.

Alternatively I could "roll the pointer back" on error but I didn't want a try catch there.
What should I do?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So it's the same problem as was solved by calledCompobentWillUnmount in the old reconciler.

We need to recover if we committed some unmounts, one of them failed, and we need to safely unmount the rest without repeating failed ones.

Copy link
Collaborator

Choose a reason for hiding this comment

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

It does look like you're tracking the currently unmounting Fiber anywhere so how do you know which error boundary to invoke if componentWillUnmount throws during a normal unmount?

In theory, since you need to track the currently unmounting Fiber you should be able to know which one failed. Since only one can fail, you can continue walking the tree from that point on. You'd know that anything after the failing node hasn't yet unmounted and anything before it has unmounted.

@@ -155,15 +156,15 @@ module.exports = function<T, P, I, TI, C>(config : HostConfig<T, P, I, TI, C>) {
}
}

function commitNestedUnmounts(root : Fiber) {
function commitNestedUnmounts(root : Fiber, safely : boolean) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

In ReactChildFiber, I used a flag in a closure. The idea is that it makes it easy to just clone this to two separate paths. That way, in the normal case, we don't have to copy this boolean between stack frames and check its value at every stop.

commitAllWork(workInProgress, isHandlingError);
// Swap the pointer after committing all work so that if committing fails,
// we still treat it as a work in progress in case there is an error boundary.
root.current = workInProgress;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This probably makes the most sense but I'm worried about how this recovery works.

I make a lot of assumptions about how this works and if something is partially committed I'm not sure if it is safe to continue from the "current" tree. Will it try to do deletions and insertions again? We might be able to reset those flags so that we can skip them.

In React Native we rely on things like indices so that we don't have to read from the native tree. Once we have already sent off a mutation, it's too late to go back on it.

We might need to guarantee that at least all host mutations are committed.

function revertLifeCyclesCommittedSoFar(finishedWork : Fiber, failedEffect : Fiber) {
// Gather effects in an array because this is a rare code path.
// We need to call them in the reverse order.
const fibersCommittedSoFar = [];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it be possible to reuse the nextEffect pointer here since you probably don't need it anymore? In that case, it's just a "reversing a singly linked list" problem.

@@ -133,7 +133,8 @@ module.exports = function<T, P, I, TI, C>(config : HostConfig<T, P, I, TI, C>) {
break;
}
case Deletion: {
commitDeletion(effectfulFiber);
const safely = isHandlingError;
commitDeletion(effectfulFiber, safely);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just an observation that this is the only thing in the first pass that might throw.

@gaearon gaearon force-pushed the fiber-error-boundaries-take-two branch from 53c3212 to 26ccc8f Compare October 26, 2016 15:01
@sebmarkbage
Copy link
Collaborator

Question: Is the error boundary forced to unmount all its children if an error happens? And then render something else?

If not, how do we know they don't need to clean up the failed update? If we force componentWillUnmount then that is the hook to that enables that clean up. If not, then maybe we need something like componentWillRecover that lets it handle recovery - but will anyone implement that?

expect(() => {
if (ReactDOMFeatureFlags.useFiber) {
// This test implements a new feature in Fiber.
xit('catches errors in componentDidMount', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

this intentionally still xit?

});
}

fit('recovers from componentWillUnmount errors on update', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

...and fit?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's all broken now :-) I'm just pushing to make myself feel better. I'll push the final version when it's ready.

@gaearon gaearon force-pushed the fiber-error-boundaries-take-two branch from 1768e71 to 352bf6d Compare October 27, 2016 22:49
@gaearon
Copy link
Collaborator Author

gaearon commented Oct 27, 2016

@sebmarkbage

Pushed a commit with new semantics: we treat commits as atomic and instead of trying to "revert" lifecycle hooks we isolate each of them, and leave error handling until after commit.

The tests are passing but this is missing the case where independent error boundaries get notified about their updates separately. I'll add a failing test for this and try to figure it out. For now, I only handle the first error but I should have enough information to handle the first error per each boundary separately.

I'm not very happy about the way error handling is nested inside the commit phase. Maybe we can put trapped errors as a top level variable and handle them between normal work items as part of finding the next unit or something. Maybe #8127 would help too.

I'd like your feedback on the overall approach here. If this looks okay maybe merge it as is and I'll do the rest on top?

@gaearon gaearon force-pushed the fiber-error-boundaries-take-two branch from 352bf6d to 57c5acd Compare October 27, 2016 22:57
@sebmarkbage
Copy link
Collaborator

Awesome! I'll take a look tonight.

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.

Ok, looks good. I think this is in a state that we can land this as the first version.

@@ -267,7 +300,7 @@ module.exports = function<T, P, I, TI, C>(config : HostConfig<T, P, I, TI, C>) {
// "next" scheduled work since we've already scanned passed. That
// also ensures that work scheduled during reconciliation gets deferred.
// const hasMoreWork = workInProgress.pendingWorkPriority !== NoWork;
commitAllWork(workInProgress);
commitAllWork(workInProgress, ignoreUnmountingErrors);
Copy link
Collaborator

Choose a reason for hiding this comment

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

FWIW, the fact that commit happens here is an unfortunate artifact. When the last child completes, it have to backtrack quite a bit. Then finally it have to commit all work. All without checking remainingTime(). So we're actually quite likely to push into the next frame here. Ideally commitAllWork should move out so that the commit can happen in the beginning of the next requestIdleCallback instead of at the end of this one. That would make it so we don't have to pass this flag along.

}
default:
throw new Error('This unit of work tag should not have side-effects.');
}
}

function tryCallComponentDidMount(instance) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I believe there is a way we can rewrite the commit phase so that we don't have to do nested try catches deep in it but this is fine as the first version.

} catch (nextError) {
// Propagate error to the next boundary or rethrow.
const nextTrappedError = trapError(boundary, nextError);
handleError(nextTrappedError);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is the only place we have recursion. Kind of unfortunate.


// Now that the tree has been committed, we can handle errors.
if (allTrappedErrors) {
// TODO: handle multiple errors with distinct boundaries.
Copy link
Collaborator

Choose a reason for hiding this comment

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

A plausible strategy: When an error happens you can keep walking through the unmounting children and the effect list until you hit the lastEffect edge of the boundary. Then you can handle the error right there without keeping an array of them. Then you continue committing the rest.

However, then the tree wouldn't be fully committed in unstable_handleError which is probably bad. So the array strategy might be needed after all.

@gaearon gaearon merged commit 3c6abbf into facebook:master Oct 28, 2016
@gaearon gaearon deleted the fiber-error-boundaries-take-two branch October 28, 2016 13:16
acusti pushed a commit to brandcast/react that referenced this pull request Mar 15, 2017
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