Repository: facebook/react
Pull request: [Fiber] fix useId tracking on replay

When Fiber replays work after suspending and resolving in a microtask it stripped the Forked flag from Fibers because this flag type was not considered a Static flag. The Forked nature of a Fiber is not render dependent and should persist after unwinding work. By making this change the replay correctly generates the necessary tree context.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -9478,4 +9478,159 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
       </div>,
     );
   });
+
+  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 <span id={id}>inner</span>;
+    }
+
+    // Outer component uses useId and renders a lazy inner
+    function OuterComponent({innerElement}) {
+      const id = React.useId();
+      Scheduler.log('OuterComponent id: ' + id);
+      return <div id={id}>{innerElement}</div>;
+    }
+
+    // This sibling also has useId - its ID must be consistent with server
+    function Sibling() {
+      const id = React.useId();
+      Scheduler.log('Sibling id: ' + id);
+      return <span id={id}>sibling</span>;
+    }
+
+    // Create fresh lazy components for SERVER (resolve immediately)
+    const serverLazyInner = React.lazy(async () => {
+      Scheduler.log('server lazy inner initializer');
+      return {default: <InnerComponent />};
+    });
+
+    const serverLazyOuter = React.lazy(async () => {
+      Scheduler.log('server lazy outer initializer');
+      return {
+        default: <OuterComponent key="outer" innerElement={serverLazyInner} />,
+      };
+    });
+
+    // Server render with lazy (resolves immediately)
+    await act(() => {
+      const {pipe} = renderToPipeableStream(
+        <html>
+          <body>
+            <>{serverLazyOuter}</>
+            <>
+              <Sibling />
+            </>
+          </body>
+        </html>,
+      );
+      pipe(writable);
+    });
+
+    expect(getVisibleChildren(document)).toEqual(
+      <html>
+        <head />
+        <body>
+          <div id="_R_1_">
+            <span id="_R_5_">inner</span>
+          </div>
+          <span id="_R_2_">sibling</span>
+        </body>
+      </html>,
+    );
+
+    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: <InnerComponent />};
+      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: <OuterComponent innerElement={clientLazyInner} />,
+      };
+      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,
+        <html>
+          <body>
+            <>{clientLazyOuter}</>
+            <>
+              <Sibling />
+            </>
+          </body>
+        </html>,
+        {
+          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(
+      <html>
+        <head />
+        <body>
+          <div id="_R_1_">
+            <span id="_R_5_">inner</span>
+          </div>
+          <span id="_R_2_">sibling</span>
+        </body>
+      </html>,
+    );
+
+    root.unmount();
+  });
 });

filename: packages/react-reconciler/src/ReactFiberFlags.js
patch: @@ -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;