@samitouri / QOS-React / commits / 26cf280480

Switch the default revealOrder to "forwards" and tail "hidden" on SuspenseList (#35018)

We have warned about this for a while now so we can make the switch. Often when you reach for SuspenseList, you mean forwards. It doesn't make sense to have the default to just be a noop. While "together" is another useful mode that's more like a Group so isn't so associated with the default as List. So we're switching it. However, tail=hidden isn't as obvious of a default it does allow for a convenient pattern for streaming in list of items by default. This doesn't yet switch the rendering order of "backwards". That's coming in a follow up.

Sebastian Markbåge committed Oct 31, 2025 at 12:58 UTC 26cf2804802f3d32c4d8f9db73ddea12ad6c1670
7 files changed +142 -125
packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js
+14 -7
@@ -134,7 +134,7 @@ describe('ReactDOMFizzSuspenseList', () => {
134 }
135
136 // @gate enableSuspenseList
137 - it('shows content independently by default', async () => {
137 + it('shows content forwards by default', async () => {
138 const A = createAsyncText('A');
139 const B = createAsyncText('B');
140 const C = createAsyncText('C');
@@ -157,31 +157,38 @@ describe('ReactDOMFizzSuspenseList', () => {
157 );
158 }
159
160 - await A.resolve();
160 + await C.resolve();
161
162 await serverAct(async () => {
163 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
164 pipe(writable);
165 });
166
167 - assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']);
167 + assertLog([
168 + 'Suspend! [A]',
169 + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
170 + 'C',
171 + 'Loading A',
172 + 'Loading B',
173 + 'Loading C',
174 + ]);
175
176 expect(getVisibleChildren(container)).toEqual(
177 <div>
171 - <span>A</span>
178 + <span>Loading A</span>
179 <span>Loading B</span>
180 <span>Loading C</span>
181 </div>,
182 );
183
177 - await serverAct(() => C.resolve());
178 - assertLog(['C']);
184 + await serverAct(() => A.resolve());
185 + assertLog(['A']);
186
187 expect(getVisibleChildren(container)).toEqual(
188 <div>
189 <span>A</span>
190 <span>Loading B</span>
184 - <span>C</span>
191 + <span>Loading C</span>
192 </div>,
193 );
194
packages/react-reconciler/src/ReactChildFiber.js
+2 -1
@@ -2104,7 +2104,8 @@ export function validateSuspenseListChildren(
2104 ) {
2105 if (__DEV__) {
2106 if (
2107 - (revealOrder === 'forwards' ||
2107 + (revealOrder == null ||
2108 + revealOrder === 'forwards' ||
2109 revealOrder === 'backwards' ||
2110 revealOrder === 'unstable_legacy-backwards') &&
2111 children !== undefined &&
packages/react-reconciler/src/ReactFiberBeginWork.js
+34 -46
@@ -3245,6 +3245,7 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) {
3245 if (__DEV__) {
3246 const cacheKey = revealOrder == null ? 'null' : revealOrder;
3247 if (
3248 + revealOrder != null &&
3249 revealOrder !== 'forwards' &&
3250 revealOrder !== 'unstable_legacy-backwards' &&
3251 revealOrder !== 'together' &&
@@ -3252,13 +3253,7 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) {
3253 !didWarnAboutRevealOrder[cacheKey]
3254 ) {
3255 didWarnAboutRevealOrder[cacheKey] = true;
3255 - if (revealOrder == null) {
3256 - console.error(
3257 - 'The default for the <SuspenseList revealOrder="..."> prop is changing. ' +
3258 - 'To be future compatible you must explictly specify either ' +
3259 - '"independent" (the current default), "together", "forwards" or "legacy_unstable-backwards".',
3260 - );
3261 - } else if (revealOrder === 'backwards') {
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.',
@@ -3314,18 +3309,7 @@ function validateTailOptions(
3309 const cacheKey = tailMode == null ? 'null' : tailMode;
3310 if (!didWarnAboutTailOptions[cacheKey]) {
3311 if (tailMode == null) {
3317 - if (
3318 - revealOrder === 'forwards' ||
3319 - revealOrder === 'backwards' ||
3320 - revealOrder === 'unstable_legacy-backwards'
3321 - ) {
3322 - didWarnAboutTailOptions[cacheKey] = true;
3323 - console.error(
3324 - 'The default for the <SuspenseList tail="..."> prop is changing. ' +
3325 - 'To be future compatible you must explictly specify either ' +
3326 - '"visible" (the current default), "collapsed" or "hidden".',
3327 - );
3328 - }
3312 + // The default tail is now "hidden".
3313 } else if (
3314 tailMode !== 'visible' &&
3315 tailMode !== 'collapsed' &&
@@ -3338,6 +3322,7 @@ function validateTailOptions(
3322 tailMode,
3323 );
3324 } else if (
3325 + revealOrder != null &&
3326 revealOrder !== 'forwards' &&
3327 revealOrder !== 'backwards' &&
3328 revealOrder !== 'unstable_legacy-backwards'
@@ -3345,7 +3330,7 @@ function validateTailOptions(
3330 didWarnAboutTailOptions[cacheKey] = true;
3331 console.error(
3332 '<SuspenseList tail="%s" /> is only valid if revealOrder is ' +
3348 - '"forwards" or "backwards". ' +
3333 + '"forwards" (default) or "backwards". ' +
3334 'Did you mean to specify revealOrder="forwards"?',
3335 tailMode,
3336 );
@@ -3449,30 +3434,6 @@ function updateSuspenseListComponent(
3434 workInProgress.memoizedState = null;
3435 } else {
3436 switch (revealOrder) {
3452 - case 'forwards': {
3453 - const lastContentRow = findLastContentRow(workInProgress.child);
3454 - let tail;
3455 - if (lastContentRow === null) {
3456 - // The whole list is part of the tail.
3457 - // TODO: We could fast path by just rendering the tail now.
3458 - tail = workInProgress.child;
3459 - workInProgress.child = null;
3460 - } else {
3461 - // Disconnect the tail rows after the content row.
3462 - // We're going to render them separately later.
3463 - tail = lastContentRow.sibling;
3464 - lastContentRow.sibling = null;
3465 - }
3466 - initSuspenseListRenderState(
3467 - workInProgress,
3468 - false, // isBackwards
3469 - tail,
3470 - lastContentRow,
3471 - tailMode,
3472 - treeForkCount,
3473 - );
3474 - break;
3475 - }
3437 case 'backwards':
3438 case 'unstable_legacy-backwards': {
3439 // We're going to find the first row that has existing content.
@@ -3517,10 +3478,37 @@ function updateSuspenseListComponent(
3478 );
3479 break;
3480 }
3520 - default: {
3521 - // The default reveal order is the same as not having
3481 + case 'independent': {
3482 + // The "independent" reveal order is the same as not having
3483 // a boundary.
3484 workInProgress.memoizedState = null;
3485 + break;
3486 + }
3487 + // The default is now forwards.
3488 + case 'forwards':
3489 + default: {
3490 + const lastContentRow = findLastContentRow(workInProgress.child);
3491 + let tail;
3492 + if (lastContentRow === null) {
3493 + // The whole list is part of the tail.
3494 + // TODO: We could fast path by just rendering the tail now.
3495 + tail = workInProgress.child;
3496 + workInProgress.child = null;
3497 + } else {
3498 + // Disconnect the tail rows after the content row.
3499 + // We're going to render them separately later.
3500 + tail = lastContentRow.sibling;
3501 + lastContentRow.sibling = null;
3502 + }
3503 + initSuspenseListRenderState(
3504 + workInProgress,
3505 + false, // isBackwards
3506 + tail,
3507 + lastContentRow,
3508 + tailMode,
3509 + treeForkCount,
3510 + );
3511 + break;
3512 }
3513 }
3514 }
packages/react-reconciler/src/ReactFiberCompleteWork.js
+18 -11
@@ -698,7 +698,11 @@ function cutOffTailIfNeeded(
698 return;
699 }
700 switch (renderState.tailMode) {
701 - case 'hidden': {
701 + case 'visible': {
702 + // Everything should remain as it was.
703 + break;
704 + }
705 + case 'collapsed': {
706 // Any insertions at the end of the tail list after this point
707 // should be invisible. If there are already mounted boundaries
708 // anything before them are not considered for collapsing.
@@ -716,7 +720,13 @@ function cutOffTailIfNeeded(
720 // last rendered item.
721 if (lastTailNode === null) {
722 // All remaining items in the tail are insertions.
719 - renderState.tail = null;
723 + if (!hasRenderedATailFallback && renderState.tail !== null) {
724 + // We suspended during the head. We want to show at least one
725 + // row at the tail. So we'll keep on and cut off the rest.
726 + renderState.tail.sibling = null;
727 + } else {
728 + renderState.tail = null;
729 + }
730 } else {
731 // Detach the insertion after the last node that was already
732 // inserted.
@@ -724,7 +734,9 @@ function cutOffTailIfNeeded(
734 }
735 break;
736 }
727 - case 'collapsed': {
737 + // Hidden is now the default.
738 + case 'hidden':
739 + default: {
740 // Any insertions at the end of the tail list after this point
741 // should be invisible. If there are already mounted boundaries
742 // anything before them are not considered for collapsing.
@@ -742,13 +754,7 @@ function cutOffTailIfNeeded(
754 // last rendered item.
755 if (lastTailNode === null) {
756 // All remaining items in the tail are insertions.
745 - if (!hasRenderedATailFallback && renderState.tail !== null) {
746 - // We suspended during the head. We want to show at least one
747 - // row at the tail. So we'll keep on and cut off the rest.
748 - renderState.tail.sibling = null;
749 - } else {
750 - renderState.tail = null;
751 - }
757 + renderState.tail = null;
758 } else {
759 // Detach the insertion after the last node that was already
760 // inserted.
@@ -1795,7 +1801,8 @@ function completeWork(
1801 // This might have been modified.
1802 if (
1803 renderState.tail === null &&
1798 - renderState.tailMode === 'hidden' &&
1804 + renderState.tailMode !== 'collapsed' &&
1805 + renderState.tailMode !== 'visible' &&
1806 !renderedTail.alternate &&
1807 !getIsHydrating() // We don't cut it if we're hydrating.
1808 ) {
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+1 -4
@@ -79,10 +79,7 @@ export function findFirstSuspended(row: Fiber): null | Fiber {
79 node.tag === SuspenseListComponent &&
80 // Independent revealOrder can't be trusted because it doesn't
81 // keep track of whether it suspended or not.
82 - (node.memoizedProps.revealOrder === 'forwards' ||
83 - node.memoizedProps.revealOrder === 'backwards' ||
84 - node.memoizedProps.revealOrder === 'unstable_legacy-backwards' ||
85 - node.memoizedProps.revealOrder === 'together')
82 + node.memoizedProps.revealOrder !== 'independent'
83 ) {
84 const didSuspend = (node.flags & DidCapture) !== NoFlags;
85 if (didSuspend) {
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
+71 -50
@@ -218,7 +218,7 @@ describe('ReactSuspenseList', () => {
218 });
219
220 // @gate enableSuspenseList
221 - it('warns if no revealOrder is specified', async () => {
221 + it('behaves as revealOrder=forwards by default', async () => {
222 const A = createAsyncText('A');
223 const B = createAsyncText('B');
224 const C = createAsyncText('C');
@@ -239,54 +239,36 @@ describe('ReactSuspenseList', () => {
239 );
240 }
241
242 + ReactNoop.render(<Foo />);
243 +
244 + await waitForAll(['Suspend! [A]', 'Loading A']);
245 +
246 + expect(ReactNoop).toMatchRenderedOutput(null);
247 +
248 await A.resolve();
249
244 - ReactNoop.render(<Foo />);
250 + await waitForAll(['A', 'Suspend! [B]', 'Loading B']);
251
246 - await waitForAll([
247 - 'A',
248 - 'Suspend! [B]',
249 - 'Loading B',
250 - 'Suspend! [C]',
251 - 'Loading C',
252 - // pre-warming
253 - 'Suspend! [B]',
254 - 'Suspend! [C]',
255 - ]);
252 + // Incremental loading is suspended.
253 + jest.advanceTimersByTime(500);
254
257 - assertConsoleErrorDev([
258 - 'The default for the <SuspenseList revealOrder="..."> prop is changing. ' +
259 - 'To be future compatible you must explictly specify either ' +
260 - '"independent" (the current default), "together", "forwards" or "legacy_unstable-backwards".' +
261 - '\n in SuspenseList (at **)' +
262 - '\n in Foo (at **)',
263 - ]);
255 + expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
256
265 - expect(ReactNoop).toMatchRenderedOutput(
266 - <>
267 - <span>A</span>
268 - <span>Loading B</span>
269 - <span>Loading C</span>
270 - </>,
271 - );
257 + await act(() => B.resolve());
258 + assertLog(['B', 'Suspend! [C]', 'Loading C']);
259
273 - await act(() => C.resolve());
274 - assertLog(
275 - gate('alwaysThrottleRetries')
276 - ? ['Suspend! [B]', 'C', 'Suspend! [B]']
277 - : ['C'],
278 - );
260 + // Incremental loading is suspended.
261 + jest.advanceTimersByTime(500);
262
263 expect(ReactNoop).toMatchRenderedOutput(
264 <>
265 <span>A</span>
283 - <span>Loading B</span>
284 - <span>C</span>
266 + <span>B</span>
267 </>,
268 );
269
288 - await act(() => B.resolve());
289 - assertLog(['B']);
270 + await act(() => C.resolve());
271 + assertLog(['C']);
272
273 expect(ReactNoop).toMatchRenderedOutput(
274 <>
@@ -1699,26 +1681,65 @@ describe('ReactSuspenseList', () => {
1681 });
1682
1683 // @gate enableSuspenseList
1702 - it('warns if no tail option is specified', async () => {
1684 + it('behaves as tail=hidden if no tail option is specified', async () => {
1685 + const A = createAsyncText('A');
1686 + const B = createAsyncText('B');
1687 + const C = createAsyncText('C');
1688 +
1689 function Foo() {
1690 return (
1691 <SuspenseList revealOrder="forwards">
1706 - <Suspense fallback="Loading">A</Suspense>
1707 - <Suspense fallback="Loading">B</Suspense>
1692 + <Suspense fallback={<Text text="Loading A" />}>
1693 + <A />
1694 + </Suspense>
1695 + <Suspense fallback={<Text text="Loading B" />}>
1696 + <B />
1697 + </Suspense>
1698 + <Suspense fallback={<Text text="Loading C" />}>
1699 + <C />
1700 + </Suspense>
1701 </SuspenseList>
1702 );
1703 }
1704
1712 - await act(() => {
1713 - ReactNoop.render(<Foo />);
1714 - });
1715 - assertConsoleErrorDev([
1716 - 'The default for the <SuspenseList tail="..."> prop is changing. ' +
1717 - 'To be future compatible you must explictly specify either ' +
1718 - '"visible" (the current default), "collapsed" or "hidden".' +
1719 - '\n in SuspenseList (at **)' +
1720 - '\n in Foo (at **)',
1721 - ]);
1705 + ReactNoop.render(<Foo />);
1706 +
1707 + await waitForAll(['Suspend! [A]', 'Loading A']);
1708 +
1709 + expect(ReactNoop).toMatchRenderedOutput(null);
1710 +
1711 + await A.resolve();
1712 +
1713 + await waitForAll(['A', 'Suspend! [B]', 'Loading B']);
1714 +
1715 + // Incremental loading is suspended.
1716 + jest.advanceTimersByTime(500);
1717 +
1718 + expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
1719 +
1720 + await act(() => B.resolve());
1721 + assertLog(['B', 'Suspend! [C]', 'Loading C']);
1722 +
1723 + // Incremental loading is suspended.
1724 + jest.advanceTimersByTime(500);
1725 +
1726 + expect(ReactNoop).toMatchRenderedOutput(
1727 + <>
1728 + <span>A</span>
1729 + <span>B</span>
1730 + </>,
1731 + );
1732 +
1733 + await act(() => C.resolve());
1734 + assertLog(['C']);
1735 +
1736 + expect(ReactNoop).toMatchRenderedOutput(
1737 + <>
1738 + <span>A</span>
1739 + <span>B</span>
1740 + <span>C</span>
1741 + </>,
1742 + );
1743 });
1744
1745 // @gate enableSuspenseList
@@ -1758,7 +1779,7 @@ describe('ReactSuspenseList', () => {
1779 });
1780 assertConsoleErrorDev([
1781 '<SuspenseList tail="collapsed" /> is only valid if ' +
1761 - 'revealOrder is "forwards" or "backwards". ' +
1782 + 'revealOrder is "forwards" (default) or "backwards". ' +
1783 'Did you mean to specify revealOrder="forwards"?' +
1784 '\n in SuspenseList (at **)' +
1785 '\n in Foo (at **)',
packages/react-server/src/ReactFizzServer.js
+2 -6
@@ -1913,7 +1913,7 @@ function renderSuspenseListRows(
1913 task: Task,
1914 keyPath: KeyNode,
1915 rows: Array<ReactNodeList>,
1916 - revealOrder: 'forwards' | 'backwards' | 'unstable_legacy-backwards',
1916 + revealOrder: void | 'forwards' | 'backwards' | 'unstable_legacy-backwards',
1917 ): void {
1918 // This is a fork of renderChildrenArray that's aware of tracking rows.
1919 const prevKeyPath = task.keyPath;
@@ -2098,11 +2098,7 @@ function renderSuspenseList(
2098 const revealOrder: SuspenseListRevealOrder = props.revealOrder;
2099 // TODO: Support tail hidden/collapsed modes.
2100 // const tailMode: SuspenseListTailMode = props.tail;
2101 - if (
2102 - revealOrder === 'forwards' ||
2103 - revealOrder === 'backwards' ||
2104 - revealOrder === 'unstable_legacy-backwards'
2105 - ) {
2101 + if (revealOrder !== 'independent' && revealOrder !== 'together') {
2102 // For ordered reveal, we need to produce rows from the children.
2103 if (isArray(children)) {
2104 renderSuspenseListRows(request, task, keyPath, children, revealOrder);