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

Partial Hydration #14717

Merged
merged 21 commits into from
Feb 12, 2019
Merged

Partial Hydration #14717

merged 21 commits into from
Feb 12, 2019

Conversation

sebmarkbage
Copy link
Collaborator

@sebmarkbage sebmarkbage commented Jan 29, 2019

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:

  1. 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.

  2. 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:

  • Ensure that unrelated updates bail out early. E.g. using memo or shouldComponentUpdate.
  • Avoid updates to top level Contexts.
  • If that can't be avoided, use a low priority update in Concurrent Mode, and long suspense durations. This will delay this scenario for as long as possible.

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:

  • Wrap in feature flag
  • Delete the content when any parent Context updates.
  • Deal with Suspense boundaries without fallback defined.

Follow up 1:

  • If props or context has changed, first try hydrating at higher priority in case that lets us hydrate.
  • Mark fallback content in the server rendered content. Wait to try to hydrate it until it switches to real content.
  • Gracefully handle sync mode (including strict mode) or disable it completely.
  • Add warnings for failing to hydrate suspense boundaries.

Follow up 2:

  • Add mechanism to defer and replay events that happen in dehydrated trees.
  • Use deferred events as hints for which trees to prioritize.
  • If a deferred event can't be replayed on a hydrated tree within an expiration time, delete the content and show fallback.
  • Consider hydrating each level at a higher priority than "offscreen", or let hydration take priority over updates to the same trees.

@sizebot
Copy link

sizebot commented Jan 29, 2019

ReactDOM: size: 🔺+0.1%, gzip: 0.0%

Details of bundled changes.

Comparing: 1d48b4a...34a132c

react-dom

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-dom.development.js +1.7% +1.5% 751.86 KB 765 KB 171.87 KB 174.39 KB UMD_DEV
react-dom.production.min.js 🔺+0.1% 0.0% 105.12 KB 105.21 KB 33.92 KB 33.94 KB UMD_PROD
react-dom.profiling.min.js +0.1% +0.1% 108.05 KB 108.13 KB 34.78 KB 34.83 KB UMD_PROFILING
react-dom.development.js +1.8% +1.5% 746.46 KB 759.6 KB 170.36 KB 172.9 KB NODE_DEV
react-dom.production.min.js 🔺+0.1% 🔺+0.1% 105.29 KB 105.38 KB 33.37 KB 33.41 KB NODE_PROD
react-dom.profiling.min.js +0.1% +0.1% 108.37 KB 108.45 KB 34.19 KB 34.23 KB NODE_PROFILING
ReactDOM-dev.js +1.8% +1.5% 769.12 KB 782.63 KB 171.58 KB 174.13 KB FB_WWW_DEV
ReactDOM-prod.js 🔺+2.3% 🔺+2.0% 314.66 KB 321.92 KB 57.57 KB 58.7 KB FB_WWW_PROD
ReactDOM-profiling.js +2.3% +2.1% 321 KB 328.5 KB 58.95 KB 60.19 KB FB_WWW_PROFILING
react-dom-unstable-fire.development.js +1.7% +1.5% 752.21 KB 765.34 KB 172.01 KB 174.54 KB UMD_DEV
react-dom-unstable-fire.production.min.js 🔺+0.1% 0.0% 105.14 KB 105.23 KB 33.93 KB 33.95 KB UMD_PROD
react-dom-unstable-fire.profiling.min.js +0.1% +0.1% 108.06 KB 108.15 KB 34.79 KB 34.84 KB UMD_PROFILING
react-dom-unstable-fire.development.js +1.8% +1.5% 746.8 KB 759.94 KB 170.5 KB 173.04 KB NODE_DEV
react-dom-unstable-fire.production.min.js 🔺+0.1% 🔺+0.1% 105.31 KB 105.39 KB 33.38 KB 33.42 KB NODE_PROD
react-dom-unstable-fire.profiling.min.js +0.1% +0.1% 108.38 KB 108.46 KB 34.2 KB 34.24 KB NODE_PROFILING
ReactFire-dev.js +1.8% +1.5% 768.33 KB 781.84 KB 171.5 KB 174.05 KB FB_WWW_DEV
ReactFire-prod.js 🔺+2.4% 🔺+2.0% 303.14 KB 310.35 KB 55.25 KB 56.38 KB FB_WWW_PROD
ReactFire-profiling.js +2.4% +2.1% 309.51 KB 316.96 KB 56.64 KB 57.8 KB FB_WWW_PROFILING
react-dom-test-utils.production.min.js 0.0% -0.1% 10.27 KB 10.27 KB 3.8 KB 3.8 KB UMD_PROD
react-dom-test-utils.development.js 0.0% -0.0% 46.78 KB 46.78 KB 12.92 KB 12.92 KB NODE_DEV
react-dom-test-utils.production.min.js 0.0% -0.0% 10.05 KB 10.05 KB 3.73 KB 3.73 KB NODE_PROD
react-dom-unstable-native-dependencies.development.js 0.0% -0.0% 60.61 KB 60.61 KB 15.92 KB 15.92 KB UMD_DEV
react-dom-unstable-native-dependencies.production.min.js 0.0% -0.1% 11.01 KB 11.01 KB 3.81 KB 3.81 KB UMD_PROD
react-dom-unstable-native-dependencies.production.min.js 0.0% -0.1% 10.75 KB 10.75 KB 3.71 KB 3.71 KB NODE_PROD
react-dom-server.browser.development.js +0.5% +0.4% 126.02 KB 126.7 KB 33.69 KB 33.81 KB UMD_DEV
react-dom-server.browser.production.min.js 0.0% -0.0% 18.66 KB 18.66 KB 7.18 KB 7.18 KB UMD_PROD
react-dom-server.browser.development.js +0.6% +0.4% 122.15 KB 122.83 KB 32.76 KB 32.88 KB NODE_DEV
react-dom-server.browser.production.min.js 0.0% -0.0% 18.58 KB 18.58 KB 7.17 KB 7.17 KB NODE_PROD
ReactDOMServer-dev.js +0.5% +0.4% 123.13 KB 123.77 KB 32.27 KB 32.39 KB FB_WWW_DEV
ReactDOMServer-prod.js 🔺+1.0% 🔺+0.7% 44.78 KB 45.25 KB 10.36 KB 10.43 KB FB_WWW_PROD
react-dom-server.node.development.js +0.5% +0.4% 124.21 KB 124.89 KB 33.3 KB 33.42 KB NODE_DEV
react-dom-server.node.production.min.js 0.0% -0.0% 19.45 KB 19.45 KB 7.48 KB 7.48 KB NODE_PROD
react-dom-unstable-fizz.browser.production.min.js 0.0% -0.1% 1.21 KB 1.21 KB 706 B 705 B UMD_PROD
react-dom-unstable-fizz.browser.development.js 0.0% -0.1% 3.45 KB 3.45 KB 1.39 KB 1.39 KB NODE_DEV
react-dom-unstable-fizz.browser.production.min.js 0.0% -0.2% 1.05 KB 1.05 KB 637 B 636 B NODE_PROD
react-dom-unstable-fizz.node.development.js 0.0% -0.1% 3.7 KB 3.7 KB 1.42 KB 1.42 KB NODE_DEV

react-art

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-art.development.js +2.0% +1.8% 531.1 KB 541.68 KB 115.58 KB 117.71 KB UMD_DEV
react-art.production.min.js 🔺+0.1% 🔺+0.1% 97.18 KB 97.25 KB 29.83 KB 29.86 KB UMD_PROD
react-art.development.js +2.3% +2.1% 462.11 KB 472.73 KB 98.38 KB 100.48 KB NODE_DEV
react-art.production.min.js 🔺+0.1% 🔺+0.1% 62.23 KB 62.29 KB 19.01 KB 19.02 KB NODE_PROD
ReactART-dev.js +2.3% +2.1% 471.15 KB 482.11 KB 97.72 KB 99.79 KB FB_WWW_DEV
ReactART-prod.js 🔺+2.9% 🔺+2.5% 189.86 KB 195.37 KB 32.26 KB 33.07 KB FB_WWW_PROD

react-native-renderer

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
ReactNativeRenderer-dev.js +1.8% +1.7% 595.71 KB 606.62 KB 128.02 KB 130.14 KB RN_FB_DEV
ReactNativeRenderer-prod.js 0.0% 🔺+0.1% 246.01 KB 246.05 KB 42.94 KB 42.99 KB RN_FB_PROD
ReactNativeRenderer-profiling.js 0.0% +0.2% 252.3 KB 252.4 KB 44.47 KB 44.54 KB RN_FB_PROFILING
ReactNativeRenderer-dev.js +1.8% +1.7% 595.63 KB 606.53 KB 127.98 KB 130.11 KB RN_OSS_DEV
ReactNativeRenderer-prod.js 0.0% 🔺+0.1% 246.03 KB 246.07 KB 42.93 KB 42.99 KB RN_OSS_PROD
ReactNativeRenderer-profiling.js 0.0% +0.2% 252.32 KB 252.42 KB 44.46 KB 44.54 KB RN_OSS_PROFILING
ReactFabric-dev.js +1.9% +1.7% 586.57 KB 597.47 KB 125.75 KB 127.87 KB RN_FB_DEV
ReactFabric-prod.js 0.0% 🔺+0.1% 238.48 KB 238.49 KB 41.5 KB 41.55 KB RN_FB_PROD
ReactFabric-profiling.js 0.0% +0.1% 244.65 KB 244.72 KB 43.01 KB 43.06 KB RN_FB_PROFILING
ReactFabric-dev.js +1.9% +1.7% 586.47 KB 597.38 KB 125.71 KB 127.81 KB RN_OSS_DEV
ReactFabric-prod.js 0.0% 🔺+0.1% 238.49 KB 238.5 KB 41.49 KB 41.54 KB RN_OSS_PROD
ReactFabric-profiling.js 0.0% +0.1% 244.66 KB 244.73 KB 43 KB 43.06 KB RN_OSS_PROFILING

react-test-renderer

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-test-renderer.development.js +2.2% +2.1% 474.19 KB 484.8 KB 100.76 KB 102.85 KB UMD_DEV
react-test-renderer.production.min.js 🔺+0.1% 🔺+0.3% 63.58 KB 63.66 KB 19.46 KB 19.53 KB UMD_PROD
react-test-renderer.development.js +2.3% +2.1% 468.63 KB 479.24 KB 99.45 KB 101.55 KB NODE_DEV
react-test-renderer.production.min.js 🔺+0.1% 🔺+0.2% 63.26 KB 63.32 KB 19.16 KB 19.19 KB NODE_PROD
ReactTestRenderer-dev.js +2.3% +2.1% 478.33 KB 489.34 KB 99.11 KB 101.2 KB FB_WWW_DEV
react-test-renderer-shallow.production.min.js 0.0% -0.0% 11.12 KB 11.12 KB 3.35 KB 3.35 KB UMD_PROD
react-test-renderer-shallow.production.min.js 0.0% -0.0% 11.77 KB 11.77 KB 3.65 KB 3.65 KB NODE_PROD

react-reconciler

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-reconciler.development.js +2.4% +2.2% 459.13 KB 470.03 KB 96.73 KB 98.83 KB NODE_DEV
react-reconciler.production.min.js 🔺+0.1% 🔺+0.1% 63.44 KB 63.51 KB 18.79 KB 18.8 KB NODE_PROD
react-reconciler-persistent.development.js +2.4% +2.2% 457.51 KB 468.41 KB 96.08 KB 98.18 KB NODE_DEV
react-reconciler-persistent.production.min.js 🔺+0.1% 🔺+0.1% 63.45 KB 63.53 KB 18.8 KB 18.81 KB NODE_PROD
react-reconciler-reflection.development.js 0.0% -0.0% 15.76 KB 15.76 KB 4.98 KB 4.98 KB NODE_DEV
react-reconciler-reflection.production.min.js 0.0% -0.1% 2.7 KB 2.7 KB 1.23 KB 1.22 KB NODE_PROD

Generated by 🚫 dangerJS

@facebook facebook deleted a comment Feb 3, 2019
This requires the enableSuspenseServerRenderer flag to be manually enabled
for the build to work.
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)
) {
Copy link
Collaborator

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?

Copy link
Collaborator Author

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.

Copy link
Collaborator

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.

@sebmarkbage sebmarkbage changed the title [WIP] Partial Hydration Partial Hydration Feb 12, 2019
@sebmarkbage
Copy link
Collaborator Author

sebmarkbage commented Feb 12, 2019

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.

@sebmarkbage sebmarkbage merged commit f3a1495 into facebook:master Feb 12, 2019
// 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;
Copy link
Contributor

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?

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.

5 participants