From 0e2e396460a9f781cf43ec2750da05eb58bdb30e Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Wed, 26 Nov 2025 14:51:16 +0100 Subject: [PATCH 1/7] [Fizz] Push a stalled use() to the ownerStack/debugTask --- fixtures/fizz/server/render-to-stream.js | 5 ++ fixtures/fizz/src/App.js | 13 ++- fixtures/fizz/src/MaybeHaltedComponent.js | 6 ++ packages/react-server/src/ReactFizzServer.js | 50 ++++++++++-- .../react-server/src/ReactFizzThenable.js | 79 +++++++++++++++++++ .../src/__tests__/ReactServer-test.js | 55 ++++++++++--- 6 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 fixtures/fizz/src/MaybeHaltedComponent.js diff --git a/fixtures/fizz/server/render-to-stream.js b/fixtures/fizz/server/render-to-stream.js index 9c9c0d019366c..2ae822f0b1a5f 100644 --- a/fixtures/fizz/server/render-to-stream.js +++ b/fixtures/fizz/server/render-to-stream.js @@ -45,6 +45,11 @@ module.exports = function render(url, res) { onError(x) { didError = true; console.error(x); + // Redundant with `console.createTask`. Only added for debugging. + console.error( + 'The above error occurred during server rendering: %s', + React.captureOwnerStack() + ); }, }); // Abandon and switch to client rendering if enough time passes. diff --git a/fixtures/fizz/src/App.js b/fixtures/fizz/src/App.js index df7cbd6d02338..8a82dd925d694 100644 --- a/fixtures/fizz/src/App.js +++ b/fixtures/fizz/src/App.js @@ -6,10 +6,17 @@ * */ +import {Suspense} from 'react'; import Html from './Html'; import BigComponent from './BigComponent'; +import MaybeHaltedComponent from './MaybeHaltedComponent'; -export default function App({assets, title}) { +const serverHalt = + typeof window === 'undefined' + ? new Promise(() => {}) + : Promise.resolve('client'); + +export default function App({assets, promise, title}) { const components = []; for (let i = 0; i <= 250; i++) { @@ -21,6 +28,10 @@ export default function App({assets, title}) {

{title}

{components}

all done

+

or maybe not

+ + + ); } diff --git a/fixtures/fizz/src/MaybeHaltedComponent.js b/fixtures/fizz/src/MaybeHaltedComponent.js new file mode 100644 index 0000000000000..c9f08ee3bccc7 --- /dev/null +++ b/fixtures/fizz/src/MaybeHaltedComponent.js @@ -0,0 +1,6 @@ +import {use} from 'react'; + +export default function MaybeHaltedComponent({promise}) { + use(promise); + return
Did not halt
; +} diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 4ad48d79fba4f..b73603b3a8266 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -190,7 +190,13 @@ import assign from 'shared/assign'; import noop from 'shared/noop'; import getComponentNameFromType from 'shared/getComponentNameFromType'; import isArray from 'shared/isArray'; -import {SuspenseException, getSuspendedThenable} from './ReactFizzThenable'; +import { + SuspenseException, + getSuspendedThenable, + getSuspendedCallSiteStackDEV, + getSuspendedCallSiteDebugTaskDEV, + setCaptureSuspendedCallSiteDEV, +} from './ReactFizzThenable'; // Linked list representing the identity of a component given the component/tag name and key. // The name might be minified but we assume that it's going to be the same generated name. Typically @@ -1023,6 +1029,39 @@ function pushHaltedAwaitOnComponentStack( } } +function pushSuspendedCallSiteOnComponentStack( + request: Request, + task: Task, +): void { + setCaptureSuspendedCallSiteDEV(true); + let suspendCallSiteStack: ComponentStackNode | null = null; + let suspendCallSiteDebugTask: ConsoleTask | null = null; + const previousPingedTasks = request.pingedTasks; + try { + // TODO: Use a dedicated method to re-render instead of abusing ping. + request.pingedTasks = [task]; + performWork(request); + suspendCallSiteStack = getSuspendedCallSiteStackDEV(); + suspendCallSiteDebugTask = getSuspendedCallSiteDebugTaskDEV(); + } finally { + request.pingedTasks = previousPingedTasks; + setCaptureSuspendedCallSiteDEV(false); + } + + if (suspendCallSiteStack !== null) { + const ownerStack = task.componentStack; + task.componentStack = { + // The owner of the suspended call site would be the owner of this task. + // We need the task itself otherwise we'd miss a frame. + owner: ownerStack, + parent: suspendCallSiteStack.parent, + stack: suspendCallSiteStack.stack, + type: suspendCallSiteStack.type, + }; + } + task.debugTask = suspendCallSiteDebugTask; +} + function pushServerComponentStack( task: Task, debugInfo: void | null | ReactDebugInfo, @@ -4535,12 +4574,9 @@ function abortTask(task: Task, request: Request, error: mixed): void { debugInfo = node._debugInfo; } pushHaltedAwaitOnComponentStack(task, debugInfo); - /* if (task.thenableState !== null) { - // TODO: If we were stalled inside use() of a Client Component then we should - // rerender to get the stack trace from the use() call. + pushSuspendedCallSiteOnComponentStack(request, task); } - */ } } @@ -4962,7 +4998,9 @@ function retryRenderTask( task: RenderTask, segment: Segment, ): void { - if (segment.status !== PENDING) { + // TODO: We only retry when aborted to get the suspended callsite. + // Use a dedicated mechanism to re-render. + if (segment.status !== PENDING && segment.status !== ABORTED) { // We completed this by other means before we had a chance to retry it. return; } diff --git a/packages/react-server/src/ReactFizzThenable.js b/packages/react-server/src/ReactFizzThenable.js index cb0ccfac69928..6bf6bb4a4ba98 100644 --- a/packages/react-server/src/ReactFizzThenable.js +++ b/packages/react-server/src/ReactFizzThenable.js @@ -19,8 +19,10 @@ import type { FulfilledThenable, RejectedThenable, } from 'shared/ReactTypes'; +import type {ComponentStackNode} from './ReactFizzComponentStack'; import noop from 'shared/noop'; +import {currentTaskInDEV} from './ReactFizzCurrentTask'; export opaque type ThenableState = Array>; @@ -126,6 +128,9 @@ export function trackUsedThenable( // get captured by the work loop, log a warning, because that means // something in userspace must have caught it. suspendedThenable = thenable; + if (__DEV__ && shouldCaptureSuspendedCallSite) { + captureSuspendedCallSite(); + } throw SuspenseException; } } @@ -163,3 +168,77 @@ export function getSuspendedThenable(): Thenable { suspendedThenable = null; return thenable; } + +let shouldCaptureSuspendedCallSite: boolean = false; +export function setCaptureSuspendedCallSiteDEV(capture: boolean): void { + if (!__DEV__) { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'setCaptureSuspendedCallSiteDEV was called in a production environment. ' + + 'This is a bug in React.', + ); + } + shouldCaptureSuspendedCallSite = capture; +} + +// DEV-only +let suspendedCallSiteStack: ComponentStackNode | null = null; +let suspendedCallSiteDebugTask: ConsoleTask | null = null; +function captureSuspendedCallSite(): void { + const currentTask = currentTaskInDEV; + if (currentTask === null) { + // eslint-disable-next-line react-internal/prod-error-codes -- not a prod error + throw new Error( + 'Expected to have a current task when tracking a suspend call site. ' + + 'This is a bug in React.', + ); + } + const currentComponentStack = currentTask.componentStack; + if (currentComponentStack === null) { + // eslint-disable-next-line react-internal/prod-error-codes -- not a prod error + throw new Error( + 'Expected to have a component stack on the current task when ' + + 'tracking a suspended call site. This is a bug in React.', + ); + } + suspendedCallSiteStack = { + parent: currentComponentStack.parent, + type: currentComponentStack.type, + owner: currentComponentStack.owner, + stack: Error('react-stack-top-frame'), + }; + suspendedCallSiteDebugTask = currentTask.debugTask; +} +export function getSuspendedCallSiteStackDEV(): ComponentStackNode | null { + if (__DEV__) { + if (suspendedCallSiteStack === null) { + return null; + } + const callSite = suspendedCallSiteStack; + suspendedCallSiteStack = null; + return callSite; + } else { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'getSuspendedCallSiteDEV was called in a production environment. ' + + 'This is a bug in React.', + ); + } +} + +export function getSuspendedCallSiteDebugTaskDEV(): ConsoleTask | null { + if (__DEV__) { + if (suspendedCallSiteDebugTask === null) { + return null; + } + const debugTask = suspendedCallSiteDebugTask; + suspendedCallSiteDebugTask = null; + return debugTask; + } else { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'getSuspendedCallSiteDebugTaskDEV was called in a production environment. ' + + 'This is a bug in React.', + ); + } +} diff --git a/packages/react-server/src/__tests__/ReactServer-test.js b/packages/react-server/src/__tests__/ReactServer-test.js index c6504fd21f578..96f972843c2ac 100644 --- a/packages/react-server/src/__tests__/ReactServer-test.js +++ b/packages/react-server/src/__tests__/ReactServer-test.js @@ -17,13 +17,25 @@ let ReactNoopServer; function normalizeCodeLocInfo(str) { return ( str && - str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { - const dot = name.lastIndexOf('.'); - if (dot !== -1) { - name = name.slice(dot + 1); - } - return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); - }) + str + .split('\n') + .filter(frame => { + // These frames should be ignore-listed since they point into + // React internals i.e. node_modules. + return ( + frame.indexOf('ReactFizzHooks') === -1 && + frame.indexOf('ReactFizzThenable') === -1 && + frame.indexOf('ReactHooks') === -1 + ); + }) + .join('\n') + .replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { + const dot = name.lastIndexOf('.'); + if (dot !== -1) { + name = name.slice(dot + 1); + } + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + }) ); } @@ -53,8 +65,21 @@ describe('ReactServer', () => { React.use(promise); return
Hello, Dave!
; } + function Indirection({promise}) { + return ( +
+ +
+ ); + } function App({promise}) { - return ; + return ( +
+
+ +
+
+ ); } let caughtError; @@ -80,10 +105,20 @@ describe('ReactServer', () => { }), ); expect(normalizeCodeLocInfo(componentStack)).toEqual( - '\n in Component (at **)' + '\n in App (at **)', + '\n in Component (at **)' + + '\n in div' + + '\n in Indirection (at **)' + + '\n in div' + + '\n in section' + + '\n in App (at **)', ); expect(normalizeCodeLocInfo(ownerStack)).toEqual( - __DEV__ ? '\n in App (at **)' : null, + __DEV__ + ? '' + + '\n in Component (at **)' + + '\n in Indirection (at **)' + + '\n in App (at **)' + : null, ); }); }); From 3200a65971f6f96369431331873c468d21a9e0e8 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Wed, 26 Nov 2025 15:21:04 +0100 Subject: [PATCH 2/7] Use forked implementation of performWork --- packages/react-server/src/ReactFizzServer.js | 63 +++++++++++++++---- .../src/__tests__/ReactServer-test.js | 10 ++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index b73603b3a8266..fc2b719803890 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1029,25 +1029,66 @@ function pushHaltedAwaitOnComponentStack( } } +// performWork + retryTask without mutation +function rerenderHaltedTask(request: Request, task: Task): void { + const prevContext = getActiveContext(); + const prevDispatcher = ReactSharedInternals.H; + ReactSharedInternals.H = HooksDispatcher; + const prevAsyncDispatcher = ReactSharedInternals.A; + ReactSharedInternals.A = DefaultAsyncDispatcher; + + const prevRequest = currentRequest; + currentRequest = request; + + const prevGetCurrentStackImpl = ReactSharedInternals.getCurrentStack; + ReactSharedInternals.getCurrentStack = getCurrentStackInDEV; + + const prevResumableState = currentResumableState; + setCurrentResumableState(request.resumableState); + switchContext(task.context); + const prevTaskInDEV = currentTaskInDEV; + setCurrentTaskInDEV(task); + try { + retryNode(request, task); + } catch (x) { + // Suspended again. + resetHooksState(); + } finally { + setCurrentTaskInDEV(prevTaskInDEV); + setCurrentResumableState(prevResumableState); + + ReactSharedInternals.H = prevDispatcher; + ReactSharedInternals.A = prevAsyncDispatcher; + + ReactSharedInternals.getCurrentStack = prevGetCurrentStackImpl; + if (prevDispatcher === HooksDispatcher) { + // This means that we were in a reentrant work loop. This could happen + // in a renderer that supports synchronous work like renderToString, + // when it's called from within another renderer. + // Normally we don't bother switching the contexts to their root/default + // values when leaving because we'll likely need the same or similar + // context again. However, when we're inside a synchronous loop like this + // we'll to restore the context to what it was before returning. + switchContext(prevContext); + } + currentRequest = prevRequest; + } +} + function pushSuspendedCallSiteOnComponentStack( request: Request, task: Task, ): void { setCaptureSuspendedCallSiteDEV(true); - let suspendCallSiteStack: ComponentStackNode | null = null; - let suspendCallSiteDebugTask: ConsoleTask | null = null; - const previousPingedTasks = request.pingedTasks; try { - // TODO: Use a dedicated method to re-render instead of abusing ping. - request.pingedTasks = [task]; - performWork(request); - suspendCallSiteStack = getSuspendedCallSiteStackDEV(); - suspendCallSiteDebugTask = getSuspendedCallSiteDebugTaskDEV(); + rerenderHaltedTask(request, task); } finally { - request.pingedTasks = previousPingedTasks; setCaptureSuspendedCallSiteDEV(false); } + const suspendCallSiteStack = getSuspendedCallSiteStackDEV(); + const suspendCallSiteDebugTask = getSuspendedCallSiteDebugTaskDEV(); + if (suspendCallSiteStack !== null) { const ownerStack = task.componentStack; task.componentStack = { @@ -4998,9 +5039,7 @@ function retryRenderTask( task: RenderTask, segment: Segment, ): void { - // TODO: We only retry when aborted to get the suspended callsite. - // Use a dedicated mechanism to re-render. - if (segment.status !== PENDING && segment.status !== ABORTED) { + if (segment.status !== PENDING) { // We completed this by other means before we had a chance to retry it. return; } diff --git a/packages/react-server/src/__tests__/ReactServer-test.js b/packages/react-server/src/__tests__/ReactServer-test.js index 96f972843c2ac..e6ad0c1492dbc 100644 --- a/packages/react-server/src/__tests__/ReactServer-test.js +++ b/packages/react-server/src/__tests__/ReactServer-test.js @@ -61,7 +61,13 @@ describe('ReactServer', () => { }); it('has Owner Stacks in DEV when aborted', async () => { + const Context = React.createContext(null); + function Component({promise}) { + const context = React.use(Context); + if (context === null) { + throw new Error('Missing context'); + } React.use(promise); return
Hello, Dave!
; } @@ -86,7 +92,9 @@ describe('ReactServer', () => { let componentStack; let ownerStack; const result = ReactNoopServer.render( - {})} />, + + {})} /> + , { onError: (error, errorInfo) => { caughtError = error; From fca82854a6702ed5006acda1a48dac5da3888b10 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Thu, 27 Nov 2025 17:23:27 +0100 Subject: [PATCH 3/7] Explain why the ConsoleTask is incomplete --- packages/react-server/src/ReactFizzThenable.js | 6 ++++++ .../src/__tests__/ReactServer-test.js | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/react-server/src/ReactFizzThenable.js b/packages/react-server/src/ReactFizzThenable.js index 6bf6bb4a4ba98..8230a3d734346 100644 --- a/packages/react-server/src/ReactFizzThenable.js +++ b/packages/react-server/src/ReactFizzThenable.js @@ -207,6 +207,12 @@ function captureSuspendedCallSite(): void { owner: currentComponentStack.owner, stack: Error('react-stack-top-frame'), }; + // TODO: If this is used in error handlers, the ConsoleTask stack + // will just be this debugTask + the stack of the abort() call which usually means + // it's just this debugTask. + // Ideally we'd be able to reconstruct the owner ConsoleTask as well. + // The stack of the debugTask would not point to the suspend location anyway. + // The focus is really on callsite which should be used in captureOwnerStack(). suspendedCallSiteDebugTask = currentTask.debugTask; } export function getSuspendedCallSiteStackDEV(): ComponentStackNode | null { diff --git a/packages/react-server/src/__tests__/ReactServer-test.js b/packages/react-server/src/__tests__/ReactServer-test.js index e6ad0c1492dbc..3715186e39b04 100644 --- a/packages/react-server/src/__tests__/ReactServer-test.js +++ b/packages/react-server/src/__tests__/ReactServer-test.js @@ -9,6 +9,7 @@ */ 'use strict'; +import {AsyncLocalStorage} from 'node:async_hooks'; let act; let React; @@ -39,10 +40,21 @@ function normalizeCodeLocInfo(str) { ); } +const currentTask = new AsyncLocalStorage({defaultValue: null}); + describe('ReactServer', () => { beforeEach(() => { jest.resetModules(); + console.createTask = jest.fn(taskName => { + return { + run: taskFn => { + const parentTask = currentTask.getStore() || ''; + return currentTask.run(parentTask + '\n' + taskName, taskFn); + }, + }; + }); + act = require('internal-test-utils').act; React = require('react'); ReactNoopServer = require('react-noop-renderer/server'); @@ -91,6 +103,7 @@ describe('ReactServer', () => { let caughtError; let componentStack; let ownerStack; + let task; const result = ReactNoopServer.render( {})} /> @@ -100,6 +113,7 @@ describe('ReactServer', () => { caughtError = error; componentStack = errorInfo.componentStack; ownerStack = __DEV__ ? React.captureOwnerStack() : null; + task = currentTask.getStore(); }, }, ); @@ -128,5 +142,6 @@ describe('ReactServer', () => { '\n in App (at **)' : null, ); + expect(task).toEqual(__DEV__ ? '\n' : null); }); }); From cd68bd4f15953e33a50ab73b67bfc2e8192bb729 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Thu, 27 Nov 2025 23:13:05 +0100 Subject: [PATCH 4/7] should we reset thenables to unknown status? --- packages/react-server/src/ReactFizzServer.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index fc2b719803890..2b962b85e8c14 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -4616,6 +4616,13 @@ function abortTask(task: Task, request: Request, error: mixed): void { } pushHaltedAwaitOnComponentStack(task, debugInfo); if (task.thenableState !== null) { + // TODO: really? + // If the thenable was resolved in the meantime, we won't get a stack. + // We won't know which thenable in thenableState is newly settled though. + // We can't just clear status fields on each thenable because then the + // stack may point at a thenable that wasn't stalled. In those cases + // it's better to point at the callsite of the stalled Component as an + // entrypoint instead of the wrong thenable. pushSuspendedCallSiteOnComponentStack(request, task); } } From 848d9160d5e59bcb1fc4fca7f443855c87f1347d Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Fri, 28 Nov 2025 13:23:46 +0100 Subject: [PATCH 5/7] Current behavior for what happens if you resolve before React gets a chance to process the ping --- .../src/__tests__/ReactServer-test.js | 87 ++++++++++++------- 1 file changed, 57 insertions(+), 30 deletions(-) diff --git a/packages/react-server/src/__tests__/ReactServer-test.js b/packages/react-server/src/__tests__/ReactServer-test.js index 3715186e39b04..b686225771ec3 100644 --- a/packages/react-server/src/__tests__/ReactServer-test.js +++ b/packages/react-server/src/__tests__/ReactServer-test.js @@ -18,28 +18,38 @@ let ReactNoopServer; function normalizeCodeLocInfo(str) { return ( str && - str - .split('\n') - .filter(frame => { - // These frames should be ignore-listed since they point into - // React internals i.e. node_modules. - return ( - frame.indexOf('ReactFizzHooks') === -1 && - frame.indexOf('ReactFizzThenable') === -1 && - frame.indexOf('ReactHooks') === -1 - ); - }) - .join('\n') - .replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { - const dot = name.lastIndexOf('.'); - if (dot !== -1) { - name = name.slice(dot + 1); - } - return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); - }) + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { + const dot = name.lastIndexOf('.'); + if (dot !== -1) { + name = name.slice(dot + 1); + } + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + }) ); } +/** + * Removes all stackframes not pointing into this file + */ +function ignoreListStack(str) { + if (!str) { + return str; + } + + let ignoreListedStack = ''; + const lines = str.split('\n'); + + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const line of lines) { + if (line.indexOf(__filename) === -1) { + } else { + ignoreListedStack += '\n' + line.replace(__dirname, '.'); + } + } + + return ignoreListedStack; +} + const currentTask = new AsyncLocalStorage({defaultValue: null}); describe('ReactServer', () => { @@ -75,26 +85,28 @@ describe('ReactServer', () => { it('has Owner Stacks in DEV when aborted', async () => { const Context = React.createContext(null); - function Component({promise}) { + function Component({p1, p2, p3}) { const context = React.use(Context); if (context === null) { throw new Error('Missing context'); } - React.use(promise); + React.use(p1); + React.use(p2); + React.use(p3); return
Hello, Dave!
; } - function Indirection({promise}) { + function Indirection({p1, p2, p3}) { return (
- +
); } - function App({promise}) { + function App({p1, p2, p3}) { return (
- +
); @@ -104,9 +116,21 @@ describe('ReactServer', () => { let componentStack; let ownerStack; let task; + const resolvedPromise = Promise.resolve('one'); + resolvedPromise.status = 'fulfilled'; + resolvedPromise.value = 'one'; + let resolvePendingPromise; + const pendingPromise = new Promise(resolve => { + resolvePendingPromise = value => { + pendingPromise.status = 'fulfilled'; + pendingPromise.value = value; + resolve(value); + }; + }); + const hangingPromise = new Promise(() => {}); const result = ReactNoopServer.render( - {})} /> + , { onError: (error, errorInfo) => { @@ -119,6 +143,7 @@ describe('ReactServer', () => { ); await act(async () => { + resolvePendingPromise('two'); result.abort(); }); expect(caughtError).toEqual( @@ -134,12 +159,14 @@ describe('ReactServer', () => { '\n in section' + '\n in App (at **)', ); - expect(normalizeCodeLocInfo(ownerStack)).toEqual( + expect(ignoreListStack(ownerStack)).toEqual( __DEV__ ? '' + - '\n in Component (at **)' + - '\n in Indirection (at **)' + - '\n in App (at **)' + // The concrete location may change as this test is updated + // Just make sure they still point at the same code + '\n at Component (./ReactServer-test.js:95:13)' + + '\n at Indirection (./ReactServer-test.js:101:44)' + + '\n at App (./ReactServer-test.js:109:46)' : null, ); expect(task).toEqual(__DEV__ ? '\n' : null); From 21ead2caace44519f5e30b476fb137251fbee1ae Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Fri, 28 Nov 2025 13:58:02 +0100 Subject: [PATCH 6/7] Ensure we rerender with the original suspender --- fixtures/fizz/package.json | 2 +- fixtures/fizz/server/delays.js | 2 +- fixtures/fizz/src/App.js | 1 - fixtures/fizz/yarn.lock | 40 +++++++----------- packages/react-server/src/ReactFizzServer.js | 17 ++++---- .../react-server/src/ReactFizzThenable.js | 42 +++++++++++++++++-- .../src/__tests__/ReactServer-test.js | 4 +- 7 files changed, 64 insertions(+), 44 deletions(-) diff --git a/fixtures/fizz/package.json b/fixtures/fizz/package.json index 7156aa9e8fa0c..2f4cd22a174a2 100644 --- a/fixtures/fizz/package.json +++ b/fixtures/fizz/package.json @@ -34,7 +34,7 @@ "start": "concurrently \"npm run start:server\" \"npm run start:bundler\"", "dev:server": "cross-env NODE_ENV=development nodemon -- --inspect server/server.js", "start:server": "cross-env NODE_ENV=production nodemon -- server/server.js", - "dev:bundler": "cross-env NODE_ENV=development nodemon -- scripts/build.js", + "dev:bundler": "cross-env NODE_OPTIONS=--openssl-legacy-provider NODE_ENV=development nodemon -- scripts/build.js", "start:bundler": "cross-env NODE_ENV=production nodemon -- scripts/build.js" }, "babel": { diff --git a/fixtures/fizz/server/delays.js b/fixtures/fizz/server/delays.js index 0f51247d28d6a..9b0b5653c9f18 100644 --- a/fixtures/fizz/server/delays.js +++ b/fixtures/fizz/server/delays.js @@ -12,7 +12,7 @@ exports.API_DELAY = 2000; // How long the server waits for data before giving up. -exports.ABORT_DELAY = 10000; +exports.ABORT_DELAY = 1; // How long serving the JS bundles is delayed. exports.JS_BUNDLE_DELAY = 4000; diff --git a/fixtures/fizz/src/App.js b/fixtures/fizz/src/App.js index 8a82dd925d694..f37b28d56f783 100644 --- a/fixtures/fizz/src/App.js +++ b/fixtures/fizz/src/App.js @@ -26,7 +26,6 @@ export default function App({assets, promise, title}) { return (

{title}

- {components}

all done

or maybe not

diff --git a/fixtures/fizz/yarn.lock b/fixtures/fizz/yarn.lock index 0f9768adfabca..b86904f4d06df 100644 --- a/fixtures/fizz/yarn.lock +++ b/fixtures/fizz/yarn.lock @@ -3176,7 +3176,7 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: +js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== @@ -3300,13 +3300,6 @@ lodash@^4.17.15, lodash@^4.17.19: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -loose-envify@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" @@ -4095,13 +4088,12 @@ rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-dom@^18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" - integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== +react-dom@^19.0.0: + version "19.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.0.tgz#00ed1e959c365e9a9d48f8918377465466ec3af8" + integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ== dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.0" + scheduler "^0.27.0" react-error-boundary@^3.1.3: version "3.1.4" @@ -4110,12 +4102,10 @@ react-error-boundary@^3.1.3: dependencies: "@babel/runtime" "^7.12.5" -react@^18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" - integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== - dependencies: - loose-envify "^1.1.0" +react@^19.0.0: + version "19.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5" + integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== read-pkg@^4.0.1: version "4.0.1" @@ -4383,12 +4373,10 @@ safe-regex@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -scheduler@^0.23.0: - version "0.23.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" - integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== - dependencies: - loose-envify "^1.1.0" +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== schema-utils@^1.0.0: version "1.0.0" diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 2b962b85e8c14..aa43204c72d03 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -193,6 +193,7 @@ import isArray from 'shared/isArray'; import { SuspenseException, getSuspendedThenable, + ensureSuspendableThenableStateDEV, getSuspendedCallSiteStackDEV, getSuspendedCallSiteDebugTaskDEV, setCaptureSuspendedCallSiteDEV, @@ -1030,7 +1031,7 @@ function pushHaltedAwaitOnComponentStack( } // performWork + retryTask without mutation -function rerenderHaltedTask(request: Request, task: Task): void { +function rerenderStalledTask(request: Request, task: Task): void { const prevContext = getActiveContext(); const prevDispatcher = ReactSharedInternals.H; ReactSharedInternals.H = HooksDispatcher; @@ -1080,9 +1081,14 @@ function pushSuspendedCallSiteOnComponentStack( task: Task, ): void { setCaptureSuspendedCallSiteDEV(true); + const restoreThenableState = ensureSuspendableThenableStateDEV( + // refined at the callsite + ((task.thenableState: any): ThenableState), + ); try { - rerenderHaltedTask(request, task); + rerenderStalledTask(request, task); } finally { + restoreThenableState(); setCaptureSuspendedCallSiteDEV(false); } @@ -4616,13 +4622,6 @@ function abortTask(task: Task, request: Request, error: mixed): void { } pushHaltedAwaitOnComponentStack(task, debugInfo); if (task.thenableState !== null) { - // TODO: really? - // If the thenable was resolved in the meantime, we won't get a stack. - // We won't know which thenable in thenableState is newly settled though. - // We can't just clear status fields on each thenable because then the - // stack may point at a thenable that wasn't stalled. In those cases - // it's better to point at the callsite of the stalled Component as an - // entrypoint instead of the wrong thenable. pushSuspendedCallSiteOnComponentStack(request, task); } } diff --git a/packages/react-server/src/ReactFizzThenable.js b/packages/react-server/src/ReactFizzThenable.js index 8230a3d734346..5b068b48e764a 100644 --- a/packages/react-server/src/ReactFizzThenable.js +++ b/packages/react-server/src/ReactFizzThenable.js @@ -7,12 +7,9 @@ * @flow */ -// Corresponds to ReactFiberWakeable and ReactFlightWakeable modules. Generally, +// Corresponds to ReactFiberThenable and ReactFlightThenable modules. Generally, // changes to one module should be reflected in the others. -// TODO: Rename this module and the corresponding Fiber one to "Thenable" -// instead of "Wakeable". Or some other more appropriate name. - import type { Thenable, PendingThenable, @@ -185,6 +182,10 @@ export function setCaptureSuspendedCallSiteDEV(capture: boolean): void { let suspendedCallSiteStack: ComponentStackNode | null = null; let suspendedCallSiteDebugTask: ConsoleTask | null = null; function captureSuspendedCallSite(): void { + // This is currently only used when aborting in Fizz. + // You can only abort the render in Fizz and Flight. + // In Fiber we only track suspended use via DevTools. + // In Flight, we track suspended use via async debug info. const currentTask = currentTaskInDEV; if (currentTask === null) { // eslint-disable-next-line react-internal/prod-error-codes -- not a prod error @@ -248,3 +249,36 @@ export function getSuspendedCallSiteDebugTaskDEV(): ConsoleTask | null { ); } } + +export function ensureSuspendableThenableStateDEV( + thenableState: ThenableState, +): () => void { + if (__DEV__) { + const lastThenable = thenableState[thenableState.length - 1]; + switch (lastThenable.status) { + case 'fulfilled': + const previousThenableValue = lastThenable.value; + delete lastThenable.value; + delete (lastThenable: any).status; + return () => { + lastThenable.value = previousThenableValue; + lastThenable.status = 'fulfilled'; + }; + case 'rejected': + const previousThenableReason = lastThenable.reason; + delete lastThenable.reason; + delete (lastThenable: any).status; + return () => { + lastThenable.reason = previousThenableReason; + lastThenable.status = 'rejected'; + }; + } + return noop; + } else { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'ensureSuspendableThenableStateDEV was called in a production environment. ' + + 'This is a bug in React.', + ); + } +} diff --git a/packages/react-server/src/__tests__/ReactServer-test.js b/packages/react-server/src/__tests__/ReactServer-test.js index b686225771ec3..e05f9c0397f3c 100644 --- a/packages/react-server/src/__tests__/ReactServer-test.js +++ b/packages/react-server/src/__tests__/ReactServer-test.js @@ -163,8 +163,8 @@ describe('ReactServer', () => { __DEV__ ? '' + // The concrete location may change as this test is updated - // Just make sure they still point at the same code - '\n at Component (./ReactServer-test.js:95:13)' + + // Just make sure they still point at React.use(p2) + '\n at Component (./ReactServer-test.js:94:13)' + '\n at Indirection (./ReactServer-test.js:101:44)' + '\n at App (./ReactServer-test.js:109:46)' : null, From 7cbcc6a77aea07d67eb83f29386029069353e1e0 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Fri, 28 Nov 2025 14:39:25 +0100 Subject: [PATCH 7/7] WIP test with lazy types --- .../src/__tests__/ReactFlightDOMNode-test.js | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 38a95f676284f..163b4137a8b89 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -762,6 +762,161 @@ describe('ReactFlightDOMNode', () => { } }); + // @gate enableHalt + it('includes source locations in component and owner stacks for halted Client components', async () => { + function SharedComponent({p1, p2, p3}) { + use(p1); + use(p2); + use(p3); + return
Hello, Dave!
; + } + const ClientComponent = clientExports( + SharedComponent, + 123, + 'path/to/chunk.js', + ); + + let resolvePendingPromise; + function ServerComponent() { + const p1 = Promise.resolve(); + const p2 = new Promise(resolve => { + resolvePendingPromise = value => { + p2.status = 'fulfilled'; + p2.value = value; + }; + }); + const p3 = new Promise(() => {}); + return ReactServer.createElement(ClientComponent, { + p1: p1, + p2: p2, + p3: p3, + }); + } + + function App() { + return ReactServer.createElement( + 'html', + null, + ReactServer.createElement( + 'body', + null, + ReactServer.createElement( + ReactServer.Suspense, + {fallback: 'Loading...'}, + ReactServer.createElement(ServerComponent, null), + ), + ), + ); + } + + const errors = []; + const serverAbortController = new AbortController(); + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerender( + ReactServer.createElement(App, null), + webpackMap, + { + signal: serverAbortController.signal, + onError(error) { + errors.push(error); + }, + }, + ), + }; + }); + + await serverAct( + async () => + new Promise(resolve => { + setImmediate(() => { + resolve(); + }); + }), + ); + + const {prelude} = await pendingResult; + + expect(errors).toEqual([]); + + function ClientRoot({response}) { + return use(response); + } + + const prerenderResponse = ReactServerDOMClient.createFromReadableStream( + await createBufferedUnclosingStream(prelude), + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + + let componentStack; + let ownerStack; + + const clientAbortController = new AbortController(); + + const fizzPrerenderStreamResult = ReactDOMFizzStatic.prerender( + React.createElement(ClientRoot, {response: prerenderResponse}), + { + signal: clientAbortController.signal, + onError(error, errorInfo) { + componentStack = errorInfo.componentStack; + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ); + + await serverAct( + async () => + new Promise(resolve => { + setImmediate(() => { + resolvePendingPromise(); + serverAbortController.abort(); + clientAbortController.abort(); + resolve(); + }); + }), + ); + + const fizzPrerenderStream = await fizzPrerenderStreamResult; + const prerenderHTML = await readWebResult(fizzPrerenderStream.prelude); + + expect(prerenderHTML).toContain('Loading...'); + + if (__DEV__) { + expect(normalizeCodeLocInfo(componentStack)).toBe( + '\n' + + ' in Component (at **)\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', + ); + } else { + expect(normalizeCodeLocInfo(componentStack)).toBe( + '\n in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in ClientRoot (at **)', + ); + } + + if (__DEV__) { + expect(normalizeCodeLocInfo(ownerStack)).toBe( + '\n in Component (at **)\n in App (at **)', + ); + } else { + expect(ownerStack).toBeNull(); + } + }); + // @gate enableHalt it('includes deeper location for aborted stacks', async () => { async function getData() {