@samitouri / QOS-React-2 / commits / c38e268978

[Fiber] Fix hydration of useId in SuspenseList (#33491)

Includes #31412. The issue is that `pushTreeFork` stores some global state when reconcile children. This gets popped by `popTreeContext` in `completeWork`. Normally `completeWork` returns its own `Fiber` again if it wants to do a second pass which will call `pushTreeFork` again in the next pass. However, `SuspenseList` doesn't return itself, it returns the next child to work on. The fix is to keep track of the count and push it again it when we return the next child to attempt. There are still some outstanding issues with hydration. Like the backwards test still has the wrong behavior in it because it hydrates backwards and so it picks up the DOM nodes in reverse order. `tail="hidden"` also doesn't work correctly. There's also another issue with `useId` and `AsyncIterable` in SuspenseList when there's an unknown number of children. We don't support those showing one at a time yet though so it's not an issue yet. To fix it we need to add variable total count to the `useId` algorithm. E.g. by falling back to varint encoding. --------- Co-authored-by: Rick Hanlon <rickhanlonii@fb.com> Co-authored-by: Ricky <rickhanlonii@gmail.com>

Sebastian Markbåge committed Jun 9, 2025 at 19:37 UTC c38e26897848374c34ac6b651fce4a9088ed4dd0
4 files changed +390 -2
packages/react-dom/src/__tests__/ReactDOMUseId-test.js
+371 -1
@@ -7,7 +7,6 @@
7 * @emails react-core
8 * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9 */
10 -
10 let JSDOM;
11 let React;
12 let ReactDOMClient;
@@ -24,6 +23,8 @@ let buffer = '';
23 let hasErrored = false;
24 let fatalError = undefined;
25 let waitForPaint;
26 +let SuspenseList;
27 +let assertConsoleErrorDev;
28
29 describe('useId', () => {
30 beforeEach(() => {
@@ -32,11 +33,16 @@ describe('useId', () => {
33 React = require('react');
34 ReactDOMClient = require('react-dom/client');
35 clientAct = require('internal-test-utils').act;
36 + assertConsoleErrorDev =
37 + require('internal-test-utils').assertConsoleErrorDev;
38 ReactDOMFizzServer = require('react-dom/server');
39 Stream = require('stream');
40 Suspense = React.Suspense;
41 useId = React.useId;
42 useState = React.useState;
43 + if (gate(flags => flags.enableSuspenseList)) {
44 + SuspenseList = React.unstable_SuspenseList;
45 + }
46
47 const InternalTestUtils = require('internal-test-utils');
48 waitForPaint = InternalTestUtils.waitForPaint;
@@ -375,6 +381,370 @@ describe('useId', () => {
381 `);
382 });
383
384 + // @gate enableSuspenseList
385 + it('Supports SuspenseList (reveal order independent)', async () => {
386 + function Baz({id, children}) {
387 + return <span id={id}>{children}</span>;
388 + }
389 +
390 + function Bar({children}) {
391 + const id = useId();
392 + return <Baz id={id}>{children}</Baz>;
393 + }
394 +
395 + function Foo() {
396 + return (
397 + <SuspenseList revealOrder="independent">
398 + <Bar>A</Bar>
399 + <Bar>B</Bar>
400 + </SuspenseList>
401 + );
402 + }
403 +
404 + await serverAct(async () => {
405 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
406 + pipe(writable);
407 + });
408 + expect(container).toMatchInlineSnapshot(`
409 + <div
410 + id="container"
411 + >
412 + <span
413 + id="_R_1_"
414 + >
415 + A
416 + </span>
417 + <span
418 + id="_R_2_"
419 + >
420 + B
421 + </span>
422 + </div>
423 + `);
424 +
425 + await clientAct(async () => {
426 + ReactDOMClient.hydrateRoot(container, <Foo />);
427 + });
428 +
429 + expect(container).toMatchInlineSnapshot(`
430 + <div
431 + id="container"
432 + >
433 + <span
434 + id="_R_1_"
435 + >
436 + A
437 + </span>
438 + <span
439 + id="_R_2_"
440 + >
441 + B
442 + </span>
443 + </div>
444 + `);
445 + });
446 +
447 + // @gate enableSuspenseList
448 + it('Supports SuspenseList (reveal order "together")', async () => {
449 + function Baz({id, children}) {
450 + return <span id={id}>{children}</span>;
451 + }
452 +
453 + function Bar({children}) {
454 + const id = useId();
455 + return <Baz id={id}>{children}</Baz>;
456 + }
457 +
458 + function Foo() {
459 + return (
460 + <SuspenseList revealOrder="together">
461 + <Bar>A</Bar>
462 + <Bar>B</Bar>
463 + </SuspenseList>
464 + );
465 + }
466 +
467 + await serverAct(async () => {
468 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
469 + pipe(writable);
470 + });
471 + expect(container).toMatchInlineSnapshot(`
472 + <div
473 + id="container"
474 + >
475 + <span
476 + id="_R_1_"
477 + >
478 + A
479 + </span>
480 + <span
481 + id="_R_2_"
482 + >
483 + B
484 + </span>
485 + </div>
486 + `);
487 +
488 + await clientAct(async () => {
489 + ReactDOMClient.hydrateRoot(container, <Foo />);
490 + });
491 +
492 + expect(container).toMatchInlineSnapshot(`
493 + <div
494 + id="container"
495 + >
496 + <span
497 + id="_R_1_"
498 + >
499 + A
500 + </span>
501 + <span
502 + id="_R_2_"
503 + >
504 + B
505 + </span>
506 + </div>
507 + `);
508 + });
509 +
510 + // @gate enableSuspenseList
511 + it('Supports SuspenseList (reveal order "forwards")', async () => {
512 + function Baz({id, children}) {
513 + return <span id={id}>{children}</span>;
514 + }
515 +
516 + function Bar({children}) {
517 + const id = useId();
518 + return <Baz id={id}>{children}</Baz>;
519 + }
520 +
521 + function Foo() {
522 + return (
523 + <SuspenseList revealOrder="forwards" tail="visible">
524 + <Bar>A</Bar>
525 + <Bar>B</Bar>
526 + </SuspenseList>
527 + );
528 + }
529 +
530 + await serverAct(async () => {
531 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
532 + pipe(writable);
533 + });
534 + expect(container).toMatchInlineSnapshot(`
535 + <div
536 + id="container"
537 + >
538 + <span
539 + id="_R_1_"
540 + >
541 + A
542 + </span>
543 + <span
544 + id="_R_2_"
545 + >
546 + B
547 + </span>
548 + </div>
549 + `);
550 +
551 + await clientAct(async () => {
552 + ReactDOMClient.hydrateRoot(container, <Foo />);
553 + });
554 +
555 + expect(container).toMatchInlineSnapshot(`
556 + <div
557 + id="container"
558 + >
559 + <span
560 + id="_R_1_"
561 + >
562 + A
563 + </span>
564 + <span
565 + id="_R_2_"
566 + >
567 + B
568 + </span>
569 + </div>
570 + `);
571 + });
572 +
573 + // @gate enableSuspenseList
574 + it('Supports SuspenseList (reveal order "backwards") with a single child in a list of many', async () => {
575 + function Baz({id, children}) {
576 + return <span id={id}>{children}</span>;
577 + }
578 +
579 + function Bar({children}) {
580 + const id = useId();
581 + return <Baz id={id}>{children}</Baz>;
582 + }
583 +
584 + function Foo() {
585 + return (
586 + <SuspenseList revealOrder="unstable_legacy-backwards" tail="visible">
587 + {null}
588 + <Bar>A</Bar>
589 + {null}
590 + </SuspenseList>
591 + );
592 + }
593 +
594 + await serverAct(async () => {
595 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
596 + pipe(writable);
597 + });
598 + expect(container).toMatchInlineSnapshot(`
599 + <div
600 + id="container"
601 + >
602 + <span
603 + id="_R_2_"
604 + >
605 + A
606 + </span>
607 + <!-- -->
608 + </div>
609 + `);
610 +
611 + await clientAct(async () => {
612 + ReactDOMClient.hydrateRoot(container, <Foo />);
613 + });
614 +
615 + expect(container).toMatchInlineSnapshot(`
616 + <div
617 + id="container"
618 + >
619 + <span
620 + id="_R_2_"
621 + >
622 + A
623 + </span>
624 + <!-- -->
625 + </div>
626 + `);
627 + });
628 +
629 + // @gate enableSuspenseList
630 + it('Supports SuspenseList (reveal order "backwards")', async () => {
631 + function Baz({id, children}) {
632 + return <span id={id}>{children}</span>;
633 + }
634 +
635 + function Bar({children}) {
636 + const id = useId();
637 + return <Baz id={id}>{children}</Baz>;
638 + }
639 +
640 + function Foo() {
641 + return (
642 + <SuspenseList revealOrder="unstable_legacy-backwards" tail="visible">
643 + <Bar>A</Bar>
644 + <Bar>B</Bar>
645 + </SuspenseList>
646 + );
647 + }
648 +
649 + await serverAct(async () => {
650 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
651 + pipe(writable);
652 + });
653 + expect(container).toMatchInlineSnapshot(`
654 + <div
655 + id="container"
656 + >
657 + <span
658 + id="_R_1_"
659 + >
660 + A
661 + </span>
662 + <span
663 + id="_R_2_"
664 + >
665 + B
666 + </span>
667 + </div>
668 + `);
669 +
670 + if (gate(flags => flags.favorSafetyOverHydrationPerf)) {
671 + // TODO: This is a bug with revealOrder="backwards" in that it hydrates in reverse.
672 + await expect(async () => {
673 + await clientAct(async () => {
674 + ReactDOMClient.hydrateRoot(container, <Foo />);
675 + });
676 + }).rejects.toThrowError(
677 + `Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client.`,
678 + );
679 +
680 + expect(container).toMatchInlineSnapshot(`
681 + <div
682 + id="container"
683 + >
684 + <span
685 + id="_r_1_"
686 + >
687 + A
688 + </span>
689 + <span
690 + id="_r_0_"
691 + >
692 + B
693 + </span>
694 + </div>
695 + `);
696 + } else {
697 + await clientAct(async () => {
698 + ReactDOMClient.hydrateRoot(container, <Foo />);
699 + });
700 +
701 + // TODO: This is a bug with revealOrder="backwards" in that it hydrates in reverse.
702 + assertConsoleErrorDev([
703 + `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:
704 +
705 +- A server/client branch \`if (typeof window !== 'undefined')\`.
706 +- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
707 +- Date formatting in a user's locale which doesn't match the server.
708 +- External changing data without sending a snapshot of it along with the HTML.
709 +- Invalid HTML tag nesting.
710 +
711 +It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
712 +
713 +https://react.dev/link/hydration-mismatch
714 +
715 + <Foo>
716 + <SuspenseList revealOrder="unstable_l..." tail="visible">
717 + <Bar>
718 + <Bar>
719 + <Baz id="_R_2_">
720 + <span
721 ++ id="_R_2_"
722 +- id="_R_1_"
723 + >
724 ++ B
725 +- A
726 +`,
727 + ]);
728 +
729 + expect(container).toMatchInlineSnapshot(`
730 + <div
731 + id="container"
732 + >
733 + <span
734 + id="_R_1_"
735 + >
736 + A
737 + </span>
738 + <span
739 + id="_R_2_"
740 + >
741 + B
742 + </span>
743 + </div>
744 + `);
745 + }
746 + });
747 +
748 it('basic incremental hydration', async () => {
749 function App() {
750 return (
packages/react-reconciler/src/ReactFiberBeginWork.js
+8
@@ -3342,6 +3342,7 @@ function initSuspenseListRenderState(
3342 tail: null | Fiber,
3343 lastContentRow: null | Fiber,
3344 tailMode: SuspenseListTailMode,
3345 + treeForkCount: number,
3346 ): void {
3347 const renderState: null | SuspenseListRenderState =
3348 workInProgress.memoizedState;
@@ -3353,6 +3354,7 @@ function initSuspenseListRenderState(
3354 last: lastContentRow,
3355 tail: tail,
3356 tailMode: tailMode,
3357 + treeForkCount: treeForkCount,
3358 }: SuspenseListRenderState);
3359 } else {
3360 // We can reuse the existing object from previous renders.
@@ -3362,6 +3364,7 @@ function initSuspenseListRenderState(
3364 renderState.last = lastContentRow;
3365 renderState.tail = tail;
3366 renderState.tailMode = tailMode;
3367 + renderState.treeForkCount = treeForkCount;
3368 }
3369 }
3370
@@ -3404,6 +3407,8 @@ function updateSuspenseListComponent(
3407 validateSuspenseListChildren(newChildren, revealOrder);
3408
3409 reconcileChildren(current, workInProgress, newChildren, renderLanes);
3410 + // Read how many children forks this set pushed so we can push it every time we retry.
3411 + const treeForkCount = getIsHydrating() ? getForksAtLevel(workInProgress) : 0;
3412
3413 if (!shouldForceFallback) {
3414 const didSuspendBefore =
@@ -3446,6 +3451,7 @@ function updateSuspenseListComponent(
3451 tail,
3452 lastContentRow,
3453 tailMode,
3454 + treeForkCount,
3455 );
3456 break;
3457 }
@@ -3478,6 +3484,7 @@ function updateSuspenseListComponent(
3484 tail,
3485 null, // last
3486 tailMode,
3487 + treeForkCount,
3488 );
3489 break;
3490 }
@@ -3488,6 +3495,7 @@ function updateSuspenseListComponent(
3495 null, // tail
3496 null, // last
3497 undefined,
3498 + treeForkCount,
3499 );
3500 break;
3501 }
packages/react-reconciler/src/ReactFiberCompleteWork.js
+9 -1
@@ -184,7 +184,7 @@ import {resetChildFibers} from './ReactChildFiber';
184 import {createScopeInstance} from './ReactFiberScope';
185 import {transferActualDuration} from './ReactProfilerTimer';
186 import {popCacheProvider} from './ReactFiberCacheComponent';
187 -import {popTreeContext} from './ReactFiberTreeContext';
187 +import {popTreeContext, pushTreeFork} from './ReactFiberTreeContext';
188 import {popRootTransition, popTransition} from './ReactFiberTransition';
189 import {
190 popMarkerInstance,
@@ -1764,6 +1764,10 @@ function completeWork(
1764 ForceSuspenseFallback,
1765 ),
1766 );
1767 + if (getIsHydrating()) {
1768 + // Re-apply tree fork since we popped the tree fork context in the beginning of this function.
1769 + pushTreeFork(workInProgress, renderState.treeForkCount);
1770 + }
1771 // Don't bubble properties in this case.
1772 return workInProgress.child;
1773 }
@@ -1890,6 +1894,10 @@ function completeWork(
1894 }
1895 pushSuspenseListContext(workInProgress, suspenseContext);
1896 // Do a pass over the next row.
1897 + if (getIsHydrating()) {
1898 + // Re-apply tree fork since we popped the tree fork context in the beginning of this function.
1899 + pushTreeFork(workInProgress, renderState.treeForkCount);
1900 + }
1901 // Don't bubble properties in this case.
1902 return next;
1903 }
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+2
@@ -54,6 +54,8 @@ export type SuspenseListRenderState = {
54 tail: null | Fiber,
55 // Tail insertions setting.
56 tailMode: SuspenseListTailMode,
57 + // Keep track of total number of forks during multiple passes
58 + treeForkCount: number,
59 };
60
61 export type RetryQueue = Set<Wakeable>;