-
Notifications
You must be signed in to change notification settings - Fork 47.2k
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
Add stack unwinding phase for handling errors #12201
Conversation
@@ -784,7 +788,7 @@ describe('ReactErrorBoundaries', () => { | |||
]); | |||
}); | |||
|
|||
it('propagates errors on retry on mounting', () => { | |||
xit('propagates errors on retry on mounting', () => { |
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 is related to item 2 in the "Unresolved items" section. Waiting for feedback on whether this is too drastic a change and if we should hew more closely to the existing retry semantics.
Before reviewing, I'm curious: are these changes intended for (or necessary for) 16.3? I ask because the change is kind of large, and I'm wondering what the impact on timing would be (e.g. time to review, sync and test internally, update more documentation, etc.) |
If we were to do this, should the boundary also handle its own errors? Our justification for not doing it was that this leads to loops. But now we have loops anyway? |
@gaearon Talked it through with @sebmarkbage. What we'll do is keep the |
if (value instanceof Error) { | ||
algebraicEffectTag = Err; | ||
} else if (value !== null && typeof value === 'object') { | ||
// If instanceof fails, fall back to duck typing. |
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 feels surprising. Moving code into an iframe could fundamentally change the behavior if it used to pass the first check but doesn't pass the second one now.
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 we should only do duck typing and skip instanceof
entirely?
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 would make sense to me. I would maybe check just stack
since it's added by the browser, but message
could potentially be empty (I think?)
On the other hand is it plausible that Promise
ever gets a stack
?
Also, what happens if you just throw
a string? It's bad but it can happen. We used to handle this, do we not anymore?
My impression is that the plan is to treat thenables specially, and treat everything else as an error. Why is the logic written around special casing the error object?
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 want to leave the door open for supporting custom algebraic effects. Error boundaries are a normal effect boundary that pattern matches on errors; there’s another boundary that pattern matches on promises, and so on. That’s what these lines here are intended to model.
Error boundaries will no longer catch null or undefined, but the root still will (that’s why the Unknown type exists).
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 error boundaries still catch strings?
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.
No they only catch errors. A string will propagate to the root. I’ll add this to the list of known deviations.
If this is too drastic a change I’m fine with for holding off on that part until React 17. Though if we do that, we should warn so people don’t rely on that behavior.
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.
To be honest I'd say it's okay to say this was unsupported. It's a known bad pattern, and error boundaries should not (yet :-) be used for control flow anyway. We always suggested they're meant to be used for exceptional situations.
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 don't think this is quite ok for a breaking change. The cross-realm issue and the fact that throwing strings, while not good practice, is still common.
Thenables are a lot more restrictive since they will essentially always unwrap through any async boundary, they're pretty useless for anything other than as promises. So limiting this to thenable would be a lot safer.
I understand that our general strategy is to attempt to limit componentDidCatch to errors only but not quite sure about the mechanism here.
@bvaughn It would nice to get this into 16.3 because the changes we're planning for 16.4 depend on it. |
@@ -42,6 +39,9 @@ export type FiberRoot = { | |||
pendingContext: Object | null, | |||
// Determines if we should attempt to hydrate on the initial mount | |||
+hydrate: boolean, | |||
// Remaining expiration time on this root. | |||
// TODO: List this into the renderer |
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.
Typo
let thrownValue = value; | ||
// Check if the thrown value is an error. If it is, we will replay the | ||
// begin phase using invokeGuardedCallback, so that the error is not | ||
// treated as caught by the debugger. |
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.
😮
typeof value.stack === 'string') | ||
) { | ||
// Retore the original state of the work-in-progress | ||
Object.assign(workInProgress, stashedWorkInProgressProperties); |
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.
Not a super big fan of this. I usually grep for assignments when debugging to figure out what piece of code changed a field. On the other hand the code is smaller..
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 don't love it either but all the alternatives I considered were worse. I can try to convince you out of band if needed :D
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.
// We assume any use of `deferredUpdates` is to split a high-priority | ||
// interactive update into separate high and low values. Set this to true so | ||
// that the low-pri value is treated as if it were the result of an | ||
// interaction, even if we're in the render phase. |
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 don't understand this, can you explain more? Does this fix the issue I reported or is it unrelated?
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.
Meant to delete this, not needed
'BadMount', | ||
'componentDidCatch: Error 1', | ||
'componentDidCatch: Error 2', | ||
'componentDidCatch: Error 3', |
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 makes sense theoretically but how practical is it? Once we're in an error state it's likely other things can break due to mistaken assumptions with cryptic errors that can't be reproduced otherwise.
I guess a logger could remember whether it saw an error earlier, and attach that as metadata.
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 in most cases, if you catch multiple errors, it will be because they can from different siblings. (Another difference between this implementation and the previous one is that if there's an error, we continue rendering the siblings before we unwind.) In that case, you'd want to log all three errors.
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.
Realizing this test made more sense when it was using the render phase version of the lifecycle. I'll rewrite. Never mind, I still think this makes sense. Three siblings, three errors.
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 it makes sense to start dealing with this because it is less confusing than sometimes getting different errors depending on which sibling got furthest.
|
||
// Bits greater than Incomplete are used to identify fibers that threw. These | ||
// are like algebraic effects. These bits are mutually exclusive. | ||
export const Combined = /* */ 0b010000000000; |
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 explain why this is called "combined"? What is being combined here? Isn't the first effect that we "catch" the only one? Or do we attempt to render siblings as deep as we can and then "wait on all"?
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 I'll just get rid of this, but the idea was that you could dispatch multiple errors at the same time. It's only being used for unhandled errors at the root. Don't think it's necessary.
2570d09
to
dfe3ab3
Compare
If you're looking for a place to start, most of the interesting stuff happens in a new module called ReactFiberIncompleteWork. This is where we handle unwinding an incomplete component stack. https://github.com/acdlite/react/blob/1f38a7b1b5acb3c2b7a3a075c5358b934ef6d50e/packages/react-reconciler/src/ReactFiberIncompleteWork.js |
1f38a7b
to
06b7db4
Compare
The biggest code additions are the ReactFiberIncompleteWork module, which handles stack unwinding, and the uncommenting of resumeMountClassInstance, since error boundaries are now a form of resuming. Both overlap heavily with planned features, but the flip side of that is if we weren’t planning to eventually add those, this PR could be structured in a different way to make the code smaller. There are several places where some more abstraction (or better abstraction) could cut down on repetition and reduce code size, particularly in ReactFiberClassComponent. But I’d like to receive feedback on the overall architecture before I start optimizing too much. |
break; | ||
case IndeterminateComponent: | ||
break; | ||
default: |
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.
Is this code path commonly exercised in tests? Is there a risk that we'll add a new tag, forget to add it to this switch, and it blows up only on the error path (which is rare enough that we will miss it)?
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.
It’s triggered whenever anything throws, so we need test cases that throw for every possible type. Working on adding this to the fuzz tester.
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 a good candidate for a Flow exhaustiveness check.
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.
True. Though we still should test every path to prevent stack push/pop mismatch bugs, like we’ve seen in the past.
@@ -139,6 +139,8 @@ export type Fiber = {| | |||
firstEffect: Fiber | null, | |||
lastEffect: Fiber | null, | |||
|
|||
thrownValue: any, |
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.
You'll have to make the case for this addition.
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.
Strongest case I think is resuming. We should be able to reuse parts of the effect list during a bail out, including any of the values that were thrown.
Can you think of some viable alternatives? I'm having trouble coming up with one.
it('does not break with a bad Map polyfill', () => { | ||
// We don't currently use fibers as keys. Re-enable this test if we | ||
// ever do again. | ||
it.skip('does not break with a bad Map polyfill', () => { |
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 it matter having this enabled? We will definitely not enable this if we ever do again. :)
(Which might be this diff instead of the extra field.)
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.
Yeah, I can reenable, but if we ever do this again, we'll have to ensure that this test triggers whatever path adds a fiber to Map/Set. So I think we'll need to think about it regardless.
'BadMount', | ||
'componentDidCatch: Error 1', | ||
'componentDidCatch: Error 2', | ||
'componentDidCatch: Error 3', |
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 it makes sense to start dealing with this because it is less confusing than sometimes getting different errors depending on which sibling got furthest.
@@ -380,6 +380,7 @@ describe('ReactDOM', () => { | |||
it('throws in DEV if jsdom is destroyed by the time setState() is called', () => { | |||
class App extends React.Component { | |||
state = {x: 1}; | |||
componentDidUpdate() {} |
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 is this for?
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's a comment below. It's used to trigger invokeGuardedCallback
, which no longer wraps the begin phase unless an error is thrown.
workInProgress.nextEffect = null; | ||
workInProgress.firstEffect = null; | ||
workInProgress.lastEffect = null; | ||
workInProgress.thrownValue = 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.
Why did this move? We should be careful not to add things to things that happen in the hottest paths unless they always need to happen. If they sometimes need to happen, it should only happen in those paths.
@@ -148,6 +163,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>( | |||
workInProgress, | |||
current.child, | |||
nextChildren, | |||
deleteExistingChildren, |
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.
Like discussed offline. Let's call the reconciler twice instead of using boolean flag, first with nextChildren = null, then with current.child = null.
@@ -237,19 +253,22 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>( | |||
// During mounting we don't know the child context yet as the instance doesn't exist. | |||
// We will invalidate the child context in finishClassComponent() right after rendering. | |||
const hasContext = pushLegacyContextProvider(workInProgress); | |||
const instance = workInProgress.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.
You're not using the instance. Let's inline it back in place. It avoids reading the stateNode property here when current is not null.
fiber: Fiber, | ||
startTime: ExpirationTime, |
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.
rm until we need it
break; | ||
case Fragment: | ||
break; | ||
case Mode: |
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.
Should we just remove all these branches that do nothing?
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 guess so. I thought maybe the “unknown tag” invariant would reduce the likelihood that we’d overlook one of the types but it doesn’t provide that much safety. Need tests regardless.
cee3941
to
401ab7b
Compare
2ca1f63
to
d889f97
Compare
@sebmarkbage Ready for another look |
// begin phase using invokeGuardedCallback, so that the error is not | ||
// treated as caught by the debugger. | ||
if ( | ||
value instanceof Error || |
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 inverse this check to look for thenables instead.
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'll remove it entirely until the next PR
expirationTime, | ||
) { | ||
const stashedWorkInProgressProperties = Object.assign({}, workInProgress); | ||
try { |
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 we move this out of the hot loop and instead be around workLoop?
ddac776
to
750bae0
Compare
750bae0
to
521054b
Compare
A rewrite of error handling, with semantics that more closely match stack unwinding. Errors that are thrown during the render phase unwind to the nearest error boundary, like before. But rather than synchronously unmount the children before retrying, we restart the failed subtree within the same render phase. The failed children are still unmounted (as if all their keys changed) but without an extra commit. Commit phase errors are different. They work by scheduling an error on the update queue of the error boundary. When we enter the render phase, the error is popped off the queue. The rest of the algorithm is the same. This approach is designed to work for throwing non-errors, too, though that feature is not implemented yet.
Fires during the render phase, so you can recover from an error within the same pass. This aligns error boundaries more closely with try-catch semantics. Let's keep this behind a feature flag until a future release. For now, the recommendation is to keep using componentDidCatch. Eventually, the advice will be to use getDerivedStateFromCatch for handling errors and componentDidCatch only for logging.
This way we don't have to store the thrown values on the effect list.
We don't need it yet. We'll reconsider once we add another exception type.
This moves it out of the hot path.
521054b
to
559bbb5
Compare
const errorInfo = capturedErrors[i]; | ||
const error = errorInfo.value; | ||
logError(finishedWork, errorInfo); | ||
instance.componentDidCatch(error); |
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.
Did we intentionally "lose" the errorInfo
argument here? Seems like a major change.
* Add stack unwinding phase for handling errors A rewrite of error handling, with semantics that more closely match stack unwinding. Errors that are thrown during the render phase unwind to the nearest error boundary, like before. But rather than synchronously unmount the children before retrying, we restart the failed subtree within the same render phase. The failed children are still unmounted (as if all their keys changed) but without an extra commit. Commit phase errors are different. They work by scheduling an error on the update queue of the error boundary. When we enter the render phase, the error is popped off the queue. The rest of the algorithm is the same. This approach is designed to work for throwing non-errors, too, though that feature is not implemented yet. * Add experimental getDerivedStateFromCatch lifecycle Fires during the render phase, so you can recover from an error within the same pass. This aligns error boundaries more closely with try-catch semantics. Let's keep this behind a feature flag until a future release. For now, the recommendation is to keep using componentDidCatch. Eventually, the advice will be to use getDerivedStateFromCatch for handling errors and componentDidCatch only for logging. * Reconcile twice to remount failed children, instead of using a boolean * Handle effect immediately after its thrown This way we don't have to store the thrown values on the effect list. * ReactFiberIncompleteWork -> ReactFiberUnwindWork * Remove startTime * Remove TypeOfException We don't need it yet. We'll reconsider once we add another exception type. * Move replay to outer catch block This moves it out of the hot path.
* Add stack unwinding phase for handling errors A rewrite of error handling, with semantics that more closely match stack unwinding. Errors that are thrown during the render phase unwind to the nearest error boundary, like before. But rather than synchronously unmount the children before retrying, we restart the failed subtree within the same render phase. The failed children are still unmounted (as if all their keys changed) but without an extra commit. Commit phase errors are different. They work by scheduling an error on the update queue of the error boundary. When we enter the render phase, the error is popped off the queue. The rest of the algorithm is the same. This approach is designed to work for throwing non-errors, too, though that feature is not implemented yet. * Add experimental getDerivedStateFromCatch lifecycle Fires during the render phase, so you can recover from an error within the same pass. This aligns error boundaries more closely with try-catch semantics. Let's keep this behind a feature flag until a future release. For now, the recommendation is to keep using componentDidCatch. Eventually, the advice will be to use getDerivedStateFromCatch for handling errors and componentDidCatch only for logging. * Reconcile twice to remount failed children, instead of using a boolean * Handle effect immediately after its thrown This way we don't have to store the thrown values on the effect list. * ReactFiberIncompleteWork -> ReactFiberUnwindWork * Remove startTime * Remove TypeOfException We don't need it yet. We'll reconsider once we add another exception type. * Move replay to outer catch block This moves it out of the hot path.
Should 'render phase' be 'commit phase'? |
@@ -1379,6 +1387,10 @@ describe('ReactErrorBoundaries', () => { | |||
// The initial render was aborted, so | |||
// Fiber retries from the root. | |||
'ErrorBoundary componentWillUpdate', | |||
'ErrorBoundary componentDidUpdate', |
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.
* Add stack unwinding phase for handling errors A rewrite of error handling, with semantics that more closely match stack unwinding. Errors that are thrown during the render phase unwind to the nearest error boundary, like before. But rather than synchronously unmount the children before retrying, we restart the failed subtree within the same render phase. The failed children are still unmounted (as if all their keys changed) but without an extra commit. Commit phase errors are different. They work by scheduling an error on the update queue of the error boundary. When we enter the render phase, the error is popped off the queue. The rest of the algorithm is the same. This approach is designed to work for throwing non-errors, too, though that feature is not implemented yet. * Add experimental getDerivedStateFromCatch lifecycle Fires during the render phase, so you can recover from an error within the same pass. This aligns error boundaries more closely with try-catch semantics. Let's keep this behind a feature flag until a future release. For now, the recommendation is to keep using componentDidCatch. Eventually, the advice will be to use getDerivedStateFromCatch for handling errors and componentDidCatch only for logging. * Reconcile twice to remount failed children, instead of using a boolean * Handle effect immediately after its thrown This way we don't have to store the thrown values on the effect list. * ReactFiberIncompleteWork -> ReactFiberUnwindWork * Remove startTime * Remove TypeOfException We don't need it yet. We'll reconsider once we add another exception type. * Move replay to outer catch block This moves it out of the hot path.
A rewrite of error handling, with semantics that more closely match stack unwinding.
Errors that are thrown during the render phase unwind to the nearest error boundary, like before. But rather than synchronously unmount the children before retrying, we restart the failed subtree within the same render phase. The failed children are still unmounted (as if all their keys changed) but without an extra commit.
Commit phase errors are different. They work by scheduling an error on the update queue of the error boundary. When we enter the render phase, the error is popped off the queue. The rest of the algorithm is the same.
This approach is designed to work for throwing non-errors, too, though that feature is not implemented yet.
Known differences from existing error handling semantics in synchronous mode:
componentDidCatch
for each error that a boundary captures, instead of just the first one.Unresolved items:
getDerivedStateFromCatch
lifecycle behind a feature flag. This is fired during the render phase so that we can retry without committing, as opposed tocomponentDidCatch
, which fires in the commit phase. We'll keepcomponentDidCatch
around for logging purposes. IfgetDerivedStateFromCatch
is not implemented butcomponentDidCatch
is, the fallback behavior is to unmount all the boundary's children. (This part is already implemented.) We may warn about the missinggetDerivedStateFromCatch
in a future release.componentDidCatch
will have mostly the same semantics, with one key exception. We no longer track whether an error boundary failed in a previous commit. So it's now possible to fall into a recursive error loop, in the same way as an unguardedsetState
insidecomponentDidUpdate
.