From 87a45ae37f4014b6df548a5d9b06bad5dc557992 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 18 Aug 2025 15:12:49 +0200 Subject: [PATCH 1/3] [eslint-plugin-react-hooks][RulesOfHooks] handle React.useEffect in addition to useEffect (#34076) ## Summary This is a fix for https://github.com/facebook/react/issues/34074 ## How did you test this change? I added tests in the eslint package, and ran `yarn jest`. After adding the new tests, I have this: On main | On this branch -|- image | image ## Changes - Add tests to check that we are checking both `CallExpression` (`useEffect(`), and `MemberExpression` (`React.useEffect(`). To do that, I copied the `getNodeWithoutReactNamespace(` fn from `ExhaustiveDeps.ts` to `RulesOfHooks.ts` --- .../ESLintRuleExhaustiveDeps-test.js | 32 +++++++++++++++++++ .../__tests__/ESLintRulesOfHooks-test.js | 11 +++++++ .../src/rules/RulesOfHooks.ts | 28 ++++++++++++++-- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js index 555c54148ec1e..7a6b09c5440c4 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js @@ -7735,6 +7735,9 @@ if (__EXPERIMENTAL__) { useEffect(() => { onStuff(); }, []); + React.useEffect(() => { + onStuff(); + }, []); } `, }, @@ -7751,6 +7754,9 @@ if (__EXPERIMENTAL__) { useEffect(() => { onStuff(); }, [onStuff]); + React.useEffect(() => { + onStuff(); + }, [onStuff]); } `, errors: [ @@ -7769,6 +7775,32 @@ if (__EXPERIMENTAL__) { useEffect(() => { onStuff(); }, []); + React.useEffect(() => { + onStuff(); + }, [onStuff]); + } + `, + }, + ], + }, + { + message: + 'Functions returned from `useEffectEvent` must not be included in the dependency array. ' + + 'Remove `onStuff` from the list.', + suggestions: [ + { + desc: 'Remove the dependency `onStuff`', + output: normalizeIndent` + function MyComponent({ theme }) { + const onStuff = useEffectEvent(() => { + showNotification(theme); + }); + useEffect(() => { + onStuff(); + }, [onStuff]); + React.useEffect(() => { + onStuff(); + }, []); } `, }, diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 7cb3ef0495341..ac8886c776802 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -1368,6 +1368,9 @@ if (__EXPERIMENTAL__) { useEffect(() => { onClick(); }); + React.useEffect(() => { + onClick(); + }); } `, }, @@ -1389,6 +1392,10 @@ if (__EXPERIMENTAL__) { let id = setInterval(() => onClick(), 100); return () => clearInterval(onClick); }, []); + React.useEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); return null; } `, @@ -1408,6 +1415,7 @@ if (__EXPERIMENTAL__) { { code: normalizeIndent` function MyComponent({ theme }) { + // Can receive arguments const onEvent = useEffectEvent((text) => { console.log(text); }); @@ -1415,6 +1423,9 @@ if (__EXPERIMENTAL__) { useEffect(() => { onEvent('Hello world'); }); + React.useEffect(() => { + onEvent('Hello world'); + }); } `, }, diff --git a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts index f0a2ffbda9e18..0721a75e00642 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts @@ -11,7 +11,10 @@ import type { CallExpression, CatchClause, DoWhileStatement, + Expression, + Identifier, Node, + Super, TryStatement, } from 'estree'; @@ -129,6 +132,24 @@ function isInsideTryCatch( return false; } +function getNodeWithoutReactNamespace( + node: Expression | Super, +): Expression | Identifier | Super { + if ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'React' && + node.property.type === 'Identifier' && + !node.computed + ) { + return node.property; + } + return node; +} + +function isUseEffectIdentifier(node: Node): boolean { + return node.type === 'Identifier' && node.name === 'useEffect'; +} function isUseEffectEventIdentifier(node: Node): boolean { if (__EXPERIMENTAL__) { return node.type === 'Identifier' && node.name === 'useEffectEvent'; @@ -702,10 +723,11 @@ const rule = { // useEffectEvent: useEffectEvent functions can be passed by reference within useEffect as well as in // another useEffectEvent + // Check all `useEffect` and `React.useEffect`, `useEffectEvent`, and `React.useEffectEvent` + const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee); if ( - node.callee.type === 'Identifier' && - (node.callee.name === 'useEffect' || - isUseEffectEventIdentifier(node.callee)) && + (isUseEffectIdentifier(nodeWithoutNamespace) || + isUseEffectEventIdentifier(nodeWithoutNamespace)) && node.arguments.length > 0 ) { // Denote that we have traversed into a useEffect call, and stash the CallExpr for From 0c89b160f6382814aa02cc469a80f59c720ab6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 18 Aug 2025 11:34:00 -0400 Subject: [PATCH 2/3] [Flight] Add DebugInfo for Bundler Chunks (#34226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a "suspended by" row for each chunk that is referenced from a client reference. So when you select a client component, you can see what bundles will block that client component when loading on the client. This is only done in the browser build since if we added it on the server, it would show up as a blocking resource and while it's possible we expect that a typical server request won't block on loading JS. Screenshot 2025-08-17 at 3 45 14 PM Screenshot 2025-08-17 at 3 46 58 PM Currently this is only included if it ends up wrapped in a lazy like in the typical type position of a Client Component, but there's a general issue that maybe hard references need to transfer their debug info to the parent which can transfer it to the Fiber. --- .eslintrc.js | 1 + .../react-client/src/ReactFlightClient.js | 23 +++- .../forks/ReactFlightClientConfig.custom.js | 1 + .../forks/ReactFlightClientConfig.dom-bun.js | 1 + .../ReactFlightClientConfig.dom-legacy.js | 1 + .../forks/ReactFlightClientConfig.markup.js | 6 ++ .../ReactFlightClientConfigBundlerESM.js | 94 ++++++++++++++++ .../ReactFlightClientConfigBundlerParcel.js | 9 +- ...ReactFlightClientConfigBundlerTurbopack.js | 22 +++- ...ightClientConfigBundlerTurbopackBrowser.js | 96 +++++++++++++++++ ...lightClientConfigBundlerTurbopackServer.js | 10 ++ .../src/__tests__/utils/WebpackMock.js | 3 + .../ReactFlightClientConfigBundlerNode.js | 6 ++ .../ReactFlightClientConfigBundlerWebpack.js | 23 +++- ...FlightClientConfigBundlerWebpackBrowser.js | 101 ++++++++++++++++++ ...tFlightClientConfigBundlerWebpackServer.js | 11 ++ scripts/flow/environment.js | 1 + scripts/rollup/validate/eslintrc.cjs.js | 1 + scripts/rollup/validate/eslintrc.cjs2015.js | 1 + scripts/rollup/validate/eslintrc.esm.js | 1 + 20 files changed, 405 insertions(+), 7 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 2c9ad7a4c925d..cd2489589e3fe 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -468,6 +468,7 @@ module.exports = { files: ['packages/react-server-dom-webpack/**/*.js'], globals: { __webpack_chunk_load__: 'readonly', + __webpack_get_script_filename__: 'readonly', __webpack_require__: 'readonly', }, }, diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index c4d47bfecc55d..2eef555d36f82 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -55,6 +55,7 @@ import { resolveServerReference, preloadModule, requireModule, + getModuleDebugInfo, dispatchHint, readPartialStringChunk, readFinalStringChunk, @@ -790,8 +791,14 @@ function resolveModuleChunk( resolvedChunk.status = RESOLVED_MODULE; resolvedChunk.value = value; if (__DEV__) { - // We don't expect to have any debug info for this row. - resolvedChunk._debugInfo = null; + const debugInfo = getModuleDebugInfo(value); + if (debugInfo !== null && resolvedChunk._debugInfo != null) { + // Add to the live set if it was already initialized. + // $FlowFixMe[method-unbinding] + resolvedChunk._debugInfo.push.apply(resolvedChunk._debugInfo, debugInfo); + } else { + resolvedChunk._debugInfo = debugInfo; + } } if (resolveListeners !== null) { initializeModuleChunk(resolvedChunk); @@ -3977,7 +3984,11 @@ function flushComponentPerformance( // Track the root most component of the result for deduping logging. result.component = componentInfo; isLastComponent = false; - } else if (candidateInfo.awaited) { + } else if ( + candidateInfo.awaited && + // Skip awaits on client resources since they didn't block the server component. + candidateInfo.awaited.env != null + ) { if (endTime > childrenEndTime) { childrenEndTime = endTime; } @@ -4059,7 +4070,11 @@ function flushComponentPerformance( // Track the root most component of the result for deduping logging. result.component = componentInfo; isLastComponent = false; - } else if (candidateInfo.awaited) { + } else if ( + candidateInfo.awaited && + // Skip awaits on client resources since they didn't block the server component. + candidateInfo.awaited.env != null + ) { // If we don't have an end time for an await, that means we aborted. const asyncInfo: ReactAsyncInfo = candidateInfo; const env = response._rootEnvironmentName; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js index 2f204fd51b098..a6c0c933d2a58 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js @@ -35,6 +35,7 @@ export const resolveClientReference = $$$config.resolveClientReference; export const resolveServerReference = $$$config.resolveServerReference; export const preloadModule = $$$config.preloadModule; export const requireModule = $$$config.requireModule; +export const getModuleDebugInfo = $$$config.getModuleDebugInfo; export const dispatchHint = $$$config.dispatchHint; export const prepareDestinationForModule = $$$config.prepareDestinationForModule; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js index 75c942966bd99..24caf0df88f52 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js @@ -24,5 +24,6 @@ export const resolveClientReference: any = null; export const resolveServerReference: any = null; export const preloadModule: any = null; export const requireModule: any = null; +export const getModuleDebugInfo: any = null; export const prepareDestinationForModule: any = null; export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index 5be648ff0ad93..0f7381fc5a3f2 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -24,6 +24,7 @@ export const resolveClientReference: any = null; export const resolveServerReference: any = null; export const preloadModule: any = null; export const requireModule: any = null; +export const getModuleDebugInfo: any = null; export const dispatchHint: any = null; export const prepareDestinationForModule: any = null; export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js index b0b2f198fd97b..fcd672450446f 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js @@ -62,6 +62,12 @@ export function requireModule(metadata: ClientReference): T { ); } +export function getModuleDebugInfo(metadata: ClientReference): null { + throw new Error( + 'renderToHTML should not have emitted Client References. This is a bug in React.', + ); +} + export const usedWithSSR = true; type HintCode = string; diff --git a/packages/react-server-dom-esm/src/client/ReactFlightClientConfigBundlerESM.js b/packages/react-server-dom-esm/src/client/ReactFlightClientConfigBundlerESM.js index 6a74e63854991..ebd281298331d 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightClientConfigBundlerESM.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightClientConfigBundlerESM.js @@ -11,7 +11,11 @@ import type { Thenable, FulfilledThenable, RejectedThenable, + ReactDebugInfo, + ReactIOInfo, + ReactAsyncInfo, } from 'shared/ReactTypes'; + import type {ModuleLoading} from 'react-client/src/ReactFlightClientConfig'; export type ServerConsumerModuleMap = string; // Module root path @@ -118,3 +122,93 @@ export function requireModule(metadata: ClientReference): T { } return moduleExports[metadata.name]; } + +// We cache ReactIOInfo across requests so that inner refreshes can dedupe with outer. +const moduleIOInfoCache: Map = __DEV__ + ? new Map() + : (null: any); + +export function getModuleDebugInfo( + metadata: ClientReference, +): null | ReactDebugInfo { + if (!__DEV__) { + return null; + } + const filename = metadata.specifier; + let ioInfo = moduleIOInfoCache.get(filename); + if (ioInfo === undefined) { + let href; + try { + // $FlowFixMe + href = new URL(filename, document.baseURI).href; + } catch (_) { + href = filename; + } + let start = -1; + let end = -1; + let byteSize = 0; + // $FlowFixMe[method-unbinding] + if (typeof performance.getEntriesByType === 'function') { + // We may be able to collect the start and end time of this resource from Performance Observer. + const resourceEntries = performance.getEntriesByType('resource'); + for (let i = 0; i < resourceEntries.length; i++) { + const resourceEntry = resourceEntries[i]; + if (resourceEntry.name === href) { + start = resourceEntry.startTime; + end = start + resourceEntry.duration; + // $FlowFixMe[prop-missing] + byteSize = (resourceEntry.transferSize: any) || 0; + } + } + } + const value = Promise.resolve(href); + // $FlowFixMe + value.status = 'fulfilled'; + // Is there some more useful representation for the chunk? + // $FlowFixMe + value.value = href; + // Create a fake stack frame that points to the beginning of the chunk. This is + // probably not source mapped so will link to the compiled source rather than + // any individual file that goes into the chunks. + const fakeStack = new Error('react-stack-top-frame'); + if (fakeStack.stack.startsWith('Error: react-stack-top-frame')) { + // Looks like V8 + fakeStack.stack = + 'Error: react-stack-top-frame\n' + + // Add two frames since we always trim one off the top. + ' at Client Component Bundle (' + + href + + ':1:1)\n' + + ' at Client Component Bundle (' + + href + + ':1:1)'; + } else { + // Looks like Firefox or Safari. + // Add two frames since we always trim one off the top. + fakeStack.stack = + 'Client Component Bundle@' + + href + + ':1:1\n' + + 'Client Component Bundle@' + + href + + ':1:1'; + } + ioInfo = ({ + name: 'script', + start: start, + end: end, + value: value, + debugStack: fakeStack, + }: ReactIOInfo); + if (byteSize > 0) { + // $FlowFixMe[cannot-write] + ioInfo.byteSize = byteSize; + } + moduleIOInfoCache.set(filename, ioInfo); + } + // We could dedupe the async info too but conceptually each request is its own await. + const asyncInfo: ReactAsyncInfo = { + awaited: ioInfo, + }; + return [asyncInfo]; +} diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js b/packages/react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js index 1d380a3059108..6c652c93c22d7 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js @@ -7,7 +7,7 @@ * @flow */ -import type {Thenable} from 'shared/ReactTypes'; +import type {Thenable, ReactDebugInfo} from 'shared/ReactTypes'; import type {ImportMetadata} from '../shared/ReactFlightImportMetadata'; @@ -80,3 +80,10 @@ export function requireModule(metadata: ClientReference): T { const moduleExports = parcelRequire(metadata[ID]); return moduleExports[metadata[NAME]]; } + +export function getModuleDebugInfo( + metadata: ClientReference, +): null | ReactDebugInfo { + // TODO + return null; +} diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js index fa20f032e0541..ba1c220fa4f32 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js @@ -11,6 +11,7 @@ import type { Thenable, FulfilledThenable, RejectedThenable, + ReactDebugInfo, } from 'shared/ReactTypes'; import type { @@ -28,7 +29,10 @@ import { import {prepareDestinationWithChunks} from 'react-client/src/ReactFlightClientConfig'; -import {loadChunk} from 'react-client/src/ReactFlightClientConfig'; +import { + loadChunk, + addChunkDebugInfo, +} from 'react-client/src/ReactFlightClientConfig'; export type ServerConsumerModuleMap = null | { [clientId: string]: { @@ -231,3 +235,19 @@ export function requireModule(metadata: ClientReference): T { } return moduleExports[metadata[NAME]]; } + +export function getModuleDebugInfo( + metadata: ClientReference, +): null | ReactDebugInfo { + if (!__DEV__) { + return null; + } + const chunks = metadata[CHUNKS]; + const debugInfo: ReactDebugInfo = []; + let i = 0; + while (i < chunks.length) { + const chunkFilename = chunks[i++]; + addChunkDebugInfo(debugInfo, chunkFilename); + } + return debugInfo; +} diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackBrowser.js b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackBrowser.js index 59aab436cbc2a..b00fe3cb06f08 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackBrowser.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackBrowser.js @@ -7,6 +7,102 @@ * @flow */ +import type { + ReactDebugInfo, + ReactIOInfo, + ReactAsyncInfo, +} from 'shared/ReactTypes'; + export function loadChunk(filename: string): Promise { return __turbopack_load_by_url__(filename); } + +// We cache ReactIOInfo across requests so that inner refreshes can dedupe with outer. +const chunkIOInfoCache: Map = __DEV__ + ? new Map() + : (null: any); + +export function addChunkDebugInfo( + target: ReactDebugInfo, + filename: string, +): void { + if (!__DEV__) { + return; + } + let ioInfo = chunkIOInfoCache.get(filename); + if (ioInfo === undefined) { + let href; + try { + // $FlowFixMe + href = new URL(filename, document.baseURI).href; + } catch (_) { + href = filename; + } + let start = -1; + let end = -1; + let byteSize = 0; + // $FlowFixMe[method-unbinding] + if (typeof performance.getEntriesByType === 'function') { + // We may be able to collect the start and end time of this resource from Performance Observer. + const resourceEntries = performance.getEntriesByType('resource'); + for (let i = 0; i < resourceEntries.length; i++) { + const resourceEntry = resourceEntries[i]; + if (resourceEntry.name === href) { + start = resourceEntry.startTime; + end = start + resourceEntry.duration; + // $FlowFixMe[prop-missing] + byteSize = (resourceEntry.transferSize: any) || 0; + } + } + } + const value = Promise.resolve(href); + // $FlowFixMe + value.status = 'fulfilled'; + // Is there some more useful representation for the chunk? + // $FlowFixMe + value.value = href; + // Create a fake stack frame that points to the beginning of the chunk. This is + // probably not source mapped so will link to the compiled source rather than + // any individual file that goes into the chunks. + const fakeStack = new Error('react-stack-top-frame'); + if (fakeStack.stack.startsWith('Error: react-stack-top-frame')) { + // Looks like V8 + fakeStack.stack = + 'Error: react-stack-top-frame\n' + + // Add two frames since we always trim one off the top. + ' at Client Component Bundle (' + + href + + ':1:1)\n' + + ' at Client Component Bundle (' + + href + + ':1:1)'; + } else { + // Looks like Firefox or Safari. + // Add two frames since we always trim one off the top. + fakeStack.stack = + 'Client Component Bundle@' + + href + + ':1:1\n' + + 'Client Component Bundle@' + + href + + ':1:1'; + } + ioInfo = ({ + name: 'script', + start: start, + end: end, + value: value, + debugStack: fakeStack, + }: ReactIOInfo); + if (byteSize > 0) { + // $FlowFixMe[cannot-write] + ioInfo.byteSize = byteSize; + } + chunkIOInfoCache.set(filename, ioInfo); + } + // We could dedupe the async info too but conceptually each request is its own await. + const asyncInfo: ReactAsyncInfo = { + awaited: ioInfo, + }; + target.push(asyncInfo); +} diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer.js b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer.js index 59aab436cbc2a..600f80c6fc7a8 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer.js @@ -7,6 +7,16 @@ * @flow */ +import type {ReactDebugInfo} from 'shared/ReactTypes'; + export function loadChunk(filename: string): Promise { return __turbopack_load_by_url__(filename); } + +export function addChunkDebugInfo( + target: ReactDebugInfo, + filename: string, +): void { + // We don't emit any debug info on the server since we assume the loading + // of the bundle is insignificant on the server. +} diff --git a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js index 654bcdc9b6d5c..5574b069a4903 100644 --- a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js +++ b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js @@ -27,6 +27,9 @@ global.__webpack_require__ = function (id) { } return webpackClientModules[id] || webpackServerModules[id]; }; +global.__webpack_get_script_filename__ = function (id) { + return id; +}; const previousCompile = Module.prototype._compile; diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js index e560275cd1fbd..de38569e52a57 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js @@ -160,3 +160,9 @@ export function requireModule(metadata: ClientReference): T { } return moduleExports[metadata.name]; } + +export function getModuleDebugInfo(metadata: ClientReference): null { + // We don't emit any debug info on the server since we assume the loading + // of the bundle is insignificant on the server. + return null; +} diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js index a43c26ac82bb1..550e10eb00822 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js @@ -11,6 +11,7 @@ import type { Thenable, FulfilledThenable, RejectedThenable, + ReactDebugInfo, } from 'shared/ReactTypes'; import type { @@ -28,7 +29,10 @@ import { import {prepareDestinationWithChunks} from 'react-client/src/ReactFlightClientConfig'; -import {loadChunk} from 'react-client/src/ReactFlightClientConfig'; +import { + loadChunk, + addChunkDebugInfo, +} from 'react-client/src/ReactFlightClientConfig'; export type ServerConsumerModuleMap = null | { [clientId: string]: { @@ -251,3 +255,20 @@ export function requireModule(metadata: ClientReference): T { } return moduleExports[metadata[NAME]]; } + +export function getModuleDebugInfo( + metadata: ClientReference, +): null | ReactDebugInfo { + if (!__DEV__) { + return null; + } + const chunks = metadata[CHUNKS]; + const debugInfo: ReactDebugInfo = []; + let i = 0; + while (i < chunks.length) { + const chunkId = chunks[i++]; + const chunkFilename = chunks[i++]; + addChunkDebugInfo(debugInfo, chunkId, chunkFilename); + } + return debugInfo; +} diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackBrowser.js b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackBrowser.js index 48779fb1e65e9..7f49e9fd15a8f 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackBrowser.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackBrowser.js @@ -7,6 +7,12 @@ * @flow */ +import type { + ReactDebugInfo, + ReactIOInfo, + ReactAsyncInfo, +} from 'shared/ReactTypes'; + const chunkMap: Map = new Map(); /** @@ -26,3 +32,98 @@ export function loadChunk(chunkId: string, filename: string): Promise { chunkMap.set(chunkId, filename); return __webpack_chunk_load__(chunkId); } + +// We cache ReactIOInfo across requests so that inner refreshes can dedupe with outer. +const chunkIOInfoCache: Map = __DEV__ + ? new Map() + : (null: any); + +export function addChunkDebugInfo( + target: ReactDebugInfo, + chunkId: string, + filename: string, +): void { + if (!__DEV__) { + return; + } + let ioInfo = chunkIOInfoCache.get(chunkId); + if (ioInfo === undefined) { + const scriptFilename = __webpack_get_script_filename__(chunkId); + let href; + try { + // $FlowFixMe + href = new URL(scriptFilename, document.baseURI).href; + } catch (_) { + href = scriptFilename; + } + let start = -1; + let end = -1; + let byteSize = 0; + // $FlowFixMe[method-unbinding] + if (typeof performance.getEntriesByType === 'function') { + // We may be able to collect the start and end time of this resource from Performance Observer. + const resourceEntries = performance.getEntriesByType('resource'); + for (let i = 0; i < resourceEntries.length; i++) { + const resourceEntry = resourceEntries[i]; + if (resourceEntry.name === href) { + start = resourceEntry.startTime; + end = start + resourceEntry.duration; + // $FlowFixMe[prop-missing] + byteSize = (resourceEntry.transferSize: any) || 0; + } + } + } + const value = Promise.resolve(href); + // $FlowFixMe + value.status = 'fulfilled'; + // $FlowFixMe + value.value = { + chunkId: chunkId, + href: href, + // Is there some more useful representation for the chunk? + }; + // Create a fake stack frame that points to the beginning of the chunk. This is + // probably not source mapped so will link to the compiled source rather than + // any individual file that goes into the chunks. + const fakeStack = new Error('react-stack-top-frame'); + if (fakeStack.stack.startsWith('Error: react-stack-top-frame')) { + // Looks like V8 + fakeStack.stack = + 'Error: react-stack-top-frame\n' + + // Add two frames since we always trim one off the top. + ' at Client Component Bundle (' + + href + + ':1:1)\n' + + ' at Client Component Bundle (' + + href + + ':1:1)'; + } else { + // Looks like Firefox or Safari. + // Add two frames since we always trim one off the top. + fakeStack.stack = + 'Client Component Bundle@' + + href + + ':1:1\n' + + 'Client Component Bundle@' + + href + + ':1:1'; + } + ioInfo = ({ + name: 'script', + start: start, + end: end, + value: value, + debugStack: fakeStack, + }: ReactIOInfo); + if (byteSize > 0) { + // $FlowFixMe[cannot-write] + ioInfo.byteSize = byteSize; + } + chunkIOInfoCache.set(chunkId, ioInfo); + } + // We could dedupe the async info too but conceptually each request is its own await. + const asyncInfo: ReactAsyncInfo = { + awaited: ioInfo, + }; + target.push(asyncInfo); +} diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackServer.js b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackServer.js index 8eeb39a24a3e1..7dcbdf3fb2b81 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackServer.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackServer.js @@ -7,6 +7,17 @@ * @flow */ +import type {ReactDebugInfo} from 'shared/ReactTypes'; + export function loadChunk(chunkId: string, filename: string): Promise { return __webpack_chunk_load__(chunkId); } + +export function addChunkDebugInfo( + target: ReactDebugInfo, + chunkId: string, + filename: string, +): void { + // We don't emit any debug info on the server since we assume the loading + // of the bundle is insignificant on the server. +} diff --git a/scripts/flow/environment.js b/scripts/flow/environment.js index f52e6fd428e20..823defbd8df24 100644 --- a/scripts/flow/environment.js +++ b/scripts/flow/environment.js @@ -146,6 +146,7 @@ declare module 'EventListener' { } declare function __webpack_chunk_load__(id: string): Promise; +declare function __webpack_get_script_filename__(id: string): string; declare const __webpack_require__: ((id: string) => any) & { u: string => string, }; diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index e4cf904e5974d..57b9043c9bd9e 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -62,6 +62,7 @@ module.exports = { // Flight Webpack __webpack_chunk_load__: 'readonly', + __webpack_get_script_filename__: 'readonly', __webpack_require__: 'readonly', // Flight Turbopack diff --git a/scripts/rollup/validate/eslintrc.cjs2015.js b/scripts/rollup/validate/eslintrc.cjs2015.js index 4cda9b06a636e..c9757ecd8708b 100644 --- a/scripts/rollup/validate/eslintrc.cjs2015.js +++ b/scripts/rollup/validate/eslintrc.cjs2015.js @@ -59,6 +59,7 @@ module.exports = { // Flight Webpack __webpack_chunk_load__: 'readonly', + __webpack_get_script_filename__: 'readonly', __webpack_require__: 'readonly', // Flight Turbopack diff --git a/scripts/rollup/validate/eslintrc.esm.js b/scripts/rollup/validate/eslintrc.esm.js index 98fdc56c48c5f..26592005311d7 100644 --- a/scripts/rollup/validate/eslintrc.esm.js +++ b/scripts/rollup/validate/eslintrc.esm.js @@ -62,6 +62,7 @@ module.exports = { // Flight Webpack __webpack_chunk_load__: 'readonly', + __webpack_get_script_filename__: 'readonly', __webpack_require__: 'readonly', // Flight Turbopack From f508edc83fa1eb316a974c274b4411f081d6e94d Mon Sep 17 00:00:00 2001 From: lauren Date: Mon, 18 Aug 2025 11:34:55 -0400 Subject: [PATCH 3/3] [compiler] Stop publishing eslint-plugin-react-compiler to npm (#34228) While we still use this package internally, we now ask users to install eslint-plugin-react-hooks instead, so this package can now be deprecated on npm. --- compiler/scripts/release/shared/packages.js | 1 - 1 file changed, 1 deletion(-) diff --git a/compiler/scripts/release/shared/packages.js b/compiler/scripts/release/shared/packages.js index 39970bdde6c39..235ba0f1ddb54 100644 --- a/compiler/scripts/release/shared/packages.js +++ b/compiler/scripts/release/shared/packages.js @@ -7,7 +7,6 @@ const PUBLISHABLE_PACKAGES = [ 'babel-plugin-react-compiler', - 'eslint-plugin-react-compiler', 'react-compiler-healthcheck', 'react-compiler-runtime', ];