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(`
+
+ `);
+
+ // 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()) {