From 0ba2f01f7470f2f78a2698adf2644b0801ef3c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 5 Nov 2025 14:12:09 -0500 Subject: [PATCH 1/3] Rename to and implement in SSR (#35022) We've long had the CPU suspense feature behind a flag under the terrible API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a long time we want it to just be `defer={true}` (or just `` in the short hand syntax). So this adds the new name and warns for the old name. For only the new name, I also implemented SSR semantics in Fizz. It has two effects here. 1) It renders the fallback before the content (similar to prerender) allowing siblings to complete quicker. 2) It always outlines the result. When streaming this should really happen naturally but if you defer a prerendered content it also implies that it's expensive and should be outlined. It gives you a opt-in to outlining similar to suspensey images and css but let you control it manually. --- .../src/__tests__/ReactDOMFizzServer-test.js | 30 +++++++++ .../src/ReactFiberBeginWork.js | 15 ++++- .../src/__tests__/ReactCPUSuspense-test.js | 67 +++++++++++++++---- packages/react-server/src/ReactFizzServer.js | 51 +++++++++----- packages/shared/ReactTypes.js | 1 + 5 files changed, 132 insertions(+), 32 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 18cf6124444..e17729c032a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue , ); }); + + // @gate enableCPUSuspense + it('outlines deferred Suspense boundaries', async () => { + function Log({text}) { + Scheduler.log(text); + return text; + } + + await act(async () => { + renderToPipeableStream( +
+ }> + {} + +
, + ).pipe(writable); + await jest.runAllTimers(); + const temp = document.createElement('body'); + temp.innerHTML = buffer; + expect(getVisibleChildren(temp)).toEqual(
Waiting
); + }); + + assertLog(['Waiting', 'hello']); + + expect(getVisibleChildren(container)).toEqual( +
+ hello +
, + ); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 5875455f34c..e9ea77fb4c6 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean; let didWarnAboutRevealOrder; let didWarnAboutTailOptions; let didWarnAboutClassNameOnViewTransition; +let didWarnAboutExpectedLoadTime = false; if (__DEV__) { didWarnAboutBadClass = ({}: {[string]: boolean}); @@ -2458,8 +2459,20 @@ function updateSuspenseComponent( return bailoutOffscreenComponent(null, primaryChildFragment); } else if ( enableCPUSuspense && - typeof nextProps.unstable_expectedLoadTime === 'number' + (typeof nextProps.unstable_expectedLoadTime === 'number' || + nextProps.defer === true) ) { + if (__DEV__) { + if (typeof nextProps.unstable_expectedLoadTime === 'number') { + if (!didWarnAboutExpectedLoadTime) { + didWarnAboutExpectedLoadTime = true; + console.error( + ' is deprecated. ' + + 'Use instead.', + ); + } + } + } // This is a CPU-bound tree. Skip this tree and show a placeholder to // unblock the surrounding content. Then immediately retry after the // initial commit. diff --git a/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js index 1b1489de33a..7d4ebadc62d 100644 --- a/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js @@ -1,3 +1,5 @@ +/* eslint-disable react/jsx-boolean-value */ + let React; let ReactNoop; let Scheduler; @@ -11,6 +13,7 @@ let resolveText; // let rejectText; let assertLog; +let assertConsoleErrorDev; let waitForPaint; describe('ReactSuspenseWithNoopRenderer', () => { @@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { const InternalTestUtils = require('internal-test-utils'); assertLog = InternalTestUtils.assertLog; + assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev; waitForPaint = InternalTestUtils.waitForPaint; textCache = new Map(); @@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => { } // @gate enableCPUSuspense - it('skips CPU-bound trees on initial mount', async () => { + it('warns for the old name is used', async () => { function App() { return ( <>
}> @@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => { ); } + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitForPaint(['Outer', 'Loading...']); + assertConsoleErrorDev([ + ' is deprecated. ' + + 'Use instead.' + + '\n in Suspense (at **)' + + '\n in App (at **)', + ]); + expect(root).toMatchRenderedOutput( + <> + Outer +
Loading...
+ , + ); + }); + + // Inner contents finish in separate commit from outer + assertLog(['Inner']); + expect(root).toMatchRenderedOutput( + <> + Outer +
Inner
+ , + ); + }); + + // @gate enableCPUSuspense + it('skips CPU-bound trees on initial mount', async () => { + function App() { + return ( + <> + +
+ }> + + +
+ + ); + } + const root = ReactNoop.createRoot(); await act(async () => { root.render(); @@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { <>
- }> + }>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { <>
- }> + }>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => { <>
- }> + }>
- }> + }>
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 531f1202762..b439607e158 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -181,6 +181,7 @@ import { enableViewTransition, enableFizzBlockingRender, enableAsyncDebugInfo, + enableCPUSuspense, } from 'shared/ReactFeatureFlags'; import assign from 'shared/assign'; @@ -250,6 +251,7 @@ type SuspenseBoundary = { row: null | SuspenseListRow, // the row that this boundary blocks from completing. completedSegments: Array, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. + defer: boolean, // never inline deferred boundaries fallbackAbortableTasks: Set, // used to cancel task on the fallback if the boundary completes or gets canceled. contentState: HoistableState, fallbackState: HoistableState, @@ -456,7 +458,9 @@ function isEligibleForOutlining( // The larger this limit is, the more we can save on preparing fallbacks in case we end up // outlining. return ( - (boundary.byteSize > 500 || hasSuspenseyContent(boundary.contentState)) && + (boundary.byteSize > 500 || + hasSuspenseyContent(boundary.contentState) || + boundary.defer) && // For boundaries that can possibly contribute to the preamble we don't want to outline // them regardless of their size since the fallbacks should only be emitted if we've // errored the boundary. @@ -782,6 +786,7 @@ function createSuspenseBoundary( fallbackAbortableTasks: Set, contentPreamble: null | Preamble, fallbackPreamble: null | Preamble, + defer: boolean, ): SuspenseBoundary { const boundary: SuspenseBoundary = { status: PENDING, @@ -791,6 +796,7 @@ function createSuspenseBoundary( row: row, completedSegments: [], byteSize: 0, + defer: defer, fallbackAbortableTasks, errorDigest: null, contentState: createHoistableState(), @@ -1274,6 +1280,7 @@ function renderSuspenseBoundary( // in case it ends up generating a large subtree of content. const fallback: ReactNodeList = props.fallback; const content: ReactNodeList = props.children; + const defer: boolean = enableCPUSuspense && props.defer === true; const fallbackAbortSet: Set = new Set(); let newBoundary: SuspenseBoundary; @@ -1284,6 +1291,7 @@ function renderSuspenseBoundary( fallbackAbortSet, createPreambleState(), createPreambleState(), + defer, ); } else { newBoundary = createSuspenseBoundary( @@ -1292,6 +1300,7 @@ function renderSuspenseBoundary( fallbackAbortSet, null, null, + defer, ); } if (request.trackedPostpones !== null) { @@ -1327,29 +1336,32 @@ function renderSuspenseBoundary( // no parent segment so there's nothing to wait on. contentRootSegment.parentFlushed = true; - if (request.trackedPostpones !== null) { + const trackedPostpones = request.trackedPostpones; + if (trackedPostpones !== null || defer) { + // This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously + // and schedule the content to render later. This is the opposite of what we do during a normal render + // where we try to skip rendering the fallback if the content itself can render synchronously + // Stash the original stack frame. const suspenseComponentStack = task.componentStack; - // This is a prerender. In this mode we want to render the fallback synchronously and schedule - // the content to render later. This is the opposite of what we do during a normal render - // where we try to skip rendering the fallback if the content itself can render synchronously - const trackedPostpones = request.trackedPostpones; const fallbackKeyPath: KeyNode = [ keyPath[0], 'Suspense Fallback', keyPath[2], ]; - const fallbackReplayNode: ReplayNode = [ - fallbackKeyPath[1], - fallbackKeyPath[2], - ([]: Array), - null, - ]; - trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode); - // We are rendering the fallback before the boundary content so we keep track of - // the fallback replay node until we determine if the primary content suspends - newBoundary.trackedFallbackNode = fallbackReplayNode; + if (trackedPostpones !== null) { + const fallbackReplayNode: ReplayNode = [ + fallbackKeyPath[1], + fallbackKeyPath[2], + ([]: Array), + null, + ]; + trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode); + // We are rendering the fallback before the boundary content so we keep track of + // the fallback replay node until we determine if the primary content suspends + newBoundary.trackedFallbackNode = fallbackReplayNode; + } task.blockedSegment = boundarySegment; task.blockedPreamble = newBoundary.fallbackPreamble; @@ -1580,6 +1592,7 @@ function replaySuspenseBoundary( const content: ReactNodeList = props.children; const fallback: ReactNodeList = props.fallback; + const defer: boolean = enableCPUSuspense && props.defer === true; const fallbackAbortSet: Set = new Set(); let resumedBoundary: SuspenseBoundary; @@ -1590,6 +1603,7 @@ function replaySuspenseBoundary( fallbackAbortSet, createPreambleState(), createPreambleState(), + defer, ); } else { resumedBoundary = createSuspenseBoundary( @@ -1598,6 +1612,7 @@ function replaySuspenseBoundary( fallbackAbortSet, null, null, + defer, ); } resumedBoundary.parentFlushed = true; @@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary( new Set(), null, null, + false, ); resumedBoundary.parentFlushed = true; // We restore the same id of this boundary as was used during prerender. @@ -5493,7 +5509,8 @@ function flushSegment( !flushingPartialBoundaries && isEligibleForOutlining(request, boundary) && (flushedByteSize + boundary.byteSize > request.progressiveChunkSize || - hasSuspenseyContent(boundary.contentState)) + hasSuspenseyContent(boundary.contentState) || + boundary.defer) ) { // Inlining this boundary would make the current sequence being written too large // and block the parent for too long. Instead, it will be emitted separately so that we diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 0b8d222e5cd..fe36b77ee28 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -313,6 +313,7 @@ export type SuspenseProps = { unstable_avoidThisFallback?: boolean, unstable_expectedLoadTime?: number, + defer?: boolean, name?: string, }; From fa767dade6a22be994b5bf348fdabfc5301f72b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 5 Nov 2025 15:52:21 -0500 Subject: [PATCH 2/3] Remove unstable_expectedLoadTime option (#35051) Follow up to #35022. It's now replaced by the `defer` option. Sounds like nobody is actually using this option, including Meta, so we can just delete it. --- .../src/ReactFiberBeginWork.js | 18 +------ .../src/__tests__/ReactCPUSuspense-test.js | 47 ------------------- packages/shared/ReactTypes.js | 1 - 3 files changed, 1 insertion(+), 65 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index e9ea77fb4c6..7020af6e043 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -325,7 +325,6 @@ export let didWarnAboutReassigningProps: boolean; let didWarnAboutRevealOrder; let didWarnAboutTailOptions; let didWarnAboutClassNameOnViewTransition; -let didWarnAboutExpectedLoadTime = false; if (__DEV__) { didWarnAboutBadClass = ({}: {[string]: boolean}); @@ -2457,22 +2456,7 @@ function updateSuspenseComponent( } return bailoutOffscreenComponent(null, primaryChildFragment); - } else if ( - enableCPUSuspense && - (typeof nextProps.unstable_expectedLoadTime === 'number' || - nextProps.defer === true) - ) { - if (__DEV__) { - if (typeof nextProps.unstable_expectedLoadTime === 'number') { - if (!didWarnAboutExpectedLoadTime) { - didWarnAboutExpectedLoadTime = true; - console.error( - ' is deprecated. ' + - 'Use instead.', - ); - } - } - } + } else if (enableCPUSuspense && nextProps.defer === true) { // This is a CPU-bound tree. Skip this tree and show a placeholder to // unblock the surrounding content. Then immediately retry after the // initial commit. diff --git a/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js index 7d4ebadc62d..54e4207c656 100644 --- a/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js @@ -13,7 +13,6 @@ let resolveText; // let rejectText; let assertLog; -let assertConsoleErrorDev; let waitForPaint; describe('ReactSuspenseWithNoopRenderer', () => { @@ -29,7 +28,6 @@ describe('ReactSuspenseWithNoopRenderer', () => { const InternalTestUtils = require('internal-test-utils'); assertLog = InternalTestUtils.assertLog; - assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev; waitForPaint = InternalTestUtils.waitForPaint; textCache = new Map(); @@ -119,51 +117,6 @@ describe('ReactSuspenseWithNoopRenderer', () => { } } - // @gate enableCPUSuspense - it('warns for the old name is used', async () => { - function App() { - return ( - <> - -
- }> - - -
- - ); - } - - const root = ReactNoop.createRoot(); - await act(async () => { - root.render(); - await waitForPaint(['Outer', 'Loading...']); - assertConsoleErrorDev([ - ' is deprecated. ' + - 'Use instead.' + - '\n in Suspense (at **)' + - '\n in App (at **)', - ]); - expect(root).toMatchRenderedOutput( - <> - Outer -
Loading...
- , - ); - }); - - // Inner contents finish in separate commit from outer - assertLog(['Inner']); - expect(root).toMatchRenderedOutput( - <> - Outer -
Inner
- , - ); - }); - // @gate enableCPUSuspense it('skips CPU-bound trees on initial mount', async () => { function App() { diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index fe36b77ee28..bcdda6da2a7 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -312,7 +312,6 @@ export type SuspenseProps = { suspenseCallback?: (Set | null) => mixed, unstable_avoidThisFallback?: boolean, - unstable_expectedLoadTime?: number, defer?: boolean, name?: string, }; From 5a2205ba28a02596461187eecdffd066075d8685 Mon Sep 17 00:00:00 2001 From: Alexander Kachkaev Date: Wed, 5 Nov 2025 21:57:26 +0000 Subject: [PATCH 3/3] Update bug report template for eslint plugin label (#34959) ## Summary When creating https://github.com/facebook/react/issues/34957, I noticed a reference to `eslint-plugin-react-compiler` instead of `eslint-plugin-react-hooks`. Since the former is merged into the latter (https://github.com/facebook/react/pull/32416, https://github.com/facebook/react/pull/34228), I have decided to update the issue template to avoid confusion. --- .github/ISSUE_TEMPLATE/compiler_bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/compiler_bug_report.yml b/.github/ISSUE_TEMPLATE/compiler_bug_report.yml index 233201d3f5b..a2c0e3d7aa3 100644 --- a/.github/ISSUE_TEMPLATE/compiler_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/compiler_bug_report.yml @@ -11,7 +11,7 @@ body: options: - label: React Compiler core (the JS output is incorrect, or your app works incorrectly after optimization) - label: babel-plugin-react-compiler (build issue installing or using the Babel plugin) - - label: eslint-plugin-react-compiler (build issue installing or using the eslint plugin) + - label: eslint-plugin-react-hooks (build issue installing or using the eslint plugin) - label: react-compiler-healthcheck (build issue installing or using the healthcheck script) - type: input attributes: