From 5e2a7ba9cbd3b7d91f65e345a05615a5cc22f2c7 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 3 Jun 2025 11:22:55 -0400 Subject: [PATCH 1/7] Clarify that SuspenseList needs an array as children but not nested array --- .eslintrc.js | 1 + packages/shared/ReactTypes.js | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index 1bb4e868d0694..8ffc5a732c877 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -611,6 +611,7 @@ module.exports = { TimeoutID: 'readonly', WheelEventHandler: 'readonly', FinalizationRegistry: 'readonly', + Exclude: 'readonly', Omit: 'readonly', Keyframe: 'readonly', PropertyIndexedKeyframes: 'readonly', diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index b2b2f25fbaa93..8c0919fb335ca 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -313,8 +313,18 @@ export type SuspenseListRevealOrder = export type SuspenseListTailMode = 'collapsed' | 'hidden' | void; +// A SuspenseList row cannot include a nested Array since it's an easy mistake to not realize it +// is treated as a single row. A Fragment can be used to intentionally have multiple children as +// a single row. +type SuspenseListRow = Exclude< + ReactNodeList, + Iterable | AsyncIterable, +>; + type DirectionalSuspenseListProps = { - children?: ReactNodeList, + // Directional SuspenseList are defined by an array of children or multiple slots to JSX + // It does not allow a single element child. + children?: Iterable | AsyncIterable, // Note: AsyncIterable is experimental. revealOrder: 'forwards' | 'backwards', tail?: SuspenseListTailMode, }; From 27ac387aff4e644fbb07970c8e3fce91eca22121 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 3 Jun 2025 11:30:02 -0400 Subject: [PATCH 2/7] Add revealOrder="independent" This will take the place of the current default. --- .../ReactDOMFizzSuspenseList-test.js | 64 +++++++++++++++ .../src/ReactFiberBeginWork.js | 8 +- .../src/ReactFiberSuspenseComponent.js | 6 +- .../src/__tests__/ReactSuspenseList-test.js | 78 ++++++++++++++++++- packages/shared/ReactTypes.js | 3 +- 5 files changed, 150 insertions(+), 9 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 977d2dbf154f4..ee8343e4f5967 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -197,6 +197,70 @@ describe('ReactDOMFizzSuspenseList', () => { ); }); + // @gate enableSuspenseList + it('independently with revealOrder="independent"', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( + + ); + } + + await A.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + Loading B + Loading C +
, + ); + + await serverAct(() => C.resolve()); + assertLog(['C']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + Loading B + C +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + // @gate enableSuspenseList it('displays all "together"', async () => { const A = createAsyncText('A'); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 7b86962f778fe..7f42eb9936559 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -3230,6 +3230,7 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) { revealOrder !== 'forwards' && revealOrder !== 'backwards' && revealOrder !== 'together' && + revealOrder !== 'independent' && !didWarnAboutRevealOrder[revealOrder] ) { didWarnAboutRevealOrder[revealOrder] = true; @@ -3237,7 +3238,8 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) { switch (revealOrder.toLowerCase()) { case 'together': case 'forwards': - case 'backwards': { + case 'backwards': + case 'independent': { console.error( '"%s" is not a valid value for revealOrder on . ' + 'Use lowercase "%s" instead.', @@ -3259,7 +3261,7 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) { default: console.error( '"%s" is not a supported revealOrder on . ' + - 'Did you mean "together", "forwards" or "backwards"?', + 'Did you mean "independent", "together", "forwards" or "backwards"?', revealOrder, ); break; @@ -3267,7 +3269,7 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) { } else { console.error( '%s is not a supported value for revealOrder on . ' + - 'Did you mean "together", "forwards" or "backwards"?', + 'Did you mean "independent", "together", "forwards" or "backwards"?', revealOrder, ); } diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js index f7ae78ed6d313..45d9b1b270dae 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js @@ -75,9 +75,11 @@ export function findFirstSuspended(row: Fiber): null | Fiber { } } else if ( node.tag === SuspenseListComponent && - // revealOrder undefined can't be trusted because it don't + // Independent revealOrder can't be trusted because it doesn't // keep track of whether it suspended or not. - node.memoizedProps.revealOrder !== undefined + (node.memoizedProps.revealOrder === 'forwards' || + node.memoizedProps.revealOrder === 'backwards' || + node.memoizedProps.revealOrder === 'together') ) { const didSuspend = (node.flags & DidCapture) !== NoFlags; if (didSuspend) { diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index f9efb330cf891..f781000c810be 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -79,7 +79,7 @@ describe('ReactSuspenseList', () => { }); assertConsoleErrorDev([ '"something" is not a supported revealOrder on ' + - '. Did you mean "together", "forwards" or "backwards"?' + + '. Did you mean "independent", "together", "forwards" or "backwards"?' + '\n in SuspenseList (at **)' + '\n in Foo (at **)', ]); @@ -285,6 +285,78 @@ describe('ReactSuspenseList', () => { ); }); + // @gate enableSuspenseList + it('shows content independently with revealOrder="independent"', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( + + }> +
+ + }> + + + }> + + + + ); + } + + await A.resolve(); + + ReactNoop.render(); + + await waitForAll([ + 'A', + 'Suspend! [B]', + 'Loading B', + 'Suspend! [C]', + 'Loading C', + // pre-warming + 'Suspend! [B]', + 'Suspend! [C]', + ]); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + Loading B + Loading C + , + ); + + await act(() => C.resolve()); + assertLog( + gate('alwaysThrottleRetries') + ? ['Suspend! [B]', 'C', 'Suspend! [B]'] + : ['C'], + ); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + Loading B + C + , + ); + + await act(() => B.resolve()); + assertLog(['B']); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + B + C + , + ); + }); + // @gate enableSuspenseList && !disableLegacyMode it('shows content independently in legacy mode regardless of option', async () => { const A = createAsyncText('A'); @@ -564,7 +636,7 @@ describe('ReactSuspenseList', () => { }); // @gate enableSuspenseList - it('displays all "together" in nested SuspenseLists where the inner is default', async () => { + it('displays all "together" in nested SuspenseLists where the inner is "independent"', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); const C = createAsyncText('C'); @@ -575,7 +647,7 @@ describe('ReactSuspenseList', () => { }> - + }> diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 8c0919fb335ca..09916f9cf4b47 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -309,6 +309,7 @@ export type SuspenseListRevealOrder = | 'forwards' | 'backwards' | 'together' + | 'independent' | void; export type SuspenseListTailMode = 'collapsed' | 'hidden' | void; @@ -331,7 +332,7 @@ type DirectionalSuspenseListProps = { type NonDirectionalSuspenseListProps = { children?: ReactNodeList, - revealOrder?: 'together' | void, + revealOrder?: 'independent' | 'together' | void, tail?: void, }; From 323b80e4841ea3c1d9c15af1b8072d05dbb1c95a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 3 Jun 2025 11:38:59 -0400 Subject: [PATCH 3/7] Add unstable_legacy-backwards option This will just preserve the current semantics of "backwards" temporarily. --- .../ReactDOMFizzSuspenseList-test.js | 6 ++--- .../react-reconciler/src/ReactChildFiber.js | 4 ++- .../src/ReactFiberBeginWork.js | 10 ++++++-- .../src/ReactFiberSuspenseComponent.js | 1 + .../src/__tests__/ReactSuspenseList-test.js | 10 ++++---- packages/react-server/src/ReactFizzServer.js | 25 +++++++++++++++---- packages/shared/ReactTypes.js | 3 ++- 7 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index ee8343e4f5967..1a3fa9379511c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -650,7 +650,7 @@ describe('ReactDOMFizzSuspenseList', () => { }); // @gate enableSuspenseList - it('displays each items in "backwards" order', async () => { + it('displays each items in "backwards" order in legacy mode', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); const C = createAsyncText('C'); @@ -658,7 +658,7 @@ describe('ReactDOMFizzSuspenseList', () => { function Foo() { return (
- + }> @@ -730,7 +730,7 @@ describe('ReactDOMFizzSuspenseList', () => { return (
- + }> diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index fcb2406552899..f574162b41b0c 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -2097,7 +2097,9 @@ export function validateSuspenseListChildren( ) { if (__DEV__) { if ( - (revealOrder === 'forwards' || revealOrder === 'backwards') && + (revealOrder === 'forwards' || + revealOrder === 'backwards' || + revealOrder === 'unstable_legacy-backwards') && children !== undefined && children !== null && children !== false diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 7f42eb9936559..b541b564696d2 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -3229,6 +3229,7 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) { revealOrder !== undefined && revealOrder !== 'forwards' && revealOrder !== 'backwards' && + revealOrder !== 'unstable_legacy-backwards' && revealOrder !== 'together' && revealOrder !== 'independent' && !didWarnAboutRevealOrder[revealOrder] @@ -3290,7 +3291,11 @@ function validateTailOptions( 'Did you mean "collapsed" or "hidden"?', tailMode, ); - } else if (revealOrder !== 'forwards' && revealOrder !== 'backwards') { + } else if ( + revealOrder !== 'forwards' && + revealOrder !== 'backwards' && + revealOrder !== 'unstable_legacy-backwards' + ) { didWarnAboutTailOptions[tailMode] = true; console.error( ' is only valid if revealOrder is ' + @@ -3416,7 +3421,8 @@ function updateSuspenseListComponent( ); break; } - case 'backwards': { + case 'backwards': + case 'unstable_legacy-backwards': { // We're going to find the first row that has existing content. // At the same time we're going to reverse the list of everything // we pass in the meantime. That's going to be our tail in reverse diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js index 45d9b1b270dae..2542d660c4c47 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js @@ -79,6 +79,7 @@ export function findFirstSuspended(row: Fiber): null | Fiber { // keep track of whether it suspended or not. (node.memoizedProps.revealOrder === 'forwards' || node.memoizedProps.revealOrder === 'backwards' || + node.memoizedProps.revealOrder === 'unstable_legacy-backwards' || node.memoizedProps.revealOrder === 'together') ) { const didSuspend = (node.flags & DidCapture) !== NoFlags; diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index f781000c810be..f830a168c8d16 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -166,7 +166,7 @@ describe('ReactSuspenseList', () => { it('warns if a single fragment is passed to a "backwards" list', async () => { function Foo() { return ( - + <>{[]} ); @@ -176,7 +176,7 @@ describe('ReactSuspenseList', () => { ReactNoop.render(); }); assertConsoleErrorDev([ - 'A single row was passed to a . ' + + 'A single row was passed to a . ' + 'This is not useful since it needs multiple rows. ' + 'Did you mean to pass multiple children or an array?' + '\n in SuspenseList (at **)' + @@ -1035,7 +1035,7 @@ describe('ReactSuspenseList', () => { function Foo() { return ( - + }> @@ -1294,7 +1294,7 @@ describe('ReactSuspenseList', () => { function Foo({items}) { return ( - + {items.map(([key, Component]) => ( }> @@ -1868,7 +1868,7 @@ describe('ReactSuspenseList', () => { function Foo({items}) { return ( - + {items.map(([key, Component]) => ( }> diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index a18d8d16748f9..579edf25c9102 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1799,7 +1799,7 @@ function renderSuspenseListRows( task: Task, keyPath: KeyNode, rows: Array, - revealOrder: 'forwards' | 'backwards', + revealOrder: 'forwards' | 'backwards' | 'unstable_legacy-backwards', ): void { // This is a fork of renderChildrenArray that's aware of tracking rows. const prevKeyPath = task.keyPath; @@ -1827,7 +1827,11 @@ function renderSuspenseListRows( // Since we are going to resume into a slot whose order was already // determined by the prerender, we can safely resume it even in reverse // render order. - const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n; + const i = + revealOrder !== 'backwards' && + revealOrder !== 'unstable_legacy-backwards' + ? n + : totalChildren - 1 - n; const node = rows[i]; task.row = previousSuspenseListRow = createSuspenseListRow( previousSuspenseListRow, @@ -1852,7 +1856,11 @@ function renderSuspenseListRows( // Since we are going to resume into a slot whose order was already // determined by the prerender, we can safely resume it even in reverse // render order. - const i = revealOrder !== 'backwards' ? n : totalChildren - 1 - n; + const i = + revealOrder !== 'backwards' && + revealOrder !== 'unstable_legacy-backwards' + ? n + : totalChildren - 1 - n; const node = rows[i]; if (__DEV__) { warnForMissingKey(request, task, node); @@ -1869,7 +1877,10 @@ function renderSuspenseListRows( } } else { task = ((task: any): RenderTask); // Refined - if (revealOrder !== 'backwards') { + if ( + revealOrder !== 'backwards' && + revealOrder !== 'unstable_legacy-backwards' + ) { // Forwards direction for (let i = 0; i < totalChildren; i++) { const node = rows[i]; @@ -1973,7 +1984,11 @@ function renderSuspenseList( const revealOrder: SuspenseListRevealOrder = props.revealOrder; // TODO: Support tail hidden/collapsed modes. // const tailMode: SuspenseListTailMode = props.tail; - if (revealOrder === 'forwards' || revealOrder === 'backwards') { + if ( + revealOrder === 'forwards' || + revealOrder === 'backwards' || + revealOrder === 'unstable_legacy-backwards' + ) { // For ordered reveal, we need to produce rows from the children. if (isArray(children)) { renderSuspenseListRows(request, task, keyPath, children, revealOrder); diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 09916f9cf4b47..585d0d35bd5cf 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -308,6 +308,7 @@ export type SuspenseProps = { export type SuspenseListRevealOrder = | 'forwards' | 'backwards' + | 'unstable_legacy-backwards' | 'together' | 'independent' | void; @@ -326,7 +327,7 @@ type DirectionalSuspenseListProps = { // Directional SuspenseList are defined by an array of children or multiple slots to JSX // It does not allow a single element child. children?: Iterable | AsyncIterable, // Note: AsyncIterable is experimental. - revealOrder: 'forwards' | 'backwards', + revealOrder: 'forwards' | 'backwards' | 'unstable_legacy-backwards', tail?: SuspenseListTailMode, }; From 5e81f46837e316ed1f23d43be76f9fd45797ae9c Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 3 Jun 2025 11:43:39 -0400 Subject: [PATCH 4/7] Add explicit tail="visible" option It just does the same as the default atm. --- .../src/__tests__/ReactDOMServerSuspense-test.internal.js | 2 +- packages/react-reconciler/src/ReactFiberBeginWork.js | 8 ++++++-- .../src/__tests__/ReactSuspenseList-test.js | 8 ++++++-- packages/shared/ReactTypes.js | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js index 8a959a294987c..6e6f9bb09261e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js @@ -123,7 +123,7 @@ describe('ReactDOMServerSuspense', () => { // @gate enableSuspenseList it('server renders a SuspenseList component and its children', async () => { const example = ( - +
A
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index b541b564696d2..4c7cf95624600 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -3284,11 +3284,15 @@ function validateTailOptions( ) { if (__DEV__) { if (tailMode !== undefined && !didWarnAboutTailOptions[tailMode]) { - if (tailMode !== 'collapsed' && tailMode !== 'hidden') { + if ( + tailMode !== 'visible' && + tailMode !== 'collapsed' && + tailMode !== 'hidden' + ) { didWarnAboutTailOptions[tailMode] = true; console.error( '"%s" is not a supported value for tail on . ' + - 'Did you mean "collapsed" or "hidden"?', + 'Did you mean "visible", "collapsed" or "hidden"?', tailMode, ); } else if ( diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index f830a168c8d16..33c22db353eda 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -131,7 +131,11 @@ describe('ReactSuspenseList', () => { // @gate enableSuspenseList it('warns if a single element is passed to a "forwards" list', async () => { function Foo({children}) { - return {children}; + return ( + + {children} + + ); } ReactNoop.render(); @@ -1623,7 +1627,7 @@ describe('ReactSuspenseList', () => { }); assertConsoleErrorDev([ '"collapse" is not a supported value for tail on ' + - '. Did you mean "collapsed" or "hidden"?' + + '. Did you mean "visible", "collapsed" or "hidden"?' + '\n in SuspenseList (at **)' + '\n in Foo (at **)', ]); diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 585d0d35bd5cf..ea9d3d1d0789a 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -313,7 +313,7 @@ export type SuspenseListRevealOrder = | 'independent' | void; -export type SuspenseListTailMode = 'collapsed' | 'hidden' | void; +export type SuspenseListTailMode = 'visible' | 'collapsed' | 'hidden' | void; // A SuspenseList row cannot include a nested Array since it's an easy mistake to not realize it // is treated as a single row. A Fragment can be used to intentionally have multiple children as From 59e4583d79d4efbe0422d235708a647eae8feefd Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 3 Jun 2025 11:56:49 -0400 Subject: [PATCH 5/7] Warn if no explicit tail option is specified for forwards/backwards --- fixtures/ssr/src/components/LargeContent.js | 2 +- .../src/__tests__/ReactDOMFizzServer-test.js | 2 +- .../ReactDOMFizzStaticBrowser-test.js | 2 +- .../ReactDOMFizzSuspenseList-test.js | 14 +++-- .../src/__tests__/ReactDOMFloat-test.js | 2 +- ...DOMServerPartialHydration-test.internal.js | 2 +- .../__tests__/ReactWrongReturnPointer-test.js | 2 +- .../src/ReactFiberBeginWork.js | 22 +++++-- .../__tests__/ReactContextPropagation-test.js | 2 +- .../src/__tests__/ReactSuspenseList-test.js | 61 +++++++++++++------ .../ReactSuspenseyCommitPhase-test.js | 2 +- 11 files changed, 78 insertions(+), 35 deletions(-) diff --git a/fixtures/ssr/src/components/LargeContent.js b/fixtures/ssr/src/components/LargeContent.js index 7c4a6cf2258ef..f5c8adb03e233 100644 --- a/fixtures/ssr/src/components/LargeContent.js +++ b/fixtures/ssr/src/components/LargeContent.js @@ -6,7 +6,7 @@ import React, { export default function LargeContent() { return ( - +

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index d5f635f964934..57124ec6e0c0e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1289,7 +1289,7 @@ describe('ReactDOMFizzServer', () => { function App({showMore}) { return (

- + {a} {b} {showMore ? ( diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 478bed90a3a0d..c306b1369b174 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -2254,7 +2254,7 @@ describe('ReactDOMFizzStaticBrowser', () => { function App() { return (
- + diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 1a3fa9379511c..1a40c1f487a43 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -587,7 +587,7 @@ describe('ReactDOMFizzSuspenseList', () => { function Foo() { return (
- + }> @@ -658,7 +658,7 @@ describe('ReactDOMFizzSuspenseList', () => { function Foo() { return (
- + }> @@ -729,8 +729,10 @@ describe('ReactDOMFizzSuspenseList', () => { function Foo() { return (
- - + + }> @@ -800,7 +802,7 @@ describe('ReactDOMFizzSuspenseList', () => { function Foo() { return (
- + }> @@ -855,7 +857,7 @@ describe('ReactDOMFizzSuspenseList', () => { function Foo() { return (
- + }> diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 6436f1898b798..b8a4e5b86ae91 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -5755,7 +5755,7 @@ body { - + diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 8cace332cd552..5b719ddf4acc5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -2362,7 +2362,7 @@ describe('ReactDOMServerPartialHydration', () => { function App({showMore}) { return ( - + {a} {b} {showMore ? ( diff --git a/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js b/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js index e221f01b92d65..0c73410f2edf0 100644 --- a/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js +++ b/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js @@ -172,7 +172,7 @@ test('regression (#20932): return pointer is correct before entering deleted tre function App() { return ( - + }> diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 4c7cf95624600..fa71702706851 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -3283,13 +3283,27 @@ function validateTailOptions( revealOrder: SuspenseListRevealOrder, ) { if (__DEV__) { - if (tailMode !== undefined && !didWarnAboutTailOptions[tailMode]) { - if ( + const cacheKey = tailMode == null ? 'null' : tailMode; + if (!didWarnAboutTailOptions[cacheKey]) { + if (tailMode == null) { + if ( + revealOrder === 'forwards' || + revealOrder === 'backwards' || + revealOrder === 'unstable_legacy-backwards' + ) { + didWarnAboutTailOptions[cacheKey] = true; + console.error( + 'The default for the prop is changing. ' + + 'To be future compatible you must explictly specify either ' + + '"visible" (the current default), "collapsed" or "hidden".', + ); + } + } else if ( tailMode !== 'visible' && tailMode !== 'collapsed' && tailMode !== 'hidden' ) { - didWarnAboutTailOptions[tailMode] = true; + didWarnAboutTailOptions[cacheKey] = true; console.error( '"%s" is not a supported value for tail on . ' + 'Did you mean "visible", "collapsed" or "hidden"?', @@ -3300,7 +3314,7 @@ function validateTailOptions( revealOrder !== 'backwards' && revealOrder !== 'unstable_legacy-backwards' ) { - didWarnAboutTailOptions[tailMode] = true; + didWarnAboutTailOptions[cacheKey] = true; console.error( ' is only valid if revealOrder is ' + '"forwards" or "backwards". ' + diff --git a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js index 6ed19ba6d505b..0a338c8aa8710 100644 --- a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js +++ b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js @@ -677,7 +677,7 @@ describe('ReactLazyContextPropagation', () => { setContext = setValue; const children = React.useMemo( () => ( - + diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index 33c22db353eda..b2302a1399739 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -170,7 +170,7 @@ describe('ReactSuspenseList', () => { it('warns if a single fragment is passed to a "backwards" list', async () => { function Foo() { return ( - + <>{[]} ); @@ -192,7 +192,7 @@ describe('ReactSuspenseList', () => { it('warns if a nested array is passed to a "forwards" list', async () => { function Foo({items}) { return ( - + {items.map(name => ( {name} @@ -973,7 +973,7 @@ describe('ReactSuspenseList', () => { function Foo() { return ( - + }> @@ -1039,7 +1039,7 @@ describe('ReactSuspenseList', () => { function Foo() { return ( - + }> @@ -1113,7 +1113,7 @@ describe('ReactSuspenseList', () => { function Foo({items}) { return ( - + {items.map(([key, Component]) => ( }> @@ -1298,7 +1298,7 @@ describe('ReactSuspenseList', () => { function Foo({items}) { return ( - + {items.map(([key, Component]) => ( }> @@ -1476,7 +1476,7 @@ describe('ReactSuspenseList', () => { it('switches to rendering fallbacks if the tail takes long CPU time', async () => { function Foo() { return ( - + }> @@ -1611,6 +1611,29 @@ describe('ReactSuspenseList', () => { ); }); + // @gate enableSuspenseList + it('warns if no tail option is specified', async () => { + function Foo() { + return ( + + A + B + + ); + } + + await act(() => { + ReactNoop.render(); + }); + assertConsoleErrorDev([ + 'The default for the prop is changing. ' + + 'To be future compatible you must explictly specify either ' + + '"visible" (the current default), "collapsed" or "hidden".' + + '\n in SuspenseList (at **)' + + '\n in Foo (at **)', + ]); + }); + // @gate enableSuspenseList it('warns if an unsupported tail option is used', async () => { function Foo() { @@ -2230,7 +2253,7 @@ describe('ReactSuspenseList', () => { function Foo() { return ( - + }> @@ -2331,7 +2354,7 @@ describe('ReactSuspenseList', () => { function Foo({showB}) { return ( - + }> @@ -2397,7 +2420,7 @@ describe('ReactSuspenseList', () => { function Foo() { return (
- + @@ -2749,7 +2772,7 @@ describe('ReactSuspenseList', () => { function App() { Scheduler.log('App'); return ( - + }> @@ -2836,7 +2859,7 @@ describe('ReactSuspenseList', () => { Scheduler.log('App'); return ( - + }> @@ -3012,7 +3035,7 @@ describe('ReactSuspenseList', () => { // Several layers of Bailout wrappers help verify we're // marking updates all the way to the propagation root. return ( - + @@ -3105,7 +3128,7 @@ describe('ReactSuspenseList', () => { function Repro({update}) { return ( - + {update && ( }> @@ -3204,7 +3227,7 @@ describe('ReactSuspenseList', () => { } function Foo() { return ( - + ); @@ -3260,7 +3283,11 @@ describe('ReactSuspenseList', () => { }; function Foo() { - return {iterable}; + return ( + + {iterable} + + ); } await act(() => { @@ -3346,7 +3373,7 @@ describe('ReactSuspenseList', () => { it('warns if a nested async iterable is passed to a "forwards" list', async () => { function Foo({items}) { return ( - + {items}
Tail
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js index cad1a011817f6..2e252acbf3be7 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js @@ -345,7 +345,7 @@ describe('ReactSuspenseyCommitPhase', () => { it('demonstrate current behavior when used with SuspenseList (not ideal)', async () => { function App() { return ( - + }> From 52ff21823d87fc7cfae3d62f91b3034a8cf52c3a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 3 Jun 2025 12:01:55 -0400 Subject: [PATCH 6/7] Warn if no explicit revealOrder option is specified --- .../__tests__/ReactDOMFizzSuspenseList-test.js | 4 ++-- .../react-reconciler/src/ReactFiberBeginWork.js | 16 +++++++++++----- .../src/__tests__/ReactErrorStacks-test.js | 2 +- .../src/__tests__/ReactSuspenseList-test.js | 10 +++++++++- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 1a40c1f487a43..74db51d242960 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -516,7 +516,7 @@ describe('ReactDOMFizzSuspenseList', () => { }); // @gate enableSuspenseList - it('displays all "together" in nested SuspenseLists where the inner is default', async () => { + it('displays all "together" in nested SuspenseLists where the inner is "independent"', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); const C = createAsyncText('C'); @@ -528,7 +528,7 @@ describe('ReactDOMFizzSuspenseList', () => { }>
- + }> diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index fa71702706851..80d831b51aff1 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -337,7 +337,7 @@ if (__DEV__) { didWarnAboutContextTypes = ({}: {[string]: boolean}); didWarnAboutGetDerivedStateOnFunctionComponent = ({}: {[string]: boolean}); didWarnAboutReassigningProps = false; - didWarnAboutRevealOrder = ({}: {[empty]: boolean}); + didWarnAboutRevealOrder = ({}: {[string]: boolean}); didWarnAboutTailOptions = ({}: {[string]: boolean}); didWarnAboutDefaultPropsOnFunctionComponent = ({}: {[string]: boolean}); didWarnAboutClassNameOnViewTransition = ({}: {[string]: boolean}); @@ -3225,17 +3225,23 @@ function findLastContentRow(firstChild: null | Fiber): null | Fiber { function validateRevealOrder(revealOrder: SuspenseListRevealOrder) { if (__DEV__) { + const cacheKey = revealOrder == null ? 'null' : revealOrder; if ( - revealOrder !== undefined && revealOrder !== 'forwards' && revealOrder !== 'backwards' && revealOrder !== 'unstable_legacy-backwards' && revealOrder !== 'together' && revealOrder !== 'independent' && - !didWarnAboutRevealOrder[revealOrder] + !didWarnAboutRevealOrder[cacheKey] ) { - didWarnAboutRevealOrder[revealOrder] = true; - if (typeof revealOrder === 'string') { + didWarnAboutRevealOrder[cacheKey] = true; + if (revealOrder == null) { + console.error( + 'The default for the prop is changing. ' + + 'To be future compatible you must explictly specify either ' + + '"independent" (the current default), "together", "forwards" or "legacy_unstable-backwards".', + ); + } else if (typeof revealOrder === 'string') { switch (revealOrder.toLowerCase()) { case 'together': case 'forwards': diff --git a/packages/react-reconciler/src/__tests__/ReactErrorStacks-test.js b/packages/react-reconciler/src/__tests__/ReactErrorStacks-test.js index 1b8beba58b07e..9e4e9694a6a88 100644 --- a/packages/react-reconciler/src/__tests__/ReactErrorStacks-test.js +++ b/packages/react-reconciler/src/__tests__/ReactErrorStacks-test.js @@ -255,7 +255,7 @@ describe('ReactFragment', () => { onCaughtError, }).render( - + , diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index b2302a1399739..8ab13d1fc08f5 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -218,7 +218,7 @@ describe('ReactSuspenseList', () => { }); // @gate enableSuspenseList - it('shows content independently by default', async () => { + it('warns if no revealOrder is specified', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); const C = createAsyncText('C'); @@ -254,6 +254,14 @@ describe('ReactSuspenseList', () => { 'Suspend! [C]', ]); + assertConsoleErrorDev([ + 'The default for the prop is changing. ' + + 'To be future compatible you must explictly specify either ' + + '"independent" (the current default), "together", "forwards" or "legacy_unstable-backwards".' + + '\n in SuspenseList (at **)' + + '\n in Foo (at **)', + ]); + expect(ReactNoop).toMatchRenderedOutput( <> A From 87656c950a945459a022bee40ff7b5f4450fa7b1 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 3 Jun 2025 12:39:46 -0400 Subject: [PATCH 7/7] Warn if the old "backwards" option is used So we can repurpose this. --- .../src/ReactFiberBeginWork.js | 6 +- .../src/__tests__/ReactSuspenseList-test.js | 79 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 80d831b51aff1..a9316163156f5 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -3228,7 +3228,6 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) { const cacheKey = revealOrder == null ? 'null' : revealOrder; if ( revealOrder !== 'forwards' && - revealOrder !== 'backwards' && revealOrder !== 'unstable_legacy-backwards' && revealOrder !== 'together' && revealOrder !== 'independent' && @@ -3241,6 +3240,11 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) { 'To be future compatible you must explictly specify either ' + '"independent" (the current default), "together", "forwards" or "legacy_unstable-backwards".', ); + } else if (revealOrder === 'backwards') { + console.error( + 'The rendering order of is changing. ' + + 'To be future compatible you must specify revealOrder="legacy_unstable-backwards" instead.', + ); } else if (typeof revealOrder === 'string') { switch (revealOrder.toLowerCase()) { case 'together': diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index 8ab13d1fc08f5..bfbf165dbaba6 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -1039,6 +1039,85 @@ describe('ReactSuspenseList', () => { ); }); + // @gate enableSuspenseList + it('warns if revealOrder="backwards" is specified', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( + + }> + + + }> + + + }> + + + + ); + } + + await A.resolve(); + + ReactNoop.render(); + + await waitForAll([ + 'Suspend! [C]', + 'Loading C', + 'Loading B', + 'Loading A', + // pre-warming + 'Suspend! [C]', + ]); + + assertConsoleErrorDev([ + 'The rendering order of is changing. ' + + 'To be future compatible you must specify ' + + 'revealOrder="legacy_unstable-backwards" instead.' + + '\n in SuspenseList (at **)' + + '\n in Foo (at **)', + ]); + + expect(ReactNoop).toMatchRenderedOutput( + <> + Loading A + Loading B + Loading C + , + ); + + await act(() => C.resolve()); + assertLog([ + 'C', + 'Suspend! [B]', + // pre-warming + 'Suspend! [B]', + ]); + + expect(ReactNoop).toMatchRenderedOutput( + <> + Loading A + Loading B + C + , + ); + + await act(() => B.resolve()); + assertLog(['B', 'A']); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + B + C + , + ); + }); + // @gate enableSuspenseList it('displays each items in "backwards" order', async () => { const A = createAsyncText('A');