Skip to content

Commit 31eefc6

Browse files
committed
Incremental hydration
Stores the tree context on the dehydrated Suspense boundary's state object so it resume where it left off.
1 parent fa86075 commit 31eefc6

9 files changed

+242
-0
lines changed

packages/react-dom/src/__tests__/ReactDOMUseId-test.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
let JSDOM;
1111
let React;
1212
let ReactDOM;
13+
let Scheduler;
1314
let clientAct;
1415
let ReactDOMFizzServer;
1516
let Stream;
17+
let Suspense;
1618
let useId;
1719
let document;
1820
let writable;
@@ -27,9 +29,11 @@ describe('useId', () => {
2729
JSDOM = require('jsdom').JSDOM;
2830
React = require('react');
2931
ReactDOM = require('react-dom');
32+
Scheduler = require('scheduler');
3033
clientAct = require('jest-react').act;
3134
ReactDOMFizzServer = require('react-dom/server');
3235
Stream = require('stream');
36+
Suspense = React.Suspense;
3337
useId = React.unstable_useId;
3438

3539
// Test Environment
@@ -86,6 +90,11 @@ describe('useId', () => {
8690
}
8791
}
8892

93+
function Text({text}) {
94+
Scheduler.unstable_yieldValue(text);
95+
return text;
96+
}
97+
8998
function normalizeTreeIdForTesting(id) {
9099
const [serverClientPrefix, base32, hookIndex] = id.split(':');
91100
if (serverClientPrefix === 'r') {
@@ -308,4 +317,141 @@ describe('useId', () => {
308317
</div>
309318
`);
310319
});
320+
321+
test('basic incremental hydration', async () => {
322+
function App() {
323+
return (
324+
<div>
325+
<Suspense fallback="Loading...">
326+
<DivWithId label="A" />
327+
<DivWithId label="B" />
328+
</Suspense>
329+
<DivWithId label="C" />
330+
</div>
331+
);
332+
}
333+
334+
await serverAct(async () => {
335+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
336+
pipe(writable);
337+
});
338+
await clientAct(async () => {
339+
ReactDOM.hydrateRoot(container, <App />);
340+
});
341+
expect(container).toMatchInlineSnapshot(`
342+
<div
343+
id="container"
344+
>
345+
<div>
346+
<!--$-->
347+
<div
348+
id="101"
349+
/>
350+
<div
351+
id="1001"
352+
/>
353+
<!--/$-->
354+
<div
355+
id="10"
356+
/>
357+
</div>
358+
</div>
359+
`);
360+
});
361+
362+
test('inserting a sibling before a dehydrated Suspense boundary', async () => {
363+
const span = React.createRef(null);
364+
function App({showMore}) {
365+
// Note: Using a dynamic array so this is treated as an insertion instead
366+
// of an update, because Fiber currently allocates a node even for
367+
// empty children.
368+
const children = [<Text key="A" text="A" />];
369+
if (showMore) {
370+
// These are client-only nodes. They aren't not included in the initial
371+
// server render.
372+
children.push(<Text key="B" text="B" />, <DivWithId key="C" />);
373+
}
374+
children.push(
375+
<Suspense key="boundary" fallback="Loading...">
376+
<DivWithId />
377+
<DivWithId />
378+
<span ref={span} />
379+
</Suspense>,
380+
<DivWithId key="after" />,
381+
);
382+
383+
return children;
384+
}
385+
386+
await serverAct(async () => {
387+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
388+
pipe(writable);
389+
});
390+
expect(Scheduler).toHaveYielded(['A']);
391+
const dehydratedSpan = container.getElementsByTagName('span')[0];
392+
await clientAct(async () => {
393+
const root = ReactDOM.hydrateRoot(container, <App />);
394+
expect(Scheduler).toFlushUntilNextPaint(['A']);
395+
expect(container).toMatchInlineSnapshot(`
396+
<div
397+
id="container"
398+
>
399+
A
400+
<!-- -->
401+
<!--$-->
402+
<div
403+
id="110"
404+
/>
405+
<div
406+
id="1010"
407+
/>
408+
<span />
409+
<!--/$-->
410+
<div
411+
id="11"
412+
/>
413+
</div>
414+
`);
415+
416+
// The inner boundary hasn't hydrated yet
417+
expect(span.current).toBe(null);
418+
419+
// Insert another sibling before the Suspense boundary
420+
root.render(<App showMore={true} />);
421+
});
422+
expect(Scheduler).toHaveYielded([
423+
'A',
424+
'B',
425+
// The update triggers selective hydration so we render again
426+
'A',
427+
'B',
428+
]);
429+
// The insertions should not cause a mismatch.
430+
expect(container).toMatchInlineSnapshot(`
431+
<div
432+
id="container"
433+
>
434+
A
435+
<!-- -->
436+
<!--$-->
437+
B
438+
<div
439+
id="CLIENT_GENERATED_ID"
440+
/>
441+
<div
442+
id="110"
443+
/>
444+
<div
445+
id="1010"
446+
/>
447+
<span />
448+
<!--/$-->
449+
<div
450+
id="11"
451+
/>
452+
</div>
453+
`);
454+
// Should have hydrated successfully
455+
expect(span.current).toBe(dehydratedSpan);
456+
});
311457
});

packages/react-reconciler/src/ReactFiberBeginWork.new.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
18521852

18531853
const SUSPENDED_MARKER: SuspenseState = {
18541854
dehydrated: null,
1855+
treeContext: null,
18551856
retryLane: NoLane,
18561857
};
18571858

@@ -2700,6 +2701,7 @@ function updateDehydratedSuspenseComponent(
27002701
reenterHydrationStateFromDehydratedSuspenseInstance(
27012702
workInProgress,
27022703
suspenseInstance,
2704+
suspenseState.treeContext,
27032705
);
27042706
const nextProps = workInProgress.pendingProps;
27052707
const primaryChildren = nextProps.children;

packages/react-reconciler/src/ReactFiberBeginWork.old.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
18521852

18531853
const SUSPENDED_MARKER: SuspenseState = {
18541854
dehydrated: null,
1855+
treeContext: null,
18551856
retryLane: NoLane,
18561857
};
18571858

@@ -2700,6 +2701,7 @@ function updateDehydratedSuspenseComponent(
27002701
reenterHydrationStateFromDehydratedSuspenseInstance(
27012702
workInProgress,
27022703
suspenseInstance,
2704+
suspenseState.treeContext,
27032705
);
27042706
const nextProps = workInProgress.pendingProps;
27052707
const primaryChildren = nextProps.children;

packages/react-reconciler/src/ReactFiberHydrationContext.new.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
HostContext,
1818
} from './ReactFiberHostConfig';
1919
import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
20+
import type {TreeContext} from './ReactFiberTreeContext.new';
2021

2122
import {
2223
HostComponent,
@@ -62,6 +63,10 @@ import {
6263
} from './ReactFiberHostConfig';
6364
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
6465
import {OffscreenLane} from './ReactFiberLane.new';
66+
import {
67+
getSuspendedTreeContext,
68+
restoreSuspendedTreeContext,
69+
} from './ReactFiberTreeContext.new';
6570

6671
// The deepest Fiber on the stack involved in a hydration context.
6772
// This may have been an insertion or a hydration.
@@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean {
96101
function reenterHydrationStateFromDehydratedSuspenseInstance(
97102
fiber: Fiber,
98103
suspenseInstance: SuspenseInstance,
104+
treeContext: TreeContext | null,
99105
): boolean {
100106
if (!supportsHydration) {
101107
return false;
@@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
105111
);
106112
hydrationParentFiber = fiber;
107113
isHydrating = true;
114+
if (treeContext !== null) {
115+
restoreSuspendedTreeContext(fiber, treeContext);
116+
}
108117
return true;
109118
}
110119

@@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) {
287296
if (suspenseInstance !== null) {
288297
const suspenseState: SuspenseState = {
289298
dehydrated: suspenseInstance,
299+
treeContext: getSuspendedTreeContext(),
290300
retryLane: OffscreenLane,
291301
};
292302
fiber.memoizedState = suspenseState;

packages/react-reconciler/src/ReactFiberHydrationContext.old.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
HostContext,
1818
} from './ReactFiberHostConfig';
1919
import type {SuspenseState} from './ReactFiberSuspenseComponent.old';
20+
import type {TreeContext} from './ReactFiberTreeContext.old';
2021

2122
import {
2223
HostComponent,
@@ -62,6 +63,10 @@ import {
6263
} from './ReactFiberHostConfig';
6364
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
6465
import {OffscreenLane} from './ReactFiberLane.old';
66+
import {
67+
getSuspendedTreeContext,
68+
restoreSuspendedTreeContext,
69+
} from './ReactFiberTreeContext.old';
6570

6671
// The deepest Fiber on the stack involved in a hydration context.
6772
// This may have been an insertion or a hydration.
@@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean {
96101
function reenterHydrationStateFromDehydratedSuspenseInstance(
97102
fiber: Fiber,
98103
suspenseInstance: SuspenseInstance,
104+
treeContext: TreeContext | null,
99105
): boolean {
100106
if (!supportsHydration) {
101107
return false;
@@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
105111
);
106112
hydrationParentFiber = fiber;
107113
isHydrating = true;
114+
if (treeContext !== null) {
115+
restoreSuspendedTreeContext(fiber, treeContext);
116+
}
108117
return true;
109118
}
110119

@@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) {
287296
if (suspenseInstance !== null) {
288297
const suspenseState: SuspenseState = {
289298
dehydrated: suspenseInstance,
299+
treeContext: getSuspendedTreeContext(),
290300
retryLane: OffscreenLane,
291301
};
292302
fiber.memoizedState = suspenseState;

packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes';
1111
import type {Fiber} from './ReactInternalTypes';
1212
import type {SuspenseInstance} from './ReactFiberHostConfig';
1313
import type {Lane} from './ReactFiberLane.new';
14+
import type {TreeContext} from './ReactFiberTreeContext.new';
15+
1416
import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags';
1517
import {NoFlags, DidCapture} from './ReactFiberFlags';
1618
import {
@@ -40,6 +42,7 @@ export type SuspenseState = {|
4042
// here to indicate that it is dehydrated (flag) and for quick access
4143
// to check things like isSuspenseInstancePending.
4244
dehydrated: null | SuspenseInstance,
45+
treeContext: null | TreeContext,
4346
// Represents the lane we should attempt to hydrate a dehydrated boundary at.
4447
// OffscreenLane is the default for dehydrated boundaries.
4548
// NoLane is the default for normal boundaries, which turns into "normal" pri.

packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes';
1111
import type {Fiber} from './ReactInternalTypes';
1212
import type {SuspenseInstance} from './ReactFiberHostConfig';
1313
import type {Lane} from './ReactFiberLane.old';
14+
import type {TreeContext} from './ReactFiberTreeContext.old';
15+
1416
import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags';
1517
import {NoFlags, DidCapture} from './ReactFiberFlags';
1618
import {
@@ -40,6 +42,7 @@ export type SuspenseState = {|
4042
// here to indicate that it is dehydrated (flag) and for quick access
4143
// to check things like isSuspenseInstancePending.
4244
dehydrated: null | SuspenseInstance,
45+
treeContext: null | TreeContext,
4346
// Represents the lane we should attempt to hydrate a dehydrated boundary at.
4447
// OffscreenLane is the default for dehydrated boundaries.
4548
// NoLane is the default for normal boundaries, which turns into "normal" pri.

packages/react-reconciler/src/ReactFiberTreeContext.new.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ import {getIsHydrating} from './ReactFiberHydrationContext.new';
6363
import {clz32} from './clz32';
6464
import {Forked, NoFlags} from './ReactFiberFlags';
6565

66+
export type TreeContext = {
67+
id: number,
68+
length: number,
69+
overflow: string,
70+
};
71+
6672
// TODO: Use the unified fiber stack module instead of this local one?
6773
// Intentionally not using it yet to derisk the initial implementation, because
6874
// the way we push/pop these values is a bit unusual. If there's a mistake, I'd
@@ -228,6 +234,33 @@ export function popTreeContext(workInProgress: Fiber) {
228234
}
229235
}
230236

237+
export function getSuspendedTreeContext(): TreeContext | null {
238+
warnIfNotHydrating();
239+
if (treeContextProvider !== null) {
240+
return {
241+
id: treeContextId,
242+
overflow: treeContextOverflow,
243+
};
244+
} else {
245+
return null;
246+
}
247+
}
248+
249+
export function restoreSuspendedTreeContext(
250+
workInProgress: Fiber,
251+
suspendedContext: TreeContext,
252+
) {
253+
warnIfNotHydrating();
254+
255+
idStack[idStackIndex++] = treeContextId;
256+
idStack[idStackIndex++] = treeContextOverflow;
257+
idStack[idStackIndex++] = treeContextProvider;
258+
259+
treeContextId = suspendedContext.id;
260+
treeContextOverflow = suspendedContext.overflow;
261+
treeContextProvider = workInProgress;
262+
}
263+
231264
function warnIfNotHydrating() {
232265
if (__DEV__) {
233266
if (!getIsHydrating()) {

0 commit comments

Comments
 (0)