Skip to content

Commit 78df833

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 fe3a32d commit 78df833

9 files changed

+235
-0
lines changed

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

Lines changed: 131 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') {
@@ -282,4 +291,126 @@ describe('useId', () => {
282291
expect(divs[i].id).toMatch(/^R:.(((al)*a?)((la)*l?))*$/);
283292
}
284293
});
294+
295+
test('basic incremental hydration', async () => {
296+
function App() {
297+
return (
298+
<div>
299+
<Suspense fallback="Loading...">
300+
<DivWithId label="A" />
301+
<DivWithId label="B" />
302+
</Suspense>
303+
<DivWithId label="C" />
304+
</div>
305+
);
306+
}
307+
308+
await serverAct(async () => {
309+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
310+
pipe(writable);
311+
});
312+
await clientAct(async () => {
313+
ReactDOM.hydrateRoot(container, <App />);
314+
});
315+
expect(container).toMatchInlineSnapshot(`
316+
<div
317+
id="container"
318+
>
319+
<div>
320+
<!--$-->
321+
<div
322+
id="101"
323+
/>
324+
<div
325+
id="1001"
326+
/>
327+
<!--/$-->
328+
<div
329+
id="10"
330+
/>
331+
</div>
332+
</div>
333+
`);
334+
});
335+
336+
test('inserting a sibling before a dehydrated Suspense boundary', async () => {
337+
function App({showMore}) {
338+
const siblings = showMore
339+
? [<Text key="A" text="A" />, <Text key="B" text="B" />]
340+
: [<Text key="A" text="A" />];
341+
342+
return (
343+
<div>
344+
{siblings}
345+
<Suspense fallback="Loading...">
346+
<DivWithId label="A" />
347+
<DivWithId label="B" />
348+
</Suspense>
349+
<DivWithId label="C" />
350+
</div>
351+
);
352+
}
353+
354+
await serverAct(async () => {
355+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
356+
pipe(writable);
357+
});
358+
expect(Scheduler).toHaveYielded(['A']);
359+
await clientAct(async () => {
360+
const root = ReactDOM.hydrateRoot(container, <App />);
361+
expect(Scheduler).toFlushUntilNextPaint(['A']);
362+
expect(container).toMatchInlineSnapshot(`
363+
<div
364+
id="container"
365+
>
366+
<div>
367+
A
368+
<!-- -->
369+
<!--$-->
370+
<div
371+
id="110"
372+
/>
373+
<div
374+
id="1010"
375+
/>
376+
<!--/$-->
377+
<div
378+
id="11"
379+
/>
380+
</div>
381+
</div>
382+
`);
383+
// Insert another sibling before the Suspense boundary
384+
root.render(<App showMore={true} />);
385+
});
386+
expect(Scheduler).toHaveYielded([
387+
'A',
388+
'B',
389+
// The update triggers selective hydration so we render again
390+
'A',
391+
'B',
392+
]);
393+
expect(container).toMatchInlineSnapshot(`
394+
<div
395+
id="container"
396+
>
397+
<div>
398+
A
399+
<!-- -->
400+
<!--$-->
401+
B
402+
<div
403+
id="110"
404+
/>
405+
<div
406+
id="1010"
407+
/>
408+
<!--/$-->
409+
<div
410+
id="11"
411+
/>
412+
</div>
413+
</div>
414+
`);
415+
});
285416
});

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

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

18871887
const SUSPENDED_MARKER: SuspenseState = {
18881888
dehydrated: null,
1889+
treeContext: null,
18891890
retryLane: NoLane,
18901891
};
18911892

@@ -2734,6 +2735,7 @@ function updateDehydratedSuspenseComponent(
27342735
reenterHydrationStateFromDehydratedSuspenseInstance(
27352736
workInProgress,
27362737
suspenseInstance,
2738+
suspenseState.treeContext,
27372739
);
27382740
const nextProps = workInProgress.pendingProps;
27392741
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
@@ -1886,6 +1886,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
18861886

18871887
const SUSPENDED_MARKER: SuspenseState = {
18881888
dehydrated: null,
1889+
treeContext: null,
18891890
retryLane: NoLane,
18901891
};
18911892

@@ -2734,6 +2735,7 @@ function updateDehydratedSuspenseComponent(
27342735
reenterHydrationStateFromDehydratedSuspenseInstance(
27352736
workInProgress,
27362737
suspenseInstance,
2738+
suspenseState.treeContext,
27372739
);
27382740
const nextProps = workInProgress.pendingProps;
27392741
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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ import {getIsHydrating} from './ReactFiberHydrationContext.new';
6767
import {clz32} from './clz32';
6868
import {Forked, NoFlags} from './ReactFiberFlags';
6969

70+
export type TreeContext = {
71+
id: number,
72+
length: number,
73+
overflow: string,
74+
};
75+
7076
let treeContextId: number = 0;
7177
let treeContextLength: number = 0;
7278
let treeContextOverflow: string = '';
@@ -199,6 +205,37 @@ export function popTreeContext(workInProgress: Fiber) {
199205
}
200206
}
201207

208+
export function getSuspendedTreeContext(): TreeContext | null {
209+
warnIfNotHydrating();
210+
211+
if (treeIdProvider !== null) {
212+
return {
213+
id: treeContextId,
214+
length: treeContextLength,
215+
overflow: treeContextOverflow,
216+
};
217+
} else {
218+
return null;
219+
}
220+
}
221+
222+
export function restoreSuspendedTreeContext(
223+
workInProgress: Fiber,
224+
suspendedContext: TreeContext,
225+
) {
226+
warnIfNotHydrating();
227+
228+
stack[stackIndex++] = treeContextId;
229+
stack[stackIndex++] = treeContextLength;
230+
stack[stackIndex++] = treeContextOverflow;
231+
stack[stackIndex++] = treeIdProvider;
232+
233+
treeContextId = suspendedContext.id;
234+
treeContextLength = suspendedContext.length;
235+
treeContextOverflow = suspendedContext.overflow;
236+
treeIdProvider = workInProgress;
237+
}
238+
202239
function warnIfNotHydrating() {
203240
if (__DEV__) {
204241
if (!getIsHydrating()) {

0 commit comments

Comments
 (0)