@samitouri / QOS-React / commits / f0fbb0d199

[Fiber] fix useId tracking on replay (#35518)

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.

Josh Story committed Jan 15, 2026 at 17:27 UTC f0fbb0d199c166a634084c66a7cf9486ebb64bc1
2 files changed +158 -2
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+155
@@ -9478,4 +9478,159 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
9478 </div>,
9479 );
9480 });
9481 +
9482 + it('useId is consistent for siblings when component suspends with nested lazy', async () => {
9483 + // Inner component uses useId
9484 + function InnerComponent() {
9485 + const id = React.useId();
9486 + Scheduler.log('InnerComponent id: ' + id);
9487 + return <span id={id}>inner</span>;
9488 + }
9489 +
9490 + // Outer component uses useId and renders a lazy inner
9491 + function OuterComponent({innerElement}) {
9492 + const id = React.useId();
9493 + Scheduler.log('OuterComponent id: ' + id);
9494 + return <div id={id}>{innerElement}</div>;
9495 + }
9496 +
9497 + // This sibling also has useId - its ID must be consistent with server
9498 + function Sibling() {
9499 + const id = React.useId();
9500 + Scheduler.log('Sibling id: ' + id);
9501 + return <span id={id}>sibling</span>;
9502 + }
9503 +
9504 + // Create fresh lazy components for SERVER (resolve immediately)
9505 + const serverLazyInner = React.lazy(async () => {
9506 + Scheduler.log('server lazy inner initializer');
9507 + return {default: <InnerComponent />};
9508 + });
9509 +
9510 + const serverLazyOuter = React.lazy(async () => {
9511 + Scheduler.log('server lazy outer initializer');
9512 + return {
9513 + default: <OuterComponent key="outer" innerElement={serverLazyInner} />,
9514 + };
9515 + });
9516 +
9517 + // Server render with lazy (resolves immediately)
9518 + await act(() => {
9519 + const {pipe} = renderToPipeableStream(
9520 + <html>
9521 + <body>
9522 + <>{serverLazyOuter}</>
9523 + <>
9524 + <Sibling />
9525 + </>
9526 + </body>
9527 + </html>,
9528 + );
9529 + pipe(writable);
9530 + });
9531 +
9532 + expect(getVisibleChildren(document)).toEqual(
9533 + <html>
9534 + <head />
9535 + <body>
9536 + <div id="_R_1_">
9537 + <span id="_R_5_">inner</span>
9538 + </div>
9539 + <span id="_R_2_">sibling</span>
9540 + </body>
9541 + </html>,
9542 + );
9543 +
9544 + assertLog([
9545 + 'server lazy outer initializer',
9546 + 'Sibling id: _R_2_',
9547 + 'OuterComponent id: _R_1_',
9548 + 'server lazy inner initializer',
9549 + 'InnerComponent id: _R_5_',
9550 + ]);
9551 +
9552 + // Create fresh lazy components for CLIENT
9553 + let resolveClientInner;
9554 + const clientLazyInner = React.lazy(() => {
9555 + Scheduler.log('client lazy inner initializer');
9556 + const payload = {default: <InnerComponent />};
9557 + const promise = new Promise(r => {
9558 + resolveClientInner = () => {
9559 + promise.status = 'fulfilled';
9560 + promise.value = payload;
9561 + r(payload);
9562 + };
9563 + });
9564 + return promise;
9565 + });
9566 +
9567 + let resolveClientOuter;
9568 + const clientLazyOuter = React.lazy(() => {
9569 + Scheduler.log('client lazy outer initializer');
9570 + const payload = {
9571 + default: <OuterComponent innerElement={clientLazyInner} />,
9572 + };
9573 + const promise = new Promise(r => {
9574 + resolveClientOuter = () => {
9575 + promise.status = 'fulfilled';
9576 + promise.value = payload;
9577 + r(payload);
9578 + };
9579 + });
9580 + return promise;
9581 + });
9582 +
9583 + const hydrationErrors = [];
9584 +
9585 + // Client hydrates with nested lazy components
9586 + let root;
9587 + React.startTransition(() => {
9588 + root = ReactDOMClient.hydrateRoot(
9589 + document,
9590 + <html>
9591 + <body>
9592 + <>{clientLazyOuter}</>
9593 + <>
9594 + <Sibling />
9595 + </>
9596 + </body>
9597 + </html>,
9598 + {
9599 + onRecoverableError(error) {
9600 + hydrationErrors.push(error.message);
9601 + },
9602 + },
9603 + );
9604 + });
9605 +
9606 + // First suspension on outer lazy
9607 + await waitFor(['client lazy outer initializer']);
9608 + resolveClientOuter();
9609 +
9610 + // Second suspension on inner lazy
9611 + await waitFor([
9612 + 'OuterComponent id: _R_1_',
9613 + 'client lazy inner initializer',
9614 + ]);
9615 + resolveClientInner();
9616 +
9617 + await waitForAll(['InnerComponent id: _R_5_', 'Sibling id: _R_2_']);
9618 +
9619 + // The IDs should match the server-generated IDs
9620 + expect(hydrationErrors).toEqual([]);
9621 +
9622 + expect(getVisibleChildren(document)).toEqual(
9623 + <html>
9624 + <head />
9625 + <body>
9626 + <div id="_R_1_">
9627 + <span id="_R_5_">inner</span>
9628 + </div>
9629 + <span id="_R_2_">sibling</span>
9630 + </body>
9631 + </html>,
9632 + );
9633 +
9634 + root.unmount();
9635 + });
9636 });
packages/react-reconciler/src/ReactFiberFlags.js
+3 -2
@@ -62,13 +62,13 @@ export const ShouldCapture = /* */ 0b0000000000000010000000000000
62 export const ForceUpdateForLegacySuspense = /* */ 0b0000000000000100000000000000000;
63 export const DidPropagateContext = /* */ 0b0000000000001000000000000000000;
64 export const NeedsPropagation = /* */ 0b0000000000010000000000000000000;
65 -export const Forked = /* */ 0b0000000000100000000000000000000;
65
66 // Static tags describe aspects of a fiber that are not specific to a render,
67 // e.g. a fiber uses a passive effect (even if there are no updates on this particular render).
68 // This enables us to defer more work in the unmount case,
69 // since we can defer traversing the tree during layout to look for Passive effects,
70 // and instead rely on the static flag as a signal that there may be cleanup work.
71 +export const Forked = /* */ 0b0000000000100000000000000000000;
72 export const SnapshotStatic = /* */ 0b0000000001000000000000000000000;
73 export const LayoutStatic = /* */ 0b0000000010000000000000000000000;
74 export const RefStatic = LayoutStatic;
@@ -142,4 +142,5 @@ export const StaticMask =
142 MaySuspendCommit |
143 ViewTransitionStatic |
144 ViewTransitionNamedStatic |
145 - PortalStatic;
145 + PortalStatic |
146 + Forked;