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);