diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 32745e4b109..93a16411d9f 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -9478,4 +9478,159 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue , ); }); + + it('useId is consistent for siblings when component suspends with nested lazy', async () => { + // Inner component uses useId + function InnerComponent() { + const id = React.useId(); + Scheduler.log('InnerComponent id: ' + id); + return inner; + } + + // Outer component uses useId and renders a lazy inner + function OuterComponent({innerElement}) { + const id = React.useId(); + Scheduler.log('OuterComponent id: ' + id); + return
{innerElement}
; + } + + // This sibling also has useId - its ID must be consistent with server + function Sibling() { + const id = React.useId(); + Scheduler.log('Sibling id: ' + id); + return sibling; + } + + // Create fresh lazy components for SERVER (resolve immediately) + const serverLazyInner = React.lazy(async () => { + Scheduler.log('server lazy inner initializer'); + return {default: }; + }); + + const serverLazyOuter = React.lazy(async () => { + Scheduler.log('server lazy outer initializer'); + return { + default: , + }; + }); + + // Server render with lazy (resolves immediately) + await act(() => { + const {pipe} = renderToPipeableStream( + + + <>{serverLazyOuter} + <> + + + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + +
+ inner +
+ sibling + + , + ); + + assertLog([ + 'server lazy outer initializer', + 'Sibling id: _R_2_', + 'OuterComponent id: _R_1_', + 'server lazy inner initializer', + 'InnerComponent id: _R_5_', + ]); + + // Create fresh lazy components for CLIENT + let resolveClientInner; + const clientLazyInner = React.lazy(() => { + Scheduler.log('client lazy inner initializer'); + const payload = {default: }; + const promise = new Promise(r => { + resolveClientInner = () => { + promise.status = 'fulfilled'; + promise.value = payload; + r(payload); + }; + }); + return promise; + }); + + let resolveClientOuter; + const clientLazyOuter = React.lazy(() => { + Scheduler.log('client lazy outer initializer'); + const payload = { + default: , + }; + const promise = new Promise(r => { + resolveClientOuter = () => { + promise.status = 'fulfilled'; + promise.value = payload; + r(payload); + }; + }); + return promise; + }); + + const hydrationErrors = []; + + // Client hydrates with nested lazy components + let root; + React.startTransition(() => { + root = ReactDOMClient.hydrateRoot( + document, + + + <>{clientLazyOuter} + <> + + + + , + { + onRecoverableError(error) { + hydrationErrors.push(error.message); + }, + }, + ); + }); + + // First suspension on outer lazy + await waitFor(['client lazy outer initializer']); + resolveClientOuter(); + + // Second suspension on inner lazy + await waitFor([ + 'OuterComponent id: _R_1_', + 'client lazy inner initializer', + ]); + resolveClientInner(); + + await waitForAll(['InnerComponent id: _R_5_', 'Sibling id: _R_2_']); + + // The IDs should match the server-generated IDs + expect(hydrationErrors).toEqual([]); + + expect(getVisibleChildren(document)).toEqual( + + + +
+ inner +
+ sibling + + , + ); + + root.unmount(); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index cc43edc66b6..7fa1dcb8173 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -62,13 +62,13 @@ export const ShouldCapture = /* */ 0b0000000000000010000000000000 export const ForceUpdateForLegacySuspense = /* */ 0b0000000000000100000000000000000; export const DidPropagateContext = /* */ 0b0000000000001000000000000000000; export const NeedsPropagation = /* */ 0b0000000000010000000000000000000; -export const Forked = /* */ 0b0000000000100000000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. +export const Forked = /* */ 0b0000000000100000000000000000000; export const SnapshotStatic = /* */ 0b0000000001000000000000000000000; export const LayoutStatic = /* */ 0b0000000010000000000000000000000; export const RefStatic = LayoutStatic; @@ -142,4 +142,5 @@ export const StaticMask = MaySuspendCommit | ViewTransitionStatic | ViewTransitionNamedStatic | - PortalStatic; + PortalStatic | + Forked;