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;