@samitouri / QOS-React / commits / 488d88b018

Render children passed to "backwards" SuspenseList in reverse mount order (#35021)

Stacked on #35018. This mounts the children of SuspenseList backwards. Meaning the first child is mounted last in the DOM (and effect list). It's like calling reverse() on the children. This is meant to set us up for allowing AsyncIterable children where the unknown number of children streams in at the end (which is the beginning in a backwards SuspenseList). For consistency we do that with other children too. `unstable_legacy-backwards` still exists for the old mode but is meant to be deprecated. <img width="100" alt="image" src="https://github.com/user-attachments/assets/5c2a95d7-34c4-4a4e-b602-3646a834d779" />

Sebastian Markbåge committed Oct 31, 2025 at 13:33 UTC 488d88b018ee8fd1fac56cab22dfa8796ebce30b
5 files changed +140 -33
packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js
+75 -6
@@ -656,6 +656,77 @@ describe('ReactDOMFizzSuspenseList', () => {
656 );
657 });
658
659 + // @gate enableSuspenseList
660 + it('displays each items in "backwards" mount order', async () => {
661 + const A = createAsyncText('A');
662 + const B = createAsyncText('B');
663 + const C = createAsyncText('C');
664 +
665 + function Foo() {
666 + return (
667 + <div>
668 + <SuspenseList revealOrder="backwards" tail="visible">
669 + <Suspense fallback={<Text text="Loading C" />}>
670 + <C />
671 + </Suspense>
672 + <Suspense fallback={<Text text="Loading B" />}>
673 + <B />
674 + </Suspense>
675 + <Suspense fallback={<Text text="Loading A" />}>
676 + <A />
677 + </Suspense>
678 + </SuspenseList>
679 + </div>
680 + );
681 + }
682 +
683 + await A.resolve();
684 +
685 + await serverAct(async () => {
686 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
687 + pipe(writable);
688 + });
689 +
690 + assertLog([
691 + 'Suspend! [C]',
692 + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
693 + 'A',
694 + 'Loading C',
695 + 'Loading B',
696 + 'Loading A',
697 + ]);
698 +
699 + expect(getVisibleChildren(container)).toEqual(
700 + <div>
701 + <span>Loading A</span>
702 + <span>Loading B</span>
703 + <span>Loading C</span>
704 + </div>,
705 + );
706 +
707 + await serverAct(() => C.resolve());
708 + assertLog(['C']);
709 +
710 + expect(getVisibleChildren(container)).toEqual(
711 + <div>
712 + <span>Loading A</span>
713 + <span>Loading B</span>
714 + <span>C</span>
715 + </div>,
716 + );
717 +
718 + await serverAct(() => B.resolve());
719 + assertLog(['B']);
720 +
721 + expect(getVisibleChildren(container)).toEqual(
722 + <div>
723 + <span>A</span>
724 + <span>B</span>
725 + <span>C</span>
726 + </div>,
727 + );
728 + });
729 +
730 // @gate enableSuspenseList
731 it('displays each items in "backwards" order in legacy mode', async () => {
732 const A = createAsyncText('A');
@@ -737,15 +808,13 @@ describe('ReactDOMFizzSuspenseList', () => {
808 return (
809 <div>
810 <SuspenseList revealOrder="forwards" tail="visible">
740 - <SuspenseList
741 - revealOrder="unstable_legacy-backwards"
742 - tail="visible">
743 - <Suspense fallback={<Text text="Loading A" />}>
744 - <A />
745 - </Suspense>
811 + <SuspenseList revealOrder="backwards" tail="visible">
812 <Suspense fallback={<Text text="Loading B" />}>
813 <B />
814 </Suspense>
815 + <Suspense fallback={<Text text="Loading A" />}>
816 + <A />
817 + </Suspense>
818 </SuspenseList>
819 <Suspense fallback={<Text text="Loading C" />}>
820 <C />
packages/react-reconciler/src/ReactFiberBeginWork.js
+54 -8
@@ -3247,18 +3247,14 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) {
3247 if (
3248 revealOrder != null &&
3249 revealOrder !== 'forwards' &&
3250 + revealOrder !== 'backwards' &&
3251 revealOrder !== 'unstable_legacy-backwards' &&
3252 revealOrder !== 'together' &&
3253 revealOrder !== 'independent' &&
3254 !didWarnAboutRevealOrder[cacheKey]
3255 ) {
3256 didWarnAboutRevealOrder[cacheKey] = true;
3256 - if (revealOrder === 'backwards') {
3257 - console.error(
3258 - 'The rendering order of <SuspenseList revealOrder="backwards"> is changing. ' +
3259 - 'To be future compatible you must specify revealOrder="legacy_unstable-backwards" instead.',
3260 - );
3261 - } else if (typeof revealOrder === 'string') {
3257 + if (typeof revealOrder === 'string') {
3258 switch (revealOrder.toLowerCase()) {
3259 case 'together':
3260 case 'forwards':
@@ -3371,6 +3367,17 @@ function initSuspenseListRenderState(
3367 }
3368 }
3369
3370 +function reverseChildren(fiber: Fiber): void {
3371 + let row = fiber.child;
3372 + fiber.child = null;
3373 + while (row !== null) {
3374 + const nextRow = row.sibling;
3375 + row.sibling = fiber.child;
3376 + fiber.child = row;
3377 + row = nextRow;
3378 + }
3379 +}
3380 +
3381 // This can end up rendering this component multiple passes.
3382 // The first pass splits the children fibers into two sets. A head and tail.
3383 // We first render the head. If anything is in fallback state, we do another
@@ -3409,7 +3416,16 @@ function updateSuspenseListComponent(
3416 validateTailOptions(tailMode, revealOrder);
3417 validateSuspenseListChildren(newChildren, revealOrder);
3418
3412 - reconcileChildren(current, workInProgress, newChildren, renderLanes);
3419 + if (revealOrder === 'backwards' && current !== null) {
3420 + // For backwards the current mounted set will be backwards. Reconciling against it
3421 + // will lead to mismatches and reorders. We need to swap the original set first
3422 + // and then restore it afterwards.
3423 + reverseChildren(current);
3424 + reconcileChildren(current, workInProgress, newChildren, renderLanes);
3425 + reverseChildren(current);
3426 + } else {
3427 + reconcileChildren(current, workInProgress, newChildren, renderLanes);
3428 + }
3429 // Read how many children forks this set pushed so we can push it every time we retry.
3430 const treeForkCount = getIsHydrating() ? getForksAtLevel(workInProgress) : 0;
3431
@@ -3434,7 +3450,37 @@ function updateSuspenseListComponent(
3450 workInProgress.memoizedState = null;
3451 } else {
3452 switch (revealOrder) {
3437 - case 'backwards':
3453 + case 'backwards': {
3454 + // We're going to find the first row that has existing content.
3455 + // We are also going to reverse the order of anything in the existing content
3456 + // since we want to actually render them backwards from the reconciled set.
3457 + // The tail is left in order, because it'll be added to the front as we
3458 + // complete each item.
3459 + const lastContentRow = findLastContentRow(workInProgress.child);
3460 + let tail;
3461 + if (lastContentRow === null) {
3462 + // The whole list is part of the tail.
3463 + tail = workInProgress.child;
3464 + workInProgress.child = null;
3465 + } else {
3466 + // Disconnect the tail rows after the content row.
3467 + // We're going to render them separately later in reverse order.
3468 + tail = lastContentRow.sibling;
3469 + lastContentRow.sibling = null;
3470 + // We have to now reverse the main content so it renders backwards too.
3471 + reverseChildren(workInProgress);
3472 + }
3473 + // TODO: If workInProgress.child is null, we can continue on the tail immediately.
3474 + initSuspenseListRenderState(
3475 + workInProgress,
3476 + true, // isBackwards
3477 + tail,
3478 + null, // last
3479 + tailMode,
3480 + treeForkCount,
3481 + );
3482 + break;
3483 + }
3484 case 'unstable_legacy-backwards': {
3485 // We're going to find the first row that has existing content.
3486 // At the same time we're going to reverse the list of everything
packages/react-reconciler/src/ReactFiberCompleteWork.js
-4
@@ -1838,10 +1838,6 @@ function completeWork(
1838 }
1839 }
1840 if (renderState.isBackwards) {
1841 - // The effect list of the backwards tail will have been added
1842 - // to the end. This breaks the guarantee that life-cycles fire in
1843 - // sibling order but that isn't a strong guarantee promised by React.
1844 - // Especially since these might also just pop in during future commits.
1841 // Append to the beginning of the list.
1842 renderedTail.sibling = workInProgress.child;
1843 workInProgress.child = renderedTail;
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
+6 -14
@@ -1022,7 +1022,7 @@ describe('ReactSuspenseList', () => {
1022 });
1023
1024 // @gate enableSuspenseList
1025 - it('warns if revealOrder="backwards" is specified', async () => {
1025 + it('displays each items in "backwards" order', async () => {
1026 const A = createAsyncText('A');
1027 const B = createAsyncText('B');
1028 const C = createAsyncText('C');
@@ -1030,14 +1030,14 @@ describe('ReactSuspenseList', () => {
1030 function Foo() {
1031 return (
1032 <SuspenseList revealOrder="backwards" tail="visible">
1033 - <Suspense fallback={<Text text="Loading A" />}>
1034 - <A />
1033 + <Suspense fallback={<Text text="Loading C" />}>
1034 + <C />
1035 </Suspense>
1036 <Suspense fallback={<Text text="Loading B" />}>
1037 <B />
1038 </Suspense>
1039 - <Suspense fallback={<Text text="Loading C" />}>
1040 - <C />
1039 + <Suspense fallback={<Text text="Loading A" />}>
1040 + <A />
1041 </Suspense>
1042 </SuspenseList>
1043 );
@@ -1056,14 +1056,6 @@ describe('ReactSuspenseList', () => {
1056 'Suspend! [C]',
1057 ]);
1058
1059 - assertConsoleErrorDev([
1060 - 'The rendering order of <SuspenseList revealOrder="backwards"> is changing. ' +
1061 - 'To be future compatible you must specify ' +
1062 - 'revealOrder="legacy_unstable-backwards" instead.' +
1063 - '\n in SuspenseList (at **)' +
1064 - '\n in Foo (at **)',
1065 - ]);
1066 -
1059 expect(ReactNoop).toMatchRenderedOutput(
1060 <>
1061 <span>Loading A</span>
@@ -1101,7 +1093,7 @@ describe('ReactSuspenseList', () => {
1093 });
1094
1095 // @gate enableSuspenseList
1104 - it('displays each items in "backwards" order', async () => {
1096 + it('displays each items in "backwards" order (legacy)', async () => {
1097 const A = createAsyncText('A');
1098 const B = createAsyncText('B');
1099 const C = createAsyncText('C');
packages/react-server/src/ReactFizzServer.js
+5 -1
@@ -2017,7 +2017,11 @@ function renderSuspenseListRows(
2017 const parentSegment = task.blockedSegment;
2018 const childIndex = parentSegment.children.length;
2019 const insertionIndex = parentSegment.chunks.length;
2020 - for (let i = totalChildren - 1; i >= 0; i--) {
2020 + for (let n = 0; n < totalChildren; n++) {
2021 + const i =
2022 + revealOrder === 'unstable_legacy-backwards'
2023 + ? totalChildren - 1 - n
2024 + : n;
2025 const node = rows[i];
2026 task.row = previousSuspenseListRow = createSuspenseListRow(
2027 previousSuspenseListRow,