diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js
index 895c2f9b0dae7..0654e8c6e6851 100644
--- a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js
@@ -7,7 +7,6 @@
* @emails react-core
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
*/
-
let JSDOM;
let React;
let ReactDOMClient;
@@ -24,6 +23,8 @@ let buffer = '';
let hasErrored = false;
let fatalError = undefined;
let waitForPaint;
+let SuspenseList;
+let assertConsoleErrorDev;
describe('useId', () => {
beforeEach(() => {
@@ -32,11 +33,16 @@ describe('useId', () => {
React = require('react');
ReactDOMClient = require('react-dom/client');
clientAct = require('internal-test-utils').act;
+ assertConsoleErrorDev =
+ require('internal-test-utils').assertConsoleErrorDev;
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');
Suspense = React.Suspense;
useId = React.useId;
useState = React.useState;
+ if (gate(flags => flags.enableSuspenseList)) {
+ SuspenseList = React.unstable_SuspenseList;
+ }
const InternalTestUtils = require('internal-test-utils');
waitForPaint = InternalTestUtils.waitForPaint;
@@ -375,6 +381,370 @@ describe('useId', () => {
`);
});
+ // @gate enableSuspenseList
+ it('Supports SuspenseList (reveal order independent)', async () => {
+ function Baz({id, children}) {
+ return {children};
+ }
+
+ function Bar({children}) {
+ const id = useId();
+ return {children};
+ }
+
+ function Foo() {
+ return (
+
+ A
+ B
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+ B
+
+
+ `);
+
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+ B
+
+
+ `);
+ });
+
+ // @gate enableSuspenseList
+ it('Supports SuspenseList (reveal order "together")', async () => {
+ function Baz({id, children}) {
+ return {children};
+ }
+
+ function Bar({children}) {
+ const id = useId();
+ return {children};
+ }
+
+ function Foo() {
+ return (
+
+ A
+ B
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+ B
+
+
+ `);
+
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+ B
+
+
+ `);
+ });
+
+ // @gate enableSuspenseList
+ it('Supports SuspenseList (reveal order "forwards")', async () => {
+ function Baz({id, children}) {
+ return {children};
+ }
+
+ function Bar({children}) {
+ const id = useId();
+ return {children};
+ }
+
+ function Foo() {
+ return (
+
+ A
+ B
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+ B
+
+
+ `);
+
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+ B
+
+
+ `);
+ });
+
+ // @gate enableSuspenseList
+ it('Supports SuspenseList (reveal order "backwards") with a single child in a list of many', async () => {
+ function Baz({id, children}) {
+ return {children};
+ }
+
+ function Bar({children}) {
+ const id = useId();
+ return {children};
+ }
+
+ function Foo() {
+ return (
+
+ {null}
+ A
+ {null}
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+
+ `);
+
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+
+ `);
+ });
+
+ // @gate enableSuspenseList
+ it('Supports SuspenseList (reveal order "backwards")', async () => {
+ function Baz({id, children}) {
+ return {children};
+ }
+
+ function Bar({children}) {
+ const id = useId();
+ return {children};
+ }
+
+ function Foo() {
+ return (
+
+ A
+ B
+
+ );
+ }
+
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+ B
+
+
+ `);
+
+ if (gate(flags => flags.favorSafetyOverHydrationPerf)) {
+ // TODO: This is a bug with revealOrder="backwards" in that it hydrates in reverse.
+ await expect(async () => {
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+ }).rejects.toThrowError(
+ `Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client.`,
+ );
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+ B
+
+
+ `);
+ } else {
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+
+ // TODO: This is a bug with revealOrder="backwards" in that it hydrates in reverse.
+ assertConsoleErrorDev([
+ `A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
+
+- A server/client branch \`if (typeof window !== 'undefined')\`.
+- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
+- Date formatting in a user's locale which doesn't match the server.
+- External changing data without sending a snapshot of it along with the HTML.
+- Invalid HTML tag nesting.
+
+It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
+
+https://react.dev/link/hydration-mismatch
+
+
+
+
+
+
+
++ B
+- A
+`,
+ ]);
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+ A
+
+
+ B
+
+
+ `);
+ }
+ });
+
it('basic incremental hydration', async () => {
function App() {
return (
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index a9316163156f5..2b9e4e7fdb9b0 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -3342,6 +3342,7 @@ function initSuspenseListRenderState(
tail: null | Fiber,
lastContentRow: null | Fiber,
tailMode: SuspenseListTailMode,
+ treeForkCount: number,
): void {
const renderState: null | SuspenseListRenderState =
workInProgress.memoizedState;
@@ -3353,6 +3354,7 @@ function initSuspenseListRenderState(
last: lastContentRow,
tail: tail,
tailMode: tailMode,
+ treeForkCount: treeForkCount,
}: SuspenseListRenderState);
} else {
// We can reuse the existing object from previous renders.
@@ -3362,6 +3364,7 @@ function initSuspenseListRenderState(
renderState.last = lastContentRow;
renderState.tail = tail;
renderState.tailMode = tailMode;
+ renderState.treeForkCount = treeForkCount;
}
}
@@ -3404,6 +3407,8 @@ function updateSuspenseListComponent(
validateSuspenseListChildren(newChildren, revealOrder);
reconcileChildren(current, workInProgress, newChildren, renderLanes);
+ // Read how many children forks this set pushed so we can push it every time we retry.
+ const treeForkCount = getIsHydrating() ? getForksAtLevel(workInProgress) : 0;
if (!shouldForceFallback) {
const didSuspendBefore =
@@ -3446,6 +3451,7 @@ function updateSuspenseListComponent(
tail,
lastContentRow,
tailMode,
+ treeForkCount,
);
break;
}
@@ -3478,6 +3484,7 @@ function updateSuspenseListComponent(
tail,
null, // last
tailMode,
+ treeForkCount,
);
break;
}
@@ -3488,6 +3495,7 @@ function updateSuspenseListComponent(
null, // tail
null, // last
undefined,
+ treeForkCount,
);
break;
}
diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js
index d62e3c3fea11d..a415e6217ab8a 100644
--- a/packages/react-reconciler/src/ReactFiberCompleteWork.js
+++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js
@@ -184,7 +184,7 @@ import {resetChildFibers} from './ReactChildFiber';
import {createScopeInstance} from './ReactFiberScope';
import {transferActualDuration} from './ReactProfilerTimer';
import {popCacheProvider} from './ReactFiberCacheComponent';
-import {popTreeContext} from './ReactFiberTreeContext';
+import {popTreeContext, pushTreeFork} from './ReactFiberTreeContext';
import {popRootTransition, popTransition} from './ReactFiberTransition';
import {
popMarkerInstance,
@@ -1764,6 +1764,10 @@ function completeWork(
ForceSuspenseFallback,
),
);
+ if (getIsHydrating()) {
+ // Re-apply tree fork since we popped the tree fork context in the beginning of this function.
+ pushTreeFork(workInProgress, renderState.treeForkCount);
+ }
// Don't bubble properties in this case.
return workInProgress.child;
}
@@ -1890,6 +1894,10 @@ function completeWork(
}
pushSuspenseListContext(workInProgress, suspenseContext);
// Do a pass over the next row.
+ if (getIsHydrating()) {
+ // Re-apply tree fork since we popped the tree fork context in the beginning of this function.
+ pushTreeFork(workInProgress, renderState.treeForkCount);
+ }
// Don't bubble properties in this case.
return next;
}
diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js
index 2542d660c4c47..64ab4f29fcd14 100644
--- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js
@@ -54,6 +54,8 @@ export type SuspenseListRenderState = {
tail: null | Fiber,
// Tail insertions setting.
tailMode: SuspenseListTailMode,
+ // Keep track of total number of forks during multiple passes
+ treeForkCount: number,
};
export type RetryQueue = Set;