From c0c75e4aaa8ece2c9e789e2e3f150d7487b60bbc Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 29 Oct 2025 09:55:51 -0700 Subject: [PATCH] Avoid proxying React modules through workUnitStore (#85486) Today the `captureOwnerStack()` function is provided to shared utilities through an AsyncLocalStorage that scopes the method from the appropriate React instance. This is so that external code like patches to sync IO methods can still generate errors with the appropriate React owner information even when the patched code itself is not bundled and can be called from etiher SSR or RSC contexts. This works but it makes plumbing the React instances around tricky. There is a simpler way. Most of the time you can just try both React's. If one gives you a non-null/undefined result then you know you are in that scope. If neither do then you're outside a React scope altogether. In this change I remove `captureOwnerStack()` from the workUnitStore types and just call it from the shared server runtime which gives even external code access to the appropriate React instances for bundled code --- .../next/src/server/app-render/app-render.tsx | 18 +----- .../work-unit-async-storage.external.ts | 5 -- .../console-dim.external.test.ts | 22 +++++-- .../console-dim.external.tsx | 58 +++++++++---------- .../node-environment-extensions/utils.tsx | 22 ++++--- .../server/route-modules/app-page/module.ts | 13 ++--- .../server/route-modules/app-route/module.ts | 2 - .../src/server/runtime-reacts.external.ts | 15 +++++ .../next-server-nft/next-server-nft.test.ts | 1 + 9 files changed, 75 insertions(+), 81 deletions(-) create mode 100644 packages/next/src/server/runtime-reacts.external.ts diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 8d851d7066270..ee5e6e75ee870 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -955,7 +955,6 @@ async function prospectiveRuntimeServerPrerender( renderResumeDataCache, prerenderResumeDataCache, hmrRefreshHash: undefined, - captureOwnerStack: undefined, // We only need task sequencing in the final prerender. runtimeStagePromise: null, // These are not present in regular prerenders, but allowed in a runtime prerender. @@ -1092,7 +1091,6 @@ async function finalRuntimeServerPrerender( prerenderResumeDataCache, renderResumeDataCache, hmrRefreshHash: undefined, - captureOwnerStack: undefined, // Used to separate the "Static" stage from the "Runtime" stage. runtimeStagePromise, // These are not present in regular prerenders, but allowed in a runtime prerender. @@ -3315,9 +3313,7 @@ async function spawnDynamicValidationInDev( // ready to cut the render off. const cacheSignal = new CacheSignal() - const captureOwnerStackClient = ReactClient.captureOwnerStack - const { captureOwnerStack: captureOwnerStackServer, createElement } = - ComponentMod + const { createElement } = ComponentMod // The resume data cache here should use a fresh instance as it's // performing a fresh prerender. If we get to implementing the @@ -3350,7 +3346,6 @@ async function spawnDynamicValidationInDev( prerenderResumeDataCache, renderResumeDataCache: null, hmrRefreshHash, - captureOwnerStack: captureOwnerStackServer, } // We're not going to use the result of this render because the only time it could be used @@ -3384,7 +3379,6 @@ async function spawnDynamicValidationInDev( prerenderResumeDataCache, renderResumeDataCache: null, hmrRefreshHash, - captureOwnerStack: captureOwnerStackServer, } const pendingInitialServerResult = workUnitAsyncStorage.run( @@ -3505,7 +3499,6 @@ async function spawnDynamicValidationInDev( prerenderResumeDataCache, renderResumeDataCache: null, hmrRefreshHash: undefined, - captureOwnerStack: captureOwnerStackClient, } const prerender = ( @@ -3616,7 +3609,6 @@ async function spawnDynamicValidationInDev( prerenderResumeDataCache, renderResumeDataCache: null, hmrRefreshHash, - captureOwnerStack: captureOwnerStackServer, } const finalAttemptRSCPayload = await workUnitAsyncStorage.run( @@ -3650,7 +3642,6 @@ async function spawnDynamicValidationInDev( prerenderResumeDataCache, renderResumeDataCache: null, hmrRefreshHash, - captureOwnerStack: captureOwnerStackServer, } const reactServerResult = await createReactServerPrerenderResult( @@ -3730,7 +3721,6 @@ async function spawnDynamicValidationInDev( prerenderResumeDataCache, renderResumeDataCache: null, hmrRefreshHash, - captureOwnerStack: captureOwnerStackClient, } let dynamicValidation = createDynamicValidationState() @@ -4108,7 +4098,6 @@ async function prerenderToStream( prerenderResumeDataCache, renderResumeDataCache, hmrRefreshHash: undefined, - captureOwnerStack: undefined, // Not available in production. } // We're not going to use the result of this render because the only time it could be used @@ -4142,7 +4131,6 @@ async function prerenderToStream( prerenderResumeDataCache, renderResumeDataCache, hmrRefreshHash: undefined, - captureOwnerStack: undefined, // Not available in production. }) const pendingInitialServerResult = workUnitAsyncStorage.run( @@ -4257,7 +4245,6 @@ async function prerenderToStream( prerenderResumeDataCache, renderResumeDataCache, hmrRefreshHash: undefined, - captureOwnerStack: undefined, // Not available in production. } const prerender = ( @@ -4367,7 +4354,6 @@ async function prerenderToStream( prerenderResumeDataCache, renderResumeDataCache, hmrRefreshHash: undefined, - captureOwnerStack: undefined, // Not available in production. } const finalAttemptRSCPayload = await workUnitAsyncStorage.run( @@ -4402,7 +4388,6 @@ async function prerenderToStream( prerenderResumeDataCache, renderResumeDataCache, hmrRefreshHash: undefined, - captureOwnerStack: undefined, // Not available in production. }) let prerenderIsPending = true @@ -4488,7 +4473,6 @@ async function prerenderToStream( prerenderResumeDataCache, renderResumeDataCache, hmrRefreshHash: undefined, - captureOwnerStack: undefined, // Not available in production. } let dynamicValidation = createDynamicValidationState() diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index 679dc6766fbe4..d321cbc008093 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -207,11 +207,6 @@ interface PrerenderStoreModernCommon * subsequent dynamic render. */ readonly hmrRefreshHash: string | undefined - - /** - * Only available in dev mode. - */ - readonly captureOwnerStack: undefined | (() => string | null) } interface StaticPrerenderStoreCommon { diff --git a/packages/next/src/server/node-environment-extensions/console-dim.external.test.ts b/packages/next/src/server/node-environment-extensions/console-dim.external.test.ts index 6fa27fd78f515..2a0bade809b03 100644 --- a/packages/next/src/server/node-environment-extensions/console-dim.external.test.ts +++ b/packages/next/src/server/node-environment-extensions/console-dim.external.test.ts @@ -321,13 +321,23 @@ describe('console-exit patches', () => { } // Install patches - this wraps the current console.log - const { - registerGetCacheSignal, - } = require('next/dist/server/node-environment-extensions/console-dim.external') + require('next/dist/server/node-environment-extensions/console-dim.external') - registerGetCacheSignal(() => null) - registerGetCacheSignal(() => controller?.signal) - registerGetCacheSignal(() => null) + const { + registerServerReact, + registerClientReact, + } = require('next/dist/server/runtime-reacts.external') + + registerServerReact({ + cacheSignal() { + return controller?.signal + }, + }) + registerClientReact({ + cacheSignal() { + return null + }, + }) console.log('before abort') controller.abort() diff --git a/packages/next/src/server/node-environment-extensions/console-dim.external.tsx b/packages/next/src/server/node-environment-extensions/console-dim.external.tsx index 2f83c1e820aea..87396a043c8d3 100644 --- a/packages/next/src/server/node-environment-extensions/console-dim.external.tsx +++ b/packages/next/src/server/node-environment-extensions/console-dim.external.tsx @@ -4,12 +4,7 @@ import { type ConsoleStore, } from '../app-render/console-async-storage.external' import { workUnitAsyncStorage } from '../app-render/work-unit-async-storage.external' - -type GetCacheSignal = () => AbortSignal | null -const cacheSignals: Array = [] -export function registerGetCacheSignal(getSignal: GetCacheSignal): void { - cacheSignals.push(getSignal) -} +import { getServerReact, getClientReact } from '../runtime-reacts.external' // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we may use later and want parity with the HIDDEN_STYLE value const DIMMED_STYLE = 'dimmed' @@ -191,33 +186,32 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void { // the server React cacheSignal implementation. Any particular console call will be in one, the other, or neither // scope and these signals return null if you are out of scope so this can be called from a single global patch // and still work properly. - for (let i = 0; i < cacheSignals.length; i++) { - const signal = cacheSignals[i]() // try to get a signal from registered functions - if (signal) { - // We are in a React Server render and can consult the React cache signal to determine if logs - // are now dimmable. - if (signal.aborted) { - if (currentAbortedLogsStyle === HIDDEN_STYLE) { - return - } - return applyWithDimming.call( - this, - consoleStore, - originalMethod, - methodName, - args - ) - } else if (consoleStore?.dim === true) { - return applyWithDimming.call( - this, - consoleStore, - originalMethod, - methodName, - args - ) - } else { - return originalMethod.apply(this, args) + const signal = + getClientReact()?.cacheSignal() ?? getServerReact()?.cacheSignal() + if (signal) { + // We are in a React Server render and can consult the React cache signal to determine if logs + // are now dimmable. + if (signal.aborted) { + if (currentAbortedLogsStyle === HIDDEN_STYLE) { + return } + return applyWithDimming.call( + this, + consoleStore, + originalMethod, + methodName, + args + ) + } else if (consoleStore?.dim === true) { + return applyWithDimming.call( + this, + consoleStore, + originalMethod, + methodName, + args + ) + } else { + return originalMethod.apply(this, args) } } diff --git a/packages/next/src/server/node-environment-extensions/utils.tsx b/packages/next/src/server/node-environment-extensions/utils.tsx index 3b7840bd186cb..30213664c9bf0 100644 --- a/packages/next/src/server/node-environment-extensions/utils.tsx +++ b/packages/next/src/server/node-environment-extensions/utils.tsx @@ -1,14 +1,13 @@ import { workAsyncStorage } from '../app-render/work-async-storage.external' -import { - workUnitAsyncStorage, - type PrerenderStoreModern, -} from '../app-render/work-unit-async-storage.external' +import { workUnitAsyncStorage } from '../app-render/work-unit-async-storage.external' import { abortOnSynchronousPlatformIOAccess, trackSynchronousPlatformIOAccessInDev, } from '../app-render/dynamic-rendering' import { InvariantError } from '../../shared/lib/invariant-error' +import { getServerReact, getClientReact } from '../runtime-reacts.external' + type ApiType = 'time' | 'random' | 'crypto' export function io(expression: string, type: ApiType) { @@ -47,7 +46,7 @@ export function io(expression: string, type: ApiType) { abortOnSynchronousPlatformIOAccess( workStore.route, expression, - applyOwnerStack(new Error(message), workUnitStore), + applyOwnerStack(new Error(message)), workUnitStore ) } @@ -79,7 +78,7 @@ export function io(expression: string, type: ApiType) { abortOnSynchronousPlatformIOAccess( workStore.route, expression, - applyOwnerStack(new Error(message), workUnitStore), + applyOwnerStack(new Error(message)), workUnitStore ) } @@ -101,16 +100,15 @@ export function io(expression: string, type: ApiType) { } } -function applyOwnerStack(error: Error, workUnitStore: PrerenderStoreModern) { +function applyOwnerStack(error: Error) { // TODO: Instead of stitching the stacks here, we should log the original // error as-is when it occurs, and let `patchErrorInspect` handle adding the // owner stack, instead of logging it deferred in the `LogSafely` component // via `throwIfDisallowedDynamic`. - if ( - process.env.NODE_ENV !== 'production' && - workUnitStore.captureOwnerStack - ) { - const ownerStack = workUnitStore.captureOwnerStack() + if (process.env.NODE_ENV !== 'production') { + const ownerStack = + getClientReact()?.captureOwnerStack() ?? + getServerReact()?.captureOwnerStack() if (ownerStack) { let stack = ownerStack diff --git a/packages/next/src/server/route-modules/app-page/module.ts b/packages/next/src/server/route-modules/app-page/module.ts index 58d4b773718aa..44968511476bd 100644 --- a/packages/next/src/server/route-modules/app-page/module.ts +++ b/packages/next/src/server/route-modules/app-page/module.ts @@ -39,13 +39,12 @@ if (process.env.NEXT_RUNTIME !== 'edge') { vendoredReactSSR = require('./vendored/ssr/entrypoints') as typeof import('./vendored/ssr/entrypoints') - // In Node environments we augment console logging with information contextual to a React render. - // This patching is global so we need to register the cacheSignal getter from our bundled React instances - // here when we load them rather than in the external module itself when the patch is applied. - const { registerGetCacheSignal } = - require('../../node-environment-extensions/console-dim.external') as typeof import('../../node-environment-extensions/console-dim.external') - registerGetCacheSignal(vendoredReactRSC.React.cacheSignal) - registerGetCacheSignal(vendoredReactSSR.React.cacheSignal) + // In Node environments we need to access the correct React instance from external modules such + // as global patches. We register the loaded React instances here. + const { registerServerReact, registerClientReact } = + require('../../runtime-reacts.external') as typeof import('../../runtime-reacts.external') + registerServerReact(vendoredReactRSC.React) + registerClientReact(vendoredReactSSR.React) } /** diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index c10944bd0d9a5..ae9e62bfb290e 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -413,7 +413,6 @@ export class AppRouteRouteModule extends RouteModule< prerenderResumeDataCache, renderResumeDataCache: null, hmrRefreshHash: undefined, - captureOwnerStack: undefined, }) let prospectiveResult @@ -505,7 +504,6 @@ export class AppRouteRouteModule extends RouteModule< prerenderResumeDataCache, renderResumeDataCache: null, hmrRefreshHash: undefined, - captureOwnerStack: undefined, }) let responseHandled = false diff --git a/packages/next/src/server/runtime-reacts.external.ts b/packages/next/src/server/runtime-reacts.external.ts new file mode 100644 index 0000000000000..fce6fde59ec51 --- /dev/null +++ b/packages/next/src/server/runtime-reacts.external.ts @@ -0,0 +1,15 @@ +let ClientReact: typeof import('react') | null = null +export function registerClientReact(react: typeof import('react')) { + ClientReact = react +} +export function getClientReact() { + return ClientReact +} + +let ServerReact: typeof import('react') | null = null +export function registerServerReact(react: typeof import('react')) { + ServerReact = react +} +export function getServerReact() { + return ServerReact +} diff --git a/test/production/next-server-nft/next-server-nft.test.ts b/test/production/next-server-nft/next-server-nft.test.ts index 36f0d665bc3a9..4f59f42c3717f 100644 --- a/test/production/next-server-nft/next-server-nft.test.ts +++ b/test/production/next-server-nft/next-server-nft.test.ts @@ -321,6 +321,7 @@ const isReact18 = parseInt(process.env.NEXT_TEST_REACT_VERSION) === 18 "/node_modules/next/dist/server/route-modules/pages/vendored/contexts/loadable.js", "/node_modules/next/dist/server/route-modules/pages/vendored/contexts/router-context.js", "/node_modules/next/dist/server/route-modules/pages/vendored/contexts/server-inserted-html.js", + "/node_modules/next/dist/server/runtime-reacts.external.js", "/node_modules/next/dist/shared/lib/deep-freeze.js", "/node_modules/next/dist/shared/lib/invariant-error.js", "/node_modules/next/dist/shared/lib/is-plain-object.js",