Skip to content

Commit

Permalink
Incremental hydration
Browse files Browse the repository at this point in the history
Stores the tree context on the dehydrated Suspense boundary's state
object so it resume where it left off.
  • Loading branch information
acdlite committed Oct 30, 2021
1 parent 3a8c5be commit 5659f33
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 0 deletions.
146 changes: 146 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMUseId-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -282,4 +291,141 @@ describe('useId', () => {
expect(divs[i].id).toMatch(/^R:.(((al)*a?)((la)*l?))*$/);
}
});

test('basic incremental hydration', async () => {
function App() {
return (
<div>
<Suspense fallback="Loading...">
<DivWithId label="A" />
<DivWithId label="B" />
</Suspense>
<DivWithId label="C" />
</div>
);
}

await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
await clientAct(async () => {
ReactDOM.hydrateRoot(container, <App />);
});
expect(container).toMatchInlineSnapshot(`
<div
id="container"
>
<div>
<!--$-->
<div
id="101"
/>
<div
id="1001"
/>
<!--/$-->
<div
id="10"
/>
</div>
</div>
`);
});

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 = [<Text key="A" text="A" />];
if (showMore) {
// These are client-only nodes. They aren't not included in the initial
// server render.
children.push(<Text key="B" text="B" />, <DivWithId key="C" />);
}
children.push(
<Suspense key="boundary" fallback="Loading...">
<DivWithId />
<DivWithId />
<span ref={span} />
</Suspense>,
<DivWithId key="after" />,
);

return children;
}

await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(Scheduler).toHaveYielded(['A']);
const dehydratedSpan = container.getElementsByTagName('span')[0];
await clientAct(async () => {
const root = ReactDOM.hydrateRoot(container, <App />);
expect(Scheduler).toFlushUntilNextPaint(['A']);
expect(container).toMatchInlineSnapshot(`
<div
id="container"
>
A
<!-- -->
<!--$-->
<div
id="110"
/>
<div
id="1010"
/>
<span />
<!--/$-->
<div
id="11"
/>
</div>
`);

// The inner boundary hasn't hydrated yet
expect(span.current).toBe(null);

// Insert another sibling before the Suspense boundary
root.render(<App showMore={true} />);
});
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(`
<div
id="container"
>
A
<!-- -->
<!--$-->
B
<div
id="CLIENT_GENERATED_ID"
/>
<div
id="110"
/>
<div
id="1010"
/>
<span />
<!--/$-->
<div
id="11"
/>
</div>
`);
// Should have hydrated successfully
expect(span.current).toBe(dehydratedSpan);
});
});
2 changes: 2 additions & 0 deletions packages/react-reconciler/src/ReactFiberBeginWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -1852,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {

const SUSPENDED_MARKER: SuspenseState = {
dehydrated: null,
treeContext: null,
retryLane: NoLane,
};

Expand Down Expand Up @@ -2700,6 +2701,7 @@ function updateDehydratedSuspenseComponent(
reenterHydrationStateFromDehydratedSuspenseInstance(
workInProgress,
suspenseInstance,
suspenseState.treeContext,
);
const nextProps = workInProgress.pendingProps;
const primaryChildren = nextProps.children;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-reconciler/src/ReactFiberBeginWork.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -1852,6 +1852,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {

const SUSPENDED_MARKER: SuspenseState = {
dehydrated: null,
treeContext: null,
retryLane: NoLane,
};

Expand Down Expand Up @@ -2700,6 +2701,7 @@ function updateDehydratedSuspenseComponent(
reenterHydrationStateFromDehydratedSuspenseInstance(
workInProgress,
suspenseInstance,
suspenseState.treeContext,
);
const nextProps = workInProgress.pendingProps;
const primaryChildren = nextProps.children;
Expand Down
10 changes: 10 additions & 0 deletions packages/react-reconciler/src/ReactFiberHydrationContext.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
HostContext,
} from './ReactFiberHostConfig';
import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
import type {TreeContext} from './ReactFiberTreeContext.new';

import {
HostComponent,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean {
function reenterHydrationStateFromDehydratedSuspenseInstance(
fiber: Fiber,
suspenseInstance: SuspenseInstance,
treeContext: TreeContext | null,
): boolean {
if (!supportsHydration) {
return false;
Expand All @@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
);
hydrationParentFiber = fiber;
isHydrating = true;
if (treeContext !== null) {
restoreSuspendedTreeContext(fiber, treeContext);
}
return true;
}

Expand Down Expand Up @@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) {
if (suspenseInstance !== null) {
const suspenseState: SuspenseState = {
dehydrated: suspenseInstance,
treeContext: getSuspendedTreeContext(),
retryLane: OffscreenLane,
};
fiber.memoizedState = suspenseState;
Expand Down
10 changes: 10 additions & 0 deletions packages/react-reconciler/src/ReactFiberHydrationContext.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
HostContext,
} from './ReactFiberHostConfig';
import type {SuspenseState} from './ReactFiberSuspenseComponent.old';
import type {TreeContext} from './ReactFiberTreeContext.old';

import {
HostComponent,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean {
function reenterHydrationStateFromDehydratedSuspenseInstance(
fiber: Fiber,
suspenseInstance: SuspenseInstance,
treeContext: TreeContext | null,
): boolean {
if (!supportsHydration) {
return false;
Expand All @@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
);
hydrationParentFiber = fiber;
isHydrating = true;
if (treeContext !== null) {
restoreSuspendedTreeContext(fiber, treeContext);
}
return true;
}

Expand Down Expand Up @@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) {
if (suspenseInstance !== null) {
const suspenseState: SuspenseState = {
dehydrated: suspenseInstance,
treeContext: getSuspendedTreeContext(),
retryLane: OffscreenLane,
};
fiber.memoizedState = suspenseState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions packages/react-reconciler/src/ReactFiberTreeContext.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down
Loading

0 comments on commit 5659f33

Please sign in to comment.