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

[Flight] Let Errored/Blocked Direct References Turn Nearest Element Lazy #29823

Merged
merged 9 commits into from
Jun 11, 2024

Conversation

sebmarkbage
Copy link
Collaborator

@sebmarkbage sebmarkbage commented Jun 10, 2024

Stacked on #29807.

This lets the nearest Suspense/Error Boundary handle it even if that boundary is defined by the model itself.

It also ensures that when we have an error during serialization of properties, those can be associated with the nearest JSX element and since we have a stack/owner for that element we can use it to point to the source code of that line. We can't track the source of any nested arbitrary objects deeper inside since objects don’t track their stacks but close enough. Ideally we have the property path but we don’t have that right now. We have a partial in the message itself.

Screenshot 2024-06-09 at 10 08 27 PM

Note: The component name (Counter) is lost in the first message because we don't print it in the Task. We use "use client" instead because we expect the next stack frame to have the name. We also don't include it in the actual error message because the Server doesn't know the component name yet. Ideally Client References should be able to have a name. If the nearest is a Host Component then we do use the name though. However, it's not actually inside that Component that the error happens it's in App and that points to the right line number.

An interesting case is that if something that's actually going to be consumed by the props to a Suspense/Error Boundary or the Client Component that wraps them fails, then it can't be handled by the boundary. However, a counter intuitive case might be when that's on the children props. E.g. <ErrorBoundary>{clientReferenceOrInvalidSerialization}</ErrorBoundary>. This value can be inspected by the boundary so it's not safe to pass it so if it's errored it is not caught.

Implementation

The first insight is that this is best solved on the Client rather than in the Server because that way it also covers Client References that end up erroring.

The key insight is that while we don't have a true stack when using JSON.parse and therefore no begin/complete we can still infer these phases for Elements because the first child of an Element is always '$' which is also a leaf. In depth first that's our begin phase. When the Element itself completes, we have the complete phase. Anything in between is within the Element.

Using this idea I was able to refactor the blocking tracking mechanism to stash the blocked information on initializingHandler and then on the way up do we let whatever is nearest handle it - whether that's an Element or the root Chunk. It's kind of like an Algebraic Effect.

cc @unstubbable This is something you might want to deep dive into to find more edge cases. I'm sure I've missed something.

@sebmarkbage sebmarkbage requested review from gnoff and eps1lon June 10, 2024 01:46
Copy link

vercel bot commented Jun 10, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
react-compiler-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jun 11, 2024 10:26pm

@react-sizebot
Copy link

react-sizebot commented Jun 10, 2024

The size diff is too large to display in a single comment. The CircleCI job contains an artifact called 'sizebot-message.md' with the full message.

Generated by 🚫 dangerJS against 977c078

