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/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', ]; 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 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