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/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index fd68830f929dd..3e15c81904f02 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -650,6 +650,14 @@ export const EnvironmentConfigSchema = z.object({ * useMemo(() => { ... }, [...]); */ validateNoVoidUseMemo: z.boolean().default(false), + + /** + * A list of function identifier names that should never be skipped by + * memoization/flattening optimizations. Calls to any identifier with a name + * in this list behave like the `use` operator in terms of never being + * skipped, and may be called conditionally. + */ + neverSkipFunctionName: z.array(z.string()).default([]), }); export type EnvironmentConfig = z.infer; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts index 6b3ba6f94c8ae..35e39e590d772 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -1915,6 +1915,34 @@ export function isUseOperator(id: Identifier): boolean { ); } +/** + * Treat any call with a neverSkip identifier name similar to the `use` operator: + * - It may be called conditionally. + * - It should never be skipped by memoization/flattening logic. + */ +export function isNeverSkipIdentifier( + env: Environment, + id: Identifier, +): boolean { + const list = env.config.neverSkipFunctionName; + if (list == null || list.length === 0) { + return false; + } + if (id.name != null && list.includes(id.name.value)) { + return true; + } + const loc = id.loc as any; + if ( + loc && + typeof loc !== 'symbol' && + typeof loc.identifierName === 'string' && + list.includes(loc.identifierName) + ) { + return true; + } + return false; +} + export function getHookKindForType( env: Environment, type: Type, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts index 19e220b235694..637a7c973dff4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts @@ -21,6 +21,7 @@ import { isStableType, isStableTypeContainer, isUseOperator, + isNeverSkipIdentifier, } from '../HIR'; import {PostDominator} from '../HIR/Dominator'; import { @@ -302,13 +303,15 @@ export function inferReactivePlaces(fn: HIRFunction): void { if ( value.kind === 'CallExpression' && (getHookKind(fn.env, value.callee.identifier) != null || - isUseOperator(value.callee.identifier)) + isUseOperator(value.callee.identifier) || + isNeverSkipIdentifier(fn.env, value.callee.identifier)) ) { hasReactiveInput = true; } else if ( value.kind === 'MethodCall' && (getHookKind(fn.env, value.property.identifier) != null || - isUseOperator(value.property.identifier)) + isUseOperator(value.property.identifier) || + isNeverSkipIdentifier(fn.env, value.property.identifier)) ) { hasReactiveInput = true; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenScopesWithHooksOrUseHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenScopesWithHooksOrUseHIR.ts index 103923a2e4d5e..2b644568b60f0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenScopesWithHooksOrUseHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenScopesWithHooksOrUseHIR.ts @@ -13,6 +13,7 @@ import { PrunedScopeTerminal, getHookKind, isUseOperator, + isNeverSkipIdentifier, } from '../HIR'; import {retainWhere} from '../Utils/utils'; @@ -53,7 +54,8 @@ export function flattenScopesWithHooksOrUseHIR(fn: HIRFunction): void { value.kind === 'MethodCall' ? value.property : value.callee; if ( getHookKind(fn.env, callee.identifier) != null || - isUseOperator(callee.identifier) + isUseOperator(callee.identifier) || + isNeverSkipIdentifier(fn.env, callee.identifier) ) { prune.push(...activeScopes.map(entry => entry.block)); activeScopes.length = 0; 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-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 94c394cbd701b..a49cf25a1d1f4 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -1543,6 +1543,22 @@ export function attach( return Array.from(knownEnvironmentNames); } + function isFiberHydrated(fiber: Fiber): boolean { + if (OffscreenComponent === -1) { + throw new Error('not implemented for legacy suspense'); + } + switch (fiber.tag) { + case HostRoot: + const rootState = fiber.memoizedState; + return !rootState.isDehydrated; + case SuspenseComponent: + const suspenseState = fiber.memoizedState; + return suspenseState === null || suspenseState.dehydrated === null; + default: + throw new Error('not implemented for work tag ' + fiber.tag); + } + } + function shouldFilterVirtual( data: ReactComponentInfo, secondaryEnv: null | string, @@ -3327,6 +3343,7 @@ export function attach( } 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. @@ -3336,6 +3353,8 @@ export function attach( if (resourceEntry.name === href) { start = resourceEntry.startTime; end = start + resourceEntry.duration; + // $FlowFixMe[prop-missing] + byteSize = (resourceEntry.transferSize: any) || 0; } } } @@ -3351,6 +3370,10 @@ export function attach( // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. owner: fiber, // Allow linking to the if it's not filtered. }; + if (byteSize > 0) { + // $FlowFixMe[cannot-write] + ioInfo.byteSize = byteSize; + } const asyncInfo: ReactAsyncInfo = { awaited: ioInfo, // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. @@ -3415,6 +3438,7 @@ export function attach( } let start = -1; let end = -1; + let byteSize = 0; let fileSize = 0; // $FlowFixMe[method-unbinding] if (typeof performance.getEntriesByType === 'function') { @@ -3426,7 +3450,9 @@ export function attach( start = resourceEntry.startTime; end = start + resourceEntry.duration; // $FlowFixMe[prop-missing] - fileSize = (resourceEntry.encodedBodySize: any) || 0; + fileSize = (resourceEntry.decodedBodySize: any) || 0; + // $FlowFixMe[prop-missing] + byteSize = (resourceEntry.transferSize: any) || 0; } } } @@ -3460,6 +3486,10 @@ export function attach( // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. owner: fiber, // Allow linking to the if it's not filtered. }; + if (byteSize > 0) { + // $FlowFixMe[cannot-write] + ioInfo.byteSize = byteSize; + } const asyncInfo: ReactAsyncInfo = { awaited: ioInfo, // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. @@ -3610,6 +3640,50 @@ export function attach( ); } + function mountSuspenseChildrenRecursively( + contentFiber: Fiber, + traceNearestHostComponentUpdate: boolean, + stashedSuspenseParent: SuspenseNode | null, + stashedSuspensePrevious: SuspenseNode | null, + stashedSuspenseRemaining: SuspenseNode | null, + ) { + const fallbackFiber = contentFiber.sibling; + + // First update only the Offscreen boundary. I.e. the main content. + mountVirtualChildrenRecursively( + contentFiber, + fallbackFiber, + traceNearestHostComponentUpdate, + 0, // first level + ); + + if (fallbackFiber !== null) { + const fallbackStashedSuspenseParent = stashedSuspenseParent; + const fallbackStashedSuspensePrevious = stashedSuspensePrevious; + const fallbackStashedSuspenseRemaining = stashedSuspenseRemaining; + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. + // Since the fallback conceptually blocks the parent. + reconcilingParentSuspenseNode = stashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; + try { + mountVirtualChildrenRecursively( + fallbackFiber, + null, + traceNearestHostComponentUpdate, + 0, // first level + ); + } finally { + reconcilingParentSuspenseNode = fallbackStashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = + fallbackStashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = + fallbackStashedSuspenseRemaining; + } + } + } + function mountFiberRecursively( fiber: Fiber, traceNearestHostComponentUpdate: boolean, @@ -3632,11 +3706,17 @@ export function attach( newSuspenseNode.rects = measureInstance(newInstance); } } else { - const contentFiber = fiber.child; - if (contentFiber === null) { - throw new Error( - 'There should always be an Offscreen Fiber child in a Suspense boundary.', - ); + const hydrated = isFiberHydrated(fiber); + if (hydrated) { + const contentFiber = fiber.child; + if (contentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } + } else { + // This Suspense Fiber is still dehydrated. It won't have any children + // until hydration. } const isTimedOut = fiber.memoizedState !== null; if (!isTimedOut) { @@ -3684,13 +3764,20 @@ export function attach( newSuspenseNode.rects = measureInstance(newInstance); } } else { - const contentFiber = fiber.child; - if (contentFiber === null) { - throw new Error( - 'There should always be an Offscreen Fiber child in a Suspense boundary.', - ); + const hydrated = isFiberHydrated(fiber); + if (hydrated) { + const contentFiber = fiber.child; + if (contentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } + } else { + // This Suspense Fiber is still dehydrated. It won't have any children + // until hydration. } - const isTimedOut = fiber.memoizedState !== null; + const suspenseState = fiber.memoizedState; + const isTimedOut = suspenseState !== null; if (!isTimedOut) { newSuspenseNode.rects = measureInstance(newInstance); } @@ -3820,38 +3907,26 @@ export function attach( ) { // Modern Suspense path const contentFiber = fiber.child; - if (contentFiber === null) { - throw new Error( - 'There should always be an Offscreen Fiber child in a Suspense boundary.', - ); - } - - trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode); - - const fallbackFiber = contentFiber.sibling; + const hydrated = isFiberHydrated(fiber); + if (hydrated) { + if (contentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } - // First update only the Offscreen boundary. I.e. the main content. - mountVirtualChildrenRecursively( - contentFiber, - fallbackFiber, - traceNearestHostComponentUpdate, - 0, // first level - ); + trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode); - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll - // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. - // Since the fallback conceptually blocks the parent. - reconcilingParentSuspenseNode = stashedSuspenseParent; - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; - shouldPopSuspenseNode = false; - if (fallbackFiber !== null) { - mountVirtualChildrenRecursively( - fallbackFiber, - null, + mountSuspenseChildrenRecursively( + contentFiber, traceNearestHostComponentUpdate, - 0, // first level + stashedSuspenseParent, + stashedSuspensePrevious, + stashedSuspenseRemaining, ); + } else { + // This Suspense Fiber is still dehydrated. It won't have any children + // until hydration. } } else { if (fiber.child !== null) { @@ -4505,6 +4580,63 @@ export function attach( ); } + function updateSuspenseChildrenRecursively( + nextContentFiber: Fiber, + prevContentFiber: Fiber, + traceNearestHostComponentUpdate: boolean, + stashedSuspenseParent: null | SuspenseNode, + stashedSuspensePrevious: null | SuspenseNode, + stashedSuspenseRemaining: null | SuspenseNode, + ): number { + let updateFlags = NoUpdate; + const prevFallbackFiber = prevContentFiber.sibling; + const nextFallbackFiber = nextContentFiber.sibling; + + // First update only the Offscreen boundary. I.e. the main content. + updateFlags |= updateVirtualChildrenRecursively( + nextContentFiber, + nextFallbackFiber, + prevContentFiber, + traceNearestHostComponentUpdate, + 0, + ); + + if (prevFallbackFiber !== null || nextFallbackFiber !== null) { + const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode; + const fallbackStashedSuspensePrevious = + previouslyReconciledSiblingSuspenseNode; + const fallbackStashedSuspenseRemaining = + remainingReconcilingChildrenSuspenseNodes; + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll + // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode. + // Since the fallback conceptually blocks the parent. + reconcilingParentSuspenseNode = stashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; + try { + if (nextFallbackFiber === null) { + unmountRemainingChildren(); + } else { + updateFlags |= updateVirtualChildrenRecursively( + nextFallbackFiber, + null, + prevFallbackFiber, + traceNearestHostComponentUpdate, + 0, + ); + } + } finally { + reconcilingParentSuspenseNode = fallbackStashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = + fallbackStashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = + fallbackStashedSuspenseRemaining; + } + } + + return updateFlags; + } + // Returns whether closest unfiltered fiber parent needs to reset its child list. function updateFiberRecursively( fiberInstance: null | FiberInstance | FilteredFiberInstance, // null if this should be filtered @@ -4586,16 +4718,15 @@ export function attach( trackDebugInfoFromLazyType(nextFiber); trackDebugInfoFromUsedThenables(nextFiber); - if ( - nextFiber.tag === HostHoistable && - prevFiber.memoizedState !== nextFiber.memoizedState - ) { + if (nextFiber.tag === HostHoistable) { const nearestInstance = reconcilingParent; if (nearestInstance === null) { throw new Error('Did not expect a host hoistable to be the root'); } - releaseHostResource(nearestInstance, prevFiber.memoizedState); - aquireHostResource(nearestInstance, nextFiber.memoizedState); + if (prevFiber.memoizedState !== nextFiber.memoizedState) { + releaseHostResource(nearestInstance, prevFiber.memoizedState); + aquireHostResource(nearestInstance, nextFiber.memoizedState); + } trackDebugInfoFromHostResource(nearestInstance, nextFiber); } else if ( nextFiber.tag === HostComponent || @@ -4765,71 +4896,67 @@ export function attach( fiberInstance.suspenseNode !== null ) { // Modern Suspense path + const suspenseNode = fiberInstance.suspenseNode; const prevContentFiber = prevFiber.child; const nextContentFiber = nextFiber.child; - if (nextContentFiber === null || prevContentFiber === null) { - throw new Error( - 'There should always be an Offscreen Fiber child in a Suspense boundary.', - ); - } - const prevFallbackFiber = prevContentFiber.sibling; - const nextFallbackFiber = nextContentFiber.sibling; + const previousHydrated = isFiberHydrated(prevFiber); + const nextHydrated = isFiberHydrated(nextFiber); + if (previousHydrated && nextHydrated) { + if (nextContentFiber === null || prevContentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } - if ((prevFiber.stateNode === null) !== (nextFiber.stateNode === null)) { - trackThrownPromisesFromRetryCache( - fiberInstance.suspenseNode, - nextFiber.stateNode, + if ( + (prevFiber.stateNode === null) !== + (nextFiber.stateNode === null) + ) { + trackThrownPromisesFromRetryCache( + suspenseNode, + nextFiber.stateNode, + ); + } + + shouldMeasureSuspenseNode = false; + updateFlags |= updateSuspenseChildrenRecursively( + nextContentFiber, + prevContentFiber, + traceNearestHostComponentUpdate, + stashedSuspenseParent, + stashedSuspensePrevious, + stashedSuspenseRemaining, ); - } + if (nextFiber.memoizedState === null) { + // Measure this Suspense node in case it changed. We don't update the rect while + // we're inside a disconnected subtree nor if we are the Suspense boundary that + // is suspended. This lets us keep the rectangle of the displayed content while + // we're suspended to visualize the resulting state. + shouldMeasureSuspenseNode = !isInDisconnectedSubtree; + } + } else if (!previousHydrated && nextHydrated) { + if (nextContentFiber === null) { + throw new Error( + 'There should always be an Offscreen Fiber child in a hydrated Suspense boundary.', + ); + } - // First update only the Offscreen boundary. I.e. the main content. - updateFlags |= updateVirtualChildrenRecursively( - nextContentFiber, - nextFallbackFiber, - prevContentFiber, - traceNearestHostComponentUpdate, - 0, - ); + trackThrownPromisesFromRetryCache(suspenseNode, nextFiber.stateNode); - shouldMeasureSuspenseNode = false; - if (prevFallbackFiber !== null || nextFallbackFiber !== null) { - const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode; - const fallbackStashedSuspensePrevious = - previouslyReconciledSiblingSuspenseNode; - const fallbackStashedSuspenseRemaining = - remainingReconcilingChildrenSuspenseNodes; - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll - // reconcile the fallback, reconciling anything in the context of the parent SuspenseNode. - // Since the fallback conceptually blocks the parent. - reconcilingParentSuspenseNode = stashedSuspenseParent; - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; - try { - if (nextFallbackFiber === null) { - unmountRemainingChildren(); - } else { - updateFlags |= updateVirtualChildrenRecursively( - nextFallbackFiber, - null, - prevFallbackFiber, - traceNearestHostComponentUpdate, - 0, - ); - } - } finally { - reconcilingParentSuspenseNode = fallbackStashedSuspenseParent; - previouslyReconciledSiblingSuspenseNode = - fallbackStashedSuspensePrevious; - remainingReconcilingChildrenSuspenseNodes = - fallbackStashedSuspenseRemaining; - } - } - if (nextFiber.memoizedState === null) { - // Measure this Suspense node in case it changed. We don't update the rect while - // we're inside a disconnected subtree nor if we are the Suspense boundary that - // is suspended. This lets us keep the rectangle of the displayed content while - // we're suspended to visualize the resulting state. - shouldMeasureSuspenseNode = !isInDisconnectedSubtree; + mountSuspenseChildrenRecursively( + nextContentFiber, + traceNearestHostComponentUpdate, + stashedSuspenseParent, + stashedSuspensePrevious, + stashedSuspenseRemaining, + ); + } else if (previousHydrated && !nextHydrated) { + throw new Error( + 'Encountered a dehydrated Suspense boundary that was previously hydrated.', + ); + } else { + // This Suspense Fiber is still dehydrated. It won't have any children + // until hydration. } } else { // Common case: Primary -> Primary. @@ -5164,14 +5291,9 @@ export function attach( // TODO: relying on this seems a bit fishy. const wasMounted = prevFiber.memoizedState != null && - prevFiber.memoizedState.element != null && - // A dehydrated root is not considered mounted - prevFiber.memoizedState.isDehydrated !== true; + prevFiber.memoizedState.element != null; const isMounted = - current.memoizedState != null && - current.memoizedState.element != null && - // A dehydrated root is not considered mounted - current.memoizedState.isDehydrated !== true; + current.memoizedState != null && current.memoizedState.element != null; if (!wasMounted && isMounted) { // Mount a new root. setRootPseudoKey(currentRoot.id, current); @@ -5839,6 +5961,7 @@ export function attach( description: getIODescription(resolvedValue), start: ioInfo.start, end: ioInfo.end, + byteSize: ioInfo.byteSize == null ? null : ioInfo.byteSize, value: ioInfo.value == null ? null : ioInfo.value, env: ioInfo.env == null ? null : ioInfo.env, owner: diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index ffbacea01aaba..12b082aeb2e55 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -239,6 +239,7 @@ export type SerializedIOInfo = { description: string, start: number, end: number, + byteSize: null | number, value: null | Promise, env: null | string, owner: null | SerializedElement, diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index f8fa4da372549..f6c11fb10d66a 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -221,6 +221,7 @@ function backendToFrontendSerializedAsyncInfo( description: ioInfo.description, start: ioInfo.start, end: ioInfo.end, + byteSize: ioInfo.byteSize, value: ioInfo.value, env: ioInfo.env, owner: diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index f4150c75570fc..664b65bf7d7a8 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -1206,6 +1206,14 @@ export default class Store extends EventEmitter<{ } set.add(id); } + + const suspense = this._idToSuspense.get(id); + if (suspense !== undefined) { + // We're reconnecting a node. + if (suspense.name === null) { + suspense.name = this._guessSuspenseName(element); + } + } } break; } @@ -1432,21 +1440,12 @@ export default class Store extends EventEmitter<{ const element = this._idToElement.get(id); if (element === undefined) { - this._throwAndEmitError( - Error( - `Cannot add suspense node "${id}" because no matching element was found in the Store.`, - ), - ); + // This element isn't connected yet. } else { if (name === null) { // The boundary isn't explicitly named. // Pick a sensible default. - // TODO: Use key - const owner = this._idToElement.get(element.ownerID); - if (owner !== undefined) { - // TODO: This is clowny - name = `${owner.displayName || 'Unknown'}>?`; - } + name = this._guessSuspenseName(element); } } @@ -1936,4 +1935,15 @@ export default class Store extends EventEmitter<{ // and for unit testing the Store itself. throw error; } + + _guessSuspenseName(element: Element): string | null { + // TODO: Use key + const owner = this._idToElement.get(element.ownerID); + if (owner !== undefined) { + // TODO: This is clowny + return `${owner.displayName || 'Unknown'}>?`; + } + + return null; + } } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js index 8f1d1cd7586a9..e7b7052057257 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -34,6 +34,8 @@ export type Props = {}; // TODO Make edits and deletes also use transition API! +const noSourcePromise = Promise.resolve(null); + export default function InspectedElementWrapper(_: Props): React.Node { const {inspectedElementID} = useContext(TreeStateContext); const bridge = useContext(BridgeContext); @@ -59,11 +61,11 @@ export default function InspectedElementWrapper(_: Props): React.Node { ? inspectedElement.stack[0] : null; - const symbolicatedSourcePromise: null | Promise = + const symbolicatedSourcePromise: Promise = React.useMemo(() => { - if (fetchFileWithCaching == null) return Promise.resolve(null); + if (fetchFileWithCaching == null) return noSourcePromise; - if (source == null) return Promise.resolve(null); + if (source == null) return noSourcePromise; const [, sourceURL, line, column] = source; return symbolicateSourceWithCache( @@ -291,7 +293,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
Loading...
)} - {inspectedElement !== null && symbolicatedSourcePromise != null && ( + {inspectedElement !== null && ( setIsOpen(prevIsOpen => !prevIsOpen)} - title={longName + ' — ' + (end - start).toFixed(2) + ' ms'}> + title={ + longName + + ' — ' + + (end - start).toFixed(2) + + ' ms' + + (ioInfo.byteSize != null ? ' — ' + formatBytes(ioInfo.byteSize) : '') + }> , env: null | string, owner: null | SerializedElement, diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index ea921c2988c3d..34c258ebe2b98 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -300,7 +300,7 @@ export function printOperationsArray(operations: Array) { } case TREE_OPERATION_SET_SUBTREE_MODE: { const id = operations[i + 1]; - const mode = operations[i + 1]; + const mode = operations[i + 2]; i += 3; @@ -339,11 +339,11 @@ export function printOperationsArray(operations: Array) { const fiberID = operations[i + 1]; const parentID = operations[i + 2]; const nameStringID = operations[i + 3]; - const name = stringTable[nameStringID]; const numRects = operations[i + 4]; i += 5; + const name = stringTable[nameStringID]; let rects: string; if (numRects === -1) { rects = 'null'; diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 0cfff921d0a3d..8c4a0a8e9a00a 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -6,7 +6,7 @@ export const markShellTime = export const clientRenderBoundary = '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};'; export const completeBoundary = - '$RB=[];$RV=function(b){$RT=performance.now();for(var a=0;aa&&2E3a&&2E3q&&2E3(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/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index ff2649a23dc0d..f2228233259cd 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -237,6 +237,7 @@ export type ReactIOInfo = { +name: string, // the name of the async function being called (e.g. "fetch") +start: number, // the start time +end: number, // the end time (this might be different from the time the await was unblocked) + +byteSize?: number, // the byte size of this resource across the network. (should only be included if affecting the client.) +value?: null | Promise, // the Promise that was awaited if any, may be rejected +env?: string, // the environment where this I/O was spawned. +owner?: null | ReactComponentInfo, 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