@@ -4126,6 +4127,11 @@ function beginWork(
}
break;
}
case Throw: {
// This represents a Component that threw in the reconciliation phase.
// So we'll rethrow here. This might be
Copy link
Collaborator

Choose a reason for hiding this comment

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

This might be what?

// We do this before parsing in case we try to initialize the same chunk
// while parsing the model. Such as in a cyclic reference.
const cyclicChunk: CyclicChunk<T> = (chunk: any);
cyclicChunk.status = CYCLIC;
const cyclicChunk: BlockedChunk<T> = (chunk: any);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we rename this to blockedChunk now that the cyclic chunks are not a separate concept anymore?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yea I was considering but decided to keep it. It's not a separate status but it's still cyclic at this point which is why we feel comfortable invoke the listeners later in this function - because nobody should be listening to it (not 100% true because a require() into a module could but not likely).

'\n in div' + '\n in ErrorBoundary (at **)' + '\n in App'
? '\n in Throw' +
'\n in div' +
'\n in ErrorBoundary (at **)' +
Copy link
Collaborator

Choose a reason for hiding this comment

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

I know that this is not new in this PR, but I'm wondering why the error boundary is included in the stack unconditionally. Shouldn't this be gated on enableOwnerStacks and be omitted from the stack when the flag is enabled? It is handled like that in this test. Why not here?

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 stack we're comparing here is coming from the componentStack field passed to Error Boundaries. This will remain the parent stack and available in both DEV and PROD.

if (
initializingHandler !== null &&
isArray(parentObject) &&
key === '0'
Copy link
Collaborator

Choose a reason for hiding this comment

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

What is the use case for guarding with isArray(parentObject) && key === '0'? I feel like we should either omit this here, or also consider it for inferring whether we should return REACT_ELEMENT_TYPE. Or did you have a special case in mind that I can't think of right now where this check would only be relevant for the initializingHandler?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You can pass a symbol in arbitrary places in theory { mySymbol: REACT_ELEMENT_TYPE }. I mean it's really considered internal so you probably shouldn't. But the only place we special case its usage is here:

https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClient.js#L1116

So it needs to line up with that check to be a balanced push/pop.

However, I did notice that we don't actually check isArray() there so either we should add that check or remove the one here.

initializingChunk.then(freeze, freeze);
} else {
Object.freeze(element.props);
blockedChunk.then(freeze, freeze);
Copy link
Collaborator

Choose a reason for hiding this comment

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

If I'm not misreading the curly braces I think we are now freezing the props of blocked models in prod. I assume this is unintentional?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Nice catch. Yea. This should be gated on __DEV__.

initializingHandler = handler.parent;
if (handler.errored) {
// Something errored inside this Element's props. We can turn this Element
// into a Lazy so that we can still render up until that Lazy is rendered.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should there be a new unit test for this? It's still a bit unclear to me how this affects siblings.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is effectively covered by the Throw cases that has the new component stacks. It is split into a synchronous and an asynchronous case where the error is already known vs becomes known though. Not sure if both are covered but meh.

Copy link
Collaborator

@eps1lon eps1lon left a comment

Choose a reason for hiding this comment

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

Code looks fine though I don't know enough about the potential edge cases.

returnFiber,
oldFiber,
unwrapThenable(thenable),
lanes,
debugInfo,
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I thought we need to pushDebugInfo where we used to mergeDebugInfo and don't need to pushDebugInfo when we just passed debugInfo as an argument. But here we're now pushing where we didn't used to merge. Is this a bug fix or change in behavior?

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 a bug fix. I missed this before. It should've merged.

break;
case Throw: {
if (__DEV__) {
// For an error in child position we use the of the inner most parent component.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// For an error in child position we use the of the inner most parent component.
// For an error in child position we use the name of the inner most parent component.

?

@eps1lon
Copy link
Collaborator

eps1lon commented Jun 11, 2024

there's sebmarkbage#5 for some improved type coverage if that makes sense to you.

sebmarkbage and others added 7 commits June 11, 2024 18:14
While JSON.parse doesn't let us inspect the begin/complete phase to push/po
we can rely on the REACT_ELEMENT_TYPE marker being a leaf that's in the
beginning of the element as the begin an the element itself is the complete
phase.

This lets us more deeply handle suspense or errors in case there's an
Error or Suspense boundary in the current model.

The tricky bit is to keep in mind cycles and deep references that might now
be broken apart.
It's just a blocked chunk that eagerly calls its listeners.
We feel comfortable turning any Element into Lazy since it serializes as
Node. So if any error happens inside of the deserialization such as if
a direct reference errored or a client reference failed to load we can
scope it to that element. That way if any Error boundaries were on the
stack inside the same model, they can handle the error.

This also gives us better debug info for things like serialization errors
because they now can get a stack trace pointing to the exact JSX.
@sebmarkbage
Copy link
Collaborator Author

@acdlite Took a look but deferred to @unstubbable's deeper review. I think I've addressed everything.

@sebmarkbage sebmarkbage merged commit fb57fc5 into facebook:main Jun 11, 2024
44 checks passed
unstubbable added a commit to unstubbable/react that referenced this pull request Jul 3, 2024
The issue reported in facebook#30172 was fixed with facebook#29823. This PR also added
the test [`should resolve deduped objects that are themselves
blocked`](https://github.com/facebook/react/blob/6d2a97a7113dfac2ad45067001b7e49a98718324/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js#L348-L393),
which tests a similar scenario. However, this test would have also
succeeded before applying the changes from facebook#29823. Therefore, I believe
it makes sense to add this additional test, which does not succeed
without facebook#29823, to prevent regressions.
sebmarkbage pushed a commit that referenced this pull request Jul 3, 2024
The issue reported in #30172 was fixed with #29823. The PR also added
the test [`should resolve deduped objects that are themselves
blocked`](https://github.com/facebook/react/blob/6d2a97a7113dfac2ad45067001b7e49a98718324/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js#L348-L393),
which tests a similar scenario. However, the existing test would have
also succeeded before applying the changes from #29823. Therefore, I
believe it makes sense to add an additional test `should resolve deduped
objects in nested children of blocked models`, which does not succeed
without #29823, to prevent regressions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants