From 5659f33a1418b9cb0bd4b5bd121010931bc5417f Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 28 Oct 2021 22:30:22 -0400 Subject: [PATCH] Incremental hydration Stores the tree context on the dehydrated Suspense boundary's state object so it resume where it left off. --- .../src/__tests__/ReactDOMUseId-test.js | 146 ++++++++++++++++++ .../src/ReactFiberBeginWork.new.js | 2 + .../src/ReactFiberBeginWork.old.js | 2 + .../src/ReactFiberHydrationContext.new.js | 10 ++ .../src/ReactFiberHydrationContext.old.js | 10 ++ .../src/ReactFiberSuspenseComponent.new.js | 3 + .../src/ReactFiberSuspenseComponent.old.js | 3 + .../src/ReactFiberTreeContext.new.js | 36 +++++ .../src/ReactFiberTreeContext.old.js | 36 +++++ 9 files changed, 248 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js index f501d1724dd8a..f2bda81aa39ab 100644 --- a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js @@ -10,9 +10,11 @@ let JSDOM; let React; let ReactDOM; +let Scheduler; let clientAct; let ReactDOMFizzServer; let Stream; +let Suspense; let useId; let document; let writable; @@ -27,9 +29,11 @@ describe('useId', () => { JSDOM = require('jsdom').JSDOM; React = require('react'); ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); clientAct = require('jest-react').act; ReactDOMFizzServer = require('react-dom/server'); Stream = require('stream'); + Suspense = React.Suspense; useId = React.unstable_useId; // Test Environment @@ -86,6 +90,11 @@ describe('useId', () => { } } + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + function normalizeTreeIdForTesting(id) { const [serverClientPrefix, base32, hookIndex] = id.split(':'); if (serverClientPrefix === 'r') { @@ -282,4 +291,141 @@ describe('useId', () => { expect(divs[i].id).toMatch(/^R:.(((al)*a?)((la)*l?))*$/); } }); + + test('basic incremental hydration', async () => { + function App() { + return ( +
+ + + + + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + await clientAct(async () => { + ReactDOM.hydrateRoot(container, ); + }); + expect(container).toMatchInlineSnapshot(` +
+
+ +
+
+ +
+
+
+ `); + }); + + test('inserting a sibling before a dehydrated Suspense boundary', async () => { + const span = React.createRef(null); + function App({showMore}) { + // Note: Using a dynamic array so this is treated as an insertion instead + // of an update, because Fiber currently allocates a node even for + // empty children. + const children = []; + if (showMore) { + // These are client-only nodes. They aren't not included in the initial + // server render. + children.push(, ); + } + children.push( + + + + + , + , + ); + + return children; + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(Scheduler).toHaveYielded(['A']); + const dehydratedSpan = container.getElementsByTagName('span')[0]; + await clientAct(async () => { + const root = ReactDOM.hydrateRoot(container, ); + expect(Scheduler).toFlushUntilNextPaint(['A']); + expect(container).toMatchInlineSnapshot(` +
+ A + + +
+
+ + +
+
+ `); + + // The inner boundary hasn't hydrated yet + expect(span.current).toBe(null); + + // Insert another sibling before the Suspense boundary + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 'A', + 'B', + // The update triggers selective hydration so we render again + 'A', + 'B', + ]); + // The insertions should not cause a mismatch. + expect(container).toMatchInlineSnapshot(` +
+ A + + + B +
+
+
+ + +
+
+ `); + // Should have hydrated successfully + expect(span.current).toBe(dehydratedSpan); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index aac6239d8bbd5..653ee9e1b4ea7 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -1852,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) { const SUSPENDED_MARKER: SuspenseState = { dehydrated: null, + treeContext: null, retryLane: NoLane, }; @@ -2700,6 +2701,7 @@ function updateDehydratedSuspenseComponent( reenterHydrationStateFromDehydratedSuspenseInstance( workInProgress, suspenseInstance, + suspenseState.treeContext, ); const nextProps = workInProgress.pendingProps; const primaryChildren = nextProps.children; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 8d34832a3df48..9833ef481af70 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -1852,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) { const SUSPENDED_MARKER: SuspenseState = { dehydrated: null, + treeContext: null, retryLane: NoLane, }; @@ -2700,6 +2701,7 @@ function updateDehydratedSuspenseComponent( reenterHydrationStateFromDehydratedSuspenseInstance( workInProgress, suspenseInstance, + suspenseState.treeContext, ); const nextProps = workInProgress.pendingProps; const primaryChildren = nextProps.children; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 7275f1663cad8..eabc5e43116bb 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -17,6 +17,7 @@ import type { HostContext, } from './ReactFiberHostConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; +import type {TreeContext} from './ReactFiberTreeContext.new'; import { HostComponent, @@ -62,6 +63,10 @@ import { } from './ReactFiberHostConfig'; import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; import {OffscreenLane} from './ReactFiberLane.new'; +import { + getSuspendedTreeContext, + restoreSuspendedTreeContext, +} from './ReactFiberTreeContext.new'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean { function reenterHydrationStateFromDehydratedSuspenseInstance( fiber: Fiber, suspenseInstance: SuspenseInstance, + treeContext: TreeContext | null, ): boolean { if (!supportsHydration) { return false; @@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( ); hydrationParentFiber = fiber; isHydrating = true; + if (treeContext !== null) { + restoreSuspendedTreeContext(fiber, treeContext); + } return true; } @@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) { if (suspenseInstance !== null) { const suspenseState: SuspenseState = { dehydrated: suspenseInstance, + treeContext: getSuspendedTreeContext(), retryLane: OffscreenLane, }; fiber.memoizedState = suspenseState; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 654de3f9a2894..48e60581e0f28 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -17,6 +17,7 @@ import type { HostContext, } from './ReactFiberHostConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; +import type {TreeContext} from './ReactFiberTreeContext.old'; import { HostComponent, @@ -62,6 +63,10 @@ import { } from './ReactFiberHostConfig'; import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; import {OffscreenLane} from './ReactFiberLane.old'; +import { + getSuspendedTreeContext, + restoreSuspendedTreeContext, +} from './ReactFiberTreeContext.old'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean { function reenterHydrationStateFromDehydratedSuspenseInstance( fiber: Fiber, suspenseInstance: SuspenseInstance, + treeContext: TreeContext | null, ): boolean { if (!supportsHydration) { return false; @@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( ); hydrationParentFiber = fiber; isHydrating = true; + if (treeContext !== null) { + restoreSuspendedTreeContext(fiber, treeContext); + } return true; } @@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) { if (suspenseInstance !== null) { const suspenseState: SuspenseState = { dehydrated: suspenseInstance, + treeContext: getSuspendedTreeContext(), retryLane: OffscreenLane, }; fiber.memoizedState = suspenseState; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js index 5ad7ae650249a..9dbaf7fb76efd 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js @@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {Lane} from './ReactFiberLane.new'; +import type {TreeContext} from './ReactFiberTreeContext.new'; + import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags'; import {NoFlags, DidCapture} from './ReactFiberFlags'; import { @@ -40,6 +42,7 @@ export type SuspenseState = {| // here to indicate that it is dehydrated (flag) and for quick access // to check things like isSuspenseInstancePending. dehydrated: null | SuspenseInstance, + treeContext: null | TreeContext, // Represents the lane we should attempt to hydrate a dehydrated boundary at. // OffscreenLane is the default for dehydrated boundaries. // NoLane is the default for normal boundaries, which turns into "normal" pri. diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js index 51bef1df3a568..726f0ca52005f 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js @@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {Lane} from './ReactFiberLane.old'; +import type {TreeContext} from './ReactFiberTreeContext.old'; + import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags'; import {NoFlags, DidCapture} from './ReactFiberFlags'; import { @@ -40,6 +42,7 @@ export type SuspenseState = {| // here to indicate that it is dehydrated (flag) and for quick access // to check things like isSuspenseInstancePending. dehydrated: null | SuspenseInstance, + treeContext: null | TreeContext, // Represents the lane we should attempt to hydrate a dehydrated boundary at. // OffscreenLane is the default for dehydrated boundaries. // NoLane is the default for normal boundaries, which turns into "normal" pri. diff --git a/packages/react-reconciler/src/ReactFiberTreeContext.new.js b/packages/react-reconciler/src/ReactFiberTreeContext.new.js index 716ee60e817af..02c5f1ae402ad 100644 --- a/packages/react-reconciler/src/ReactFiberTreeContext.new.js +++ b/packages/react-reconciler/src/ReactFiberTreeContext.new.js @@ -65,6 +65,12 @@ import {getIsHydrating} from './ReactFiberHydrationContext.new'; import {clz32} from './clz32'; import {Forked, NoFlags} from './ReactFiberFlags'; +export type TreeContext = { + id: number, + length: number, + overflow: string, +}; + // TODO: Use the unified fiber stack module instead of this local one? // Intentionally not using it yet to derisk the initial implementation, because // the way we push/pop these values is a bit unusual. If there's a mistake, I'd @@ -225,6 +231,36 @@ export function popTreeContext(workInProgress: Fiber) { } } +export function getSuspendedTreeContext(): TreeContext | null { + warnIfNotHydrating(); + if (treeContextProvider !== null) { + return { + id: treeContextId, + length: treeContextLength, + overflow: treeContextOverflow, + }; + } else { + return null; + } +} + +export function restoreSuspendedTreeContext( + workInProgress: Fiber, + suspendedContext: TreeContext, +) { + warnIfNotHydrating(); + + idStack[idStackIndex++] = treeContextId; + idStack[idStackIndex++] = treeContextLength; + idStack[idStackIndex++] = treeContextOverflow; + idStack[idStackIndex++] = treeContextProvider; + + treeContextId = suspendedContext.id; + treeContextLength = suspendedContext.length; + treeContextOverflow = suspendedContext.overflow; + treeContextProvider = workInProgress; +} + function warnIfNotHydrating() { if (__DEV__) { if (!getIsHydrating()) { diff --git a/packages/react-reconciler/src/ReactFiberTreeContext.old.js b/packages/react-reconciler/src/ReactFiberTreeContext.old.js index 6fdf982cfe199..e71cb8f8f99d1 100644 --- a/packages/react-reconciler/src/ReactFiberTreeContext.old.js +++ b/packages/react-reconciler/src/ReactFiberTreeContext.old.js @@ -65,6 +65,12 @@ import {getIsHydrating} from './ReactFiberHydrationContext.old'; import {clz32} from './clz32'; import {Forked, NoFlags} from './ReactFiberFlags'; +export type TreeContext = { + id: number, + length: number, + overflow: string, +}; + // TODO: Use the unified fiber stack module instead of this local one? // Intentionally not using it yet to derisk the initial implementation, because // the way we push/pop these values is a bit unusual. If there's a mistake, I'd @@ -225,6 +231,36 @@ export function popTreeContext(workInProgress: Fiber) { } } +export function getSuspendedTreeContext(): TreeContext | null { + warnIfNotHydrating(); + if (treeContextProvider !== null) { + return { + id: treeContextId, + length: treeContextLength, + overflow: treeContextOverflow, + }; + } else { + return null; + } +} + +export function restoreSuspendedTreeContext( + workInProgress: Fiber, + suspendedContext: TreeContext, +) { + warnIfNotHydrating(); + + idStack[idStackIndex++] = treeContextId; + idStack[idStackIndex++] = treeContextLength; + idStack[idStackIndex++] = treeContextOverflow; + idStack[idStackIndex++] = treeContextProvider; + + treeContextId = suspendedContext.id; + treeContextLength = suspendedContext.length; + treeContextOverflow = suspendedContext.overflow; + treeContextProvider = workInProgress; +} + function warnIfNotHydrating() { if (__DEV__) { if (!getIsHydrating()) {