-
Notifications
You must be signed in to change notification settings - Fork 46.9k
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
Partial Hydration #14717
Partial Hydration #14717
Conversation
ReactDOM: size: 🔺+0.1%, gzip: 0.0% Details of bundled changes.Comparing: 1d48b4a...34a132c react-dom
react-art
react-native-renderer
react-test-renderer
react-reconciler
Generated by 🚫 dangerJS |
We need this to be able to identify how far to skip ahead if we're not going to hydrate this subtree yet.
Will be used for Suspense boundaries that are left with their server rendered content intact.
This lets us continue hydrating sibling nodes.
2bdd79a
to
c818d14
Compare
This requires the enableSuspenseServerRenderer flag to be manually enabled for the build to work.
c818d14
to
eb3ea2d
Compare
We mark dehydrated boundaries as having child work, since they might have components that read from the changed context. We check this in beginWork and if it does we treat it as if the input has changed (same as if props changes).
enableSuspenseServerRenderer && | ||
workInProgress.tag === DehydratedSuspenseComponent && | ||
shouldCaptureDehydratedSuspense(workInProgress) | ||
) { |
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.
Since this branch doesn't suspend (it doesn't call renderDidSuspend
) I would expect React to keep rendering the same level over and over until the promise resolves. Is that what's happening?
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. What happens is that only the first path schedules remaining work at "Never" expiration time. Then if it throws, it doesn't suspend but it also doesn't leave any work on it. Instead it commits. Then it waits for the retry. The retry gets scheduled at normal priority. If that update also throws a promise, then it commits in the dehydrated state again and waits for the retry.
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 see so when something suspends inside a dehydrated Suspense boundary it always bails out and clears the expiration time. The ping/retry adds the expiration time back. There’s no need to suspend the commit because it’s not blocking anything.
These don't come into play for purposes of hydration.
Co-Authored-By: sebmarkbage <sebastian@calyptus.eu>
The bad case in this solution happens when a props/context changes which then causes that update to suspend. This triggers the fallback state. In the fully client-side solution, we keep the state while we load the missing data. However, in this case we'll delete the server rendered nodes and lose their state. With one of the followups this won't happen as long as we can hydrate before the timeout happens because we can hydrate right before committing the suspended state. It might seems like we should be able to hide the server rendered content and then hydrate it and show it. That's slightly different because if we commit the fallback state, we have now lost whatever was "current" right before that. We've lost the props and the context of all parent components - which we need to hydrate the child in its original state. In theory we could do something advanced in this case like snapshotting the props and values of all contexts. However, we'd have to keep this indefinitely and rely on that these snapshots are the only sources of data and that they're fully immutable. This adds a new constraint, that we're able to render states older than current. Even if we could, we don't want to replay clicks when a fallback was rendered between. Since it's not seamless anyway. The only thing we'd gain is the ability to preserve state in uncontrolled forms. This doesn't seem worth it. |
// been unsuspended it has committed as a regular Suspense component. | ||
// If it needs to be retried, it should have work scheduled on it. | ||
workInProgress.effectTag |= DidCapture; | ||
break; |
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 break statement doing inside of the if
block?
This adds a mechanism for partially hydrating a server rendered result while other parts of the page are still loading the code or data. This means that you can start interacting with parts of the screen while others are still hydrating.
Model
In this model you always have to hydrate the root content first because it is what provides props to the children, which can be of arbitrary complexity. The model assumes that the root of the app is designed to be relatively shallow and then each abstraction gets progressively more complex the deeper it gets. To become interactive faster, components in the tree can themselves use progressive enhancement to add more complexity after initial hydration.
API
This mechanism works on top of the
<Suspense fallback={...}>
API. With this PR, when we try to hydrate a Suspense component, we will immediately bail out and skip past it. We'll leave the server rendered content in place. Once we've finished a hydrating of everything not in a Suspense boundary, we'll commit it. Then the next frame we'll continue to hydrate the next Suspense boundary and so on. This is a breadth first hydration. This happens even if nothing suspends.The reason for the breadth first hydration is because we need to fully complete the parent before we can make any updates to children. Once we have committed a parent, we can now prioritize any of the Suspense "holes" left dehydrated inside it - independently.
The reason this works with Suspense boundaries is that the parents already have to be resilient to the children not being fully resolved, but also because we can at any point trigger the fallback state to return to a consistent state.
If the Suspense component rerenders with new props or new context as input, before we've fully hydrated, we have two problems:
We can no longer safely hydrate the subtree. React has a strong requirement that the initial render behaves just as it did on the server. This is a tradeoff that lets us avoid a lot of metadata added to the HTML. We can't make changes to a subtree without first rendering the initial state. After that we can make updates. In theory, we could store a snapshot of all contexts and props to solve this problem.
However, semantically, changes to props or Context should change whatever was rendered. E.g. if that switch is from dark to light mode, then we can't just leave the existing content in place. Similar things happen with certain layouts or media queries.
Therefore, the semantics here is that if that happens, then we'll delete the existing content and rerender it from scratch. If that suspends, we'll show the fallback.
This is an undesirable experience but reasonable compromise. To avoid this the product code must:
memo
orshouldComponentUpdate
.Quirks
This solution is susceptible to tearing issues, common in Flux stores, just like Concurrent Mode in general. E.g. if the store is mutated before the next level is hydrated, then we'll try to hydrate it with the wrong initial state. Therefore, stores need to be able to save a snapshot of their initial state for the duration of the hydration.
In the current version of this PR, hydration of suspense boundaries always gets scheduled as concurrent. I'm not sure if a non-concurrent mode of this even makes sense.
Hoisting state up to the root can be problematic because as these update they will pass their value down and rerender components that may not have fully hydrated yet which will put them in their fallback state. The key is to make any such state have a long expiration time and long suspense time so that if it happens, we have time to hydrate it beforehand. High-pri state should be local to components and not rerender at the top. Updates to top level state placed in Context will force the fallback state of the whole tree since it can affect everything and needs to be managed carefully.
For these reasons, it is important to carefully design the shell of the app so that different parts can operate independently for at least some period of time.
Progress
Left to do in this PR:
Follow up 1:
Follow up 2: