diff --git a/compiler/apps/playground/components/Editor/EditorImpl.tsx b/compiler/apps/playground/components/Editor/EditorImpl.tsx index 7b1214b4600c3..ab85beebd5fca 100644 --- a/compiler/apps/playground/components/Editor/EditorImpl.tsx +++ b/compiler/apps/playground/components/Editor/EditorImpl.tsx @@ -235,7 +235,7 @@ function compile(source: string): [CompilerOutput, 'flow' | 'typescript'] { name: result.name, value: { type: 'FunctionDeclaration', - id, + id: withIdentifier(result.value.id), async: result.value.async, generator: result.value.generator, body: result.value.body, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts index 8307e8817b4f9..2f6d8a94021ab 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -46,18 +46,13 @@ import {instructionReordering} from '../Optimization/InstructionReordering'; import { CodegenFunction, alignObjectMethodScopes, - alignReactiveScopesToBlockScopes, assertScopeInstructionsWithinScopes, assertWellFormedBreakTargets, - buildReactiveBlocks, buildReactiveFunction, codegenFunction, extractScopeDeclarationsFromDestructuring, - flattenReactiveLoops, - flattenScopesWithHooksOrUse, inferReactiveScopeVariables, memoizeFbtAndMacroOperandsInSameScope, - mergeOverlappingReactiveScopes, mergeReactiveScopesThatInvalidateTogether, promoteUsedTemporaries, propagateEarlyReturns, @@ -300,54 +295,52 @@ function* runWithEnvironment( value: hir, }); - if (env.config.enableReactiveScopesInHIR) { - pruneUnusedLabelsHIR(hir); - yield log({ - kind: 'hir', - name: 'PruneUnusedLabelsHIR', - value: hir, - }); + pruneUnusedLabelsHIR(hir); + yield log({ + kind: 'hir', + name: 'PruneUnusedLabelsHIR', + value: hir, + }); - alignReactiveScopesToBlockScopesHIR(hir); - yield log({ - kind: 'hir', - name: 'AlignReactiveScopesToBlockScopesHIR', - value: hir, - }); + alignReactiveScopesToBlockScopesHIR(hir); + yield log({ + kind: 'hir', + name: 'AlignReactiveScopesToBlockScopesHIR', + value: hir, + }); - mergeOverlappingReactiveScopesHIR(hir); - yield log({ - kind: 'hir', - name: 'MergeOverlappingReactiveScopesHIR', - value: hir, - }); - assertValidBlockNesting(hir); + mergeOverlappingReactiveScopesHIR(hir); + yield log({ + kind: 'hir', + name: 'MergeOverlappingReactiveScopesHIR', + value: hir, + }); + assertValidBlockNesting(hir); - buildReactiveScopeTerminalsHIR(hir); - yield log({ - kind: 'hir', - name: 'BuildReactiveScopeTerminalsHIR', - value: hir, - }); + buildReactiveScopeTerminalsHIR(hir); + yield log({ + kind: 'hir', + name: 'BuildReactiveScopeTerminalsHIR', + value: hir, + }); - assertValidBlockNesting(hir); + assertValidBlockNesting(hir); - flattenReactiveLoopsHIR(hir); - yield log({ - kind: 'hir', - name: 'FlattenReactiveLoopsHIR', - value: hir, - }); + flattenReactiveLoopsHIR(hir); + yield log({ + kind: 'hir', + name: 'FlattenReactiveLoopsHIR', + value: hir, + }); - flattenScopesWithHooksOrUseHIR(hir); - yield log({ - kind: 'hir', - name: 'FlattenScopesWithHooksOrUseHIR', - value: hir, - }); - assertTerminalSuccessorsExist(hir); - assertTerminalPredsExist(hir); - } + flattenScopesWithHooksOrUseHIR(hir); + yield log({ + kind: 'hir', + name: 'FlattenScopesWithHooksOrUseHIR', + value: hir, + }); + assertTerminalSuccessorsExist(hir); + assertTerminalPredsExist(hir); const reactiveFunction = buildReactiveFunction(hir); yield log({ @@ -364,44 +357,6 @@ function* runWithEnvironment( name: 'PruneUnusedLabels', value: reactiveFunction, }); - - if (!env.config.enableReactiveScopesInHIR) { - alignReactiveScopesToBlockScopes(reactiveFunction); - yield log({ - kind: 'reactive', - name: 'AlignReactiveScopesToBlockScopes', - value: reactiveFunction, - }); - - mergeOverlappingReactiveScopes(reactiveFunction); - yield log({ - kind: 'reactive', - name: 'MergeOverlappingReactiveScopes', - value: reactiveFunction, - }); - - buildReactiveBlocks(reactiveFunction); - yield log({ - kind: 'reactive', - name: 'BuildReactiveBlocks', - value: reactiveFunction, - }); - - flattenReactiveLoops(reactiveFunction); - yield log({ - kind: 'reactive', - name: 'FlattenReactiveLoops', - value: reactiveFunction, - }); - - flattenScopesWithHooksOrUse(reactiveFunction); - yield log({ - kind: 'reactive', - name: 'FlattenScopesWithHooks', - value: reactiveFunction, - }); - } - assertScopeInstructionsWithinScopes(reactiveFunction); propagateScopeDependencies(reactiveFunction); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts index 979e9f88d1b57..c2c7d8d640846 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts @@ -310,8 +310,6 @@ export function compileProgram( pass.opts.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS, pass.opts.flowSuppressions, ); - const lintError = suppressionsToCompilerError(suppressions); - let hasCriticalError = lintError != null; const queue: Array<{ kind: 'original' | 'outlined'; fn: BabelFn; @@ -385,7 +383,8 @@ export function compileProgram( ); } - if (lintError != null) { + let compiledFn: CodegenFunction; + try { /** * Note that Babel does not attach comment nodes to nodes; they are dangling off of the * Program node itself. We need to figure out whether an eslint suppression range @@ -396,16 +395,15 @@ export function compileProgram( fn, ); if (suppressionsInFunction.length > 0) { + const lintError = suppressionsToCompilerError(suppressionsInFunction); if (optOutDirectives.length > 0) { logError(lintError, pass, fn.node.loc ?? null); } else { handleError(lintError, pass, fn.node.loc ?? null); } + return null; } - } - let compiledFn: CodegenFunction; - try { compiledFn = compileFn( fn, environment, @@ -436,7 +434,6 @@ export function compileProgram( return null; } } - hasCriticalError ||= isCriticalError(err); handleError(err, pass, fn.node.loc ?? null); return null; } @@ -470,7 +467,7 @@ export function compileProgram( return null; } - if (!pass.opts.noEmit && !hasCriticalError) { + if (!pass.opts.noEmit) { return compiledFn; } return null; diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Suppression.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Suppression.ts index 71341989a7592..4d0369f5210ca 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Suppression.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Suppression.ts @@ -14,6 +14,7 @@ import { ErrorSeverity, } from '../CompilerError'; import {assertExhaustive} from '../Utils/utils'; +import {GeneratedSource} from '../HIR'; /** * Captures the start and end range of a pair of eslint-disable ... eslint-enable comments. In the @@ -148,10 +149,11 @@ export function findProgramSuppressions( export function suppressionsToCompilerError( suppressionRanges: Array, -): CompilerError | null { - if (suppressionRanges.length === 0) { - return null; - } +): CompilerError { + CompilerError.invariant(suppressionRanges.length !== 0, { + reason: `Expected at least suppression comment source range`, + loc: GeneratedSource, + }); const error = new CompilerError(); for (const suppressionRange of suppressionRanges) { if ( diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts index 06fcfbea7ecc0..7fb12d4624c10 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts @@ -607,6 +607,7 @@ function lowerStatement( ), consequent: bodyBlock, alternate: continuationBlock.id, + fallthrough: continuationBlock.id, id: makeInstructionId(0), loc: stmt.node.loc ?? GeneratedSource, }, @@ -656,16 +657,13 @@ function lowerStatement( }, conditionalBlock, ); - /* - * The conditional block is empty and exists solely as conditional for - * (re)entering or exiting the loop - */ const test = lowerExpressionToTemporary(builder, stmt.get('test')); const terminal: BranchTerminal = { kind: 'branch', test, consequent: loopBlock, alternate: continuationBlock.id, + fallthrough: conditionalBlock.id, id: makeInstructionId(0), loc: stmt.node.loc ?? GeneratedSource, }; @@ -975,6 +973,7 @@ function lowerStatement( test, consequent: loopBlock, alternate: continuationBlock.id, + fallthrough: conditionalBlock.id, id: makeInstructionId(0), loc, }; @@ -1118,6 +1117,7 @@ function lowerStatement( consequent: loopBlock, alternate: continuationBlock.id, loc: stmt.node.loc ?? GeneratedSource, + fallthrough: continuationBlock.id, }, continuationBlock, ); @@ -1203,6 +1203,7 @@ function lowerStatement( test, consequent: loopBlock, alternate: continuationBlock.id, + fallthrough: continuationBlock.id, loc: stmt.node.loc ?? GeneratedSource, }, continuationBlock, @@ -1800,6 +1801,7 @@ function lowerExpression( test: {...testPlace}, consequent: consequentBlock, alternate: alternateBlock, + fallthrough: continuationBlock.id, id: makeInstructionId(0), loc: exprLoc, }, @@ -1878,6 +1880,7 @@ function lowerExpression( test: {...leftPlace}, consequent, alternate, + fallthrough: continuationBlock.id, id: makeInstructionId(0), loc: exprLoc, }, @@ -2611,6 +2614,7 @@ function lowerOptionalMemberExpression( test: {...object}, consequent: consequent.id, alternate, + fallthrough: continuationBlock.id, id: makeInstructionId(0), loc, }; @@ -2750,6 +2754,7 @@ function lowerOptionalCallExpression( test: {...testPlace}, consequent: consequent.id, alternate, + fallthrough: continuationBlock.id, id: makeInstructionId(0), loc, }; @@ -4025,6 +4030,7 @@ function lowerAssignment( test: {...test}, consequent, alternate, + fallthrough: continuationBlock.id, id: makeInstructionId(0), loc, }, 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 12c741641c7e0..1cd789cb874d2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -33,6 +33,7 @@ import { Type, ValidatedIdentifier, ValueKind, + getHookKindForType, makeBlockId, makeIdentifierId, makeIdentifierName, @@ -222,7 +223,15 @@ const EnvironmentConfigSchema = z.object({ */ enableUseTypeAnnotations: z.boolean().default(false), - enableReactiveScopesInHIR: z.boolean().default(true), + enablePropagateDepsInHIR: z.boolean().default(true), + + /** + * Enables inference of optional dependency chains. Without this flag + * a property chain such as `props?.items?.foo` will infer as a dep on + * just `props`. With this flag enabled, we'll infer that full path as + * the dependency. + */ + enableOptionalDependencies: z.boolean().default(true), /* * Enable validation of hooks to partially check that the component honors the rules of hooks. @@ -729,6 +738,8 @@ export class Environment { this.#globals, this.#shapes, moduleConfig, + moduleName, + loc, ); } else { moduleType = null; @@ -786,6 +797,21 @@ export class Environment { binding.imported, ); if (importedType != null) { + /* + * Check that hook-like export names are hook types, and non-hook names are non-hook types. + * The user-assigned alias isn't decidable by the type provider, so we ignore that for the check. + * Thus we allow `import {fooNonHook as useFoo} from ...` because the name and type both say + * that it's not a hook. + */ + const expectHook = isHookName(binding.imported); + const isHook = getHookKindForType(this, importedType) != null; + if (expectHook !== isHook) { + CompilerError.throwInvalidConfig({ + reason: `Invalid type configuration for module`, + description: `Expected type for \`import {${binding.imported}} from '${binding.module}'\` ${expectHook ? 'to be a hook' : 'not to be a hook'} based on the exported name`, + loc, + }); + } return importedType; } } @@ -814,13 +840,30 @@ export class Environment { } else { const moduleType = this.#resolveModuleType(binding.module, loc); if (moduleType !== null) { + let importedType: Type | null = null; if (binding.kind === 'ImportDefault') { const defaultType = this.getPropertyType(moduleType, 'default'); if (defaultType !== null) { - return defaultType; + importedType = defaultType; } } else { - return moduleType; + importedType = moduleType; + } + if (importedType !== null) { + /* + * Check that the hook-like modules are defined as types, and non hook-like modules are not typed as hooks. + * So `import Foo from 'useFoo'` is expected to be a hook based on the module name + */ + const expectHook = isHookName(binding.module); + const isHook = getHookKindForType(this, importedType) != null; + if (expectHook !== isHook) { + CompilerError.throwInvalidConfig({ + reason: `Invalid type configuration for module`, + description: `Expected type for \`import ... from '${binding.module}'\` ${expectHook ? 'to be a hook' : 'not to be a hook'} based on the module name`, + loc, + }); + } + return importedType; } } return isHookName(binding.name) ? this.#getCustomHookType() : null; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts index 2812394300ad5..c923882900cc2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts @@ -28,6 +28,8 @@ import { import {BuiltInType, PolyType} from './Types'; import {TypeConfig} from './TypeSchema'; import {assertExhaustive} from '../Utils/utils'; +import {isHookName} from './Environment'; +import {CompilerError, SourceLocation} from '..'; /* * This file exports types and defaults for JavaScript global objects. @@ -535,6 +537,8 @@ export function installTypeConfig( globals: GlobalRegistry, shapes: ShapeRegistry, typeConfig: TypeConfig, + moduleName: string, + loc: SourceLocation, ): Global { switch (typeConfig.kind) { case 'type': { @@ -567,7 +571,13 @@ export function installTypeConfig( positionalParams: typeConfig.positionalParams, restParam: typeConfig.restParam, calleeEffect: typeConfig.calleeEffect, - returnType: installTypeConfig(globals, shapes, typeConfig.returnType), + returnType: installTypeConfig( + globals, + shapes, + typeConfig.returnType, + moduleName, + loc, + ), returnValueKind: typeConfig.returnValueKind, noAlias: typeConfig.noAlias === true, mutableOnlyIfOperandsAreMutable: @@ -580,7 +590,13 @@ export function installTypeConfig( positionalParams: typeConfig.positionalParams ?? [], restParam: typeConfig.restParam ?? Effect.Freeze, calleeEffect: Effect.Read, - returnType: installTypeConfig(globals, shapes, typeConfig.returnType), + returnType: installTypeConfig( + globals, + shapes, + typeConfig.returnType, + moduleName, + loc, + ), returnValueKind: typeConfig.returnValueKind ?? ValueKind.Frozen, noAlias: typeConfig.noAlias === true, }); @@ -589,10 +605,31 @@ export function installTypeConfig( return addObject( shapes, null, - Object.entries(typeConfig.properties ?? {}).map(([key, value]) => [ - key, - installTypeConfig(globals, shapes, value), - ]), + Object.entries(typeConfig.properties ?? {}).map(([key, value]) => { + const type = installTypeConfig( + globals, + shapes, + value, + moduleName, + loc, + ); + const expectHook = isHookName(key); + let isHook = false; + if (type.kind === 'Function' && type.shapeId !== null) { + const functionType = shapes.get(type.shapeId); + if (functionType?.functionType?.hookKind !== null) { + isHook = true; + } + } + if (expectHook !== isHook) { + CompilerError.throwInvalidConfig({ + reason: `Invalid type configuration for module`, + description: `Expected type for object property '${key}' from module '${moduleName}' ${expectHook ? 'to be a hook' : 'not to be a hook'} based on the property name`, + loc, + }); + } + return [key, type]; + }), ); } default: { 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 06fa48a73656c..930dd79f2fd59 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -491,7 +491,7 @@ export type BranchTerminal = { alternate: BlockId; id: InstructionId; loc: SourceLocation; - fallthrough?: never; + fallthrough: BlockId; }; export type SwitchTerminal = { @@ -776,7 +776,7 @@ export type ManualMemoDependency = { value: Place; } | {kind: 'Global'; identifierName: string}; - path: Array; + path: DependencyPath; }; export type StartMemoize = { @@ -1492,11 +1492,37 @@ export type ReactiveScopeDeclaration = { scope: ReactiveScope; // the scope in which the variable was originally declared }; +export type DependencyPath = Array<{property: string; optional: boolean}>; export type ReactiveScopeDependency = { identifier: Identifier; - path: Array; + path: DependencyPath; }; +export function areEqualPaths(a: DependencyPath, b: DependencyPath): boolean { + return ( + a.length === b.length && + a.every( + (item, ix) => + item.property === b[ix].property && item.optional === b[ix].optional, + ) + ); +} + +export function getPlaceScope( + id: InstructionId, + place: Place, +): ReactiveScope | null { + const scope = place.identifier.scope; + if (scope !== null && isScopeActive(scope, id)) { + return scope; + } + return null; +} + +function isScopeActive(scope: ReactiveScope, id: InstructionId): boolean { + return id >= scope.range.start && id < scope.range.end; +} + /* * Simulated opaque type for BlockIds to prevent using normal numbers as block ids * accidentally. diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/MergeOverlappingReactiveScopesHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/MergeOverlappingReactiveScopesHIR.ts index 98645d5c549af..a3740539b295b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/MergeOverlappingReactiveScopesHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/MergeOverlappingReactiveScopesHIR.ts @@ -5,7 +5,7 @@ import { ReactiveScope, makeInstructionId, } from '.'; -import {getPlaceScope} from '../ReactiveScopes/BuildReactiveBlocks'; +import {getPlaceScope} from '../HIR/HIR'; import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables'; import DisjointSet from '../Utils/DisjointSet'; import {getOrInsertDefault} from '../Utils/utils'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts index 8bcab1d7b25ae..c2db20c5099a1 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts @@ -191,7 +191,7 @@ export function printTerminal(terminal: Terminal): Array | string { case 'branch': { value = `[${terminal.id}] Branch (${printPlace(terminal.test)}) then:bb${ terminal.consequent - } else:bb${terminal.alternate}`; + } else:bb${terminal.alternate} fallthrough:bb${terminal.fallthrough}`; break; } case 'logical': { @@ -869,7 +869,7 @@ export function printManualMemoDependency( ? val.root.value.identifier.name.value : printIdentifier(val.root.value.identifier); } - return `${rootStr}${val.path.length > 0 ? '.' : ''}${val.path.join('.')}`; + return `${rootStr}${val.path.map(v => `${v.optional ? '?.' : '.'}${v.property}`).join('')}`; } export function printType(type: Type): string { if (type.kind === 'Type') return ''; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts index 0df8478b39c45..904b7a4038dec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts @@ -660,11 +660,13 @@ export function mapTerminalSuccessors( case 'branch': { const consequent = fn(terminal.consequent); const alternate = fn(terminal.alternate); + const fallthrough = fn(terminal.fallthrough); return { kind: 'branch', test: terminal.test, consequent, alternate, + fallthrough, id: makeInstructionId(0), loc: terminal.loc, }; @@ -883,7 +885,6 @@ export function terminalHasFallthrough< >(terminal: T): terminal is U { switch (terminal.kind) { case 'maybe-throw': - case 'branch': case 'goto': case 'return': case 'throw': @@ -892,6 +893,7 @@ export function terminalHasFallthrough< const _: undefined = terminal.fallthrough; return false; } + case 'branch': case 'try': case 'do-while': case 'for-of': diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts index fbb24ea492c0f..684acaf298388 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts @@ -13,7 +13,6 @@ import { IdentifierName, LoweredFunction, Place, - ReactiveScopeDependency, isRefOrRefValue, makeInstructionId, } from '../HIR'; @@ -25,9 +24,14 @@ import {inferMutableContextVariables} from './InferMutableContextVariables'; import {inferMutableRanges} from './InferMutableRanges'; import inferReferenceEffects from './InferReferenceEffects'; +type Dependency = { + identifier: Identifier; + path: Array; +}; + // Helper class to track indirections such as LoadLocal and PropertyLoad. export class IdentifierState { - properties: Map = new Map(); + properties: Map = new Map(); resolve(identifier: Identifier): Identifier { const resolved = this.properties.get(identifier); @@ -39,7 +43,7 @@ export class IdentifierState { declareProperty(lvalue: Place, object: Place, property: string): void { const objectDependency = this.properties.get(object.identifier); - let nextDependency: ReactiveScopeDependency; + let nextDependency: Dependency; if (objectDependency === undefined) { nextDependency = {identifier: object.identifier, path: [property]}; } else { @@ -52,9 +56,7 @@ export class IdentifierState { } declareTemporary(lvalue: Place, value: Place): void { - const resolved: ReactiveScopeDependency = this.properties.get( - value.identifier, - ) ?? { + const resolved: Dependency = this.properties.get(value.identifier) ?? { identifier: value.identifier, path: [], }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts index c9d2a7e1412c3..4dcdc21e15ac5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts @@ -42,6 +42,7 @@ type IdentifierSidemap = { react: Set; maybeDepsLists: Map>; maybeDeps: Map; + optionals: Set; }; /** @@ -52,6 +53,7 @@ type IdentifierSidemap = { export function collectMaybeMemoDependencies( value: InstructionValue, maybeDeps: Map, + optional: boolean, ): ManualMemoDependency | null { switch (value.kind) { case 'LoadGlobal': { @@ -68,7 +70,8 @@ export function collectMaybeMemoDependencies( if (object != null) { return { root: object.root, - path: [...object.path, value.property], + // TODO: determine if the access is optional + path: [...object.path, {property: value.property, optional}], }; } break; @@ -161,7 +164,11 @@ function collectTemporaries( break; } } - const maybeDep = collectMaybeMemoDependencies(value, sidemap.maybeDeps); + const maybeDep = collectMaybeMemoDependencies( + value, + sidemap.maybeDeps, + sidemap.optionals.has(lvalue.identifier.id), + ); // We don't expect named lvalues during this pass (unlike ValidatePreservingManualMemo) if (maybeDep != null) { sidemap.maybeDeps.set(lvalue.identifier.id, maybeDep); @@ -337,12 +344,14 @@ export function dropManualMemoization(func: HIRFunction): void { func.env.config.validatePreserveExistingMemoizationGuarantees || func.env.config.validateNoSetStateInRender || func.env.config.enablePreserveExistingMemoizationGuarantees; + const optionals = findOptionalPlaces(func); const sidemap: IdentifierSidemap = { functions: new Map(), manualMemos: new Map(), react: new Set(), maybeDeps: new Map(), maybeDepsLists: new Map(), + optionals, }; let nextManualMemoId = 0; @@ -475,3 +484,46 @@ export function dropManualMemoization(func: HIRFunction): void { } } } + +function findOptionalPlaces(fn: HIRFunction): Set { + const optionals = new Set(); + for (const [, block] of fn.body.blocks) { + if (block.terminal.kind === 'optional' && block.terminal.optional) { + const optionalTerminal = block.terminal; + let testBlock = fn.body.blocks.get(block.terminal.test)!; + loop: while (true) { + const terminal = testBlock.terminal; + switch (terminal.kind) { + case 'branch': { + if (terminal.fallthrough === optionalTerminal.fallthrough) { + // found it + const consequent = fn.body.blocks.get(terminal.consequent)!; + const last = consequent.instructions.at(-1); + if (last !== undefined && last.value.kind === 'StoreLocal') { + optionals.add(last.value.value.identifier.id); + } + break loop; + } else { + testBlock = fn.body.blocks.get(terminal.fallthrough)!; + } + break; + } + case 'optional': + case 'logical': + case 'sequence': + case 'ternary': { + testBlock = fn.body.blocks.get(terminal.fallthrough)!; + break; + } + default: { + CompilerError.invariant(false, { + reason: `Unexpected terminal in optional`, + loc: terminal.loc, + }); + } + } + } + } + } + return optionals; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts index 8aa82469bdec4..1604f4813967a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts @@ -1180,18 +1180,6 @@ function inferBlock( }; break; } - case 'TaggedTemplateExpression': { - valueKind = { - kind: ValueKind.Mutable, - reason: new Set([ValueReason.Other]), - context: new Set(), - }; - effect = { - kind: Effect.ConditionallyMutate, - reason: ValueReason.Other, - }; - break; - } case 'TemplateLiteral': { /* * template literal (with no tag function) always produces @@ -1312,6 +1300,47 @@ function inferBlock( instr.lvalue.effect = Effect.Store; continue; } + case 'TaggedTemplateExpression': { + const operands = [...eachInstructionValueOperand(instrValue)]; + if (operands.length !== 1) { + // future-proofing to make sure we update this case when we support interpolation + CompilerError.throwTodo({ + reason: 'Support tagged template expressions with interpolations', + loc: instrValue.loc, + }); + } + const signature = getFunctionCallSignature( + env, + instrValue.tag.identifier.type, + ); + let calleeEffect = + signature?.calleeEffect ?? Effect.ConditionallyMutate; + const returnValueKind: AbstractValue = + signature !== null + ? { + kind: signature.returnValueKind, + reason: new Set([ + signature.returnValueReason ?? + ValueReason.KnownReturnSignature, + ]), + context: new Set(), + } + : { + kind: ValueKind.Mutable, + reason: new Set([ValueReason.Other]), + context: new Set(), + }; + state.referenceAndRecordEffects( + instrValue.tag, + calleeEffect, + ValueReason.Other, + functionEffects, + ); + state.initialize(instrValue, returnValueKind); + state.define(instr.lvalue, instrValue); + instr.lvalue.effect = Effect.ConditionallyMutate; + continue; + } case 'CallExpression': { const signature = getFunctionCallSignature( env, diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopes.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopes.ts deleted file mode 100644 index 132788f0d418b..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopes.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { - InstructionId, - Place, - ReactiveBlock, - ReactiveFunction, - ReactiveInstruction, - ReactiveScope, - ScopeId, - makeInstructionId, -} from '../HIR/HIR'; -import {getPlaceScope} from './BuildReactiveBlocks'; -import {ReactiveFunctionVisitor, visitReactiveFunction} from './visitors'; - -/* - * Note: this is the 2nd of 4 passes that determine how to break a function into discrete - * reactive scopes (independently memoizeable units of code): - * 1. InferReactiveScopeVariables (on HIR) determines operands that mutate together and assigns - * them a unique reactive scope. - * 2. AlignReactiveScopesToBlockScopes (this pass, on ReactiveFunction) aligns reactive scopes - * to block scopes. - * 3. MergeOverlappingReactiveScopes (on ReactiveFunction) ensures that reactive scopes do not - * overlap, merging any such scopes. - * 4. BuildReactiveBlocks (on ReactiveFunction) groups the statements for each scope into - * a ReactiveScopeBlock. - * - * Prior inference passes assign a reactive scope to each operand, but the ranges of these - * scopes are based on specific instructions at arbitrary points in the control-flow graph. - * However, to codegen blocks around the instructions in each scope, the scopes must be - * aligned to block-scope boundaries - we can't memoize half of a loop! - * - * This pass updates reactive scope boundaries to align to control flow boundaries, for - * example: - * - * ```javascript - * function foo(cond, a) { - * ⌵ original scope - * ⌵ expanded scope - * const x = []; ⌝ ⌝ - * if (cond) { ⎮ ⎮ - * ... ⎮ ⎮ - * x.push(a); ⌟ ⎮ - * ... ⎮ - * } ⌟ - * } - * ``` - * - * Here the original scope for `x` ended partway through the if consequent, but we can't - * memoize part of that block. This pass would align the scope to the end of the consequent. - * - * The more general rule is that a reactive scope may only end at the same block scope as it - * began: this pass therefore finds, for each scope, the block where that scope started and - * finds the first instruction after the scope's mutable range in that same block scope (which - * will be the updated end for that scope). - */ - -export function alignReactiveScopesToBlockScopes(fn: ReactiveFunction): void { - const context = new Context(); - visitReactiveFunction(fn, new Visitor(), context); -} - -class Visitor extends ReactiveFunctionVisitor { - override visitID(id: InstructionId, state: Context): void { - state.visitId(id); - } - override visitPlace(id: InstructionId, place: Place, state: Context): void { - const scope = getPlaceScope(id, place); - if (scope !== null) { - state.visitScope(scope); - } - } - override visitLValue(id: InstructionId, lvalue: Place, state: Context): void { - const scope = getPlaceScope(id, lvalue); - if (scope !== null) { - state.visitScope(scope); - } - } - - override visitInstruction(instr: ReactiveInstruction, state: Context): void { - switch (instr.value.kind) { - case 'OptionalExpression': - case 'SequenceExpression': - case 'ConditionalExpression': - case 'LogicalExpression': { - const prevScopeCount = state.currentScopes().length; - this.traverseInstruction(instr, state); - - /** - * These compound value types can have nested sequences of instructions - * with scopes that start "partway" through a block-level instruction. - * This would cause the start of the scope to not align with any block-level - * instruction and get skipped by the later BuildReactiveBlocks pass. - * - * Here we detect scopes created within compound instructions and align the - * start of these scopes to the outer instruction id to ensure the scopes - * aren't skipped. - */ - const scopes = state.currentScopes(); - for (let i = prevScopeCount; i < scopes.length; i++) { - const scope = scopes[i]; - scope.scope.range.start = makeInstructionId( - Math.min(instr.id, scope.scope.range.start), - ); - } - break; - } - default: { - this.traverseInstruction(instr, state); - } - } - } - - override visitBlock(block: ReactiveBlock, state: Context): void { - state.enter(() => { - this.traverseBlock(block, state); - }); - } -} - -type PendingReactiveScope = {active: boolean; scope: ReactiveScope}; - -class Context { - /* - * For each block scope (outer array) stores a list of ReactiveScopes that start - * in that block scope. - */ - #blockScopes: Array> = []; - - /* - * ReactiveScopes whose declaring block scope has ended but may still need to - * be "closed" (ie have their range.end be updated). A given scope can be in - * blockScopes OR this array but not both. - */ - #unclosedScopes: Array = []; - - /* - * Set of all scope ids that have been seen so far, regardless of which of - * the above data structures they're in, to avoid tracking the same scope twice. - */ - #seenScopes: Set = new Set(); - - currentScopes(): Array { - return this.#blockScopes.at(-1) ?? []; - } - - enter(fn: () => void): void { - this.#blockScopes.push([]); - fn(); - const lastScope = this.#blockScopes.pop()!; - for (const scope of lastScope) { - if (scope.active) { - this.#unclosedScopes.push(scope); - } - } - } - - visitId(id: InstructionId): void { - const currentScopes = this.#blockScopes.at(-1)!; - const scopes = [...currentScopes, ...this.#unclosedScopes]; - for (const pending of scopes) { - if (!pending.active) { - continue; - } - if (id >= pending.scope.range.end) { - pending.active = false; - pending.scope.range.end = id; - } - } - } - - visitScope(scope: ReactiveScope): void { - if (!this.#seenScopes.has(scope.id)) { - const currentScopes = this.#blockScopes.at(-1)!; - this.#seenScopes.add(scope.id); - currentScopes.push({ - active: true, - scope, - }); - } - } -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts index 6517918d02e16..2b4e890a40da8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts @@ -13,6 +13,7 @@ import { MutableRange, Place, ReactiveScope, + getPlaceScope, makeInstructionId, } from '../HIR/HIR'; import { @@ -23,7 +24,6 @@ import { terminalFallthrough, } from '../HIR/visitors'; import {retainWhere_Set} from '../Utils/utils'; -import {getPlaceScope} from './BuildReactiveBlocks'; type InstructionRange = MutableRange; /* @@ -140,7 +140,7 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { } const fallthrough = terminalFallthrough(terminal); - if (fallthrough !== null) { + if (fallthrough !== null && terminal.kind !== 'branch') { /* * Any currently active scopes that overlaps the block-fallthrough range * need their range extended to at least the first instruction of the diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AssertScopeInstructionsWithinScope.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AssertScopeInstructionsWithinScope.ts index 2bce100050175..718e28f6101d1 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AssertScopeInstructionsWithinScope.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AssertScopeInstructionsWithinScope.ts @@ -14,7 +14,7 @@ import { ReactiveScopeBlock, ScopeId, } from '../HIR'; -import {getPlaceScope} from './BuildReactiveBlocks'; +import {getPlaceScope} from '../HIR/HIR'; import {ReactiveFunctionVisitor} from './visitors'; /* diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveBlocks.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveBlocks.ts deleted file mode 100644 index 7737423e5e1c1..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveBlocks.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import {CompilerError} from '../CompilerError'; -import { - BlockId, - InstructionId, - Place, - ReactiveBlock, - ReactiveFunction, - ReactiveInstruction, - ReactiveScope, - ReactiveScopeBlock, - ReactiveStatement, - ScopeId, -} from '../HIR'; -import {eachInstructionLValue} from '../HIR/visitors'; -import {assertExhaustive} from '../Utils/utils'; -import {eachReactiveValueOperand, mapTerminalBlocks} from './visitors'; - -/* - * Note: this is the 4th of 4 passes that determine how to break a function into discrete - * reactive scopes (independently memoizeable units of code): - * 1. InferReactiveScopeVariables (on HIR) determines operands that mutate together and assigns - * them a unique reactive scope. - * 2. AlignReactiveScopesToBlockScopes (on ReactiveFunction) aligns reactive scopes - * to block scopes. - * 3. MergeOverlappingReactiveScopes (this pass, on ReactiveFunction) ensures that reactive - * scopes do not overlap, merging any such scopes. - * 4. BuildReactiveBlocks (on ReactiveFunction) groups the statements for each scope into - * a ReactiveScopeBlock. - * - * Given a function where the reactive scopes have been correctly aligned and merged, - * this pass groups the instructions for each reactive scope into ReactiveBlocks. - */ -export function buildReactiveBlocks(fn: ReactiveFunction): void { - const context = new Context(); - fn.body = context.enter(() => { - visitBlock(context, fn.body); - }); -} - -class Context { - #builders: Array = []; - #scopes: Set = new Set(); - - visitId(id: InstructionId): void { - const builder = this.#builders.at(-1)!; - builder.visitId(id); - } - - visitScope(scope: ReactiveScope): void { - if (this.#scopes.has(scope.id)) { - return; - } - this.#scopes.add(scope.id); - this.#builders.at(-1)!.startScope(scope); - } - - append( - stmt: ReactiveStatement, - label: {id: BlockId; implicit: boolean} | null, - ): void { - this.#builders.at(-1)!.append(stmt, label); - } - - enter(fn: () => void): ReactiveBlock { - const builder = new Builder(); - this.#builders.push(builder); - fn(); - const popped = this.#builders.pop(); - CompilerError.invariant(popped === builder, { - reason: 'Expected push/pop to be called 1:1', - description: null, - loc: null, - suggestions: null, - }); - return builder.complete(); - } -} - -class Builder { - #instructions: ReactiveBlock; - #stack: Array< - | {kind: 'scope'; block: ReactiveScopeBlock} - | {kind: 'block'; block: ReactiveBlock} - >; - - constructor() { - const block: ReactiveBlock = []; - this.#instructions = block; - this.#stack = [{kind: 'block', block}]; - } - - append( - item: ReactiveStatement, - label: {id: BlockId; implicit: boolean} | null, - ): void { - if (label !== null) { - CompilerError.invariant(item.kind === 'terminal', { - reason: 'Only terminals may have a label', - description: null, - loc: null, - suggestions: null, - }); - item.label = label; - } - this.#instructions.push(item); - } - - startScope(scope: ReactiveScope): void { - const block: ReactiveScopeBlock = { - kind: 'scope', - scope, - instructions: [], - }; - this.append(block, null); - this.#instructions = block.instructions; - this.#stack.push({kind: 'scope', block}); - } - - visitId(id: InstructionId): void { - for (let i = 0; i < this.#stack.length; i++) { - const entry = this.#stack[i]!; - if (entry.kind === 'scope' && id >= entry.block.scope.range.end) { - this.#stack.length = i; - break; - } - } - const last = this.#stack[this.#stack.length - 1]!; - if (last.kind === 'block') { - this.#instructions = last.block; - } else { - this.#instructions = last.block.instructions; - } - } - - complete(): ReactiveBlock { - /* - * TODO: @josephsavona debug violations of this invariant - * invariant( - * this.#stack.length === 1, - * "Expected all scopes to be closed when exiting a block" - * ); - */ - const first = this.#stack[0]!; - CompilerError.invariant(first.kind === 'block', { - reason: 'Expected first stack item to be a basic block', - description: null, - loc: null, - suggestions: null, - }); - return first.block; - } -} - -function visitBlock(context: Context, block: ReactiveBlock): void { - for (const stmt of block) { - switch (stmt.kind) { - case 'instruction': { - context.visitId(stmt.instruction.id); - const scope = getInstructionScope(stmt.instruction); - if (scope !== null) { - context.visitScope(scope); - } - context.append(stmt, null); - break; - } - case 'terminal': { - const id = stmt.terminal.id; - if (id !== null) { - context.visitId(id); - } - mapTerminalBlocks(stmt.terminal, block => { - return context.enter(() => { - visitBlock(context, block); - }); - }); - context.append(stmt, stmt.label); - break; - } - case 'pruned-scope': - case 'scope': { - CompilerError.invariant(false, { - reason: 'Expected the function to not have scopes already assigned', - description: null, - loc: null, - suggestions: null, - }); - } - default: { - assertExhaustive( - stmt, - `Unexpected statement kind \`${(stmt as any).kind}\``, - ); - } - } - } -} - -export function getInstructionScope( - instr: ReactiveInstruction, -): ReactiveScope | null { - CompilerError.invariant(instr.lvalue !== null, { - reason: - 'Expected lvalues to not be null when assigning scopes. ' + - 'Pruning lvalues too early can result in missing scope information.', - description: null, - loc: instr.loc, - suggestions: null, - }); - for (const operand of eachInstructionLValue(instr)) { - const operandScope = getPlaceScope(instr.id, operand); - if (operandScope !== null) { - return operandScope; - } - } - for (const operand of eachReactiveValueOperand(instr.value)) { - const operandScope = getPlaceScope(instr.id, operand); - if (operandScope !== null) { - return operandScope; - } - } - return null; -} - -export function getPlaceScope( - id: InstructionId, - place: Place, -): ReactiveScope | null { - const scope = place.identifier.scope; - if (scope !== null && isScopeActive(scope, id)) { - return scope; - } - return null; -} - -function isScopeActive(scope: ReactiveScope, id: InstructionId): boolean { - return id >= scope.range.start && id < scope.range.end; -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts index 624a4b604d66f..2df7b5ed1c7fd 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -1411,7 +1411,7 @@ function printDependencyComment(dependency: ReactiveScopeDependency): string { let name = identifier.name; if (dependency.path !== null) { for (const path of dependency.path) { - name += `.${path}`; + name += `.${path.property}`; } } return name; @@ -1446,9 +1446,19 @@ function codegenDependency( dependency: ReactiveScopeDependency, ): t.Expression { let object: t.Expression = convertIdentifier(dependency.identifier); - if (dependency.path !== null) { + if (dependency.path.length !== 0) { + const hasOptional = dependency.path.some(path => path.optional); for (const path of dependency.path) { - object = t.memberExpression(object, t.identifier(path)); + if (hasOptional) { + object = t.optionalMemberExpression( + object, + t.identifier(path.property), + false, + path.optional, + ); + } else { + object = t.memberExpression(object, t.identifier(path.property)); + } } } return object; diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/DeriveMinimalDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/DeriveMinimalDependencies.ts index 8c2e31fa9666e..c7e16cce7ac86 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/DeriveMinimalDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/DeriveMinimalDependencies.ts @@ -6,7 +6,7 @@ */ import {CompilerError} from '../CompilerError'; -import {Identifier, ReactiveScopeDependency} from '../HIR'; +import {DependencyPath, Identifier, ReactiveScopeDependency} from '../HIR'; import {printIdentifier} from '../HIR/PrintHIR'; import {assertExhaustive} from '../Utils/utils'; @@ -14,20 +14,8 @@ import {assertExhaustive} from '../Utils/utils'; * We need to understand optional member expressions only when determining * dependencies of a ReactiveScope (i.e. in {@link PropagateScopeDependencies}), * hence why this type lives here (not in HIR.ts) - * - * {@link ReactiveScopePropertyDependency.optionalPath} is populated only if the Property - * represents an optional member expression, and it represents the property path - * loaded conditionally. - * e.g. the member expr a.b.c?.d.e?.f is represented as - * { - * identifier: 'a'; - * path: ['b', 'c'], - * optionalPath: ['d', 'e', 'f']. - * } */ -export type ReactiveScopePropertyDependency = ReactiveScopeDependency & { - optionalPath: Array; -}; +export type ReactiveScopePropertyDependency = ReactiveScopeDependency; /* * Finalizes a set of ReactiveScopeDependencies to produce a set of minimal unconditional @@ -69,68 +57,43 @@ export class ReactiveScopeDependencyTree { } add(dep: ReactiveScopePropertyDependency, inConditional: boolean): void { - const {path, optionalPath} = dep; + const {path} = dep; let currNode = this.#getOrCreateRoot(dep.identifier); - const accessType = inConditional - ? PropertyAccessType.ConditionalAccess - : PropertyAccessType.UnconditionalAccess; - - for (const property of path) { + for (const item of path) { // all properties read 'on the way' to a dependency are marked as 'access' - let currChild = getOrMakeProperty(currNode, property); + let currChild = getOrMakeProperty(currNode, item.property); + const accessType = inConditional + ? PropertyAccessType.ConditionalAccess + : item.optional + ? PropertyAccessType.OptionalAccess + : PropertyAccessType.UnconditionalAccess; currChild.accessType = merge(currChild.accessType, accessType); currNode = currChild; } - if (optionalPath.length === 0) { - /* - * If this property does not have a conditional path (i.e. a.b.c), the - * final property node should be marked as an conditional/unconditional - * `dependency` as based on control flow. - */ - const depType = inConditional - ? PropertyAccessType.ConditionalDependency + /** + * The final property node should be marked as an conditional/unconditional + * `dependency` as based on control flow. + */ + const depType = inConditional + ? PropertyAccessType.ConditionalDependency + : isOptional(currNode.accessType) + ? PropertyAccessType.OptionalDependency : PropertyAccessType.UnconditionalDependency; - currNode.accessType = merge(currNode.accessType, depType); - } else { - /* - * Technically, we only depend on whether unconditional path `dep.path` - * is nullish (not its actual value). As long as we preserve the nullthrows - * behavior of `dep.path`, we can keep it as an access (and not promote - * to a dependency). - * See test `reduce-reactive-cond-memberexpr-join` for example. - */ - - /* - * If this property has an optional path (i.e. a?.b.c), all optional - * nodes should be marked accordingly. - */ - for (const property of optionalPath) { - let currChild = getOrMakeProperty(currNode, property); - currChild.accessType = merge( - currChild.accessType, - PropertyAccessType.ConditionalAccess, - ); - currNode = currChild; - } - - // The final node should be marked as a conditional dependency. - currNode.accessType = merge( - currNode.accessType, - PropertyAccessType.ConditionalDependency, - ); - } + currNode.accessType = merge(currNode.accessType, depType); } deriveMinimalDependencies(): Set { const results = new Set(); for (const [rootId, rootNode] of this.#roots.entries()) { - const deps = deriveMinimalDependenciesInSubtree(rootNode); + const deps = deriveMinimalDependenciesInSubtree(rootNode, null); CompilerError.invariant( deps.every( - dep => dep.accessType === PropertyAccessType.UnconditionalDependency, + dep => + dep.accessType === PropertyAccessType.UnconditionalDependency || + dep.accessType == PropertyAccessType.OptionalDependency, ), { reason: @@ -215,6 +178,27 @@ export class ReactiveScopeDependencyTree { } return res.flat().join('\n'); } + + debug(): string { + const buf: Array = [`tree() [`]; + for (const [rootId, rootNode] of this.#roots) { + buf.push(`${printIdentifier(rootId)} (${rootNode.accessType}):`); + this.#debugImpl(buf, rootNode, 1); + } + buf.push(']'); + return buf.length > 2 ? buf.join('\n') : buf.join(''); + } + + #debugImpl( + buf: Array, + node: DependencyNode, + depth: number = 0, + ): void { + for (const [property, childNode] of node.properties) { + buf.push(`${' '.repeat(depth)}.${property} (${childNode.accessType}):`); + this.#debugImpl(buf, childNode, depth + 1); + } + } } /* @@ -238,8 +222,10 @@ export class ReactiveScopeDependencyTree { */ enum PropertyAccessType { ConditionalAccess = 'ConditionalAccess', + OptionalAccess = 'OptionalAccess', UnconditionalAccess = 'UnconditionalAccess', ConditionalDependency = 'ConditionalDependency', + OptionalDependency = 'OptionalDependency', UnconditionalDependency = 'UnconditionalDependency', } @@ -253,9 +239,16 @@ function isUnconditional(access: PropertyAccessType): boolean { function isDependency(access: PropertyAccessType): boolean { return ( access === PropertyAccessType.ConditionalDependency || + access === PropertyAccessType.OptionalDependency || access === PropertyAccessType.UnconditionalDependency ); } +function isOptional(access: PropertyAccessType): boolean { + return ( + access === PropertyAccessType.OptionalAccess || + access === PropertyAccessType.OptionalDependency + ); +} function merge( access1: PropertyAccessType, @@ -264,6 +257,7 @@ function merge( const resultIsUnconditional = isUnconditional(access1) || isUnconditional(access2); const resultIsDependency = isDependency(access1) || isDependency(access2); + const resultIsOptional = isOptional(access1) || isOptional(access2); /* * Straightforward merge. @@ -279,6 +273,12 @@ function merge( } else { return PropertyAccessType.UnconditionalAccess; } + } else if (resultIsOptional) { + if (resultIsDependency) { + return PropertyAccessType.OptionalDependency; + } else { + return PropertyAccessType.OptionalAccess; + } } else { if (resultIsDependency) { return PropertyAccessType.ConditionalDependency; @@ -294,23 +294,38 @@ type DependencyNode = { }; type ReduceResultNode = { - relativePath: Array; + relativePath: DependencyPath; accessType: PropertyAccessType; }; -const promoteUncondResult = [ - { +function promoteResult( + accessType: PropertyAccessType, + path: {property: string; optional: boolean} | null, +): Array { + const result: ReduceResultNode = { relativePath: [], - accessType: PropertyAccessType.UnconditionalDependency, - }, -]; + accessType, + }; + if (path !== null) { + result.relativePath.push(path); + } + return [result]; +} -const promoteCondResult = [ - { - relativePath: [], - accessType: PropertyAccessType.ConditionalDependency, - }, -]; +function prependPath( + results: Array, + path: {property: string; optional: boolean} | null, +): Array { + if (path === null) { + return results; + } + return results.map(result => { + return { + accessType: result.accessType, + relativePath: [path, ...result.relativePath], + }; + }); +} /* * Recursively calculates minimal dependencies in a subtree. @@ -319,39 +334,76 @@ const promoteCondResult = [ */ function deriveMinimalDependenciesInSubtree( dep: DependencyNode, + property: string | null, ): Array { const results: Array = []; for (const [childName, childNode] of dep.properties) { - const childResult = deriveMinimalDependenciesInSubtree(childNode).map( - ({relativePath, accessType}) => { - return { - relativePath: [childName, ...relativePath], - accessType, - }; - }, + const childResult = deriveMinimalDependenciesInSubtree( + childNode, + childName, ); results.push(...childResult); } switch (dep.accessType) { case PropertyAccessType.UnconditionalDependency: { - return promoteUncondResult; + return promoteResult( + PropertyAccessType.UnconditionalDependency, + property !== null ? {property, optional: false} : null, + ); } case PropertyAccessType.UnconditionalAccess: { if ( results.every( ({accessType}) => - accessType === PropertyAccessType.UnconditionalDependency, + accessType === PropertyAccessType.UnconditionalDependency || + accessType === PropertyAccessType.OptionalDependency, + ) + ) { + // all children are unconditional dependencies, return them to preserve granularity + return prependPath( + results, + property !== null ? {property, optional: false} : null, + ); + } else { + /* + * at least one child is accessed conditionally, so this node needs to be promoted to + * unconditional dependency + */ + return promoteResult( + PropertyAccessType.UnconditionalDependency, + property !== null ? {property, optional: false} : null, + ); + } + } + case PropertyAccessType.OptionalDependency: { + return promoteResult( + PropertyAccessType.OptionalDependency, + property !== null ? {property, optional: true} : null, + ); + } + case PropertyAccessType.OptionalAccess: { + if ( + results.every( + ({accessType}) => + accessType === PropertyAccessType.UnconditionalDependency || + accessType === PropertyAccessType.OptionalDependency, ) ) { // all children are unconditional dependencies, return them to preserve granularity - return results; + return prependPath( + results, + property !== null ? {property, optional: true} : null, + ); } else { /* * at least one child is accessed conditionally, so this node needs to be promoted to * unconditional dependency */ - return promoteUncondResult; + return promoteResult( + PropertyAccessType.OptionalDependency, + property !== null ? {property, optional: true} : null, + ); } } case PropertyAccessType.ConditionalAccess: @@ -367,13 +419,19 @@ function deriveMinimalDependenciesInSubtree( * unconditional access. * Truncate results of child nodes here, since we shouldn't access them anyways */ - return promoteCondResult; + return promoteResult( + PropertyAccessType.ConditionalDependency, + property !== null ? {property, optional: true} : null, + ); } else { /* * at least one child is accessed unconditionally, so this node can be promoted to * unconditional dependency */ - return promoteUncondResult; + return promoteResult( + PropertyAccessType.UnconditionalDependency, + property !== null ? {property, optional: true} : null, + ); } } default: { diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenReactiveLoops.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenReactiveLoops.ts deleted file mode 100644 index 2119b8c16729f..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenReactiveLoops.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { - ReactiveFunction, - ReactiveScopeBlock, - ReactiveStatement, - ReactiveTerminal, - ReactiveTerminalStatement, -} from '../HIR/HIR'; -import {assertExhaustive} from '../Utils/utils'; -import { - ReactiveFunctionTransform, - Transformed, - visitReactiveFunction, -} from './visitors'; - -/* - * Given a reactive function, flattens any scopes contained within a loop construct. - * We won't initially support memoization within loops though this is possible in the future. - */ -export function flattenReactiveLoops(fn: ReactiveFunction): void { - visitReactiveFunction(fn, new Transform(), false); -} - -class Transform extends ReactiveFunctionTransform { - override transformScope( - scope: ReactiveScopeBlock, - isWithinLoop: boolean, - ): Transformed { - this.visitScope(scope, isWithinLoop); - if (isWithinLoop) { - return { - kind: 'replace', - value: { - kind: 'pruned-scope', - scope: scope.scope, - instructions: scope.instructions, - }, - }; - } else { - return {kind: 'keep'}; - } - } - - override visitTerminal( - stmt: ReactiveTerminalStatement, - isWithinLoop: boolean, - ): void { - switch (stmt.terminal.kind) { - // Loop terminals flatten nested scopes - case 'do-while': - case 'while': - case 'for': - case 'for-of': - case 'for-in': { - this.traverseTerminal(stmt, true); - break; - } - // Non-loop terminals passthrough is contextual, inherits the parent isWithinScope - case 'try': - case 'label': - case 'break': - case 'continue': - case 'if': - case 'return': - case 'switch': - case 'throw': { - this.traverseTerminal(stmt, isWithinLoop); - break; - } - default: { - assertExhaustive( - stmt.terminal, - `Unexpected terminal kind \`${(stmt.terminal as any).kind}\``, - ); - } - } - } -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenScopesWithHooksOrUse.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenScopesWithHooksOrUse.ts deleted file mode 100644 index 753cd3d6e87b7..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/FlattenScopesWithHooksOrUse.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { - Environment, - InstructionId, - ReactiveFunction, - ReactiveScopeBlock, - ReactiveStatement, - ReactiveValue, - getHookKind, - isUseOperator, -} from '../HIR'; -import { - ReactiveFunctionTransform, - Transformed, - visitReactiveFunction, -} from './visitors'; - -/** - * For simplicity the majority of compiler passes do not treat hooks specially. However, hooks are different - * from regular functions in two key ways: - * - They can introduce reactivity even when their arguments are non-reactive (accounted for in InferReactivePlaces) - * - They cannot be called conditionally - * - * The `use` operator is similar: - * - It can access context, and therefore introduce reactivity - * - It can be called conditionally, but _it must be called if the component needs the return value_. This is because - * React uses the fact that use was called to remember that the component needs the value, and that changes to the - * input should invalidate the component itself. - * - * This pass accounts for the "can't call conditionally" aspect of both hooks and use. Though the reasoning is slightly - * different for reach, the result is that we can't memoize scopes that call hooks or use since this would make them - * called conditionally in the output. - * - * The pass finds and removes any scopes that transitively contain a hook or use call. By running all - * the reactive scope inference first, agnostic of hooks, we know that the reactive scopes accurately - * describe the set of values which "construct together", and remove _all_ that memoization in order - * to ensure the hook call does not inadvertently become conditional. - */ -export function flattenScopesWithHooksOrUse(fn: ReactiveFunction): void { - visitReactiveFunction(fn, new Transform(), { - env: fn.env, - hasHook: false, - }); -} - -type State = { - env: Environment; - hasHook: boolean; -}; - -class Transform extends ReactiveFunctionTransform { - override transformScope( - scope: ReactiveScopeBlock, - outerState: State, - ): Transformed { - const innerState: State = { - env: outerState.env, - hasHook: false, - }; - this.visitScope(scope, innerState); - outerState.hasHook ||= innerState.hasHook; - if (innerState.hasHook) { - if (scope.instructions.length === 1) { - /* - * This was a scope just for a hook call, which doesn't need memoization. - * flatten it away - */ - return { - kind: 'replace-many', - value: scope.instructions, - }; - } - /* - * else this scope had multiple instructions and produced some other value: - * mark it as pruned - */ - return { - kind: 'replace', - value: { - kind: 'pruned-scope', - scope: scope.scope, - instructions: scope.instructions, - }, - }; - } else { - return {kind: 'keep'}; - } - } - - override visitValue( - id: InstructionId, - value: ReactiveValue, - state: State, - ): void { - this.traverseValue(id, value, state); - switch (value.kind) { - case 'CallExpression': { - if ( - getHookKind(state.env, value.callee.identifier) != null || - isUseOperator(value.callee.identifier) - ) { - state.hasHook = true; - } - break; - } - case 'MethodCall': { - if ( - getHookKind(state.env, value.property.identifier) != null || - isUseOperator(value.property.identifier) - ) { - state.hasHook = true; - } - break; - } - } - } -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts index 27aba91af2b1c..126772f591b41 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts @@ -227,6 +227,7 @@ function mayAllocate(env: Environment, instruction: Instruction): boolean { case 'StoreGlobal': { return false; } + case 'TaggedTemplateExpression': case 'CallExpression': case 'MethodCall': { return instruction.lvalue.identifier.type.kind !== 'Primitive'; @@ -241,8 +242,7 @@ function mayAllocate(env: Environment, instruction: Instruction): boolean { case 'ObjectExpression': case 'UnsupportedNode': case 'ObjectMethod': - case 'FunctionExpression': - case 'TaggedTemplateExpression': { + case 'FunctionExpression': { return true; } default: { diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeOverlappingReactiveScopes.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeOverlappingReactiveScopes.ts deleted file mode 100644 index 733730fdec5f2..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeOverlappingReactiveScopes.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { - InstructionId, - makeInstructionId, - Place, - ReactiveBlock, - ReactiveFunction, - ReactiveInstruction, - ReactiveScope, - ScopeId, -} from '../HIR'; -import DisjointSet from '../Utils/DisjointSet'; -import {retainWhere} from '../Utils/utils'; -import {getPlaceScope} from './BuildReactiveBlocks'; -import {ReactiveFunctionVisitor, visitReactiveFunction} from './visitors'; - -/* - * Note: this is the 3rd of 4 passes that determine how to break a function into discrete - * reactive scopes (independently memoizeable units of code): - * 1. InferReactiveScopeVariables (on HIR) determines operands that mutate together and assigns - * them a unique reactive scope. - * 2. AlignReactiveScopesToBlockScopes (on ReactiveFunction) aligns reactive scopes - * to block scopes. - * 3. MergeOverlappingReactiveScopes (this pass, on ReactiveFunction) ensures that reactive - * scopes do not overlap, merging any such scopes. - * 4. BuildReactiveBlocks (on ReactiveFunction) groups the statements for each scope into - * a ReactiveScopeBlock. - * - * Previous passes may leave "overlapping" scopes, ie where one or more instructions are within - * the mutable range of multiple reactive scopes. We prefer to avoid executing instructions twice - * for performance reasons (side effects are less of a concern bc components are required to be - * idempotent), so we cannot simply repeat the instruction once for each scope. Instead, the only - * option is to combine the two scopes into one. This is an area where an eventual Forget IDE - * could provide real-time feedback to the developer that two computations are accidentally merged. - * - * ## Detailed Walkthrough - * - * Two scopes overlap if there is one or more instruction that is inside the range - * of both scopes. In general, overlapping scopes are merged togther. The only - * exception to this is when one scope *shadows* another scope. For example: - * - * ```javascript - * function foo(cond, a) { - * ⌵ scope for x - * let x = []; ⌝ - * if (cond) { ⎮ - * ⌵ scope for y ⎮ - * let y = []; ⌝ ⎮ - * if (b) { ⎮ ⎮ - * y.push(b); ⌟ ⎮ - * } ⎮ - * x.push(
{y}
); ⎮ - * } ⌟ - * } - * ``` - * - * In this example the two scopes overlap, but mutation of the two scopes is not - * interleaved. Specifically within the y scope there are no instructions that - * modify any other scope: the inner scope "shadows" the outer one. This category - * of overlap does *NOT* merge the scopes together. - * - * The implementation is inspired by the Rust notion of "stacked borrows". We traverse - * the control-flow graph in tree form, at each point keeping track of which scopes are - * active. So initially we see - * - * `let x = []` - * active scopes: [x] - * - * and mark the x scope as active. - * - * Then we later encounter - * - * `let y = [];` - * active scopes: [x, y] - * - * Here we first check to see if 'y' is already in the list of active scopes. It isn't, - * so we push it to the stop of the stack. - * - * Then - * - * `y.push(b)` - * active scopes: [x, y] - * - * Mutates y, so we check if y is the top of the stack. It is, so no merging must occur. - * - * If instead we saw eg - * - * `x.push(b)` - * active scopes: [x, y] - * - * Then we would see that 'x' is active, but that it is shadowed. The two scopes would have - * to be merged. - */ -export function mergeOverlappingReactiveScopes(fn: ReactiveFunction): void { - const context = new Context(); - visitReactiveFunction(fn, new Visitor(), context); - context.complete(); -} - -class Visitor extends ReactiveFunctionVisitor { - override visitID(id: InstructionId, state: Context): void { - state.visitId(id); - } - override visitPlace(id: InstructionId, place: Place, state: Context): void { - state.visitPlace(id, place); - } - override visitLValue(id: InstructionId, lvalue: Place, state: Context): void { - state.visitPlace(id, lvalue); - } - override visitBlock(block: ReactiveBlock, state: Context): void { - state.enter(() => { - this.traverseBlock(block, state); - }); - } - override visitInstruction( - instruction: ReactiveInstruction, - state: Context, - ): void { - if ( - instruction.value.kind === 'ConditionalExpression' || - instruction.value.kind === 'LogicalExpression' || - instruction.value.kind === 'OptionalExpression' - ) { - state.enter(() => { - super.visitInstruction(instruction, state); - }); - } else { - super.visitInstruction(instruction, state); - } - } -} - -class BlockScope { - seen: Set = new Set(); - scopes: Array = []; -} - -type ShadowableReactiveScope = { - scope: ReactiveScope; - shadowedBy: ReactiveScope | null; -}; - -class Context { - scopes: Array = []; - seenScopes: Set = new Set(); - joinedScopes: DisjointSet = new DisjointSet(); - operandScopes: Map = new Map(); - - visitId(id: InstructionId): void { - const currentBlock = this.scopes[this.scopes.length - 1]!; - retainWhere(currentBlock.scopes, pending => { - if (pending.scope.range.end > id) { - return true; - } else { - currentBlock.seen.delete(pending.scope.id); - return false; - } - }); - } - - visitPlace(id: InstructionId, place: Place): void { - const scope = getPlaceScope(id, place); - if (scope === null) { - return; - } - this.operandScopes.set(place, scope); - const currentBlock = this.scopes[this.scopes.length - 1]!; - // Fast-path for the first time we see a new scope - if (!this.seenScopes.has(scope.id)) { - this.seenScopes.add(scope.id); - currentBlock.seen.add(scope.id); - currentBlock.scopes.push({shadowedBy: null, scope}); - return; - } - // Scope has already been seen, find it in the current block or a parent - let index = this.scopes.length - 1; - let nextBlock = currentBlock; - while (!nextBlock.seen.has(scope.id)) { - /* - * scopes that cross control-flow boundaries are merged with overlapping - * scopes - */ - this.joinedScopes.union([scope, ...nextBlock.scopes.map(s => s.scope)]); - index--; - if (index < 0) { - /* - * TODO: handle reassignments in multiple branches. these create new identifiers that - * add an entry to this.seenScopes but which are then removed when their blocks exit. - * this is also wrong for codegen, different versions of an identifier could be cached - * differently and so a reassigned version of a variable needs a separate declaration. - * console.log(`scope ${scope.id} not found`); - */ - - /* - * for (let i = this.scopes.length - 1; i > index; i--) { - * const s = this.scopes[i]; - * console.log( - * JSON.stringify( - * { - * seen: Array.from(s.seen), - * scopes: s.scopes, - * }, - * null, - * 2 - * ) - * ); - * } - */ - currentBlock.seen.add(scope.id); - currentBlock.scopes.push({shadowedBy: null, scope}); - return; - } - nextBlock = this.scopes[index]!; - } - - // Handle interleaving within a given block scope - let found = false; - for (let i = 0; i < nextBlock.scopes.length; i++) { - const current = nextBlock.scopes[i]!; - if (current.scope.id === scope.id) { - found = true; - if (current.shadowedBy !== null) { - this.joinedScopes.union([current.shadowedBy, current.scope]); - } - } else if (found && current.shadowedBy === null) { - // `scope` is shadowing `current` and may interleave - current.shadowedBy = scope; - if (current.scope.range.end > scope.range.end) { - /* - * Current is shadowed by `scope`, and we know that `current` will mutate - * again (per its range), so the scopes are already known to interleave. - * - * Eagerly extend the ranges of the scopes so that we don't prematurely end - * a scope relative to its eventual post-merge mutable range - */ - const end = makeInstructionId( - Math.max(current.scope.range.end, scope.range.end), - ); - current.scope.range.end = end; - scope.range.end = end; - this.joinedScopes.union([current.scope, scope]); - } - } - } - if (!currentBlock.seen.has(scope.id)) { - currentBlock.seen.add(scope.id); - currentBlock.scopes.push({shadowedBy: null, scope}); - } - } - - enter(fn: () => void): void { - this.scopes.push(new BlockScope()); - fn(); - this.scopes.pop(); - } - - complete(): void { - this.joinedScopes.forEach((scope, groupScope) => { - if (scope !== groupScope) { - groupScope.range.start = makeInstructionId( - Math.min(groupScope.range.start, scope.range.start), - ); - groupScope.range.end = makeInstructionId( - Math.max(groupScope.range.end, scope.range.end), - ); - } - }); - for (const [operand, originalScope] of this.operandScopes) { - const mergedScope = this.joinedScopes.find(originalScope); - if (mergedScope !== null) { - operand.identifier.scope = mergedScope; - } - } - } -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts index 1e73697783f0b..08d2212d86b95 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts @@ -19,6 +19,7 @@ import { ReactiveScopeDependency, ReactiveStatement, Type, + areEqualPaths, makeInstructionId, } from '../HIR'; import { @@ -525,10 +526,6 @@ function areEqualDependencies( return true; } -export function areEqualPaths(a: Array, b: Array): boolean { - return a.length === b.length && a.every((item, ix) => item === b[ix]); -} - /** * Is this scope eligible for merging with subsequent scopes? In general this * is only true if the scope's output values are guaranteed to change when its diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction.ts index f85f7071f1c49..b5aa44ead095d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction.ts @@ -113,7 +113,7 @@ export function printDependency(dependency: ReactiveScopeDependency): string { const identifier = printIdentifier(dependency.identifier) + printType(dependency.identifier.type); - return `${identifier}${dependency.path.map(prop => `.${prop}`).join('')}`; + return `${identifier}${dependency.path.map(token => `${token.optional ? '?.' : '.'}${token.property}`).join('')}`; } export function printReactiveInstructions( diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateScopeDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateScopeDependencies.ts index 690bdb758839d..dc1142b271e77 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateScopeDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateScopeDependencies.ts @@ -6,7 +6,9 @@ */ import {CompilerError} from '../CompilerError'; +import {Environment} from '../HIR'; import { + areEqualPaths, BlockId, DeclarationId, GeneratedSource, @@ -21,6 +23,7 @@ import { PrunedReactiveScopeBlock, ReactiveFunction, ReactiveInstruction, + ReactiveOptionalCallValue, ReactiveScope, ReactiveScopeBlock, ReactiveScopeDependency, @@ -35,7 +38,6 @@ import { ReactiveScopeDependencyTree, ReactiveScopePropertyDependency, } from './DeriveMinimalDependencies'; -import {areEqualPaths} from './MergeReactiveScopesThatInvalidateTogether'; import {ReactiveFunctionVisitor, visitReactiveFunction} from './visitors'; /* @@ -65,11 +67,7 @@ export function propagateScopeDependencies(fn: ReactiveFunction): void { }); } } - visitReactiveFunction( - fn, - new PropagationVisitor(fn.env.config.enableTreatFunctionDepsAsConditional), - context, - ); + visitReactiveFunction(fn, new PropagationVisitor(fn.env), context); } type TemporariesUsedOutsideDefiningScope = { @@ -465,7 +463,7 @@ class Context { #getProperty( object: Place, property: string, - isConditional: boolean, + optional: boolean, ): ReactiveScopePropertyDependency { const resolvedObject = this.resolveTemporary(object); const resolvedDependency = this.#properties.get(resolvedObject.identifier); @@ -478,36 +476,26 @@ class Context { objectDependency = { identifier: resolvedObject.identifier, path: [], - optionalPath: [], }; } else { objectDependency = { identifier: resolvedDependency.identifier, path: [...resolvedDependency.path], - optionalPath: [...resolvedDependency.optionalPath], }; } - // (2) Determine whether property is an optional access - if (objectDependency.optionalPath.length > 0) { - /* - * If the base property dependency represents a optional member expression, - * property is on the optionalPath (regardless of whether this PropertyLoad - * itself was conditional) - * e.g. for `a.b?.c.d`, `d` should be added to optionalPath - */ - objectDependency.optionalPath.push(property); - } else if (isConditional) { - objectDependency.optionalPath.push(property); - } else { - objectDependency.path.push(property); - } + objectDependency.path.push({property, optional}); return objectDependency; } - declareProperty(lvalue: Place, object: Place, property: string): void { - const nextDependency = this.#getProperty(object, property, false); + declareProperty( + lvalue: Place, + object: Place, + property: string, + optional: boolean, + ): void { + const nextDependency = this.#getProperty(object, property, optional); this.#properties.set(lvalue.identifier, nextDependency); } @@ -516,7 +504,7 @@ class Context { // ref.current access is not a valid dep if ( isUseRefType(maybeDependency.identifier) && - maybeDependency.path.at(0) === 'current' + maybeDependency.path.at(0)?.property === 'current' ) { return false; } @@ -577,7 +565,6 @@ class Context { let dependency: ReactiveScopePropertyDependency = { identifier: resolved.identifier, path: [], - optionalPath: [], }; if (resolved.identifier.name === null) { const propertyDependency = this.#properties.get(resolved.identifier); @@ -588,8 +575,8 @@ class Context { this.visitDependency(dependency); } - visitProperty(object: Place, property: string): void { - const nextDependency = this.#getProperty(object, property, false); + visitProperty(object: Place, property: string, optional: boolean): void { + const nextDependency = this.#getProperty(object, property, optional); this.visitDependency(nextDependency); } @@ -688,12 +675,11 @@ class Context { } class PropagationVisitor extends ReactiveFunctionVisitor { - enableTreatFunctionDepsAsConditional = false; + env: Environment; - constructor(enableTreatFunctionDepsAsConditional: boolean) { + constructor(env: Environment) { super(); - this.enableTreatFunctionDepsAsConditional = - enableTreatFunctionDepsAsConditional; + this.env = env; } override visitScope(scope: ReactiveScopeBlock, context: Context): void { @@ -761,51 +747,288 @@ class PropagationVisitor extends ReactiveFunctionVisitor { }); } + extractOptionalProperty( + context: Context, + optionalValue: ReactiveOptionalCallValue, + lvalue: Place, + ): { + lvalue: Place; + object: Place; + property: string; + optional: boolean; + } | null { + const sequence = optionalValue.value; + CompilerError.invariant(sequence.kind === 'SequenceExpression', { + reason: 'Expected OptionalExpression value to be a SequenceExpression', + description: `Found a \`${sequence.kind}\``, + loc: sequence.loc, + }); + /** + * Base case: inner ` "?." ` + *``` + * = OptionalExpression optional=true (`optionalValue` is here) + * Sequence (`sequence` is here) + * t0 = LoadLocal + * Sequence + * t1 = PropertyLoad t0 . + * LoadLocal t1 + * ``` + */ + if ( + sequence.instructions.length === 1 && + sequence.instructions[0].lvalue !== null && + sequence.instructions[0].value.kind === 'LoadLocal' && + sequence.instructions[0].value.place.identifier.name !== null && + !context.isUsedOutsideDeclaringScope(sequence.instructions[0].lvalue) && + sequence.value.kind === 'SequenceExpression' && + sequence.value.instructions.length === 1 && + sequence.value.instructions[0].value.kind === 'PropertyLoad' && + sequence.value.instructions[0].value.object.identifier.id === + sequence.instructions[0].lvalue.identifier.id && + sequence.value.instructions[0].lvalue !== null && + sequence.value.value.kind === 'LoadLocal' && + sequence.value.value.place.identifier.id === + sequence.value.instructions[0].lvalue.identifier.id + ) { + context.declareTemporary( + sequence.instructions[0].lvalue, + sequence.instructions[0].value.place, + ); + const propertyLoad = sequence.value.instructions[0].value; + return { + lvalue, + object: propertyLoad.object, + property: propertyLoad.property, + optional: optionalValue.optional, + }; + } + /** + * Base case 2: inner ` "." "?." + * ``` + * = OptionalExpression optional=true (`optionalValue` is here) + * Sequence (`sequence` is here) + * t0 = Sequence + * t1 = LoadLocal + * ... // see note + * PropertyLoad t1 . + * [46] Sequence + * t2 = PropertyLoad t0 . + * [46] LoadLocal t2 + * ``` + * + * Note that it's possible to have additional inner chained non-optional + * property loads at "...", from an expression like `a?.b.c.d.e`. We could + * expand to support this case by relaxing the check on the inner sequence + * length, ensuring all instructions after the first LoadLocal are PropertyLoad + * and then iterating to ensure that the lvalue of the previous is always + * the object of the next PropertyLoad, w the final lvalue as the object + * of the sequence.value's object. + * + * But this case is likely rare in practice, usually once you're optional + * chaining all property accesses are optional (not `a?.b.c` but `a?.b?.c`). + * Also, HIR-based PropagateScopeDeps will handle this case so it doesn't + * seem worth it to optimize for that edge-case here. + */ + if ( + sequence.instructions.length === 1 && + sequence.instructions[0].lvalue !== null && + sequence.instructions[0].value.kind === 'SequenceExpression' && + sequence.instructions[0].value.instructions.length === 1 && + sequence.instructions[0].value.instructions[0].lvalue !== null && + sequence.instructions[0].value.instructions[0].value.kind === + 'LoadLocal' && + sequence.instructions[0].value.instructions[0].value.place.identifier + .name !== null && + !context.isUsedOutsideDeclaringScope( + sequence.instructions[0].value.instructions[0].lvalue, + ) && + sequence.instructions[0].value.value.kind === 'PropertyLoad' && + sequence.instructions[0].value.value.object.identifier.id === + sequence.instructions[0].value.instructions[0].lvalue.identifier.id && + sequence.value.kind === 'SequenceExpression' && + sequence.value.instructions.length === 1 && + sequence.value.instructions[0].lvalue !== null && + sequence.value.instructions[0].value.kind === 'PropertyLoad' && + sequence.value.instructions[0].value.object.identifier.id === + sequence.instructions[0].lvalue.identifier.id && + sequence.value.value.kind === 'LoadLocal' && + sequence.value.value.place.identifier.id === + sequence.value.instructions[0].lvalue.identifier.id + ) { + // LoadLocal + context.declareTemporary( + sequence.instructions[0].value.instructions[0].lvalue, + sequence.instructions[0].value.instructions[0].value.place, + ); + // PropertyLoad . (the inner non-optional property) + context.declareProperty( + sequence.instructions[0].lvalue, + sequence.instructions[0].value.value.object, + sequence.instructions[0].value.value.property, + false, + ); + const propertyLoad = sequence.value.instructions[0].value; + return { + lvalue, + object: propertyLoad.object, + property: propertyLoad.property, + optional: optionalValue.optional, + }; + } + + /** + * Composed case: + * - ` "." or "?." ` + * - ` "." or "?>" ` + * + * This case is convoluted, note how `t0` appears as an lvalue *twice* + * and then is an operand of an intermediate LoadLocal and then the + * object of the final PropertyLoad: + * + * ``` + * = OptionalExpression optional=false (`optionalValue` is here) + * Sequence (`sequence` is here) + * t0 = Sequence + * t0 = + * + * LoadLocal t0 + * Sequence + * t1 = PropertyLoad t0. + * LoadLocal t1 + * ``` + */ + if ( + sequence.instructions.length === 1 && + sequence.instructions[0].value.kind === 'SequenceExpression' && + sequence.instructions[0].value.instructions.length === 1 && + sequence.instructions[0].value.instructions[0].lvalue !== null && + sequence.instructions[0].value.instructions[0].value.kind === + 'OptionalExpression' && + sequence.instructions[0].value.value.kind === 'LoadLocal' && + sequence.instructions[0].value.value.place.identifier.id === + sequence.instructions[0].value.instructions[0].lvalue.identifier.id && + sequence.value.kind === 'SequenceExpression' && + sequence.value.instructions.length === 1 && + sequence.value.instructions[0].lvalue !== null && + sequence.value.instructions[0].value.kind === 'PropertyLoad' && + sequence.value.instructions[0].value.object.identifier.id === + sequence.instructions[0].value.value.place.identifier.id && + sequence.value.value.kind === 'LoadLocal' && + sequence.value.value.place.identifier.id === + sequence.value.instructions[0].lvalue.identifier.id + ) { + const {lvalue: innerLvalue, value: innerOptional} = + sequence.instructions[0].value.instructions[0]; + const innerProperty = this.extractOptionalProperty( + context, + innerOptional, + innerLvalue, + ); + if (innerProperty === null) { + return null; + } + context.declareProperty( + innerProperty.lvalue, + innerProperty.object, + innerProperty.property, + innerProperty.optional, + ); + const propertyLoad = sequence.value.instructions[0].value; + return { + lvalue, + object: propertyLoad.object, + property: propertyLoad.property, + optional: optionalValue.optional, + }; + } + return null; + } + + visitOptionalExpression( + context: Context, + id: InstructionId, + value: ReactiveOptionalCallValue, + lvalue: Place | null, + ): void { + /** + * If this is the first optional=true optional in a recursive OptionalExpression + * subtree, we check to see if the subtree is of the form: + * ``` + * NestedOptional = + * ` . / ?. ` + * ` . / ?. ` + * ``` + * + * Ie strictly a chain like `foo?.bar?.baz` or `a?.b.c`. If the subtree contains + * any other types of expressions - for example `foo?.[makeKey(a)]` - then this + * will return null and we'll go to the default handling below. + * + * If the tree does match the NestedOptional shape, then we'll have recorded + * a sequence of declareProperty calls, and the final visitProperty call here + * will record that optional chain as a dependency (since we know it's about + * to be referenced via its lvalue which is non-null). + */ + if ( + lvalue !== null && + value.optional && + this.env.config.enableOptionalDependencies + ) { + const inner = this.extractOptionalProperty(context, value, lvalue); + if (inner !== null) { + context.visitProperty(inner.object, inner.property, inner.optional); + return; + } + } + + // Otherwise we treat everything after the optional as conditional + const inner = value.value; + /* + * OptionalExpression value is a SequenceExpression where the instructions + * represent the code prior to the `?` and the final value represents the + * conditional code that follows. + */ + CompilerError.invariant(inner.kind === 'SequenceExpression', { + reason: 'Expected OptionalExpression value to be a SequenceExpression', + description: `Found a \`${value.kind}\``, + loc: value.loc, + suggestions: null, + }); + // Instructions are the unconditionally executed portion before the `?` + for (const instr of inner.instructions) { + this.visitInstruction(instr, context); + } + // The final value is the conditional portion following the `?` + context.enterConditional(() => { + this.visitReactiveValue(context, id, inner.value, null); + }); + } + visitReactiveValue( context: Context, id: InstructionId, value: ReactiveValue, + lvalue: Place | null, ): void { switch (value.kind) { case 'OptionalExpression': { - const inner = value.value; - /* - * OptionalExpression value is a SequenceExpression where the instructions - * represent the code prior to the `?` and the final value represents the - * conditional code that follows. - */ - CompilerError.invariant(inner.kind === 'SequenceExpression', { - reason: - 'Expected OptionalExpression value to be a SequenceExpression', - description: `Found a \`${value.kind}\``, - loc: value.loc, - suggestions: null, - }); - // Instructions are the unconditionally executed portion before the `?` - for (const instr of inner.instructions) { - this.visitInstruction(instr, context); - } - // The final value is the conditional portion following the `?` - context.enterConditional(() => { - this.visitReactiveValue(context, id, inner.value); - }); + this.visitOptionalExpression(context, id, value, lvalue); break; } case 'LogicalExpression': { - this.visitReactiveValue(context, id, value.left); + this.visitReactiveValue(context, id, value.left, null); context.enterConditional(() => { - this.visitReactiveValue(context, id, value.right); + this.visitReactiveValue(context, id, value.right, null); }); break; } case 'ConditionalExpression': { - this.visitReactiveValue(context, id, value.test); + this.visitReactiveValue(context, id, value.test, null); const consequentDeps = context.enterConditional(() => { - this.visitReactiveValue(context, id, value.consequent); + this.visitReactiveValue(context, id, value.consequent, null); }); const alternateDeps = context.enterConditional(() => { - this.visitReactiveValue(context, id, value.alternate); + this.visitReactiveValue(context, id, value.alternate, null); }); context.promoteDepsFromExhaustiveConditionals([ consequentDeps, @@ -821,7 +1044,7 @@ class PropagationVisitor extends ReactiveFunctionVisitor { break; } case 'FunctionExpression': { - if (this.enableTreatFunctionDepsAsConditional) { + if (this.env.config.enableTreatFunctionDepsAsConditional) { context.enterConditional(() => { for (const operand of eachInstructionValueOperand(value)) { context.visitOperand(operand); @@ -868,9 +1091,9 @@ class PropagationVisitor extends ReactiveFunctionVisitor { } } else if (value.kind === 'PropertyLoad') { if (lvalue !== null && !context.isUsedOutsideDeclaringScope(lvalue)) { - context.declareProperty(lvalue, value.object, value.property); + context.declareProperty(lvalue, value.object, value.property, false); } else { - context.visitProperty(value.object, value.property); + context.visitProperty(value.object, value.property, false); } } else if (value.kind === 'StoreLocal') { context.visitOperand(value.value); @@ -913,7 +1136,7 @@ class PropagationVisitor extends ReactiveFunctionVisitor { }); } } else { - this.visitReactiveValue(context, id, value); + this.visitReactiveValue(context, id, value, lvalue); } } @@ -964,25 +1187,30 @@ class PropagationVisitor extends ReactiveFunctionVisitor { break; } case 'for': { - this.visitReactiveValue(context, terminal.id, terminal.init); - this.visitReactiveValue(context, terminal.id, terminal.test); + this.visitReactiveValue(context, terminal.id, terminal.init, null); + this.visitReactiveValue(context, terminal.id, terminal.test, null); context.enterConditional(() => { this.visitBlock(terminal.loop, context); if (terminal.update !== null) { - this.visitReactiveValue(context, terminal.id, terminal.update); + this.visitReactiveValue( + context, + terminal.id, + terminal.update, + null, + ); } }); break; } case 'for-of': { - this.visitReactiveValue(context, terminal.id, terminal.init); + this.visitReactiveValue(context, terminal.id, terminal.init, null); context.enterConditional(() => { this.visitBlock(terminal.loop, context); }); break; } case 'for-in': { - this.visitReactiveValue(context, terminal.id, terminal.init); + this.visitReactiveValue(context, terminal.id, terminal.init, null); context.enterConditional(() => { this.visitBlock(terminal.loop, context); }); @@ -991,12 +1219,12 @@ class PropagationVisitor extends ReactiveFunctionVisitor { case 'do-while': { this.visitBlock(terminal.loop, context); context.enterConditional(() => { - this.visitReactiveValue(context, terminal.id, terminal.test); + this.visitReactiveValue(context, terminal.id, terminal.test, null); }); break; } case 'while': { - this.visitReactiveValue(context, terminal.id, terminal.test); + this.visitReactiveValue(context, terminal.id, terminal.test, null); context.enterConditional(() => { this.visitBlock(terminal.loop, context); }); diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts index 721fa7b0ec65d..2a9d0b9793d9f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts @@ -180,8 +180,8 @@ class Visitor extends ReactiveFunctionVisitor { [...scope.scope.dependencies].forEach(ident => { let target: undefined | IdentifierId = this.aliases.find(ident.identifier.id) ?? ident.identifier.id; - ident.path.forEach(key => { - target &&= this.paths.get(target)?.get(key); + ident.path.forEach(token => { + target &&= this.paths.get(target)?.get(token.property); }); if (target && this.map.get(target) === 'Create') { scope.scope.dependencies.delete(ident); diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonEscapingScopes.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonEscapingScopes.ts index b2e91fa302728..5a9aa6b2a7368 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonEscapingScopes.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonEscapingScopes.ts @@ -26,7 +26,7 @@ import { } from '../HIR'; import {getFunctionCallSignature} from '../Inference/InferReferenceEffects'; import {assertExhaustive, getOrInsertDefault} from '../Utils/utils'; -import {getPlaceScope} from './BuildReactiveBlocks'; +import {getPlaceScope} from '../HIR/HIR'; import { ReactiveFunctionTransform, ReactiveFunctionVisitor, @@ -671,12 +671,37 @@ function computeMemoizationInputs( ], }; } + case 'TaggedTemplateExpression': { + const signature = getFunctionCallSignature( + env, + value.tag.identifier.type, + ); + let lvalues = []; + if (lvalue !== null) { + lvalues.push({place: lvalue, level: MemoizationLevel.Memoized}); + } + if (signature?.noAlias === true) { + return { + lvalues, + rvalues: [], + }; + } + const operands = [...eachReactiveValueOperand(value)]; + lvalues.push( + ...operands + .filter(operand => isMutableEffect(operand.effect, operand.loc)) + .map(place => ({place, level: MemoizationLevel.Memoized})), + ); + return { + lvalues, + rvalues: operands, + }; + } case 'CallExpression': { const signature = getFunctionCallSignature( env, value.callee.identifier.type, ); - const operands = [...eachReactiveValueOperand(value)]; let lvalues = []; if (lvalue !== null) { lvalues.push({place: lvalue, level: MemoizationLevel.Memoized}); @@ -687,6 +712,7 @@ function computeMemoizationInputs( rvalues: [], }; } + const operands = [...eachReactiveValueOperand(value)]; lvalues.push( ...operands .filter(operand => isMutableEffect(operand.effect, operand.loc)) @@ -702,7 +728,6 @@ function computeMemoizationInputs( env, value.property.identifier.type, ); - const operands = [...eachReactiveValueOperand(value)]; let lvalues = []; if (lvalue !== null) { lvalues.push({place: lvalue, level: MemoizationLevel.Memoized}); @@ -713,6 +738,7 @@ function computeMemoizationInputs( rvalues: [], }; } + const operands = [...eachReactiveValueOperand(value)]; lvalues.push( ...operands .filter(operand => isMutableEffect(operand.effect, operand.loc)) @@ -726,7 +752,6 @@ function computeMemoizationInputs( case 'RegExpLiteral': case 'ObjectMethod': case 'FunctionExpression': - case 'TaggedTemplateExpression': case 'ArrayExpression': case 'NewExpression': case 'ObjectExpression': diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/index.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/index.ts index 55f67fc2f7d23..eb778305611cf 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/index.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/index.ts @@ -6,18 +6,13 @@ */ export {alignObjectMethodScopes} from './AlignObjectMethodScopes'; -export {alignReactiveScopesToBlockScopes} from './AlignReactiveScopesToBlockScopes'; export {assertScopeInstructionsWithinScopes} from './AssertScopeInstructionsWithinScope'; export {assertWellFormedBreakTargets} from './AssertWellFormedBreakTargets'; -export {buildReactiveBlocks} from './BuildReactiveBlocks'; export {buildReactiveFunction} from './BuildReactiveFunction'; export {codegenFunction, type CodegenFunction} from './CodegenReactiveFunction'; export {extractScopeDeclarationsFromDestructuring} from './ExtractScopeDeclarationsFromDestructuring'; -export {flattenReactiveLoops} from './FlattenReactiveLoops'; -export {flattenScopesWithHooksOrUse} from './FlattenScopesWithHooksOrUse'; export {inferReactiveScopeVariables} from './InferReactiveScopeVariables'; export {memoizeFbtAndMacroOperandsInSameScope} from './MemoizeFbtAndMacroOperandsInSameScope'; -export {mergeOverlappingReactiveScopes} from './MergeOverlappingReactiveScopes'; export {mergeReactiveScopesThatInvalidateTogether} from './MergeReactiveScopesThatInvalidateTogether'; export {printReactiveFunction} from './PrintReactiveFunction'; export {promoteUsedTemporaries} from './PromoteUsedTemporaries'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts index d9f7ffd5bf8b8..b460124ec71f3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts @@ -250,6 +250,7 @@ function* generateInstructionTypes( } case 'CallExpression': { + const returnType = makeType(); /* * TODO: callee could be a hook or a function, so this type equation isn't correct. * We should change Hook to a subtype of Function or change unifier logic. @@ -258,8 +259,25 @@ function* generateInstructionTypes( yield equation(value.callee.identifier.type, { kind: 'Function', shapeId: null, - return: left, + return: returnType, }); + yield equation(left, returnType); + break; + } + + case 'TaggedTemplateExpression': { + const returnType = makeType(); + /* + * TODO: callee could be a hook or a function, so this type equation isn't correct. + * We should change Hook to a subtype of Function or change unifier logic. + * (see https://github.com/facebook/react-forget/pull/1427) + */ + yield equation(value.tag.identifier.type, { + kind: 'Function', + shapeId: null, + return: returnType, + }); + yield equation(left, returnType); break; } @@ -392,7 +410,6 @@ function* generateInstructionTypes( case 'MetaProperty': case 'ComputedStore': case 'ComputedLoad': - case 'TaggedTemplateExpression': case 'Await': case 'GetIterator': case 'IteratorNext': diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts index 0ea1814349f7f..9c41ebcae19f6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts @@ -161,6 +161,14 @@ function getContextReassignment( if (signature?.noAlias) { operands = [value.receiver, value.property]; } + } else if (value.kind === 'TaggedTemplateExpression') { + const signature = getFunctionCallSignature( + fn.env, + value.tag.identifier.type, + ); + if (signature?.noAlias) { + operands = [value.tag]; + } } for (const operand of operands) { CompilerError.invariant(operand.effect !== Effect.Unknown, { diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts index 7af4aaaccd7ab..e7615320c7b95 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts @@ -116,7 +116,7 @@ function prettyPrintScopeDependency(val: ReactiveScopeDependency): string { } else { rootStr = '[unnamed]'; } - return `${rootStr}${val.path.length > 0 ? '.' : ''}${val.path.join('.')}`; + return `${rootStr}${val.path.map(v => `${v.optional ? '?.' : '.'}${v.property}`).join('')}`; } enum CompareDependencyResult { @@ -167,9 +167,16 @@ function compareDeps( let isSubpath = true; for (let i = 0; i < Math.min(inferred.path.length, source.path.length); i++) { - if (inferred.path[i] !== source.path[i]) { + if (inferred.path[i].property !== source.path[i].property) { isSubpath = false; break; + } else if (inferred.path[i].optional !== source.path[i].optional) { + /** + * The inferred path must be at least as precise as the manual path: + * if the inferred path is optional, then the source path must have + * been optional too. + */ + return CompareDependencyResult.PathDifference; } } @@ -177,14 +184,14 @@ function compareDeps( isSubpath && (source.path.length === inferred.path.length || (inferred.path.length >= source.path.length && - !inferred.path.includes('current'))) + !inferred.path.some(token => token.property === 'current'))) ) { return CompareDependencyResult.Ok; } else { if (isSubpath) { if ( - source.path.includes('current') || - inferred.path.includes('current') + source.path.some(token => token.property === 'current') || + inferred.path.some(token => token.property === 'current') ) { return CompareDependencyResult.RefAccessDifference; } else { @@ -339,7 +346,11 @@ class Visitor extends ReactiveFunctionVisitor { return null; } default: { - const dep = collectMaybeMemoDependencies(value, this.temporaries); + const dep = collectMaybeMemoDependencies( + value, + this.temporaries, + false, + ); if (value.kind === 'StoreLocal' || value.kind === 'StoreContext') { const storeTarget = value.lvalue.place; state.manualMemoState?.decls.add( diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-within-nested-valueblock-in-array.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-within-nested-valueblock-in-array.expect.md index d59fb182c3cdb..97b3bb13d7017 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-within-nested-valueblock-in-array.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-within-nested-valueblock-in-array.expect.md @@ -2,8 +2,6 @@ ## Input ```javascript -// @enableReactiveScopesInHIR:false - import {Stringify, identity, makeArray, mutate} from 'shared-runtime'; /** @@ -37,8 +35,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false - +import { c as _c } from "react/compiler-runtime"; import { Stringify, identity, makeArray, mutate } from "shared-runtime"; /** @@ -52,11 +49,12 @@ import { Stringify, identity, makeArray, mutate } from "shared-runtime"; * handles this correctly. */ function Foo(t0) { - const $ = _c(4); + const $ = _c(3); const { cond1, cond2 } = t0; - const arr = makeArray({ a: 2 }, 2, []); let t1; - if ($[0] !== cond1 || $[1] !== cond2 || $[2] !== arr) { + if ($[0] !== cond1 || $[1] !== cond2) { + const arr = makeArray({ a: 2 }, 2, []); + t1 = cond1 ? ( <>
{identity("foo")}
@@ -65,10 +63,9 @@ function Foo(t0) { ) : null; $[0] = cond1; $[1] = cond2; - $[2] = arr; - $[3] = t1; + $[2] = t1; } else { - t1 = $[3]; + t1 = $[2]; } return t1; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-within-nested-valueblock-in-array.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-within-nested-valueblock-in-array.tsx index a8f46eaf38a54..b5e2fa0c19ece 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-within-nested-valueblock-in-array.tsx +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-within-nested-valueblock-in-array.tsx @@ -1,5 +1,3 @@ -// @enableReactiveScopesInHIR:false - import {Stringify, identity, makeArray, mutate} from 'shared-runtime'; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructuring-mixed-scope-and-local-variables-with-default.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructuring-mixed-scope-and-local-variables-with-default.expect.md index 5e8f199206f58..17dd0f835942d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructuring-mixed-scope-and-local-variables-with-default.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructuring-mixed-scope-and-local-variables-with-default.expect.md @@ -63,67 +63,63 @@ function useFragment(_arg1, _arg2) { } function Component(props) { - const $ = _c(9); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = graphql` + const $ = _c(8); + const post = useFragment( + graphql` fragment F on T { id } - `; - $[0] = t0; - } else { - t0 = $[0]; - } - const post = useFragment(t0, props.post); - let t1; - if ($[1] !== post) { + `, + props.post, + ); + let t0; + if ($[0] !== post) { const allUrls = []; - const { media: t2, comments: t3, urls: t4 } = post; - const media = t2 === undefined ? null : t2; + const { media: t1, comments: t2, urls: t3 } = post; + const media = t1 === undefined ? null : t1; + let t4; + if ($[2] !== t2) { + t4 = t2 === undefined ? [] : t2; + $[2] = t2; + $[3] = t4; + } else { + t4 = $[3]; + } + const comments = t4; let t5; - if ($[3] !== t3) { + if ($[4] !== t3) { t5 = t3 === undefined ? [] : t3; - $[3] = t3; - $[4] = t5; + $[4] = t3; + $[5] = t5; } else { - t5 = $[4]; + t5 = $[5]; } - const comments = t5; + const urls = t5; let t6; - if ($[5] !== t4) { - t6 = t4 === undefined ? [] : t4; - $[5] = t4; - $[6] = t6; - } else { - t6 = $[6]; - } - const urls = t6; - let t7; - if ($[7] !== comments.length) { - t7 = (e) => { + if ($[6] !== comments.length) { + t6 = (e) => { if (!comments.length) { return; } console.log(comments.length); }; - $[7] = comments.length; - $[8] = t7; + $[6] = comments.length; + $[7] = t6; } else { - t7 = $[8]; + t6 = $[7]; } - const onClick = t7; + const onClick = t6; allUrls.push(...urls); - t1 = ; - $[1] = post; - $[2] = t1; + t0 = ; + $[0] = post; + $[1] = t0; } else { - t1 = $[2]; + t0 = $[1]; } - return t1; + return t0; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-optional-member-expression-as-memo-dep-non-optional-in-body.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-optional-member-expression-as-memo-dep-non-optional-in-body.expect.md new file mode 100644 index 0000000000000..ba5b30418069a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-optional-member-expression-as-memo-dep-non-optional-in-body.expect.md @@ -0,0 +1,38 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees +function Component(props) { + const data = useMemo(() => { + // actual code is non-optional + return props.items.edges.nodes ?? []; + // deps are optional + }, [props.items?.edges?.nodes]); + return ; +} + +``` + + +## Error + +``` + 1 | // @validatePreserveExistingMemoizationGuarantees + 2 | function Component(props) { +> 3 | const data = useMemo(() => { + | ^^^^^^^ +> 4 | // actual code is non-optional + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 5 | return props.items.edges.nodes ?? []; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 6 | // deps are optional + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 7 | }, [props.items?.edges?.nodes]); + | ^^^^ CannotPreserveMemoization: React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected (3:7) + 8 | return ; + 9 | } + 10 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-optional-member-expression-as-memo-dep-non-optional-in-body.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-optional-member-expression-as-memo-dep-non-optional-in-body.js new file mode 100644 index 0000000000000..1a6196a494e66 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-optional-member-expression-as-memo-dep-non-optional-in-body.js @@ -0,0 +1,9 @@ +// @validatePreserveExistingMemoizationGuarantees +function Component(props) { + const data = useMemo(() => { + // actual code is non-optional + return props.items.edges.nodes ?? []; + // deps are optional + }, [props.items?.edges?.nodes]); + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook-namespace.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook-namespace.expect.md new file mode 100644 index 0000000000000..5f352281b3798 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook-namespace.expect.md @@ -0,0 +1,25 @@ + +## Input + +```javascript +import ReactCompilerTest from 'ReactCompilerTest'; + +function Component() { + return ReactCompilerTest.useHookNotTypedAsHook(); +} + +``` + + +## Error + +``` + 2 | + 3 | function Component() { +> 4 | return ReactCompilerTest.useHookNotTypedAsHook(); + | ^^^^^^^^^^^^^^^^^ InvalidConfig: Invalid type configuration for module. Expected type for object property 'useHookNotTypedAsHook' from module 'ReactCompilerTest' to be a hook based on the property name (4:4) + 5 | } + 6 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook-namespace.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook-namespace.js new file mode 100644 index 0000000000000..3a2f646569e10 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook-namespace.js @@ -0,0 +1,5 @@ +import ReactCompilerTest from 'ReactCompilerTest'; + +function Component() { + return ReactCompilerTest.useHookNotTypedAsHook(); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook.expect.md new file mode 100644 index 0000000000000..9d863ba0cbc7a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook.expect.md @@ -0,0 +1,25 @@ + +## Input + +```javascript +import {useHookNotTypedAsHook} from 'ReactCompilerTest'; + +function Component() { + return useHookNotTypedAsHook(); +} + +``` + + +## Error + +``` + 2 | + 3 | function Component() { +> 4 | return useHookNotTypedAsHook(); + | ^^^^^^^^^^^^^^^^^^^^^ InvalidConfig: Invalid type configuration for module. Expected type for object property 'useHookNotTypedAsHook' from module 'ReactCompilerTest' to be a hook based on the property name (4:4) + 5 | } + 6 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook.js new file mode 100644 index 0000000000000..d4ae58c5d9501 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook.js @@ -0,0 +1,5 @@ +import {useHookNotTypedAsHook} from 'ReactCompilerTest'; + +function Component() { + return useHookNotTypedAsHook(); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hooklike-module-default-not-hook.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hooklike-module-default-not-hook.expect.md new file mode 100644 index 0000000000000..99944b5813387 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hooklike-module-default-not-hook.expect.md @@ -0,0 +1,25 @@ + +## Input + +```javascript +import foo from 'useDefaultExportNotTypedAsHook'; + +function Component() { + return
{foo()}
; +} + +``` + + +## Error + +``` + 2 | + 3 | function Component() { +> 4 | return
{foo()}
; + | ^^^ InvalidConfig: Invalid type configuration for module. Expected type for `import ... from 'useDefaultExportNotTypedAsHook'` to be a hook based on the module name (4:4) + 5 | } + 6 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hooklike-module-default-not-hook.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hooklike-module-default-not-hook.js new file mode 100644 index 0000000000000..75d040fde079f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hooklike-module-default-not-hook.js @@ -0,0 +1,5 @@ +import foo from 'useDefaultExportNotTypedAsHook'; + +function Component() { + return
{foo()}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-nonhook-name-typed-as-hook.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-nonhook-name-typed-as-hook.expect.md new file mode 100644 index 0000000000000..ff1f4373b423c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-nonhook-name-typed-as-hook.expect.md @@ -0,0 +1,25 @@ + +## Input + +```javascript +import {notAhookTypedAsHook} from 'ReactCompilerTest'; + +function Component() { + return
{notAhookTypedAsHook()}
; +} + +``` + + +## Error + +``` + 2 | + 3 | function Component() { +> 4 | return
{notAhookTypedAsHook()}
; + | ^^^^^^^^^^^^^^^^^^^ InvalidConfig: Invalid type configuration for module. Expected type for object property 'useHookNotTypedAsHook' from module 'ReactCompilerTest' to be a hook based on the property name (4:4) + 5 | } + 6 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-nonhook-name-typed-as-hook.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-nonhook-name-typed-as-hook.js new file mode 100644 index 0000000000000..3763bed79c6bb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-nonhook-name-typed-as-hook.js @@ -0,0 +1,5 @@ +import {notAhookTypedAsHook} from 'ReactCompilerTest'; + +function Component() { + return
{notAhookTypedAsHook()}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-unclosed-eslint-suppression.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-unclosed-eslint-suppression.expect.md index b81dadf409301..9f8e15592df6f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-unclosed-eslint-suppression.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-unclosed-eslint-suppression.expect.md @@ -39,8 +39,6 @@ function CrimesAgainstReact() { 1 | // Note: Everything below this is sketchy > 2 | /* eslint-disable react-hooks/rules-of-hooks */ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ InvalidReact: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. eslint-disable react-hooks/rules-of-hooks (2:2) - -InvalidReact: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. eslint-disable-next-line react-hooks/rules-of-hooks (25:25) 3 | function lowercasecomponent() { 4 | 'use forget'; 5 | const x = []; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/multiple-components-first-is-invalid.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/multiple-components-first-is-invalid.expect.md new file mode 100644 index 0000000000000..3224997b40343 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/multiple-components-first-is-invalid.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @panicThreshold(none) +import {useHook} from 'shared-runtime'; + +function InvalidComponent(props) { + if (props.cond) { + useHook(); + } + return
Hello World!
; +} + +function ValidComponent(props) { + return
{props.greeting}
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @panicThreshold(none) +import { useHook } from "shared-runtime"; + +function InvalidComponent(props) { + if (props.cond) { + useHook(); + } + return
Hello World!
; +} + +function ValidComponent(props) { + const $ = _c(2); + let t0; + if ($[0] !== props.greeting) { + t0 =
{props.greeting}
; + $[0] = props.greeting; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/multiple-components-first-is-invalid.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/multiple-components-first-is-invalid.js new file mode 100644 index 0000000000000..6a3d52c86406a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/multiple-components-first-is-invalid.js @@ -0,0 +1,13 @@ +// @panicThreshold(none) +import {useHook} from 'shared-runtime'; + +function InvalidComponent(props) { + if (props.cond) { + useHook(); + } + return
Hello World!
; +} + +function ValidComponent(props) { + return
{props.greeting}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-call-with-optional-property-load.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-call-with-optional-property-load.expect.md index 795ab61cca997..d95461adf90e6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-call-with-optional-property-load.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-call-with-optional-property-load.expect.md @@ -15,9 +15,9 @@ import { c as _c } from "react/compiler-runtime"; function Component(props) { const $ = _c(2); let t0; - if ($[0] !== props) { + if ($[0] !== props?.items) { t0 = props?.items?.map?.(render)?.filter(Boolean) ?? []; - $[0] = props; + $[0] = props?.items; $[1] = t0; } else { t0 = $[1]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-as-memo-dep.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-as-memo-dep.expect.md new file mode 100644 index 0000000000000..2623806ba9534 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-as-memo-dep.expect.md @@ -0,0 +1,48 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +function Component(props) { + const data = useMemo(() => { + return props?.items.edges?.nodes.map(); + }, [props?.items.edges?.nodes]); + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +function Component(props) { + const $ = _c(4); + + props?.items.edges?.nodes; + let t0; + let t1; + if ($[0] !== props?.items.edges?.nodes) { + t1 = props?.items.edges?.nodes.map(); + $[0] = props?.items.edges?.nodes; + $[1] = t1; + } else { + t1 = $[1]; + } + t0 = t1; + const data = t0; + let t2; + if ($[2] !== data) { + t2 = ; + $[2] = data; + $[3] = t2; + } else { + t2 = $[3]; + } + return t2; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-as-memo-dep.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-as-memo-dep.js new file mode 100644 index 0000000000000..bbccbab90e2dd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-as-memo-dep.js @@ -0,0 +1,7 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +function Component(props) { + const data = useMemo(() => { + return props?.items.edges?.nodes.map(); + }, [props?.items.edges?.nodes]); + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-inverted-optionals-parallel-paths.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-inverted-optionals-parallel-paths.expect.md new file mode 100644 index 0000000000000..e61d8d5b3919d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-inverted-optionals-parallel-paths.expect.md @@ -0,0 +1,46 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.a.b?.c.d?.e); + x.push(props.a?.b.c?.d.e); + return x; + }, [props.a.b.c.d.e]); + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { ValidateMemoization } from "shared-runtime"; +function Component(props) { + const $ = _c(2); + let t0; + + const x$0 = []; + x$0.push(props?.a.b?.c.d?.e); + x$0.push(props.a?.b.c?.d.e); + t0 = x$0; + let t1; + if ($[0] !== props.a.b.c.d.e) { + t1 = ; + $[0] = props.a.b.c.d.e; + $[1] = t1; + } else { + t1 = $[1]; + } + return t1; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-inverted-optionals-parallel-paths.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-inverted-optionals-parallel-paths.js new file mode 100644 index 0000000000000..cf8d17b60fe0d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-inverted-optionals-parallel-paths.js @@ -0,0 +1,11 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.a.b?.c.d?.e); + x.push(props.a?.b.c?.d.e); + return x; + }, [props.a.b.c.d.e]); + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single-with-unconditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single-with-unconditional.expect.md new file mode 100644 index 0000000000000..a153c3d046595 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single-with-unconditional.expect.md @@ -0,0 +1,62 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.items); + x.push(props.items); + return x; + }, [props.items]); + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { ValidateMemoization } from "shared-runtime"; +function Component(props) { + const $ = _c(7); + let t0; + let x; + if ($[0] !== props.items) { + x = []; + x.push(props?.items); + x.push(props.items); + $[0] = props.items; + $[1] = x; + } else { + x = $[1]; + } + t0 = x; + const data = t0; + let t1; + if ($[2] !== props.items) { + t1 = [props.items]; + $[2] = props.items; + $[3] = t1; + } else { + t1 = $[3]; + } + let t2; + if ($[4] !== t1 || $[5] !== data) { + t2 = ; + $[4] = t1; + $[5] = data; + $[6] = t2; + } else { + t2 = $[6]; + } + return t2; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single-with-unconditional.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single-with-unconditional.js new file mode 100644 index 0000000000000..0a9d9bd9af247 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single-with-unconditional.js @@ -0,0 +1,11 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.items); + x.push(props.items); + return x; + }, [props.items]); + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single.expect.md new file mode 100644 index 0000000000000..f7091dc8dcd6a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single.expect.md @@ -0,0 +1,63 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.items); + return x; + }, [props?.items]); + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { ValidateMemoization } from "shared-runtime"; +function Component(props) { + const $ = _c(7); + + props?.items; + let t0; + let x; + if ($[0] !== props?.items) { + x = []; + x.push(props?.items); + $[0] = props?.items; + $[1] = x; + } else { + x = $[1]; + } + t0 = x; + const data = t0; + const t1 = props?.items; + let t2; + if ($[2] !== t1) { + t2 = [t1]; + $[2] = t1; + $[3] = t2; + } else { + t2 = $[3]; + } + let t3; + if ($[4] !== t2 || $[5] !== data) { + t3 = ; + $[4] = t2; + $[5] = data; + $[6] = t3; + } else { + t3 = $[6]; + } + return t3; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single.js new file mode 100644 index 0000000000000..4e7268e1bf9e1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-single.js @@ -0,0 +1,10 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.items); + return x; + }, [props?.items]); + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional-optional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional-optional.expect.md new file mode 100644 index 0000000000000..c8069ea47d27c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional-optional.expect.md @@ -0,0 +1,74 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.items); + if (props.cond) { + x.push(props?.items); + } + return x; + }, [props?.items, props.cond]); + return ( + + ); +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { ValidateMemoization } from "shared-runtime"; +function Component(props) { + const $ = _c(9); + + props?.items; + let t0; + let x; + if ($[0] !== props?.items || $[1] !== props.cond) { + x = []; + x.push(props?.items); + if (props.cond) { + x.push(props?.items); + } + $[0] = props?.items; + $[1] = props.cond; + $[2] = x; + } else { + x = $[2]; + } + t0 = x; + const data = t0; + + const t1 = props?.items; + let t2; + if ($[3] !== t1 || $[4] !== props.cond) { + t2 = [t1, props.cond]; + $[3] = t1; + $[4] = props.cond; + $[5] = t2; + } else { + t2 = $[5]; + } + let t3; + if ($[6] !== t2 || $[7] !== data) { + t3 = ; + $[6] = t2; + $[7] = data; + $[8] = t3; + } else { + t3 = $[8]; + } + return t3; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional-optional.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional-optional.js new file mode 100644 index 0000000000000..2245a700f2e6c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional-optional.js @@ -0,0 +1,15 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.items); + if (props.cond) { + x.push(props?.items); + } + return x; + }, [props?.items, props.cond]); + return ( + + ); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional.expect.md new file mode 100644 index 0000000000000..df498e0ad06aa --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional.expect.md @@ -0,0 +1,74 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.items); + if (props.cond) { + x.push(props.items); + } + return x; + }, [props?.items, props.cond]); + return ( + + ); +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { ValidateMemoization } from "shared-runtime"; +function Component(props) { + const $ = _c(9); + + props?.items; + let t0; + let x; + if ($[0] !== props?.items || $[1] !== props.cond) { + x = []; + x.push(props?.items); + if (props.cond) { + x.push(props.items); + } + $[0] = props?.items; + $[1] = props.cond; + $[2] = x; + } else { + x = $[2]; + } + t0 = x; + const data = t0; + + const t1 = props?.items; + let t2; + if ($[3] !== t1 || $[4] !== props.cond) { + t2 = [t1, props.cond]; + $[3] = t1; + $[4] = props.cond; + $[5] = t2; + } else { + t2 = $[5]; + } + let t3; + if ($[6] !== t2 || $[7] !== data) { + t3 = ; + $[6] = t2; + $[7] = data; + $[8] = t3; + } else { + t3 = $[8]; + } + return t3; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional.js new file mode 100644 index 0000000000000..006e516ae546e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/optional-member-expression-with-conditional.js @@ -0,0 +1,15 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.items); + if (props.cond) { + x.push(props.items); + } + return x; + }, [props?.items, props.cond]); + return ( + + ); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/allocating-logical-expression-instruction-scope.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/allocating-logical-expression-instruction-scope.expect.md deleted file mode 100644 index 9dee3bb8a705e..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/allocating-logical-expression-instruction-scope.expect.md +++ /dev/null @@ -1,61 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false - -/** - * This is a weird case as data has type `BuiltInMixedReadonly`. - * The only scoped value we currently infer in this program is the - * PropertyLoad `data?.toString`. - */ -import {useFragment} from 'shared-runtime'; - -function Foo() { - const data = useFragment(); - return [data?.toString() || '']; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Foo, - params: [], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false - -/** - * This is a weird case as data has type `BuiltInMixedReadonly`. - * The only scoped value we currently infer in this program is the - * PropertyLoad `data?.toString`. - */ -import { useFragment } from "shared-runtime"; - -function Foo() { - const $ = _c(2); - const data = useFragment(); - const t0 = data?.toString() || ""; - let t1; - if ($[0] !== t0) { - t1 = [t0]; - $[0] = t0; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Foo, - params: [], -}; - -``` - -### Eval output -(kind: ok) ["[object Object]"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/allocating-logical-expression-instruction-scope.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/allocating-logical-expression-instruction-scope.ts deleted file mode 100644 index f77a0fb8285e4..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/allocating-logical-expression-instruction-scope.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @enableReactiveScopesInHIR:false - -/** - * This is a weird case as data has type `BuiltInMixedReadonly`. - * The only scoped value we currently infer in this program is the - * PropertyLoad `data?.toString`. - */ -import {useFragment} from 'shared-runtime'; - -function Foo() { - const data = useFragment(); - return [data?.toString() || '']; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Foo, - params: [], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-hoisted-declaration-with-scope.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-hoisted-declaration-with-scope.expect.md deleted file mode 100644 index 8b1b8dabba013..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-hoisted-declaration-with-scope.expect.md +++ /dev/null @@ -1,96 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false -import {StaticText1, Stringify, identity, useHook} from 'shared-runtime'; -/** - * `button` and `dispatcher` must end up in the same memo block. It would be - * invalid for `button` to take a dependency on `dispatcher` as dispatcher - * is created later. - * - * Sprout error: - * Found differences in evaluator results - * Non-forget (expected): - * (kind: ok) "[[ function params=1 ]]" - * Forget: - * (kind: exception) Cannot access 'dispatcher' before initialization - */ -function useFoo({onClose}) { - const button = StaticText1 ?? ( - { - dispatcher.go('route2'); - }, - }} - /> - ); - - const dispatcher = useHook(); - - return button; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{onClose: identity()}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false -import { StaticText1, Stringify, identity, useHook } from "shared-runtime"; -/** - * `button` and `dispatcher` must end up in the same memo block. It would be - * invalid for `button` to take a dependency on `dispatcher` as dispatcher - * is created later. - * - * Sprout error: - * Found differences in evaluator results - * Non-forget (expected): - * (kind: ok) "[[ function params=1 ]]" - * Forget: - * (kind: exception) Cannot access 'dispatcher' before initialization - */ -function useFoo(t0) { - const $ = _c(3); - const { onClose } = t0; - let t1; - if ($[0] !== onClose || $[1] !== dispatcher) { - t1 = StaticText1 ?? ( - { - dispatcher.go("route2"); - }, - }} - /> - ); - $[0] = onClose; - $[1] = dispatcher; - $[2] = t1; - } else { - t1 = $[2]; - } - const button = t1; - - const dispatcher = useHook(); - return button; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ onClose: identity() }], -}; - -``` - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-hoisted-declaration-with-scope.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-hoisted-declaration-with-scope.tsx deleted file mode 100644 index e3883e6b4c035..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-hoisted-declaration-with-scope.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// @enableReactiveScopesInHIR:false -import {StaticText1, Stringify, identity, useHook} from 'shared-runtime'; -/** - * `button` and `dispatcher` must end up in the same memo block. It would be - * invalid for `button` to take a dependency on `dispatcher` as dispatcher - * is created later. - * - * Sprout error: - * Found differences in evaluator results - * Non-forget (expected): - * (kind: ok) "[[ function params=1 ]]" - * Forget: - * (kind: exception) Cannot access 'dispatcher' before initialization - */ -function useFoo({onClose}) { - const button = StaticText1 ?? ( - { - dispatcher.go('route2'); - }, - }} - /> - ); - - const dispatcher = useHook(); - - return button; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{onClose: identity()}], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block.expect.md deleted file mode 100644 index 1fce0aec9d2e8..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block.expect.md +++ /dev/null @@ -1,137 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false -import {identity, mutate} from 'shared-runtime'; - -/** - * The root cause of this bug is in `InferReactiveScopeVariables`. Currently, - * InferReactiveScopeVariables do not ensure that maybe-aliased values get - * assigned the same reactive scope. This is safe only when an already- - * constructed value is captured, e.g. - * ```js - * const x = makeObj(); ⌝ mutable range of x - * mutate(x); ⌟ - * <-- after this point, we can produce a canonical version - * of x for all following aliases - * const y = []; - * y.push(x); <-- y captures x - * ``` - * - * However, if a value is captured/aliased during its mutable range and the - * capturing container is separately memoized, it becomes difficult to guarantee - * that all aliases refer to the same value. - * - * Sprout error: - * Found differences in evaluator results - * Non-forget (expected): - * (kind: ok) [{"wat0":"joe"},3] - * [{"wat0":"joe"},3] - * Forget: - * (kind: ok) [{"wat0":"joe"},3] - * [[ (exception in render) Error: oh no! ]] - * - */ -function useFoo({a, b}) { - const x = {a}; - const y = {}; - mutate(x); - const z = [identity(y), b]; - mutate(y); - - if (z[0] !== y) { - throw new Error('oh no!'); - } - return z; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{a: 2, b: 3}], - sequentialRenders: [ - {a: 2, b: 3}, - {a: 4, b: 3}, - ], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false -import { identity, mutate } from "shared-runtime"; - -/** - * The root cause of this bug is in `InferReactiveScopeVariables`. Currently, - * InferReactiveScopeVariables do not ensure that maybe-aliased values get - * assigned the same reactive scope. This is safe only when an already- - * constructed value is captured, e.g. - * ```js - * const x = makeObj(); ⌝ mutable range of x - * mutate(x); ⌟ - * <-- after this point, we can produce a canonical version - * of x for all following aliases - * const y = []; - * y.push(x); <-- y captures x - * ``` - * - * However, if a value is captured/aliased during its mutable range and the - * capturing container is separately memoized, it becomes difficult to guarantee - * that all aliases refer to the same value. - * - * Sprout error: - * Found differences in evaluator results - * Non-forget (expected): - * (kind: ok) [{"wat0":"joe"},3] - * [{"wat0":"joe"},3] - * Forget: - * (kind: ok) [{"wat0":"joe"},3] - * [[ (exception in render) Error: oh no! ]] - * - */ -function useFoo(t0) { - const $ = _c(6); - const { a, b } = t0; - let z; - let y; - if ($[0] !== a || $[1] !== b) { - const x = { a }; - y = {}; - mutate(x); - let t1; - if ($[4] !== b) { - t1 = [identity(y), b]; - $[4] = b; - $[5] = t1; - } else { - t1 = $[5]; - } - z = t1; - mutate(y); - $[0] = a; - $[1] = b; - $[2] = z; - $[3] = y; - } else { - z = $[2]; - y = $[3]; - } - if (z[0] !== y) { - throw new Error("oh no!"); - } - return z; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ a: 2, b: 3 }], - sequentialRenders: [ - { a: 2, b: 3 }, - { a: 4, b: 3 }, - ], -}; - -``` - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block.ts deleted file mode 100644 index f3c5145b3f323..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block.ts +++ /dev/null @@ -1,52 +0,0 @@ -// @enableReactiveScopesInHIR:false -import {identity, mutate} from 'shared-runtime'; - -/** - * The root cause of this bug is in `InferReactiveScopeVariables`. Currently, - * InferReactiveScopeVariables do not ensure that maybe-aliased values get - * assigned the same reactive scope. This is safe only when an already- - * constructed value is captured, e.g. - * ```js - * const x = makeObj(); ⌝ mutable range of x - * mutate(x); ⌟ - * <-- after this point, we can produce a canonical version - * of x for all following aliases - * const y = []; - * y.push(x); <-- y captures x - * ``` - * - * However, if a value is captured/aliased during its mutable range and the - * capturing container is separately memoized, it becomes difficult to guarantee - * that all aliases refer to the same value. - * - * Sprout error: - * Found differences in evaluator results - * Non-forget (expected): - * (kind: ok) [{"wat0":"joe"},3] - * [{"wat0":"joe"},3] - * Forget: - * (kind: ok) [{"wat0":"joe"},3] - * [[ (exception in render) Error: oh no! ]] - * - */ -function useFoo({a, b}) { - const x = {a}; - const y = {}; - mutate(x); - const z = [identity(y), b]; - mutate(y); - - if (z[0] !== y) { - throw new Error('oh no!'); - } - return z; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{a: 2, b: 3}], - sequentialRenders: [ - {a: 2, b: 3}, - {a: 4, b: 3}, - ], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/error.capture-ref-for-later-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/error.capture-ref-for-later-mutation.expect.md deleted file mode 100644 index f0b0e6f3a8679..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/error.capture-ref-for-later-mutation.expect.md +++ /dev/null @@ -1,51 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false -import {useRef} from 'react'; -import {addOne} from 'shared-runtime'; - -function useKeyCommand() { - const currentPosition = useRef(0); - const handleKey = direction => () => { - const position = currentPosition.current; - const nextPosition = direction === 'left' ? addOne(position) : position; - currentPosition.current = nextPosition; - }; - const moveLeft = { - handler: handleKey('left'), - }; - const moveRight = { - handler: handleKey('right'), - }; - return [moveLeft, moveRight]; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useKeyCommand, - params: [], -}; - -``` - - -## Error - -``` - 11 | }; - 12 | const moveLeft = { -> 13 | handler: handleKey('left'), - | ^^^^^^^^^ InvalidReact: This function accesses a ref value (the `current` property), which may not be accessed during render. (https://react.dev/reference/react/useRef) (13:13) - -InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef) (13:13) - -InvalidReact: This function accesses a ref value (the `current` property), which may not be accessed during render. (https://react.dev/reference/react/useRef) (16:16) - -InvalidReact: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef) (16:16) - 14 | }; - 15 | const moveRight = { - 16 | handler: handleKey('right'), -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/error.capture-ref-for-later-mutation.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/error.capture-ref-for-later-mutation.tsx deleted file mode 100644 index 6f27dfe07fb33..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/error.capture-ref-for-later-mutation.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// @enableReactiveScopesInHIR:false -import {useRef} from 'react'; -import {addOne} from 'shared-runtime'; - -function useKeyCommand() { - const currentPosition = useRef(0); - const handleKey = direction => () => { - const position = currentPosition.current; - const nextPosition = direction === 'left' ? addOne(position) : position; - currentPosition.current = nextPosition; - }; - const moveLeft = { - handler: handleKey('left'), - }; - const moveRight = { - handler: handleKey('right'), - }; - return [moveLeft, moveRight]; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useKeyCommand, - params: [], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/invalid-align-scopes-within-nested-valueblock-in-array.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/invalid-align-scopes-within-nested-valueblock-in-array.expect.md deleted file mode 100644 index d59fb182c3cdb..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/invalid-align-scopes-within-nested-valueblock-in-array.expect.md +++ /dev/null @@ -1,84 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false - -import {Stringify, identity, makeArray, mutate} from 'shared-runtime'; - -/** - * Here, identity('foo') is an immutable allocating instruction. - * `arr` is a mutable value whose mutable range ends at `arr.map`. - * - * The previous (reactive function) version of alignScopesToBlocks set the range of - * both scopes to end at value blocks within the <> expression. - * However, both scope ranges should be aligned to the outer value block - * (e.g. `cond1 ? <>: null`). The HIR version of alignScopesToBlocks - * handles this correctly. - */ -function Foo({cond1, cond2}) { - const arr = makeArray({a: 2}, 2, []); - - return cond1 ? ( - <> -
{identity('foo')}
- - - ) : null; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Foo, - params: [{cond1: true, cond2: true}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false - -import { Stringify, identity, makeArray, mutate } from "shared-runtime"; - -/** - * Here, identity('foo') is an immutable allocating instruction. - * `arr` is a mutable value whose mutable range ends at `arr.map`. - * - * The previous (reactive function) version of alignScopesToBlocks set the range of - * both scopes to end at value blocks within the <> expression. - * However, both scope ranges should be aligned to the outer value block - * (e.g. `cond1 ? <>: null`). The HIR version of alignScopesToBlocks - * handles this correctly. - */ -function Foo(t0) { - const $ = _c(4); - const { cond1, cond2 } = t0; - const arr = makeArray({ a: 2 }, 2, []); - let t1; - if ($[0] !== cond1 || $[1] !== cond2 || $[2] !== arr) { - t1 = cond1 ? ( - <> -
{identity("foo")}
- - - ) : null; - $[0] = cond1; - $[1] = cond2; - $[2] = arr; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Foo, - params: [{ cond1: true, cond2: true }], -}; - -``` - -### Eval output -(kind: ok)
foo
{"value":[null,null,null]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/invalid-align-scopes-within-nested-valueblock-in-array.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/invalid-align-scopes-within-nested-valueblock-in-array.tsx deleted file mode 100644 index a8f46eaf38a54..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/invalid-align-scopes-within-nested-valueblock-in-array.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// @enableReactiveScopesInHIR:false - -import {Stringify, identity, makeArray, mutate} from 'shared-runtime'; - -/** - * Here, identity('foo') is an immutable allocating instruction. - * `arr` is a mutable value whose mutable range ends at `arr.map`. - * - * The previous (reactive function) version of alignScopesToBlocks set the range of - * both scopes to end at value blocks within the <> expression. - * However, both scope ranges should be aligned to the outer value block - * (e.g. `cond1 ? <>: null`). The HIR version of alignScopesToBlocks - * handles this correctly. - */ -function Foo({cond1, cond2}) { - const arr = makeArray({a: 2}, 2, []); - - return cond1 ? ( - <> -
{identity('foo')}
- - - ) : null; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Foo, - params: [{cond1: true, cond2: true}], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutate-outer-scope-within-value-block.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutate-outer-scope-within-value-block.expect.md deleted file mode 100644 index 62da2a8e76591..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutate-outer-scope-within-value-block.expect.md +++ /dev/null @@ -1,101 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false -import {CONST_TRUE, identity, shallowCopy} from 'shared-runtime'; - -/** - * There are three values with their own scopes in this fixture. - * - arr, whose mutable range extends to the `mutate(...)` call - * - cond, which has a mutable range of exactly 1 (e.g. created but not - * mutated) - * - { val: CONST_TRUE }, which is also not mutated after creation. However, - * its scope range becomes extended to the value block. - * - * After AlignScopesToBlockScopes, our scopes look roughly like this - * ```js - * [1] arr = shallowCopy() ⌝@0 - * [2] cond = identity() <- @1 | - * [3] $0 = Ternary test=cond ⌝@2 | - * [4] {val : CONST_TRUE} | | - * [5] mutate(arr) | | - * [6] return $0 ⌟ ⌟ - * ``` - * - * Observe that instruction 5 mutates scope 0, which means that scopes 0 and 2 - * should be merged. - */ -function useFoo({input}) { - const arr = shallowCopy(input); - - const cond = identity(false); - return cond ? {val: CONST_TRUE} : mutate(arr); -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{input: 3}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false -import { CONST_TRUE, identity, shallowCopy } from "shared-runtime"; - -/** - * There are three values with their own scopes in this fixture. - * - arr, whose mutable range extends to the `mutate(...)` call - * - cond, which has a mutable range of exactly 1 (e.g. created but not - * mutated) - * - { val: CONST_TRUE }, which is also not mutated after creation. However, - * its scope range becomes extended to the value block. - * - * After AlignScopesToBlockScopes, our scopes look roughly like this - * ```js - * [1] arr = shallowCopy() ⌝@0 - * [2] cond = identity() <- @1 | - * [3] $0 = Ternary test=cond ⌝@2 | - * [4] {val : CONST_TRUE} | | - * [5] mutate(arr) | | - * [6] return $0 ⌟ ⌟ - * ``` - * - * Observe that instruction 5 mutates scope 0, which means that scopes 0 and 2 - * should be merged. - */ -function useFoo(t0) { - const $ = _c(3); - const { input } = t0; - const arr = shallowCopy(input); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = identity(false); - $[0] = t1; - } else { - t1 = $[0]; - } - const cond = t1; - let t2; - if ($[1] !== arr) { - t2 = cond ? { val: CONST_TRUE } : mutate(arr); - $[1] = arr; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ input: 3 }], -}; - -``` - -### Eval output -(kind: exception) mutate is not defined \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutate-outer-scope-within-value-block.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutate-outer-scope-within-value-block.ts deleted file mode 100644 index 9b19b3fd7ee9e..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutate-outer-scope-within-value-block.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @enableReactiveScopesInHIR:false -import {CONST_TRUE, identity, shallowCopy} from 'shared-runtime'; - -/** - * There are three values with their own scopes in this fixture. - * - arr, whose mutable range extends to the `mutate(...)` call - * - cond, which has a mutable range of exactly 1 (e.g. created but not - * mutated) - * - { val: CONST_TRUE }, which is also not mutated after creation. However, - * its scope range becomes extended to the value block. - * - * After AlignScopesToBlockScopes, our scopes look roughly like this - * ```js - * [1] arr = shallowCopy() ⌝@0 - * [2] cond = identity() <- @1 | - * [3] $0 = Ternary test=cond ⌝@2 | - * [4] {val : CONST_TRUE} | | - * [5] mutate(arr) | | - * [6] return $0 ⌟ ⌟ - * ``` - * - * Observe that instruction 5 mutates scope 0, which means that scopes 0 and 2 - * should be merged. - */ -function useFoo({input}) { - const arr = shallowCopy(input); - - const cond = identity(false); - return cond ? {val: CONST_TRUE} : mutate(arr); -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{input: 3}], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-capture-and-mutablerange.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-capture-and-mutablerange.expect.md deleted file mode 100644 index 8b8a5f5cc4918..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-capture-and-mutablerange.expect.md +++ /dev/null @@ -1,92 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false -import {mutate} from 'shared-runtime'; - -/** - * This test fixture is similar to mutation-within-jsx. The only difference - * is that there is no `freeze` effect here, which means that `z` may be - * mutated after its memo block through mutating `y`. - * - * While this is technically correct (as `z` is a nested memo block), it - * is an edge case as we believe that values are not mutated after their - * memo blocks (which may lead to 'tearing', i.e. mutating one render's - * values in a subsequent render. - */ -function useFoo({a, b}) { - // x and y's scopes start here - const x = {a}; - const y = [b]; - mutate(x); - // z captures the result of `mutate(y)`, which may be aliased to `y`. - const z = [mutate(y)]; - // the following line may also mutate z - mutate(y); - // and end here - return z; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{a: 2, b: 3}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false -import { mutate } from "shared-runtime"; - -/** - * This test fixture is similar to mutation-within-jsx. The only difference - * is that there is no `freeze` effect here, which means that `z` may be - * mutated after its memo block through mutating `y`. - * - * While this is technically correct (as `z` is a nested memo block), it - * is an edge case as we believe that values are not mutated after their - * memo blocks (which may lead to 'tearing', i.e. mutating one render's - * values in a subsequent render. - */ -function useFoo(t0) { - const $ = _c(5); - const { a, b } = t0; - let z; - if ($[0] !== a || $[1] !== b) { - const x = { a }; - const y = [b]; - mutate(x); - - const t1 = mutate(y); - let t2; - if ($[3] !== t1) { - t2 = [t1]; - $[3] = t1; - $[4] = t2; - } else { - t2 = $[4]; - } - z = t2; - - mutate(y); - $[0] = a; - $[1] = b; - $[2] = z; - } else { - z = $[2]; - } - return z; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ a: 2, b: 3 }], -}; - -``` - -### Eval output -(kind: ok) [null] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-capture-and-mutablerange.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-capture-and-mutablerange.tsx deleted file mode 100644 index 64d6c0453398c..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-capture-and-mutablerange.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// @enableReactiveScopesInHIR:false -import {mutate} from 'shared-runtime'; - -/** - * This test fixture is similar to mutation-within-jsx. The only difference - * is that there is no `freeze` effect here, which means that `z` may be - * mutated after its memo block through mutating `y`. - * - * While this is technically correct (as `z` is a nested memo block), it - * is an edge case as we believe that values are not mutated after their - * memo blocks (which may lead to 'tearing', i.e. mutating one render's - * values in a subsequent render. - */ -function useFoo({a, b}) { - // x and y's scopes start here - const x = {a}; - const y = [b]; - mutate(x); - // z captures the result of `mutate(y)`, which may be aliased to `y`. - const z = [mutate(y)]; - // the following line may also mutate z - mutate(y); - // and end here - return z; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{a: 2, b: 3}], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx-and-break.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx-and-break.expect.md deleted file mode 100644 index 254791cc49dd1..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx-and-break.expect.md +++ /dev/null @@ -1,99 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false -import { - Stringify, - makeObject_Primitives, - mutate, - mutateAndReturn, -} from 'shared-runtime'; - -function useFoo({data}) { - let obj = null; - let myDiv = null; - label: { - if (data.cond) { - obj = makeObject_Primitives(); - if (data.cond1) { - myDiv = ; - break label; - } - mutate(obj); - } - } - - return myDiv; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{data: {cond: true, cond1: true}}], - sequentialRenders: [ - {data: {cond: true, cond1: true}}, - {data: {cond: true, cond1: true}}, - ], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false -import { - Stringify, - makeObject_Primitives, - mutate, - mutateAndReturn, -} from "shared-runtime"; - -function useFoo(t0) { - const $ = _c(5); - const { data } = t0; - let obj; - let myDiv = null; - bb0: if (data.cond) { - if ($[0] !== data.cond1) { - obj = makeObject_Primitives(); - if (data.cond1) { - const t1 = mutateAndReturn(obj); - let t2; - if ($[3] !== t1) { - t2 = ; - $[3] = t1; - $[4] = t2; - } else { - t2 = $[4]; - } - myDiv = t2; - break bb0; - } - - mutate(obj); - $[0] = data.cond1; - $[1] = obj; - $[2] = myDiv; - } else { - obj = $[1]; - myDiv = $[2]; - } - } - return myDiv; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ data: { cond: true, cond1: true } }], - sequentialRenders: [ - { data: { cond: true, cond1: true } }, - { data: { cond: true, cond1: true } }, - ], -}; - -``` - -### Eval output -(kind: ok)
{"value":{"a":0,"b":"value1","c":true,"wat0":"joe"}}
-
{"value":{"a":0,"b":"value1","c":true,"wat0":"joe"}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx-and-break.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx-and-break.tsx deleted file mode 100644 index 5d33cc211c7bc..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx-and-break.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// @enableReactiveScopesInHIR:false -import { - Stringify, - makeObject_Primitives, - mutate, - mutateAndReturn, -} from 'shared-runtime'; - -function useFoo({data}) { - let obj = null; - let myDiv = null; - label: { - if (data.cond) { - obj = makeObject_Primitives(); - if (data.cond1) { - myDiv = ; - break label; - } - mutate(obj); - } - } - - return myDiv; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{data: {cond: true, cond1: true}}], - sequentialRenders: [ - {data: {cond: true, cond1: true}}, - {data: {cond: true, cond1: true}}, - ], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx.expect.md deleted file mode 100644 index 13a69bc2a6ab3..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx.expect.md +++ /dev/null @@ -1,141 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false -import { - Stringify, - makeObject_Primitives, - mutateAndReturn, -} from 'shared-runtime'; - -/** - * In this example, the `` JSX block mutates then captures obj. - * As JSX expressions freeze their values, we know that `obj` and `myDiv` cannot - * be mutated past this. - * This set of mutable range + scopes is an edge case because the JSX expression - * references values in two scopes. - * - (freeze) the result of `mutateAndReturn` - * this is a mutable value with a mutable range starting at `makeObject()` - * - (mutate) the lvalue storing the result of `` - * this is a immutable value and so gets assigned a different scope - * - * obj@0 = makeObj(); ⌝ scope@0 - * if (cond) { | - * $1@0 = mutate(obj@0); | - * myDiv@1 = JSX $1@0 <- scope@1 | - * } ⌟ - * - * Coincidentally, the range of `obj` is extended by alignScopesToBlocks to *past* - * the end of the JSX instruction. As we currently alias identifier mutableRanges to - * scope ranges, this `freeze` reference is perceived as occurring during the mutable - * range of `obj` (even though it is after the last mutating reference). - * - * This case is technically safe as `myDiv` correctly takes `obj` as a dependency. As - * a result, developers can never observe myDiv can aliasing a different value generation - * than `obj` (e.g. the invariant `myDiv.props.value === obj` always holds). - */ -function useFoo({data}) { - let obj = null; - let myDiv = null; - if (data.cond) { - obj = makeObject_Primitives(); - if (data.cond1) { - myDiv = ; - } - } - return myDiv; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{data: {cond: true, cond1: true}}], - sequentialRenders: [ - {data: {cond: true, cond1: true}}, - {data: {cond: true, cond1: true}}, - ], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false -import { - Stringify, - makeObject_Primitives, - mutateAndReturn, -} from "shared-runtime"; - -/** - * In this example, the `` JSX block mutates then captures obj. - * As JSX expressions freeze their values, we know that `obj` and `myDiv` cannot - * be mutated past this. - * This set of mutable range + scopes is an edge case because the JSX expression - * references values in two scopes. - * - (freeze) the result of `mutateAndReturn` - * this is a mutable value with a mutable range starting at `makeObject()` - * - (mutate) the lvalue storing the result of `` - * this is a immutable value and so gets assigned a different scope - * - * obj@0 = makeObj(); ⌝ scope@0 - * if (cond) { | - * $1@0 = mutate(obj@0); | - * myDiv@1 = JSX $1@0 <- scope@1 | - * } ⌟ - * - * Coincidentally, the range of `obj` is extended by alignScopesToBlocks to *past* - * the end of the JSX instruction. As we currently alias identifier mutableRanges to - * scope ranges, this `freeze` reference is perceived as occurring during the mutable - * range of `obj` (even though it is after the last mutating reference). - * - * This case is technically safe as `myDiv` correctly takes `obj` as a dependency. As - * a result, developers can never observe myDiv can aliasing a different value generation - * than `obj` (e.g. the invariant `myDiv.props.value === obj` always holds). - */ -function useFoo(t0) { - const $ = _c(5); - const { data } = t0; - let obj; - let myDiv = null; - if (data.cond) { - if ($[0] !== data.cond1) { - obj = makeObject_Primitives(); - if (data.cond1) { - const t1 = mutateAndReturn(obj); - let t2; - if ($[3] !== t1) { - t2 = ; - $[3] = t1; - $[4] = t2; - } else { - t2 = $[4]; - } - myDiv = t2; - } - $[0] = data.cond1; - $[1] = obj; - $[2] = myDiv; - } else { - obj = $[1]; - myDiv = $[2]; - } - } - return myDiv; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ data: { cond: true, cond1: true } }], - sequentialRenders: [ - { data: { cond: true, cond1: true } }, - { data: { cond: true, cond1: true } }, - ], -}; - -``` - -### Eval output -(kind: ok)
{"value":{"a":0,"b":"value1","c":true,"wat0":"joe"}}
-
{"value":{"a":0,"b":"value1","c":true,"wat0":"joe"}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx.tsx deleted file mode 100644 index 06592044ee7b8..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/mutation-within-jsx.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// @enableReactiveScopesInHIR:false -import { - Stringify, - makeObject_Primitives, - mutateAndReturn, -} from 'shared-runtime'; - -/** - * In this example, the `` JSX block mutates then captures obj. - * As JSX expressions freeze their values, we know that `obj` and `myDiv` cannot - * be mutated past this. - * This set of mutable range + scopes is an edge case because the JSX expression - * references values in two scopes. - * - (freeze) the result of `mutateAndReturn` - * this is a mutable value with a mutable range starting at `makeObject()` - * - (mutate) the lvalue storing the result of `` - * this is a immutable value and so gets assigned a different scope - * - * obj@0 = makeObj(); ⌝ scope@0 - * if (cond) { | - * $1@0 = mutate(obj@0); | - * myDiv@1 = JSX $1@0 <- scope@1 | - * } ⌟ - * - * Coincidentally, the range of `obj` is extended by alignScopesToBlocks to *past* - * the end of the JSX instruction. As we currently alias identifier mutableRanges to - * scope ranges, this `freeze` reference is perceived as occurring during the mutable - * range of `obj` (even though it is after the last mutating reference). - * - * This case is technically safe as `myDiv` correctly takes `obj` as a dependency. As - * a result, developers can never observe myDiv can aliasing a different value generation - * than `obj` (e.g. the invariant `myDiv.props.value === obj` always holds). - */ -function useFoo({data}) { - let obj = null; - let myDiv = null; - if (data.cond) { - obj = makeObject_Primitives(); - if (data.cond1) { - myDiv = ; - } - } - return myDiv; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{data: {cond: true, cond1: true}}], - sequentialRenders: [ - {data: {cond: true, cond1: true}}, - {data: {cond: true, cond1: true}}, - ], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/repro-allocating-ternary-test-instruction-scope.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/repro-allocating-ternary-test-instruction-scope.expect.md deleted file mode 100644 index aebddad8a4e1c..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/repro-allocating-ternary-test-instruction-scope.expect.md +++ /dev/null @@ -1,61 +0,0 @@ - -## Input - -```javascript -// @enableReactiveScopesInHIR:false -import {identity, makeObject_Primitives} from 'shared-runtime'; - -function useTest({cond}) { - const val = makeObject_Primitives(); - - useHook(); - /** - * We don't technically need a reactive scope for this ternary as - * it cannot produce newly allocated values. - * While identity(...) may allocate, we can teach the compiler that - * its result is only used as as a test condition - */ - const result = identity(cond) ? val : null; - return result; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useTest, - params: [{cond: true}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableReactiveScopesInHIR:false -import { identity, makeObject_Primitives } from "shared-runtime"; - -function useTest(t0) { - const $ = _c(1); - const { cond } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = makeObject_Primitives(); - $[0] = t1; - } else { - t1 = $[0]; - } - const val = t1; - - useHook(); - - const result = identity(cond) ? val : null; - return result; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useTest, - params: [{ cond: true }], -}; - -``` - -### Eval output -(kind: exception) useHook is not defined \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/repro-allocating-ternary-test-instruction-scope.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/repro-allocating-ternary-test-instruction-scope.ts deleted file mode 100644 index d52fcaa9f1042..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/original-reactive-scopes-fork/repro-allocating-ternary-test-instruction-scope.ts +++ /dev/null @@ -1,21 +0,0 @@ -// @enableReactiveScopesInHIR:false -import {identity, makeObject_Primitives} from 'shared-runtime'; - -function useTest({cond}) { - const val = makeObject_Primitives(); - - useHook(); - /** - * We don't technically need a reactive scope for this ternary as - * it cannot produce newly allocated values. - * While identity(...) may allocate, we can teach the compiler that - * its result is only used as as a test condition - */ - const result = identity(cond) ? val : null; - return result; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useTest, - params: [{cond: true}], -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.useMemo-infer-less-specific-conditional-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.useMemo-infer-less-specific-conditional-access.expect.md index 0cda150692955..25d7bf5f7ccce 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.useMemo-infer-less-specific-conditional-access.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.useMemo-infer-less-specific-conditional-access.expect.md @@ -44,8 +44,6 @@ function Component({propA, propB}) { | ^^^^^^^^^^^^^^^^^ > 14 | }, [propA?.a, propB.x.y]); | ^^^^ CannotPreserveMemoization: React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected (6:14) - -CannotPreserveMemoization: React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected (6:14) 15 | } 16 | ``` diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/conditional-member-expr.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/conditional-member-expr.expect.md index 59b48898fda16..2dd61732f689f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/conditional-member-expr.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/conditional-member-expr.expect.md @@ -29,10 +29,10 @@ import { c as _c } from "react/compiler-runtime"; // To preserve the nullthrows function Component(props) { const $ = _c(2); let x; - if ($[0] !== props.a) { + if ($[0] !== props.a?.b) { x = []; x.push(props.a?.b); - $[0] = props.a; + $[0] = props.a?.b; $[1] = x; } else { x = $[1]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/memberexpr-join-optional-chain.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/memberexpr-join-optional-chain.expect.md index 7362cd8317091..a66540655ab79 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/memberexpr-join-optional-chain.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/memberexpr-join-optional-chain.expect.md @@ -44,11 +44,11 @@ import { c as _c } from "react/compiler-runtime"; // To preserve the nullthrows function Component(props) { const $ = _c(2); let x; - if ($[0] !== props.a) { + if ($[0] !== props.a.b) { x = []; x.push(props.a?.b); x.push(props.a.b.c); - $[0] = props.a; + $[0] = props.a.b; $[1] = x; } else { x = $[1]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/memberexpr-join-optional-chain2.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/memberexpr-join-optional-chain2.expect.md index f25ea2a31e552..8d69c008c573b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/memberexpr-join-optional-chain2.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/memberexpr-join-optional-chain2.expect.md @@ -21,16 +21,25 @@ export const FIXTURE_ENTRYPOINT = { ```javascript import { c as _c } from "react/compiler-runtime"; function Component(props) { - const $ = _c(2); + const $ = _c(5); let x; - if ($[0] !== props.items) { + if ($[0] !== props.items?.length || $[1] !== props.items?.edges) { x = []; x.push(props.items?.length); - x.push(props.items?.edges?.map?.(render)?.filter?.(Boolean) ?? []); - $[0] = props.items; - $[1] = x; + let t0; + if ($[3] !== props.items?.edges) { + t0 = props.items?.edges?.map?.(render)?.filter?.(Boolean) ?? []; + $[3] = props.items?.edges; + $[4] = t0; + } else { + t0 = $[4]; + } + x.push(t0); + $[0] = props.items?.length; + $[1] = props.items?.edges; + $[2] = x; } else { - x = $[1]; + x = $[2]; } return x; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-scope-missing-mutable-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-scope-missing-mutable-range.expect.md index 2ce8ffbe4c92e..d8e59c486a55b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-scope-missing-mutable-range.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-scope-missing-mutable-range.expect.md @@ -23,14 +23,14 @@ function HomeDiscoStoreItemTileRating(props) { const $ = _c(4); const item = useFragment(); let count; - if ($[0] !== item) { + if ($[0] !== item?.aggregates) { count = 0; const aggregates = item?.aggregates || []; aggregates.forEach((aggregate) => { count = count + (aggregate.count || 0); count; }); - $[0] = item; + $[0] = item?.aggregates; $[1] = count; } else { count = $[1]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/type-provider-tagged-template-expression.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/type-provider-tagged-template-expression.expect.md new file mode 100644 index 0000000000000..03bfef9fb2eff --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/type-provider-tagged-template-expression.expect.md @@ -0,0 +1,106 @@ + +## Input + +```javascript +import {graphql} from 'shared-runtime'; + +export function Component({a, b}) { + const fragment = graphql` + fragment Foo on User { + name + } + `; + return
{fragment}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 1, b: 0}, + {a: 1, b: 1}, + {a: 1, b: 2}, + {a: 2, b: 2}, + {a: 3, b: 2}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { graphql } from "shared-runtime"; + +export function Component(t0) { + const $ = _c(1); + const fragment = graphql` + fragment Foo on User { + name + } + `; + let t1; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t1 =
{fragment}
; + $[0] = t1; + } else { + t1 = $[0]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 1, b: 0 }, + { a: 1, b: 1 }, + { a: 1, b: 2 }, + { a: 2, b: 2 }, + { a: 3, b: 2 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
+ fragment Foo on User { + name + } +
+
+ fragment Foo on User { + name + } +
+
+ fragment Foo on User { + name + } +
+
+ fragment Foo on User { + name + } +
+
+ fragment Foo on User { + name + } +
+
+ fragment Foo on User { + name + } +
+
+ fragment Foo on User { + name + } +
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/type-provider-tagged-template-expression.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/type-provider-tagged-template-expression.js new file mode 100644 index 0000000000000..872d6b8f6fda9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/type-provider-tagged-template-expression.js @@ -0,0 +1,24 @@ +import {graphql} from 'shared-runtime'; + +export function Component({a, b}) { + const fragment = graphql` + fragment Foo on User { + name + } + `; + return
{fragment}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 1, b: 0}, + {a: 1, b: 1}, + {a: 1, b: 2}, + {a: 2, b: 2}, + {a: 3, b: 2}, + {a: 0, b: 0}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unclosed-eslint-suppression-skips-all-components.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unclosed-eslint-suppression-skips-all-components.expect.md new file mode 100644 index 0000000000000..f0c6bce34222e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unclosed-eslint-suppression-skips-all-components.expect.md @@ -0,0 +1,39 @@ + +## Input + +```javascript +// @panicThreshold(none) + +// unclosed disable rule should affect all components +/* eslint-disable react-hooks/rules-of-hooks */ + +function ValidComponent1(props) { + return
Hello World!
; +} + +function ValidComponent2(props) { + return
{props.greeting}
; +} + +``` + +## Code + +```javascript +// @panicThreshold(none) + +// unclosed disable rule should affect all components +/* eslint-disable react-hooks/rules-of-hooks */ + +function ValidComponent1(props) { + return
Hello World!
; +} + +function ValidComponent2(props) { + return
{props.greeting}
; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unclosed-eslint-suppression-skips-all-components.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unclosed-eslint-suppression-skips-all-components.js new file mode 100644 index 0000000000000..121f10041821f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unclosed-eslint-suppression-skips-all-components.js @@ -0,0 +1,12 @@ +// @panicThreshold(none) + +// unclosed disable rule should affect all components +/* eslint-disable react-hooks/rules-of-hooks */ + +function ValidComponent1(props) { + return
Hello World!
; +} + +function ValidComponent2(props) { + return
{props.greeting}
; +} diff --git a/compiler/packages/snap/src/sprout/shared-runtime-type-provider.ts b/compiler/packages/snap/src/sprout/shared-runtime-type-provider.ts index fb0877d11474f..4c1d77f2f8986 100644 --- a/compiler/packages/snap/src/sprout/shared-runtime-type-provider.ts +++ b/compiler/packages/snap/src/sprout/shared-runtime-type-provider.ts @@ -18,52 +18,92 @@ export function makeSharedRuntimeTypeProvider({ return function sharedRuntimeTypeProvider( moduleName: string, ): TypeConfig | null { - if (moduleName !== 'shared-runtime') { - return null; - } - return { - kind: 'object', - properties: { - default: { - kind: 'function', - calleeEffect: EffectEnum.Read, - positionalParams: [], - restParam: EffectEnum.Read, - returnType: {kind: 'type', name: 'Primitive'}, - returnValueKind: ValueKindEnum.Primitive, - }, - typedArrayPush: { - kind: 'function', - calleeEffect: EffectEnum.Read, - positionalParams: [EffectEnum.Store, EffectEnum.Capture], - restParam: EffectEnum.Capture, - returnType: {kind: 'type', name: 'Primitive'}, - returnValueKind: ValueKindEnum.Primitive, - }, - typedLog: { - kind: 'function', - calleeEffect: EffectEnum.Read, - positionalParams: [], - restParam: EffectEnum.Read, - returnType: {kind: 'type', name: 'Primitive'}, - returnValueKind: ValueKindEnum.Primitive, + if (moduleName === 'shared-runtime') { + return { + kind: 'object', + properties: { + default: { + kind: 'function', + calleeEffect: EffectEnum.Read, + positionalParams: [], + restParam: EffectEnum.Read, + returnType: {kind: 'type', name: 'Primitive'}, + returnValueKind: ValueKindEnum.Primitive, + }, + graphql: { + kind: 'function', + calleeEffect: EffectEnum.Read, + positionalParams: [], + restParam: EffectEnum.Read, + returnType: {kind: 'type', name: 'Primitive'}, + returnValueKind: ValueKindEnum.Primitive, + }, + typedArrayPush: { + kind: 'function', + calleeEffect: EffectEnum.Read, + positionalParams: [EffectEnum.Store, EffectEnum.Capture], + restParam: EffectEnum.Capture, + returnType: {kind: 'type', name: 'Primitive'}, + returnValueKind: ValueKindEnum.Primitive, + }, + typedLog: { + kind: 'function', + calleeEffect: EffectEnum.Read, + positionalParams: [], + restParam: EffectEnum.Read, + returnType: {kind: 'type', name: 'Primitive'}, + returnValueKind: ValueKindEnum.Primitive, + }, + useFreeze: { + kind: 'hook', + returnType: {kind: 'type', name: 'Any'}, + }, + useFragment: { + kind: 'hook', + returnType: {kind: 'type', name: 'MixedReadonly'}, + noAlias: true, + }, + useNoAlias: { + kind: 'hook', + returnType: {kind: 'type', name: 'Any'}, + returnValueKind: ValueKindEnum.Mutable, + noAlias: true, + }, }, - useFreeze: { - kind: 'hook', - returnType: {kind: 'type', name: 'Any'}, + }; + } else if (moduleName === 'ReactCompilerTest') { + /** + * Fake module used for testing validation that type providers return hook + * types for hook names and non-hook types for non-hook names + */ + return { + kind: 'object', + properties: { + useHookNotTypedAsHook: { + kind: 'type', + name: 'Any', + }, + notAhookTypedAsHook: { + kind: 'hook', + returnType: {kind: 'type', name: 'Any'}, + }, }, - useFragment: { - kind: 'hook', - returnType: {kind: 'type', name: 'MixedReadonly'}, - noAlias: true, + }; + } else if (moduleName === 'useDefaultExportNotTypedAsHook') { + /** + * Fake module used for testing validation that type providers return hook + * types for hook names and non-hook types for non-hook names + */ + return { + kind: 'object', + properties: { + default: { + kind: 'type', + name: 'Any', + }, }, - useNoAlias: { - kind: 'hook', - returnType: {kind: 'type', name: 'Any'}, - returnValueKind: ValueKindEnum.Mutable, - noAlias: true, - }, - }, - }; + }; + } + return null; }; } diff --git a/package.json b/package.json index 2bcbda538c964..f840fc278e7be 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "eslint-plugin-react-internal": "link:./scripts/eslint-rules", "fbjs-scripts": "^3.0.1", "filesize": "^6.0.1", - "flow-bin": "^0.235.0", - "flow-remove-types": "^2.235.0", + "flow-bin": "^0.245.2", + "flow-remove-types": "^2.245.2", "glob": "^7.1.6", "glob-stream": "^6.1.0", "google-closure-compiler": "^20230206.0.0", diff --git a/packages/internal-test-utils/internalAct.js b/packages/internal-test-utils/internalAct.js index 66fa324984507..752725eeb842f 100644 --- a/packages/internal-test-utils/internalAct.js +++ b/packages/internal-test-utils/internalAct.js @@ -163,6 +163,7 @@ export async function act(scope: () => Thenable): Thenable { throw thrownError; } + // $FlowFixMe[incompatible-return] return result; } finally { const depth = actingUpdatesScopeDepth; @@ -271,6 +272,7 @@ export async function serverAct(scope: () => Thenable): Thenable { throw thrownError; } + // $FlowFixMe[incompatible-return] return result; } finally { if (typeof process === 'object') { diff --git a/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js b/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js index c448a25b83179..0e9cb549f653a 100644 --- a/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js +++ b/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js @@ -18,6 +18,7 @@ let Suspense; let TextResource; let textResourceShouldFail; let waitForAll; +let waitForPaint; let assertLog; let waitForThrow; let act; @@ -37,6 +38,7 @@ describe('ReactCache', () => { waitForAll = InternalTestUtils.waitForAll; assertLog = InternalTestUtils.assertLog; waitForThrow = InternalTestUtils.waitForThrow; + waitForPaint = InternalTestUtils.waitForPaint; act = InternalTestUtils.act; TextResource = createResource( @@ -119,7 +121,12 @@ describe('ReactCache', () => { const root = ReactNoop.createRoot(); root.render(); - await waitForAll(['Suspend! [Hi]', 'Loading...']); + await waitForAll([ + 'Suspend! [Hi]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Hi]'] : []), + ]); jest.advanceTimersByTime(100); assertLog(['Promise resolved [Hi]']); @@ -138,7 +145,12 @@ describe('ReactCache', () => { const root = ReactNoop.createRoot(); root.render(); - await waitForAll(['Suspend! [Hi]', 'Loading...']); + await waitForAll([ + 'Suspend! [Hi]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Hi]'] : []), + ]); textResourceShouldFail = true; let error; @@ -179,18 +191,31 @@ describe('ReactCache', () => { if (__DEV__) { await expect(async () => { - await waitForAll(['App', 'Loading...']); + await waitForAll([ + 'App', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['App'] : []), + ]); }).toErrorDev([ 'Invalid key type. Expected a string, number, symbol, or ' + "boolean, but instead received: [ 'Hi', 100 ]\n\n" + 'To use non-primitive values as keys, you must pass a hash ' + 'function as the second argument to createResource().', + + ...(gate('enableSiblingPrerendering') ? ['Invalid key type'] : []), ]); } else { - await waitForAll(['App', 'Loading...']); + await waitForAll([ + 'App', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['App'] : []), + ]); } }); + // @gate enableSiblingPrerendering it('evicts least recently used values', async () => { ReactCache.unstable_setGlobalCacheLimit(3); @@ -203,7 +228,7 @@ describe('ReactCache', () => { , ); - await waitForAll(['Suspend! [1]', 'Loading...']); + await waitForPaint(['Suspend! [1]', 'Loading...']); jest.advanceTimersByTime(100); assertLog(['Promise resolved [1]']); await waitForAll([1, 'Suspend! [2]']); @@ -212,9 +237,11 @@ describe('ReactCache', () => { assertLog(['Promise resolved [2]']); await waitForAll([1, 2, 'Suspend! [3]']); - await act(() => jest.advanceTimersByTime(100)); - assertLog(['Promise resolved [3]', 1, 2, 3]); + jest.advanceTimersByTime(100); + assertLog(['Promise resolved [3]']); + await waitForAll([1, 2, 3]); + await act(() => jest.advanceTimersByTime(100)); expect(root).toMatchRenderedOutput('123'); // Render 1, 4, 5 @@ -226,20 +253,18 @@ describe('ReactCache', () => { , ); - await waitForAll([1, 'Suspend! [4]', 'Loading...']); - - await act(() => jest.advanceTimersByTime(100)); - assertLog([ - 'Promise resolved [4]', + await waitForAll([ 1, - 4, - 'Suspend! [5]', - 'Promise resolved [5]', + 'Suspend! [4]', + 'Loading...', 1, - 4, - 5, + 'Suspend! [4]', + 'Suspend! [5]', ]); + await act(() => jest.advanceTimersByTime(100)); + assertLog(['Promise resolved [4]', 'Promise resolved [5]', 1, 4, 5]); + expect(root).toMatchRenderedOutput('145'); // We've now rendered values 1, 2, 3, 4, 5, over our limit of 3. The least @@ -259,19 +284,14 @@ describe('ReactCache', () => { // 2 and 3 suspend because they were evicted from the cache 'Suspend! [2]', 'Loading...', - ]); - await act(() => jest.advanceTimersByTime(100)); - assertLog([ - 'Promise resolved [2]', 1, - 2, + 'Suspend! [2]', 'Suspend! [3]', - 'Promise resolved [3]', - 1, - 2, - 3, ]); + + await act(() => jest.advanceTimersByTime(100)); + assertLog(['Promise resolved [2]', 'Promise resolved [3]', 1, 2, 3]); expect(root).toMatchRenderedOutput('123'); }); @@ -346,7 +366,12 @@ describe('ReactCache', () => { , ); - await waitForAll(['Suspend! [Hi]', 'Loading...']); + await waitForAll([ + 'Suspend! [Hi]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Hi]'] : []), + ]); resolveThenable('Hi'); // This thenable improperly resolves twice. We should not update the diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 072df8108fccd..609c7c84f239d 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -58,6 +58,8 @@ import { createStringDecoder, prepareDestinationForModule, bindToConsole, + rendererVersion, + rendererPackageName, } from './ReactFlightClientConfig'; import {createBoundServerReference} from './ReactFlightReplyClient'; @@ -76,6 +78,10 @@ import getComponentNameFromType from 'shared/getComponentNameFromType'; import {getOwnerStackByComponentInfoInDev} from 'shared/ReactComponentInfoStack'; +import {injectInternals} from './ReactFlightClientDevToolsHook'; + +import ReactVersion from 'shared/ReactVersion'; + import isArray from 'shared/isArray'; import * as React from 'react'; @@ -1051,8 +1057,7 @@ function getOutlinedModel( case INITIALIZED: let value = chunk.value; for (let i = 1; i < path.length; i++) { - value = value[path[i]]; - if (value.$$typeof === REACT_LAZY_TYPE) { + while (value.$$typeof === REACT_LAZY_TYPE) { const referencedChunk: SomeChunk = value._payload; if (referencedChunk.status === INITIALIZED) { value = referencedChunk.value; @@ -1063,10 +1068,11 @@ function getOutlinedModel( key, response, map, - path.slice(i), + path.slice(i - 1), ); } } + value = value[path[i]]; } const chunkValue = map(response, value); if (__DEV__ && chunk._debugInfo) { @@ -2326,6 +2332,62 @@ function getCurrentStackInDEV(): string { return ''; } +const replayConsoleWithCallStack = { + 'react-stack-bottom-frame': function ( + response: Response, + methodName: string, + stackTrace: ReactStackTrace, + owner: null | ReactComponentInfo, + env: string, + args: Array, + ): void { + // There really shouldn't be anything else on the stack atm. + const prevStack = ReactSharedInternals.getCurrentStack; + ReactSharedInternals.getCurrentStack = getCurrentStackInDEV; + currentOwnerInDEV = owner; + + try { + const callStack = buildFakeCallStack( + response, + stackTrace, + env, + bindToConsole(methodName, args, env), + ); + if (owner != null) { + const task = initializeFakeTask(response, owner, env); + initializeFakeStack(response, owner); + if (task !== null) { + task.run(callStack); + return; + } + } + const rootTask = getRootTask(response, env); + if (rootTask != null) { + rootTask.run(callStack); + return; + } + callStack(); + } finally { + currentOwnerInDEV = null; + ReactSharedInternals.getCurrentStack = prevStack; + } + }, +}; + +const replayConsoleWithCallStackInDEV: ( + response: Response, + methodName: string, + stackTrace: ReactStackTrace, + owner: null | ReactComponentInfo, + env: string, + args: Array, +) => void = __DEV__ + ? // We use this technique to trick minifiers to preserve the function name. + (replayConsoleWithCallStack['react-stack-bottom-frame'].bind( + replayConsoleWithCallStack, + ): any) + : (null: any); + function resolveConsoleEntry( response: Response, value: UninitializedModel, @@ -2355,43 +2417,21 @@ function resolveConsoleEntry( const env = payload[3]; const args = payload.slice(4); - // There really shouldn't be anything else on the stack atm. - const prevStack = ReactSharedInternals.getCurrentStack; - ReactSharedInternals.getCurrentStack = getCurrentStackInDEV; - currentOwnerInDEV = owner; - - try { - if (!enableOwnerStacks) { - // Printing with stack isn't really limited to owner stacks but - // we gate it behind the same flag for now while iterating. - bindToConsole(methodName, args, env)(); - return; - } - const callStack = buildFakeCallStack( - response, - stackTrace, - env, - bindToConsole(methodName, args, env), - ); - if (owner != null) { - const task = initializeFakeTask(response, owner, env); - initializeFakeStack(response, owner); - if (task !== null) { - task.run(callStack); - return; - } - // TODO: Set the current owner so that captureOwnerStack() adds the component - // stack during the replay - if needed. - } - const rootTask = getRootTask(response, env); - if (rootTask != null) { - rootTask.run(callStack); - return; - } - callStack(); - } finally { - ReactSharedInternals.getCurrentStack = prevStack; + if (!enableOwnerStacks) { + // Printing with stack isn't really limited to owner stacks but + // we gate it behind the same flag for now while iterating. + bindToConsole(methodName, args, env)(); + return; } + + replayConsoleWithCallStackInDEV( + response, + methodName, + stackTrace, + owner, + env, + args, + ); } function mergeBuffer( @@ -2921,3 +2961,21 @@ export function close(response: Response): void { // ref count of pending chunks. reportGlobalError(response, new Error('Connection closed.')); } + +function getCurrentOwnerInDEV(): null | ReactComponentInfo { + return currentOwnerInDEV; +} + +export function injectIntoDevTools(): boolean { + const internals: Object = { + bundleType: __DEV__ ? 1 : 0, // Might add PROFILE later. + version: rendererVersion, + rendererPackageName: rendererPackageName, + currentDispatcherRef: ReactSharedInternals, + // Enables DevTools to detect reconciler version rather than renderer version + // which may not match for third party renderers. + reconcilerVersion: ReactVersion, + getCurrentComponentInfo: getCurrentOwnerInDEV, + }; + return injectInternals(internals); +} diff --git a/packages/react-client/src/ReactFlightClientDevToolsHook.js b/packages/react-client/src/ReactFlightClientDevToolsHook.js new file mode 100644 index 0000000000000..b8ca649d4de45 --- /dev/null +++ b/packages/react-client/src/ReactFlightClientDevToolsHook.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +declare const __REACT_DEVTOOLS_GLOBAL_HOOK__: Object | void; + +export function injectInternals(internals: Object): boolean { + if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') { + // No DevTools + return false; + } + const hook = __REACT_DEVTOOLS_GLOBAL_HOOK__; + if (hook.isDisabled) { + // This isn't a real property on the hook, but it can be set to opt out + // of DevTools integration and associated warnings and logs. + // https://github.com/facebook/react/issues/3877 + return true; + } + if (!hook.supportsFlight) { + // DevTools exists, even though it doesn't support Flight. + return true; + } + try { + hook.inject(internals); + } catch (err) { + // Catch all errors because it is unsafe to throw during initialization. + if (__DEV__) { + console.error('React instrumentation encountered an error: %s.', err); + } + } + if (hook.checkDCE) { + // This is the real DevTools. + return true; + } else { + // This is likely a hook installed by Fast Refresh runtime. + return false; + } +} diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 233f51844e2a3..35c23ed074e09 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -554,6 +554,7 @@ export function processReply( const prefix = formFieldPrefix + refId + '_'; // $FlowFixMe[prop-missing]: FormData has forEach. value.forEach((originalValue: string | File, originalKey: string) => { + // $FlowFixMe[incompatible-call] data.append(prefix + originalKey, originalValue); }); return serializeFormDataReference(refId); @@ -925,6 +926,7 @@ function defaultEncodeFormAction( const prefixedData = new FormData(); // $FlowFixMe[prop-missing] encodedFormData.forEach((value: string | File, key: string) => { + // $FlowFixMe[incompatible-call] prefixedData.append('$ACTION_' + identifierPrefix + ':' + key, value); }); data = prefixedData; @@ -1153,6 +1155,7 @@ const FunctionBind = Function.prototype.bind; const ArraySlice = Array.prototype.slice; function bind(this: Function): Function { // $FlowFixMe[unsupported-syntax] + // $FlowFixMe[prop-missing] const newFn = FunctionBind.apply(this, arguments); const reference = knownServerReferences.get(this); if (reference) { diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js index d9b031c4b5fe1..530d548a590d0 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js @@ -49,3 +49,6 @@ export const readPartialStringChunk = $$$config.readPartialStringChunk; export const readFinalStringChunk = $$$config.readFinalStringChunk; export const bindToConsole = $$$config.bindToConsole; + +export const rendererVersion = $$$config.rendererVersion; +export const rendererPackageName = $$$config.rendererPackageName; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js index 55358ab05d10d..dbc89a2677d57 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-esm'; + export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-server-dom-esm/src/client/ReactFlightClientConfigBundlerESM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js index c3c511554ee6d..6a071981be9e7 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-turbopack'; + export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js index 41bb93db386e8..73d27adefa847 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-webpack'; + export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js index 0a8027e3e12aa..7b75983cdd728 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-bun'; + export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; export * from 'react-client/src/ReactClientConsoleConfigPlain'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js index ac6d0933b7818..fbdb9fc683ac6 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-turbopack'; + export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js index eb17f259d3e19..f328a3e2ed7b1 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-webpack'; + export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index b992b01803260..05e937abdf82e 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'not-used'; + export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; export * from 'react-client/src/ReactClientConsoleConfigBrowser'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js index 9a17b9269a948..8cb512ea44aee 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-esm'; + export * from 'react-client/src/ReactFlightClientStreamConfigNode'; export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-esm/src/client/ReactFlightClientConfigBundlerESM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js index f4226a93d86bc..ec97d45077b44 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-turbopack'; + export * from 'react-client/src/ReactFlightClientStreamConfigNode'; export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js index ccc12228d837f..9840d5bc911f7 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js @@ -6,6 +6,8 @@ * * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-webpack'; export * from 'react-client/src/ReactFlightClientStreamConfigNode'; export * from 'react-client/src/ReactClientConsoleConfigServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js index 3425787b6434a..65e1252ee5b79 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-webpack'; + export * from 'react-client/src/ReactFlightClientStreamConfigNode'; export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js index a90acefccba39..ba86f7631ffce 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js @@ -7,6 +7,9 @@ * @flow */ +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-markup'; + import type {Thenable} from 'shared/ReactTypes'; export * from 'react-markup/src/ReactMarkupLegacyClientStreamConfig.js'; diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index edbef05e259d1..55a1454142235 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -20,6 +20,7 @@ import type { Dependencies, Fiber, Dispatcher as DispatcherType, + ContextDependencyWithSelect, } from 'react-reconciler/src/ReactInternalTypes'; import type {TransitionStatus} from 'react-reconciler/src/ReactFiberConfig'; @@ -37,7 +38,6 @@ import { REACT_CONTEXT_TYPE, } from 'shared/ReactSymbols'; import hasOwnProperty from 'shared/hasOwnProperty'; -import type {ContextDependencyWithSelect} from '../../react-reconciler/src/ReactInternalTypes'; type CurrentDispatcherRef = typeof ReactSharedInternals; @@ -76,6 +76,13 @@ function getPrimitiveStackCache(): Map> { try { // Use all hooks here to add them to the hook log. Dispatcher.useContext(({_currentValue: null}: any)); + if (typeof Dispatcher.unstable_useContextWithBailout === 'function') { + // This type check is for Flow only. + Dispatcher.unstable_useContextWithBailout( + ({_currentValue: null}: any), + null, + ); + } Dispatcher.useState(null); Dispatcher.useReducer((s: mixed, a: mixed) => s, null); Dispatcher.useRef(null); @@ -280,6 +287,22 @@ function useContext(context: ReactContext): T { return value; } +function unstable_useContextWithBailout( + context: ReactContext, + select: (T => Array) | null, +): T { + const value = readContext(context); + hookLog.push({ + displayName: context.displayName || null, + primitive: 'ContextWithBailout', + stackError: new Error(), + value: value, + debugInfo: null, + dispatcherHookName: 'ContextWithBailout', + }); + return value; +} + function useState( initialState: (() => S) | S, ): [S, Dispatch>] { @@ -753,6 +776,7 @@ const Dispatcher: DispatcherType = { useCacheRefresh, useCallback, useContext, + unstable_useContextWithBailout, useEffect, useImperativeHandle, useDebugValue, @@ -954,6 +978,11 @@ function parseHookName(functionName: void | string): string { } else { startIndex += 1; } + + if (functionName.slice(startIndex).startsWith('unstable_')) { + startIndex += 'unstable_'.length; + } + if (functionName.slice(startIndex, startIndex + 3) === 'use') { if (functionName.length - startIndex === 3) { return 'Use'; @@ -1004,6 +1033,7 @@ function buildTree( } // Pop back the stack as many steps as were not common. for (let j = prevStack.length - 1; j > commonSteps; j--) { + // $FlowFixMe[incompatible-type] levelChildren = stackOfChildren.pop(); } } diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index 541efa0d37e96..22aea529dd8e3 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -329,7 +329,7 @@ function startServer( const httpServer = useHttps ? require('https').createServer(httpsOptions) : require('http').createServer(); - const server = new Server({server: httpServer}); + const server = new Server({server: httpServer, maxPayload: 1e9}); let connected: WebSocket | null = null; server.on('connection', (socket: WebSocket) => { if (connected !== null) { diff --git a/packages/react-devtools-extensions/chrome/manifest.json b/packages/react-devtools-extensions/chrome/manifest.json index 4dcd951a480ac..e61ebd1e57ed0 100644 --- a/packages/react-devtools-extensions/chrome/manifest.json +++ b/packages/react-devtools-extensions/chrome/manifest.json @@ -41,8 +41,8 @@ "service_worker": "build/background.js" }, "permissions": [ - "storage", - "scripting" + "scripting", + "tabs" ], "host_permissions": [ "" diff --git a/packages/react-devtools-extensions/edge/manifest.json b/packages/react-devtools-extensions/edge/manifest.json index fd19f1c5df532..48a56c7400ce4 100644 --- a/packages/react-devtools-extensions/edge/manifest.json +++ b/packages/react-devtools-extensions/edge/manifest.json @@ -41,8 +41,8 @@ "service_worker": "build/background.js" }, "permissions": [ - "storage", - "scripting" + "scripting", + "tabs" ], "host_permissions": [ "" diff --git a/packages/react-devtools-extensions/firefox/manifest.json b/packages/react-devtools-extensions/firefox/manifest.json index ffa48634e0e0d..930c1ab11083e 100644 --- a/packages/react-devtools-extensions/firefox/manifest.json +++ b/packages/react-devtools-extensions/firefox/manifest.json @@ -1,12 +1,12 @@ { - "manifest_version": 2, + "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Firefox Developer Tools.", "version": "5.3.1", - "applications": { + "browser_specific_settings": { "gecko": { "id": "@react-devtools", - "strict_min_version": "102.0" + "strict_min_version": "128.0" } }, "icons": { @@ -15,22 +15,32 @@ "48": "icons/48-production.png", "128": "icons/128-production.png" }, - "browser_action": { + "action": { "default_icon": { "16": "icons/16-disabled.png", "32": "icons/32-disabled.png", "48": "icons/48-disabled.png", "128": "icons/128-disabled.png" }, - "default_popup": "popups/disabled.html", - "browser_style": true + "default_popup": "popups/disabled.html" }, "devtools_page": "main.html", - "content_security_policy": "script-src 'self' 'unsafe-eval' blob:; object-src 'self'", + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "web_accessible_resources": [ - "main.html", - "panel.html", - "build/*.js" + { + "resources": [ + "main.html", + "panel.html", + "build/*.js", + "build/*.js.map" + ], + "matches": [ + "" + ], + "extension_ids": [] + } ], "background": { "scripts": [ @@ -38,12 +48,11 @@ ] }, "permissions": [ - "file:///*", - "http://*/*", - "https://*/*", - "clipboardWrite", "scripting", - "devtools" + "tabs" + ], + "host_permissions": [ + "" ], "content_scripts": [ { diff --git a/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js index e19030457a89c..9398d71a54e7c 100644 --- a/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js +++ b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js @@ -1,58 +1,31 @@ /* global chrome */ -// Firefox doesn't support ExecutionWorld.MAIN yet -// equivalent logic for Firefox is in prepareInjection.js -const contentScriptsToInject = __IS_FIREFOX__ - ? [ - { - id: '@react-devtools/proxy', - js: ['build/proxy.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_end', - }, - { - id: '@react-devtools/file-fetcher', - js: ['build/fileFetcher.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_end', - }, - ] - : [ - { - id: '@react-devtools/proxy', - js: ['build/proxy.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_end', - world: chrome.scripting.ExecutionWorld.ISOLATED, - }, - { - id: '@react-devtools/file-fetcher', - js: ['build/fileFetcher.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_end', - world: chrome.scripting.ExecutionWorld.ISOLATED, - }, - { - id: '@react-devtools/hook', - js: ['build/installHook.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_start', - world: chrome.scripting.ExecutionWorld.MAIN, - }, - { - id: '@react-devtools/renderer', - js: ['build/renderer.js'], - matches: [''], - persistAcrossSessions: true, - runAt: 'document_start', - world: chrome.scripting.ExecutionWorld.MAIN, - }, - ]; +const contentScriptsToInject = [ + { + id: '@react-devtools/proxy', + js: ['build/proxy.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_end', + world: chrome.scripting.ExecutionWorld.ISOLATED, + }, + { + id: '@react-devtools/file-fetcher', + js: ['build/fileFetcher.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_end', + world: chrome.scripting.ExecutionWorld.ISOLATED, + }, + { + id: '@react-devtools/hook', + js: ['build/installHook.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_start', + world: chrome.scripting.ExecutionWorld.MAIN, + }, +]; async function dynamicallyInjectContentScripts() { try { @@ -61,9 +34,6 @@ async function dynamicallyInjectContentScripts() { // This fixes registering proxy content script in incognito mode await chrome.scripting.unregisterContentScripts(); - // equivalent logic for Firefox is in prepareInjection.js - // Manifest V3 method of injecting content script - // TODO(hoxyq): migrate Firefox to V3 manifests // Note: the "world" option in registerContentScripts is only available in Chrome v102+ // It's critical since it allows us to directly run scripts on the "main" world on the page // "document_start" allows it to run before the page's scripts diff --git a/packages/react-devtools-extensions/src/background/executeScript.js b/packages/react-devtools-extensions/src/background/executeScript.js index efe73229ecff5..8b80095d33c2e 100644 --- a/packages/react-devtools-extensions/src/background/executeScript.js +++ b/packages/react-devtools-extensions/src/background/executeScript.js @@ -1,40 +1,5 @@ /* global chrome */ -// Firefox doesn't support ExecutionWorld.MAIN yet -// https://bugzilla.mozilla.org/show_bug.cgi?id=1736575 -function executeScriptForFirefoxInMainWorld({target, files}) { - return chrome.scripting.executeScript({ - target, - func: fileNames => { - function injectScriptSync(src) { - let code = ''; - const request = new XMLHttpRequest(); - request.addEventListener('load', function () { - code = this.responseText; - }); - request.open('GET', src, false); - request.send(); - - const script = document.createElement('script'); - script.textContent = code; - - // This script runs before the element is created, - // so we add the script to instead. - if (document.documentElement) { - document.documentElement.appendChild(script); - } - - if (script.parentNode) { - script.parentNode.removeChild(script); - } - } - - fileNames.forEach(file => injectScriptSync(chrome.runtime.getURL(file))); - }, - args: [files], - }); -} - export function executeScriptInIsolatedWorld({target, files}) { return chrome.scripting.executeScript({ target, @@ -44,10 +9,6 @@ export function executeScriptInIsolatedWorld({target, files}) { } export function executeScriptInMainWorld({target, files}) { - if (__IS_FIREFOX__) { - return executeScriptForFirefoxInMainWorld({target, files}); - } - return chrome.scripting.executeScript({ target, files, diff --git a/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js b/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js index 5c6e011114014..51f233e284f0e 100644 --- a/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js +++ b/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js @@ -3,9 +3,7 @@ 'use strict'; function setExtensionIconAndPopup(reactBuildType, tabId) { - const action = __IS_FIREFOX__ ? chrome.browserAction : chrome.action; - - action.setIcon({ + chrome.action.setIcon({ tabId, path: { '16': chrome.runtime.getURL(`icons/16-${reactBuildType}.png`), @@ -15,7 +13,7 @@ function setExtensionIconAndPopup(reactBuildType, tabId) { }, }); - action.setPopup({ + chrome.action.setPopup({ tabId, popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`), }); diff --git a/packages/react-devtools-extensions/src/background/tabsManager.js b/packages/react-devtools-extensions/src/background/tabsManager.js index 23b566502a269..d46c14c6ea7c3 100644 --- a/packages/react-devtools-extensions/src/background/tabsManager.js +++ b/packages/react-devtools-extensions/src/background/tabsManager.js @@ -5,7 +5,12 @@ import setExtensionIconAndPopup from './setExtensionIconAndPopup'; function isRestrictedBrowserPage(url) { - return !url || new URL(url).protocol === 'chrome:'; + if (!url) { + return true; + } + + const urlProtocol = new URL(url).protocol; + return urlProtocol === 'chrome:' || urlProtocol === 'about:'; } function checkAndHandleRestrictedPageIfSo(tab) { @@ -14,30 +19,13 @@ function checkAndHandleRestrictedPageIfSo(tab) { } } -// update popup page of any existing open tabs, if they are restricted browser pages. -// we can't update for any other types (prod,dev,outdated etc) -// as the content script needs to be injected at document_start itself for those kinds of detection -// TODO: Show a different popup page(to reload current page probably) for old tabs, opened before the extension is installed -if (__IS_CHROME__ || __IS_EDGE__) { - chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo)); - chrome.tabs.onCreated.addListener((tabId, changeInfo, tab) => - checkAndHandleRestrictedPageIfSo(tab), - ); -} +// Update popup page of any existing open tabs, if they are restricted browser pages +chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo)); +chrome.tabs.onCreated.addListener(tab => checkAndHandleRestrictedPageIfSo(tab)); // Listen to URL changes on the active tab and update the DevTools icon. chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - if (__IS_FIREFOX__) { - // We don't properly detect protected URLs in Firefox at the moment. - // However, we can reset the DevTools icon to its loading state when the URL changes. - // It will be updated to the correct icon by the onMessage callback below. - if (tab.active && changeInfo.status === 'loading') { - setExtensionIconAndPopup('disabled', tabId); - } - } else { - // Don't reset the icon to the loading state for Chrome or Edge. - // The onUpdated callback fires more frequently for these browsers, - // often after onMessage has been called. - checkAndHandleRestrictedPageIfSo(tab); + if (changeInfo.url && isRestrictedBrowserPage(changeInfo.url)) { + setExtensionIconAndPopup('restricted', tabId); } }); diff --git a/packages/react-devtools-extensions/src/contentScripts/installHook.js b/packages/react-devtools-extensions/src/contentScripts/installHook.js index 2d33cdd89036d..ff7e041627f0e 100644 --- a/packages/react-devtools-extensions/src/contentScripts/installHook.js +++ b/packages/react-devtools-extensions/src/contentScripts/installHook.js @@ -20,10 +20,4 @@ if (!window.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) { ); }, ); - - // save native values - window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeObjectCreate = Object.create; - window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeMap = Map; - window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeWeakMap = WeakMap; - window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeSet = Set; } diff --git a/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js index d67ea7c405a1e..1b9962a9a826f 100644 --- a/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js +++ b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js @@ -1,31 +1,5 @@ /* global chrome */ -import nullthrows from 'nullthrows'; - -// We run scripts on the page via the service worker (background/index.js) for -// Manifest V3 extensions (Chrome & Edge). -// We need to inject this code for Firefox only because it does not support ExecutionWorld.MAIN -// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld -// In this content script we have access to DOM, but don't have access to the webpage's window, -// so we inject this inline script tag into the webpage (allowed in Manifest V2). -function injectScriptSync(src) { - let code = ''; - const request = new XMLHttpRequest(); - request.addEventListener('load', function () { - code = this.responseText; - }); - request.open('GET', src, false); - request.send(); - - const script = document.createElement('script'); - script.textContent = code; - - // This script runs before the element is created, - // so we add the script to instead. - nullthrows(document.documentElement).appendChild(script); - nullthrows(script.parentNode).removeChild(script); -} - let lastSentDevToolsHookMessage; // We want to detect when a renderer attaches, and notify the "background page" @@ -60,17 +34,3 @@ window.addEventListener('pageshow', function ({target}) { chrome.runtime.sendMessage(lastSentDevToolsHookMessage); }); - -if (__IS_FIREFOX__) { - injectScriptSync(chrome.runtime.getURL('build/renderer.js')); - - // Inject a __REACT_DEVTOOLS_GLOBAL_HOOK__ global for React to interact with. - // Only do this for HTML documents though, to avoid e.g. breaking syntax highlighting for XML docs. - switch (document.contentType) { - case 'text/html': - case 'application/xhtml+xml': { - injectScriptSync(chrome.runtime.getURL('build/installHook.js')); - break; - } - } -} diff --git a/packages/react-devtools-extensions/src/contentScripts/renderer.js b/packages/react-devtools-extensions/src/contentScripts/renderer.js deleted file mode 100644 index 361530334177c..0000000000000 --- a/packages/react-devtools-extensions/src/contentScripts/renderer.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * In order to support reload-and-profile functionality, the renderer needs to be injected before any other scripts. - * Since it is a complex file (with imports) we can't just toString() it like we do with the hook itself, - * So this entry point (one of the web_accessible_resources) provides a way to eagerly inject it. - * The hook will look for the presence of a global __REACT_DEVTOOLS_ATTACH__ and attach an injected renderer early. - * The normal case (not a reload-and-profile) will not make use of this entry point though. - * - * @flow - */ - -import {attach} from 'react-devtools-shared/src/backend/fiber/renderer'; -import {SESSION_STORAGE_RELOAD_AND_PROFILE_KEY} from 'react-devtools-shared/src/constants'; -import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; - -if ( - sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true' && - !window.hasOwnProperty('__REACT_DEVTOOLS_ATTACH__') -) { - Object.defineProperty( - window, - '__REACT_DEVTOOLS_ATTACH__', - ({ - enumerable: false, - // This property needs to be configurable to allow third-party integrations - // to attach their own renderer. Note that using third-party integrations - // is not officially supported. Use at your own risk. - configurable: true, - get() { - return attach; - }, - }: Object), - ); -} diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index 3a51b996e2049..36931e42194a4 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -185,7 +185,7 @@ function createComponentsPanel() { } chrome.devtools.panels.create( - __IS_CHROME__ || __IS_EDGE__ ? '⚛️ Components' : 'Components', + __IS_CHROME__ || __IS_EDGE__ ? 'Components ⚛' : 'Components', __IS_EDGE__ ? 'icons/production.svg' : '', 'panel.html', createdPanel => { @@ -224,7 +224,7 @@ function createProfilerPanel() { } chrome.devtools.panels.create( - __IS_CHROME__ || __IS_EDGE__ ? '⚛️ Profiler' : 'Profiler', + __IS_CHROME__ || __IS_EDGE__ ? 'Profiler ⚛' : 'Profiler', __IS_EDGE__ ? 'icons/production.svg' : '', 'panel.html', createdPanel => { diff --git a/packages/react-devtools-extensions/src/main/registerEventsLogger.js b/packages/react-devtools-extensions/src/main/registerEventsLogger.js index 5234866fd546c..ec57d173e42ca 100644 --- a/packages/react-devtools-extensions/src/main/registerEventsLogger.js +++ b/packages/react-devtools-extensions/src/main/registerEventsLogger.js @@ -4,14 +4,8 @@ import {registerDevToolsEventLogger} from 'react-devtools-shared/src/registerDev function registerEventsLogger() { registerDevToolsEventLogger('extension', async () => { - // TODO: after we upgrade to Firefox Manifest V3, chrome.tabs.query returns a Promise without the callback. - return new Promise(resolve => { - chrome.tabs.query({active: true}, tabs => { - resolve({ - page_url: tabs[0]?.url, - }); - }); - }); + const tabs = await chrome.tabs.query({active: true}); + return {page_url: tabs[0]?.url}; }); } diff --git a/packages/react-devtools-extensions/webpack.config.js b/packages/react-devtools-extensions/webpack.config.js index 81bf4a1c520b3..ddbb4356f658c 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -55,7 +55,6 @@ module.exports = { panel: './src/panel.js', proxy: './src/contentScripts/proxy.js', prepareInjection: './src/contentScripts/prepareInjection.js', - renderer: './src/contentScripts/renderer.js', installHook: './src/contentScripts/installHook.js', }, output: { diff --git a/packages/react-devtools-fusebox/src/frontend.d.ts b/packages/react-devtools-fusebox/src/frontend.d.ts index 4074baf507745..8a62ad54e504c 100644 --- a/packages/react-devtools-fusebox/src/frontend.d.ts +++ b/packages/react-devtools-fusebox/src/frontend.d.ts @@ -50,4 +50,5 @@ export type InitializationOptions = { canViewElementSourceFunction?: CanViewElementSource, }; -export function initialize(node: Element | Document, options: InitializationOptions): void; +export function initializeComponents(node: Element | Document, options: InitializationOptions): void; +export function initializeProfiler(node: Element | Document, options: InitializationOptions): void; diff --git a/packages/react-devtools-fusebox/src/frontend.js b/packages/react-devtools-fusebox/src/frontend.js index d55241fec7f29..2bcd897c4622c 100644 --- a/packages/react-devtools-fusebox/src/frontend.js +++ b/packages/react-devtools-fusebox/src/frontend.js @@ -19,9 +19,10 @@ import type { } from 'react-devtools-shared/src/frontend/types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; import type { + CanViewElementSource, + TabID, ViewAttributeSource, ViewElementSource, - CanViewElementSource, } from 'react-devtools-shared/src/devtools/views/DevTools'; import type {Config} from 'react-devtools-shared/src/devtools/store'; @@ -51,10 +52,11 @@ type InitializationOptions = { canViewElementSourceFunction?: CanViewElementSource, }; -export function initialize( +function initializeTab( + tab: TabID, contentWindow: Element | Document, options: InitializationOptions, -): void { +) { const { bridge, store, @@ -70,7 +72,8 @@ export function initialize( bridge={bridge} browserTheme={theme} store={store} - showTabBar={true} + showTabBar={false} + overrideTab={tab} warnIfLegacyBackendDetected={true} enabledInspectedElementContextMenu={true} viewAttributeSourceFunction={viewAttributeSourceFunction} @@ -79,3 +82,17 @@ export function initialize( />, ); } + +export function initializeComponents( + contentWindow: Element | Document, + options: InitializationOptions, +): void { + initializeTab('components', contentWindow, options); +} + +export function initializeProfiler( + contentWindow: Element | Document, + options: InitializationOptions, +): void { + initializeTab('profiler', contentWindow, options); +} diff --git a/packages/react-devtools-shared/babel.config.js b/packages/react-devtools-shared/babel.config.js index ca877aa683afd..78af34817e0a9 100644 --- a/packages/react-devtools-shared/babel.config.js +++ b/packages/react-devtools-shared/babel.config.js @@ -3,7 +3,7 @@ const firefoxManifest = require('../react-devtools-extensions/firefox/manifest.j const minChromeVersion = parseInt(chromeManifest.minimum_chrome_version, 10); const minFirefoxVersion = parseInt( - firefoxManifest.applications.gecko.strict_min_version, + firefoxManifest.browser_specific_settings.gecko.strict_min_version, 10, ); validateVersion(minChromeVersion); diff --git a/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js b/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js index 6861e1aac3309..ecbe336bdcbe0 100644 --- a/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js +++ b/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js @@ -15,6 +15,17 @@ import { normalizeCodeLocInfo, } from './utils'; +import {ReactVersion} from '../../../../ReactVersions'; +import semver from 'semver'; + +// TODO: This is how other DevTools tests access the version but we should find +// a better solution for this +const ReactVersionTestingAgainst = process.env.REACT_VERSION || ReactVersion; +// Disabling this while the flag is off in experimental. Leaving the logic so we can +// restore the behavior when we turn the flag back on. +const enableSiblingPrerendering = + false && semver.gte(ReactVersionTestingAgainst, '19.0.0'); + describe('Timeline profiler', () => { let React; let Scheduler; @@ -1651,7 +1662,11 @@ describe('Timeline profiler', () => { , ); - await waitForAll(['suspended']); + await waitForAll([ + 'suspended', + + ...(enableSiblingPrerendering ? ['suspended'] : []), + ]); Scheduler.unstable_advanceTime(10); resolveFn(); @@ -1662,9 +1677,38 @@ describe('Timeline profiler', () => { const timelineData = stopProfilingAndGetTimelineData(); // Verify the Suspense event and duration was recorded. - expect(timelineData.suspenseEvents).toHaveLength(1); - const suspenseEvent = timelineData.suspenseEvents[0]; - expect(suspenseEvent).toMatchInlineSnapshot(` + if (enableSiblingPrerendering) { + expect(timelineData.suspenseEvents).toMatchInlineSnapshot(` + [ + { + "componentName": "Example", + "depth": 0, + "duration": 10, + "id": "0", + "phase": "mount", + "promiseName": "", + "resolution": "resolved", + "timestamp": 10, + "type": "suspense", + "warning": null, + }, + { + "componentName": "Example", + "depth": 0, + "duration": 10, + "id": "0", + "phase": "mount", + "promiseName": "", + "resolution": "resolved", + "timestamp": 10, + "type": "suspense", + "warning": null, + }, + ] + `); + } else { + const suspenseEvent = timelineData.suspenseEvents[0]; + expect(suspenseEvent).toMatchInlineSnapshot(` { "componentName": "Example", "depth": 0, @@ -1678,10 +1722,13 @@ describe('Timeline profiler', () => { "warning": null, } `); + } // There should be two batches of renders: Suspeneded and resolved. expect(timelineData.batchUIDToMeasuresMap.size).toBe(2); - expect(timelineData.componentMeasures).toHaveLength(2); + expect(timelineData.componentMeasures).toHaveLength( + enableSiblingPrerendering ? 3 : 2, + ); }); it('should mark concurrent render with suspense that rejects', async () => { @@ -1708,7 +1755,11 @@ describe('Timeline profiler', () => { , ); - await waitForAll(['suspended']); + await waitForAll([ + 'suspended', + + ...(enableSiblingPrerendering ? ['suspended'] : []), + ]); Scheduler.unstable_advanceTime(10); rejectFn(); @@ -1719,9 +1770,39 @@ describe('Timeline profiler', () => { const timelineData = stopProfilingAndGetTimelineData(); // Verify the Suspense event and duration was recorded. - expect(timelineData.suspenseEvents).toHaveLength(1); - const suspenseEvent = timelineData.suspenseEvents[0]; - expect(suspenseEvent).toMatchInlineSnapshot(` + if (enableSiblingPrerendering) { + expect(timelineData.suspenseEvents).toMatchInlineSnapshot(` + [ + { + "componentName": "Example", + "depth": 0, + "duration": 10, + "id": "0", + "phase": "mount", + "promiseName": "", + "resolution": "rejected", + "timestamp": 10, + "type": "suspense", + "warning": null, + }, + { + "componentName": "Example", + "depth": 0, + "duration": 10, + "id": "0", + "phase": "mount", + "promiseName": "", + "resolution": "rejected", + "timestamp": 10, + "type": "suspense", + "warning": null, + }, + ] + `); + } else { + expect(timelineData.suspenseEvents).toHaveLength(1); + const suspenseEvent = timelineData.suspenseEvents[0]; + expect(suspenseEvent).toMatchInlineSnapshot(` { "componentName": "Example", "depth": 0, @@ -1735,10 +1816,13 @@ describe('Timeline profiler', () => { "warning": null, } `); + } // There should be two batches of renders: Suspeneded and resolved. expect(timelineData.batchUIDToMeasuresMap.size).toBe(2); - expect(timelineData.componentMeasures).toHaveLength(2); + expect(timelineData.componentMeasures).toHaveLength( + enableSiblingPrerendering ? 3 : 2, + ); }); it('should mark cascading class component state updates', async () => { diff --git a/packages/react-devtools-shared/src/__tests__/console-test.js b/packages/react-devtools-shared/src/__tests__/console-test.js index df56bb00b9e1d..516762132e884 100644 --- a/packages/react-devtools-shared/src/__tests__/console-test.js +++ b/packages/react-devtools-shared/src/__tests__/console-test.js @@ -54,14 +54,6 @@ describe('console', () => { fakeConsole, ); - const inject = global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject; - global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = internals => { - rendererID = inject(internals); - - Console.registerRenderer(internals); - return rendererID; - }; - React = require('react'); if ( React.version.startsWith('19') && @@ -1100,9 +1092,17 @@ describe('console error', () => { global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = internals => { inject(internals); - Console.registerRenderer(internals, () => { - throw Error('foo'); - }); + Console.registerRenderer( + () => { + throw Error('foo'); + }, + () => { + return { + enableOwnerStacks: true, + componentStack: '\n at FakeStack (fake-file)', + }; + }, + ); }; React = require('react'); @@ -1142,11 +1142,18 @@ describe('console error', () => { expect(mockLog.mock.calls[0][0]).toBe('log'); expect(mockWarn).toHaveBeenCalledTimes(1); - expect(mockWarn.mock.calls[0]).toHaveLength(1); + expect(mockWarn.mock.calls[0]).toHaveLength(2); expect(mockWarn.mock.calls[0][0]).toBe('warn'); + // An error in showInlineWarningsAndErrors doesn't need to break component stacks. + expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + '\n in FakeStack (at **)', + ); expect(mockError).toHaveBeenCalledTimes(1); - expect(mockError.mock.calls[0]).toHaveLength(1); + expect(mockError.mock.calls[0]).toHaveLength(2); expect(mockError.mock.calls[0][0]).toBe('error'); + expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + '\n in FakeStack (at **)', + ); }); }); diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js index cd777f8333763..65c7e12bccf34 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js @@ -2975,16 +2975,12 @@ describe('InspectedElement', () => { // Inspect and see that we cannot toggle error state // on error boundary itself let inspectedElement = await inspect(0); - expect(inspectedElement.canToggleError).toBe(false); - expect(inspectedElement.targetErrorBoundaryID).toBe(null); + expect(inspectedElement.canToggleError).toBe(true); // Inspect inspectedElement = await inspect(1); expect(inspectedElement.canToggleError).toBe(true); expect(inspectedElement.isErrored).toBe(false); - expect(inspectedElement.targetErrorBoundaryID).toBe( - targetErrorBoundaryID, - ); // Suppress expected error and warning. const consoleErrorMock = jest @@ -3009,10 +3005,6 @@ describe('InspectedElement', () => { inspectedElement = await inspect(0); expect(inspectedElement.canToggleError).toBe(true); expect(inspectedElement.isErrored).toBe(true); - // its error boundary ID is itself because it's caught the error - expect(inspectedElement.targetErrorBoundaryID).toBe( - targetErrorBoundaryID, - ); await toggleError(false); @@ -3020,9 +3012,6 @@ describe('InspectedElement', () => { inspectedElement = await inspect(1); expect(inspectedElement.canToggleError).toBe(true); expect(inspectedElement.isErrored).toBe(false); - expect(inspectedElement.targetErrorBoundaryID).toBe( - targetErrorBoundaryID, - ); }); }); diff --git a/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js b/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js index cf5304664b815..87d8132e50e63 100644 --- a/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js @@ -123,7 +123,7 @@ describe('Profiler change descriptions', () => { expect(commitData.changeDescriptions.get(element.id)) .toMatchInlineSnapshot(` { - "context": null, + "context": false, "didHooksChange": false, "hooks": null, "isFirstMount": false, diff --git a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js index 4778daffcec2b..0d6d8d02a1989 100644 --- a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js @@ -1209,6 +1209,106 @@ describe('ProfilingCache', () => { } }); + // @reactVersion >= 19.0 + it('should detect context changes or lack of changes with conditional use()', () => { + const ContextA = React.createContext(0); + const ContextB = React.createContext(1); + let setState = null; + + const Component = () => { + // These hooks may change and initiate re-renders. + let state; + [state, setState] = React.useState('abc'); + + let result = state; + + if (state.includes('a')) { + result += React.use(ContextA); + } + + result += React.use(ContextB); + + return result; + }; + + utils.act(() => + render( + + + + + , + ), + ); + + utils.act(() => store.profilerStore.startProfiling()); + + // First render changes Context. + utils.act(() => + render( + + + + + , + ), + ); + + // Second render has no changed Context, only changed state. + utils.act(() => setState('def')); + + utils.act(() => store.profilerStore.stopProfiling()); + + const rootID = store.roots[0]; + + const changeDescriptions = store.profilerStore + .getDataForRoot(rootID) + .commitData.map(commitData => commitData.changeDescriptions); + expect(changeDescriptions).toHaveLength(2); + + // 1st render: Change to Context + expect(changeDescriptions[0]).toMatchInlineSnapshot(` + Map { + 4 => { + "context": true, + "didHooksChange": false, + "hooks": [], + "isFirstMount": false, + "props": [], + "state": null, + }, + } + `); + + // 2nd render: Change to State + expect(changeDescriptions[1]).toMatchInlineSnapshot(` + Map { + 4 => { + "context": false, + "didHooksChange": true, + "hooks": [ + 0, + ], + "isFirstMount": false, + "props": [], + "state": null, + }, + } + `); + + expect(changeDescriptions).toHaveLength(2); + + // Export and re-import profile data and make sure it is retained. + utils.exportImportHelper(bridge, store); + + for (let commitIndex = 0; commitIndex < 2; commitIndex++) { + const commitData = store.profilerStore.getCommitData(rootID, commitIndex); + expect(commitData.changeDescriptions).toEqual( + changeDescriptions[commitIndex], + ); + } + }); + // @reactVersion >= 18.0 it('should calculate durations based on actual children (not filtered children)', () => { store.componentFilters = [utils.createDisplayNameFilter('^Parent$')]; diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index cebc7e0bbcb82..92e7fc6586111 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -1817,13 +1817,8 @@ describe('Store', () => { jest.runOnlyPendingTimers(); } - // Gross abstraction around pending passive warning/error delay. - function flushPendingPassiveErrorAndWarningCounts() { - jest.advanceTimersByTime(1000); - } - // @reactVersion >= 18.0 - it('are counted (after a delay)', () => { + it('are counted (after no delay)', () => { function Example() { React.useEffect(() => { console.error('test-only: passive error'); @@ -1838,13 +1833,6 @@ describe('Store', () => { }, false); }); flushPendingBridgeOperations(); - expect(store).toMatchInlineSnapshot(` - [root] - - `); - - // After a delay, passive effects should be committed as well - act(flushPendingPassiveErrorAndWarningCounts, false); expect(store).toMatchInlineSnapshot(` ✕ 1, ⚠ 1 [root] @@ -1879,8 +1867,9 @@ describe('Store', () => { }, false); flushPendingBridgeOperations(); expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 1 [root] - + ✕⚠ `); // Before warnings and errors have flushed, flush another commit. @@ -1894,22 +1883,13 @@ describe('Store', () => { }, false); flushPendingBridgeOperations(); expect(store).toMatchInlineSnapshot(` - ✕ 1, ⚠ 1 + ✕ 2, ⚠ 2 [root] ✕⚠ `); }); - // After a delay, passive effects should be committed as well - act(flushPendingPassiveErrorAndWarningCounts, false); - expect(store).toMatchInlineSnapshot(` - ✕ 2, ⚠ 2 - [root] - ✕⚠ - - `); - act(() => unmount()); expect(store).toMatchInlineSnapshot(``); }); diff --git a/packages/react-devtools-shared/src/__tests__/utils.js b/packages/react-devtools-shared/src/__tests__/utils.js index 4a42cf2703ffb..c22ac6e05dc11 100644 --- a/packages/react-devtools-shared/src/__tests__/utils.js +++ b/packages/react-devtools-shared/src/__tests__/utils.js @@ -284,6 +284,19 @@ export function createHOCFilter(isEnabled: boolean = true) { }; } +export function createEnvironmentNameFilter( + env: string, + isEnabled: boolean = true, +) { + const Types = require('react-devtools-shared/src/frontend/types'); + return { + type: Types.ComponentFilterEnvironmentName, + isEnabled, + isValid: true, + value: env, + }; +} + export function createElementTypeFilter( elementType: ElementType, isEnabled: boolean = true, diff --git a/packages/react-devtools-shared/src/attachRenderer.js b/packages/react-devtools-shared/src/attachRenderer.js new file mode 100644 index 0000000000000..3138f00cad615 --- /dev/null +++ b/packages/react-devtools-shared/src/attachRenderer.js @@ -0,0 +1,61 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type { + ReactRenderer, + RendererInterface, + DevToolsHook, + RendererID, +} from 'react-devtools-shared/src/backend/types'; + +import {attach as attachFlight} from 'react-devtools-shared/src/backend/flight/renderer'; +import {attach as attachFiber} from 'react-devtools-shared/src/backend/fiber/renderer'; +import {attach as attachLegacy} from 'react-devtools-shared/src/backend/legacy/renderer'; +import {hasAssignedBackend} from 'react-devtools-shared/src/backend/utils'; + +// this is the backend that is compatible with all older React versions +function isMatchingRender(version: string): boolean { + return !hasAssignedBackend(version); +} + +export default function attachRenderer( + hook: DevToolsHook, + id: RendererID, + renderer: ReactRenderer, + global: Object, +): RendererInterface | void { + // only attach if the renderer is compatible with the current version of the backend + if (!isMatchingRender(renderer.reconcilerVersion || renderer.version)) { + return; + } + let rendererInterface = hook.rendererInterfaces.get(id); + + // Inject any not-yet-injected renderers (if we didn't reload-and-profile) + if (rendererInterface == null) { + if (typeof renderer.getCurrentComponentInfo === 'function') { + // react-flight/client + rendererInterface = attachFlight(hook, id, renderer, global); + } else if ( + // v16-19 + typeof renderer.findFiberByHostInstance === 'function' || + // v16.8+ + renderer.currentDispatcherRef != null + ) { + // react-reconciler v16+ + rendererInterface = attachFiber(hook, id, renderer, global); + } else if (renderer.ComponentTree) { + // react-dom v15 + rendererInterface = attachLegacy(hook, id, renderer, global); + } else { + // Older react-dom or other unsupported renderer version + } + } + + return rendererInterface; +} diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index a71b259441e98..f4665e014c023 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -152,6 +152,7 @@ export default class Agent extends EventEmitter<{ traceUpdates: [Set], drawTraceUpdates: [Array], disableTraceUpdates: [], + getIfHasUnsupportedRendererVersion: [], }> { _bridge: BackendBridge; _isProfiling: boolean = false; @@ -220,6 +221,11 @@ export default class Agent extends EventEmitter<{ this.updateConsolePatchSettings, ); bridge.addListener('updateComponentFilters', this.updateComponentFilters); + bridge.addListener('getEnvironmentNames', this.getEnvironmentNames); + bridge.addListener( + 'getIfHasUnsupportedRendererVersion', + this.getIfHasUnsupportedRendererVersion, + ); // Temporarily support older standalone front-ends sending commands to newer embedded backends. // We do this because React Native embeds the React DevTools backend, @@ -229,18 +235,16 @@ export default class Agent extends EventEmitter<{ bridge.addListener('overrideProps', this.overrideProps); bridge.addListener('overrideState', this.overrideState); + setupHighlighter(bridge, this); + setupTraceUpdates(this); + + // By this time, Store should already be initialized and intercept events + bridge.send('backendInitialized'); + if (this._isProfiling) { bridge.send('profilingStatus', true); } - // Send the Bridge protocol and backend versions, after initialization, in case the frontend has already requested it. - // The Store may be instantiated beore the agent. - const version = process.env.DEVTOOLS_VERSION; - if (version) { - this._bridge.send('backendVersion', version); - } - this._bridge.send('bridgeProtocol', currentBridgeProtocol); - // Notify the frontend if the backend supports the Storage API (e.g. localStorage). // If not, features like reload-and-profile will not work correctly and must be disabled. let isBackendStorageAPISupported = false; @@ -250,9 +254,6 @@ export default class Agent extends EventEmitter<{ } catch (error) {} bridge.send('isBackendStorageAPISupported', isBackendStorageAPISupported); bridge.send('isSynchronousXHRSupported', isSynchronousXHRSupported()); - - setupHighlighter(bridge, this); - setupTraceUpdates(this); } get rendererInterfaces(): {[key: RendererID]: RendererInterface, ...} { @@ -341,84 +342,123 @@ export default class Agent extends EventEmitter<{ } getIDForHostInstance(target: HostInstance): number | null { - let bestMatch: null | HostInstance = null; - let bestRenderer: null | RendererInterface = null; - // Find the nearest ancestor which is mounted by a React. - for (const rendererID in this._rendererInterfaces) { - const renderer = ((this._rendererInterfaces[ - (rendererID: any) - ]: any): RendererInterface); - const nearestNode: null = renderer.getNearestMountedHostInstance(target); - if (nearestNode !== null) { - if (nearestNode === target) { - // Exact match we can exit early. - bestMatch = nearestNode; - bestRenderer = renderer; - break; + if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') { + // In React Native or non-DOM we simply pick any renderer that has a match. + for (const rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + try { + const match = renderer.getElementIDForHostInstance(target); + if (match != null) { + return match; + } + } catch (error) { + // Some old React versions might throw if they can't find a match. + // If so we should ignore it... } - if ( - bestMatch === null || - (!isReactNativeEnvironment() && bestMatch.contains(nearestNode)) - ) { - // If this is the first match or the previous match contains the new match, - // so the new match is a deeper and therefore better match. - bestMatch = nearestNode; - bestRenderer = renderer; + } + return null; + } else { + // In the DOM we use a smarter mechanism to find the deepest a DOM node + // that is registered if there isn't an exact match. + let bestMatch: null | Element = null; + let bestRenderer: null | RendererInterface = null; + // Find the nearest ancestor which is mounted by a React. + for (const rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + const nearestNode: null | Element = renderer.getNearestMountedDOMNode( + (target: any), + ); + if (nearestNode !== null) { + if (nearestNode === target) { + // Exact match we can exit early. + bestMatch = nearestNode; + bestRenderer = renderer; + break; + } + if (bestMatch === null || bestMatch.contains(nearestNode)) { + // If this is the first match or the previous match contains the new match, + // so the new match is a deeper and therefore better match. + bestMatch = nearestNode; + bestRenderer = renderer; + } } } - } - if (bestRenderer != null && bestMatch != null) { - try { - return bestRenderer.getElementIDForHostInstance(bestMatch, true); - } catch (error) { - // Some old React versions might throw if they can't find a match. - // If so we should ignore it... + if (bestRenderer != null && bestMatch != null) { + try { + return bestRenderer.getElementIDForHostInstance(bestMatch); + } catch (error) { + // Some old React versions might throw if they can't find a match. + // If so we should ignore it... + } } + return null; } - return null; } getComponentNameForHostInstance(target: HostInstance): string | null { // We duplicate this code from getIDForHostInstance to avoid an object allocation. - let bestMatch: null | HostInstance = null; - let bestRenderer: null | RendererInterface = null; - // Find the nearest ancestor which is mounted by a React. - for (const rendererID in this._rendererInterfaces) { - const renderer = ((this._rendererInterfaces[ - (rendererID: any) - ]: any): RendererInterface); - const nearestNode = renderer.getNearestMountedHostInstance(target); - if (nearestNode !== null) { - if (nearestNode === target) { - // Exact match we can exit early. - bestMatch = nearestNode; - bestRenderer = renderer; - break; + if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') { + // In React Native or non-DOM we simply pick any renderer that has a match. + for (const rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + try { + const id = renderer.getElementIDForHostInstance(target); + if (id) { + return renderer.getDisplayNameForElementID(id); + } + } catch (error) { + // Some old React versions might throw if they can't find a match. + // If so we should ignore it... } - if ( - bestMatch === null || - (!isReactNativeEnvironment() && bestMatch.contains(nearestNode)) - ) { - // If this is the first match or the previous match contains the new match, - // so the new match is a deeper and therefore better match. - bestMatch = nearestNode; - bestRenderer = renderer; + } + return null; + } else { + // In the DOM we use a smarter mechanism to find the deepest a DOM node + // that is registered if there isn't an exact match. + let bestMatch: null | Element = null; + let bestRenderer: null | RendererInterface = null; + // Find the nearest ancestor which is mounted by a React. + for (const rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + const nearestNode: null | Element = renderer.getNearestMountedDOMNode( + (target: any), + ); + if (nearestNode !== null) { + if (nearestNode === target) { + // Exact match we can exit early. + bestMatch = nearestNode; + bestRenderer = renderer; + break; + } + if (bestMatch === null || bestMatch.contains(nearestNode)) { + // If this is the first match or the previous match contains the new match, + // so the new match is a deeper and therefore better match. + bestMatch = nearestNode; + bestRenderer = renderer; + } } } - } - - if (bestRenderer != null && bestMatch != null) { - try { - const id = bestRenderer.getElementIDForHostInstance(bestMatch, true); - if (id) { - return bestRenderer.getDisplayNameForElementID(id); + if (bestRenderer != null && bestMatch != null) { + try { + const id = bestRenderer.getElementIDForHostInstance(bestMatch); + if (id) { + return bestRenderer.getDisplayNameForElementID(id); + } + } catch (error) { + // Some old React versions might throw if they can't find a match. + // If so we should ignore it... } - } catch (error) { - // Some old React versions might throw if they can't find a match. - // If so we should ignore it... } + return null; } - return null; } getBackendVersion: () => void = () => { @@ -674,7 +714,7 @@ export default class Agent extends EventEmitter<{ } } - setRendererInterface( + registerRendererInterface( rendererID: RendererID, rendererInterface: RendererInterface, ) { @@ -814,6 +854,24 @@ export default class Agent extends EventEmitter<{ } }; + getEnvironmentNames: () => void = () => { + let accumulatedNames = null; + for (const rendererID in this._rendererInterfaces) { + const renderer = this._rendererInterfaces[+rendererID]; + const names = renderer.getEnvironmentNames(); + if (accumulatedNames === null) { + accumulatedNames = names; + } else { + for (let i = 0; i < names.length; i++) { + if (accumulatedNames.indexOf(names[i]) === -1) { + accumulatedNames.push(names[i]); + } + } + } + } + this._bridge.send('environmentNames', accumulatedNames || []); + }; + onTraceUpdates: (nodes: Set) => void = nodes => { this.emit('traceUpdates', nodes); }; @@ -887,8 +945,12 @@ export default class Agent extends EventEmitter<{ } }; - onUnsupportedRenderer(rendererID: number) { - this._bridge.send('unsupportedRendererVersion', rendererID); + getIfHasUnsupportedRendererVersion: () => void = () => { + this.emit('getIfHasUnsupportedRendererVersion'); + }; + + onUnsupportedRenderer() { + this._bridge.send('unsupportedRendererVersion'); } _persistSelectionTimerScheduled: boolean = false; diff --git a/packages/react-devtools-shared/src/backend/console.js b/packages/react-devtools-shared/src/backend/console.js index 93725c4428269..d4e9651fa7503 100644 --- a/packages/react-devtools-shared/src/backend/console.js +++ b/packages/react-devtools-shared/src/backend/console.js @@ -7,13 +7,10 @@ * @flow */ -import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type { - LegacyDispatcherRef, - CurrentDispatcherRef, - ReactRenderer, - WorkTagMap, ConsolePatchSettings, + OnErrorOrWarning, + GetComponentStack, } from './types'; import { @@ -25,14 +22,6 @@ import { ANSI_STYLE_DIMMING_TEMPLATE, ANSI_STYLE_DIMMING_TEMPLATE_WITH_COMPONENT_STACK, } from 'react-devtools-shared/src/constants'; -import {getInternalReactConstants, getDispatcherRef} from './fiber/renderer'; -import { - getStackByFiberInDevAndProd, - getOwnerStackByFiberInDev, - supportsOwnerStacks, - supportsConsoleTasks, -} from './fiber/DevToolsFiberComponentStack'; -import {formatOwnerStack} from './shared/DevToolsOwnerStack'; import {castBool, castBrowserTheme} from '../utils'; const OVERRIDE_CONSOLE_METHODS = ['error', 'trace', 'warn']; @@ -90,21 +79,10 @@ function restorePotentiallyModifiedArgs(args: Array): Array { } } -type OnErrorOrWarning = ( - fiber: Fiber, - type: 'error' | 'warn', - args: Array, -) => void; - -const injectedRenderers: Map< - ReactRenderer, - { - currentDispatcherRef: LegacyDispatcherRef | CurrentDispatcherRef, - getCurrentFiber: () => Fiber | null, - onErrorOrWarning: ?OnErrorOrWarning, - workTagMap: WorkTagMap, - }, -> = new Map(); +const injectedRenderers: Array<{ + onErrorOrWarning: ?OnErrorOrWarning, + getComponentStack: ?GetComponentStack, +}> = []; let targetConsole: Object = console; let targetConsoleMethods: {[string]: $FlowFixMe} = {}; @@ -132,33 +110,13 @@ export function dangerous_setTargetConsoleForTesting( // These internals will be used if the console is patched. // Injecting them separately allows the console to easily be patched or un-patched later (at runtime). export function registerRenderer( - renderer: ReactRenderer, onErrorOrWarning?: OnErrorOrWarning, + getComponentStack?: GetComponentStack, ): void { - const { - currentDispatcherRef, - getCurrentFiber, - findFiberByHostInstance, - version, - } = renderer; - - // Ignore React v15 and older because they don't expose a component stack anyway. - if (typeof findFiberByHostInstance !== 'function') { - return; - } - - // currentDispatcherRef gets injected for v16.8+ to support hooks inspection. - // getCurrentFiber gets injected for v16.9+. - if (currentDispatcherRef != null && typeof getCurrentFiber === 'function') { - const {ReactTypeOfWork} = getInternalReactConstants(version); - - injectedRenderers.set(renderer, { - currentDispatcherRef, - getCurrentFiber, - workTagMap: ReactTypeOfWork, - onErrorOrWarning, - }); - } + injectedRenderers.push({ + onErrorOrWarning, + getComponentStack, + }); } const consoleSettingsRef: ConsolePatchSettings = { @@ -229,55 +187,39 @@ export function patch({ // Search for the first renderer that has a current Fiber. // We don't handle the edge case of stacks for more than one (e.g. interleaved renderers?) - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const renderer of injectedRenderers.values()) { - const currentDispatcherRef = getDispatcherRef(renderer); - const {getCurrentFiber, onErrorOrWarning, workTagMap} = renderer; - const current: ?Fiber = getCurrentFiber(); - if (current != null) { - try { - if (shouldShowInlineWarningsAndErrors) { - // patch() is called by two places: (1) the hook and (2) the renderer backend. - // The backend is what implements a message queue, so it's the only one that injects onErrorOrWarning. - if (typeof onErrorOrWarning === 'function') { - onErrorOrWarning( - current, - ((method: any): 'error' | 'warn'), - // Restore and copy args before we mutate them (e.g. adding the component stack) - restorePotentiallyModifiedArgs(args), - ); - } + for (let i = 0; i < injectedRenderers.length; i++) { + const renderer = injectedRenderers[i]; + const {getComponentStack, onErrorOrWarning} = renderer; + try { + if (shouldShowInlineWarningsAndErrors) { + // patch() is called by two places: (1) the hook and (2) the renderer backend. + // The backend is what implements a message queue, so it's the only one that injects onErrorOrWarning. + if (onErrorOrWarning != null) { + onErrorOrWarning( + ((method: any): 'error' | 'warn'), + // Restore and copy args before we mutate them (e.g. adding the component stack) + restorePotentiallyModifiedArgs(args), + ); } - - if ( - consoleSettingsRef.appendComponentStack && - !supportsConsoleTasks(current) - ) { - const enableOwnerStacks = supportsOwnerStacks(current); - let componentStack = ''; - if (enableOwnerStacks) { - // Prefix the owner stack with the current stack. I.e. what called - // console.error. While this will also be part of the native stack, - // it is hidden and not presented alongside this argument so we print - // them all together. - const topStackFrames = formatOwnerStack( - new Error('react-stack-top-frame'), - ); - if (topStackFrames) { - componentStack += '\n' + topStackFrames; - } - componentStack += getOwnerStackByFiberInDev( - workTagMap, - current, - (currentDispatcherRef: any), - ); - } else { - componentStack = getStackByFiberInDevAndProd( - workTagMap, - current, - (currentDispatcherRef: any), - ); - } + } + } catch (error) { + // Don't let a DevTools or React internal error interfere with logging. + setTimeout(() => { + throw error; + }, 0); + } + try { + if ( + consoleSettingsRef.appendComponentStack && + getComponentStack != null + ) { + // This needs to be directly in the wrapper so we can pop exactly one frame. + const topFrame = Error('react-stack-top-frame'); + const match = getComponentStack(topFrame); + if (match !== null) { + const {enableOwnerStacks, componentStack} = match; + // Empty string means we have a match but no component stack. + // We don't need to look in other renderers but we also don't add anything. if (componentStack !== '') { // Create a fake Error so that when we print it we get native source maps. Every // browser will print the .stack property of the error and then parse it back for source @@ -285,7 +227,7 @@ export function patch({ // slot doesn't line up. const fakeError = new Error(''); // In Chromium, only the stack property is printed but in Firefox the : - // gets printed so to make the colon make sense, we name it so we print Component Stack: + // gets printed so to make the colon make sense, we name it so we print Stack: // and similarly Safari leave an expandable slot. fakeError.name = enableOwnerStacks ? 'Stack' @@ -299,6 +241,7 @@ export function patch({ ? 'Error Stack:' : 'Error Component Stack:') + componentStack : componentStack; + if (alreadyHasComponentStack) { // Only modify the component stack if it matches what we would've added anyway. // Otherwise we assume it was a non-React stack. @@ -334,15 +277,15 @@ export function patch({ } } } + // Don't add stacks from other renderers. + break; } - } catch (error) { - // Don't let a DevTools or React internal error interfere with logging. - setTimeout(() => { - throw error; - }, 0); - } finally { - break; } + } catch (error) { + // Don't let a DevTools or React internal error interfere with logging. + setTimeout(() => { + throw error; + }, 0); } } diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 729c8fce5a7a5..1ac78a49a58b6 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -7,13 +7,14 @@ * @flow */ -import type {ReactComponentInfo} from 'shared/ReactTypes'; +import type {ReactComponentInfo, ReactDebugInfo} from 'shared/ReactTypes'; import { ComponentFilterDisplayName, ComponentFilterElementType, ComponentFilterHOC, ComponentFilterLocation, + ComponentFilterEnvironmentName, ElementTypeClass, ElementTypeContext, ElementTypeFunction, @@ -99,9 +100,19 @@ import { SERVER_CONTEXT_SYMBOL_STRING, } from '../shared/ReactSymbols'; import {enableStyleXFeatures} from 'react-devtools-feature-flags'; + +import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponentLogs'; + import is from 'shared/objectIs'; import hasOwnProperty from 'shared/hasOwnProperty'; +import { + getStackByFiberInDevAndProd, + getOwnerStackByFiberInDev, + supportsOwnerStacks, + supportsConsoleTasks, +} from './DevToolsFiberComponentStack'; + // $FlowFixMe[method-unbinding] const toString = Object.prototype.toString; @@ -113,7 +124,7 @@ import {getStyleXData} from '../StyleX/utils'; import {createProfilingHooks} from '../profilingHooks'; import type {GetTimelineData, ToggleProfilingStatus} from '../profilingHooks'; -import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; +import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; import type { ChangeDescription, CommitDataBackend, @@ -145,24 +156,18 @@ import {formatOwnerStack} from '../shared/DevToolsOwnerStack'; // Kinds const FIBER_INSTANCE = 0; const VIRTUAL_INSTANCE = 1; - -// Flags -const FORCE_SUSPENSE_FALLBACK = /* */ 0b001; -const FORCE_ERROR = /* */ 0b010; -const FORCE_ERROR_RESET = /* */ 0b100; +const FILTERED_FIBER_INSTANCE = 2; // This type represents a stateful instance of a Client Component i.e. a Fiber pair. // These instances also let us track stateful DevTools meta data like id and warnings. type FiberInstance = { kind: 0, id: number, - parent: null | DevToolsInstance, // filtered parent, including virtual - firstChild: null | DevToolsInstance, // filtered first child, including virtual - nextSibling: null | DevToolsInstance, // filtered next sibling, including virtual - flags: number, // Force Error/Suspense + parent: null | DevToolsInstance, + firstChild: null | DevToolsInstance, + nextSibling: null | DevToolsInstance, source: null | string | Error | Source, // source location of this component function, or owned child stack - errors: null | Map, // error messages and count - warnings: null | Map, // warning messages and count + logCount: number, // total number of errors/warnings last seen treeBaseDuration: number, // the profiled time of the last render of this subtree data: Fiber, // one of a Fiber pair }; @@ -174,15 +179,42 @@ function createFiberInstance(fiber: Fiber): FiberInstance { parent: null, firstChild: null, nextSibling: null, - flags: 0, source: null, - errors: null, - warnings: null, + logCount: 0, treeBaseDuration: 0, data: fiber, }; } +type FilteredFiberInstance = { + kind: 2, + // We exclude id from the type to get errors if we try to access it. + // However it is still in the object to preserve hidden class. + // id: number, + parent: null | DevToolsInstance, + firstChild: null | DevToolsInstance, + nextSibling: null | DevToolsInstance, + source: null | string | Error | Source, // always null here. + logCount: number, // total number of errors/warnings last seen + treeBaseDuration: number, // the profiled time of the last render of this subtree + data: Fiber, // one of a Fiber pair +}; + +// This is used to represent a filtered Fiber but still lets us find its host instance. +function createFilteredFiberInstance(fiber: Fiber): FilteredFiberInstance { + return ({ + kind: FILTERED_FIBER_INSTANCE, + id: 0, + parent: null, + firstChild: null, + nextSibling: null, + source: null, + logCount: 0, + treeBaseDuration: 0, + data: fiber, + }: any); +} + // This type represents a stateful instance of a Server Component or a Component // that gets optimized away - e.g. call-through without creating a Fiber. // It's basically a virtual Fiber. This is not a semantic concept in React. @@ -191,16 +223,11 @@ function createFiberInstance(fiber: Fiber): FiberInstance { type VirtualInstance = { kind: 1, id: number, - parent: null | DevToolsInstance, // filtered parent, including virtual - firstChild: null | DevToolsInstance, // filtered first child, including virtual - nextSibling: null | DevToolsInstance, // filtered next sibling, including virtual - flags: number, + parent: null | DevToolsInstance, + firstChild: null | DevToolsInstance, + nextSibling: null | DevToolsInstance, source: null | string | Error | Source, // source location of this server component, or owned child stack - // Errors and Warnings happen per ReactComponentInfo which can appear in - // multiple places but we track them per stateful VirtualInstance so - // that old errors/warnings don't disappear when the instance is refreshed. - errors: null | Map, // error messages and count - warnings: null | Map, // warning messages and count + logCount: number, // total number of errors/warnings last seen treeBaseDuration: number, // the profiled time of the last render of this subtree // The latest info for this instance. This can be updated over time and the // same info can appear in more than once ServerComponentInstance. @@ -216,16 +243,14 @@ function createVirtualInstance( parent: null, firstChild: null, nextSibling: null, - flags: 0, source: null, - errors: null, - warnings: null, + logCount: 0, treeBaseDuration: 0, data: debugEntry, }; } -type DevToolsInstance = FiberInstance | VirtualInstance; +type DevToolsInstance = FiberInstance | VirtualInstance | FilteredFiberInstance; type getDisplayNameForFiberType = (fiber: Fiber) => string | null; type getTypeSymbolType = (type: any) => symbol | number; @@ -721,46 +746,107 @@ export function getInternalReactConstants(version: string): { }; } -// Map of one or more Fibers in a pair to their unique id number. -// We track both Fibers to support Fast Refresh, -// which may forcefully replace one of the pair as part of hot reloading. -// In that case it's still important to be able to locate the previous ID during subsequent renders. -const fiberToFiberInstanceMap: Map = new Map(); +// All environment names we've seen so far. This lets us create a list of filters to apply. +// This should ideally include env of filtered Components too so that you can add those as +// filters at the same time as removing some other filter. +const knownEnvironmentNames: Set = new Set(); -// Map of id to one (arbitrary) Fiber in a pair. +// Map of FiberRoot to their root FiberInstance. +const rootToFiberInstanceMap: Map = new Map(); + +// Map of id to FiberInstance or VirtualInstance. // This Map is used to e.g. get the display name for a Fiber or schedule an update, // operations that should be the same whether the current and work-in-progress Fiber is used. -const idToDevToolsInstanceMap: Map = new Map(); +const idToDevToolsInstanceMap: Map = + new Map(); + +// Map of canonical HostInstances to the nearest parent DevToolsInstance. +const publicInstanceToDevToolsInstanceMap: Map = + new Map(); +// Map of resource DOM nodes to all the nearest DevToolsInstances that depend on it. +const hostResourceToDevToolsInstanceMap: Map< + HostInstance, + Set, +> = new Map(); + +function getPublicInstance(instance: HostInstance): HostInstance { + // Typically the PublicInstance and HostInstance is the same thing but not in Fabric. + // So we need to detect this and use that as the public instance. + return typeof instance === 'object' && + instance !== null && + typeof instance.canonical === 'object' + ? (instance.canonical: any) + : typeof instance._nativeTag === 'number' + ? instance._nativeTag + : instance; +} -// Map of resource DOM nodes to all the Fibers that depend on it. -const hostResourceToFiberMap: Map> = new Map(); +function aquireHostInstance( + nearestInstance: DevToolsInstance, + hostInstance: HostInstance, +): void { + const publicInstance = getPublicInstance(hostInstance); + publicInstanceToDevToolsInstanceMap.set(publicInstance, nearestInstance); +} + +function releaseHostInstance( + nearestInstance: DevToolsInstance, + hostInstance: HostInstance, +): void { + const publicInstance = getPublicInstance(hostInstance); + if ( + publicInstanceToDevToolsInstanceMap.get(publicInstance) === nearestInstance + ) { + publicInstanceToDevToolsInstanceMap.delete(publicInstance); + } +} function aquireHostResource( - fiber: Fiber, + nearestInstance: DevToolsInstance, resource: ?{instance?: HostInstance}, ): void { const hostInstance = resource && resource.instance; if (hostInstance) { - let resourceFibers = hostResourceToFiberMap.get(hostInstance); - if (resourceFibers === undefined) { - resourceFibers = new Set(); - hostResourceToFiberMap.set(hostInstance, resourceFibers); + const publicInstance = getPublicInstance(hostInstance); + let resourceInstances = + hostResourceToDevToolsInstanceMap.get(publicInstance); + if (resourceInstances === undefined) { + resourceInstances = new Set(); + hostResourceToDevToolsInstanceMap.set(publicInstance, resourceInstances); + // Store the first match in the main map for quick access when selecting DOM node. + publicInstanceToDevToolsInstanceMap.set(publicInstance, nearestInstance); } - resourceFibers.add(fiber); + resourceInstances.add(nearestInstance); } } function releaseHostResource( - fiber: Fiber, + nearestInstance: DevToolsInstance, resource: ?{instance?: HostInstance}, ): void { const hostInstance = resource && resource.instance; if (hostInstance) { - const resourceFibers = hostResourceToFiberMap.get(hostInstance); - if (resourceFibers !== undefined) { - resourceFibers.delete(fiber); - if (resourceFibers.size === 0) { - hostResourceToFiberMap.delete(hostInstance); + const publicInstance = getPublicInstance(hostInstance); + const resourceInstances = + hostResourceToDevToolsInstanceMap.get(publicInstance); + if (resourceInstances !== undefined) { + resourceInstances.delete(nearestInstance); + if (resourceInstances.size === 0) { + hostResourceToDevToolsInstanceMap.delete(publicInstance); + publicInstanceToDevToolsInstanceMap.delete(publicInstance); + } else if ( + publicInstanceToDevToolsInstanceMap.get(publicInstance) === + nearestInstance + ) { + // This was the first one. Store the next first one in the main map for easy access. + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const firstInstance of resourceInstances) { + publicInstanceToDevToolsInstanceMap.set( + firstInstance, + nearestInstance, + ); + break; + } } } } @@ -833,6 +919,7 @@ export function attach( setErrorHandler, setSuspenseHandler, scheduleUpdate, + getCurrentFiber, } = renderer; const supportsTogglingError = typeof setErrorHandler === 'function' && @@ -877,87 +964,104 @@ export function attach( toggleProfilingStatus = response.toggleProfilingStatus; } - // Tracks Fibers with recently changed number of error/warning messages. - // These collections store the Fiber rather than the DevToolsInstance, - // in order to avoid generating an DevToolsInstance for Fibers that never get mounted - // (due to e.g. Suspense or error boundaries). - // onErrorOrWarning() adds Fibers and recordPendingErrorsAndWarnings() later clears them. - const fibersWithChangedErrorOrWarningCounts: Set = new Set(); - const pendingFiberToErrorsMap: WeakMap< - Fiber, - Map, - > = new WeakMap(); - const pendingFiberToWarningsMap: WeakMap< - Fiber, - Map, - > = new WeakMap(); + type ComponentLogs = { + errors: Map, + errorsCount: number, + warnings: Map, + warningsCount: number, + }; + // Tracks Errors/Warnings logs added to a Fiber. They are added before the commit and get + // picked up a FiberInstance. This keeps it around as long as the Fiber is alive which + // lets the Fiber get reparented/remounted and still observe the previous errors/warnings. + // Unless we explicitly clear the logs from a Fiber. + const fiberToComponentLogsMap: WeakMap = new WeakMap(); + // Tracks whether we've performed a commit since the last log. This is used to know + // whether we received any new logs between the commit and post commit phases. I.e. + // if any passive effects called console.warn / console.error. + let needsToFlushComponentLogs = false; + + function bruteForceFlushErrorsAndWarnings() { + // Refresh error/warning count for all mounted unfiltered Fibers. + let hasChanges = false; + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const devtoolsInstance of idToDevToolsInstanceMap.values()) { + if (devtoolsInstance.kind === FIBER_INSTANCE) { + const fiber = devtoolsInstance.data; + const componentLogsEntry = fiberToComponentLogsMap.get(fiber); + const changed = recordConsoleLogs(devtoolsInstance, componentLogsEntry); + if (changed) { + hasChanges = true; + updateMostRecentlyInspectedElementIfNecessary(devtoolsInstance.id); + } + } else { + // Virtual Instances cannot log in passive effects and so never appear here. + } + } + if (hasChanges) { + flushPendingEvents(); + } + } function clearErrorsAndWarnings() { + // Note, this only clears logs for Fibers that have instances. If they're filtered + // and then mount, the logs are there. Ensuring we only clear what you've seen. + // If we wanted to clear the whole set, we'd replace fiberToComponentLogsMap with a + // new WeakMap. It's unclear whether we should clear componentInfoToComponentLogsMap + // since it's shared by other renderers but presumably it would. + // eslint-disable-next-line no-for-of-loops/no-for-of-loops for (const devtoolsInstance of idToDevToolsInstanceMap.values()) { - devtoolsInstance.errors = null; - devtoolsInstance.warnings = null; if (devtoolsInstance.kind === FIBER_INSTANCE) { - fibersWithChangedErrorOrWarningCounts.add(devtoolsInstance.data); + const fiber = devtoolsInstance.data; + fiberToComponentLogsMap.delete(fiber); + if (fiber.alternate) { + fiberToComponentLogsMap.delete(fiber.alternate); + } } else { - // TODO: Handle VirtualInstance. + componentInfoToComponentLogsMap.delete(devtoolsInstance.data); + } + const changed = recordConsoleLogs(devtoolsInstance, undefined); + if (changed) { + updateMostRecentlyInspectedElementIfNecessary(devtoolsInstance.id); } - updateMostRecentlyInspectedElementIfNecessary(devtoolsInstance.id); } flushPendingEvents(); } - function clearMessageCountHelper( - instanceID: number, - pendingFiberToMessageCountMap: WeakMap>, - forError: boolean, - ) { + function clearConsoleLogsHelper(instanceID: number, type: 'error' | 'warn') { const devtoolsInstance = idToDevToolsInstanceMap.get(instanceID); if (devtoolsInstance !== undefined) { - let changed = false; - if (forError) { - if ( - devtoolsInstance.errors !== null && - devtoolsInstance.errors.size > 0 - ) { - changed = true; - } - devtoolsInstance.errors = null; - } else { - if ( - devtoolsInstance.warnings !== null && - devtoolsInstance.warnings.size > 0 - ) { - changed = true; - } - devtoolsInstance.warnings = null; - } + let componentLogsEntry; if (devtoolsInstance.kind === FIBER_INSTANCE) { const fiber = devtoolsInstance.data; - // Throw out any pending changes. - pendingFiberToMessageCountMap.delete(fiber); - + componentLogsEntry = fiberToComponentLogsMap.get(fiber); + } else { + const componentInfo = devtoolsInstance.data; + componentLogsEntry = componentInfoToComponentLogsMap.get(componentInfo); + } + if (componentLogsEntry !== undefined) { + if (type === 'error') { + componentLogsEntry.errors.clear(); + componentLogsEntry.errorsCount = 0; + } else { + componentLogsEntry.warnings.clear(); + componentLogsEntry.warningsCount = 0; + } + const changed = recordConsoleLogs(devtoolsInstance, componentLogsEntry); if (changed) { - // If previous flushed counts have changed, schedule an update too. - fibersWithChangedErrorOrWarningCounts.add(fiber); flushPendingEvents(); - - updateMostRecentlyInspectedElementIfNecessary(instanceID); - } else { - fibersWithChangedErrorOrWarningCounts.delete(fiber); + updateMostRecentlyInspectedElementIfNecessary(devtoolsInstance.id); } - } else { - // TODO: Handle VirtualInstance. } } } function clearErrorsForElementID(instanceID: number) { - clearMessageCountHelper(instanceID, pendingFiberToErrorsMap, true); + clearConsoleLogsHelper(instanceID, 'error'); } function clearWarningsForElementID(instanceID: number) { - clearMessageCountHelper(instanceID, pendingFiberToWarningsMap, false); + clearConsoleLogsHelper(instanceID, 'warn'); } function updateMostRecentlyInspectedElementIfNecessary( @@ -971,19 +1075,77 @@ export function attach( } } + function getComponentStack( + topFrame: Error, + ): null | {enableOwnerStacks: boolean, componentStack: string} { + if (getCurrentFiber === undefined) { + // Expected this to be part of the renderer. Ignore. + return null; + } + const current = getCurrentFiber(); + if (current === null) { + // Outside of our render scope. + return null; + } + + if (supportsConsoleTasks(current)) { + // This will be handled natively by console.createTask. No need for + // DevTools to add it. + return null; + } + + const dispatcherRef = getDispatcherRef(renderer); + if (dispatcherRef === undefined) { + return null; + } + + const enableOwnerStacks = supportsOwnerStacks(current); + let componentStack = ''; + if (enableOwnerStacks) { + // Prefix the owner stack with the current stack. I.e. what called + // console.error. While this will also be part of the native stack, + // it is hidden and not presented alongside this argument so we print + // them all together. + const topStackFrames = formatOwnerStack(topFrame); + if (topStackFrames) { + componentStack += '\n' + topStackFrames; + } + componentStack += getOwnerStackByFiberInDev( + ReactTypeOfWork, + current, + dispatcherRef, + ); + } else { + componentStack = getStackByFiberInDevAndProd( + ReactTypeOfWork, + current, + dispatcherRef, + ); + } + return {enableOwnerStacks, componentStack}; + } + // Called when an error or warning is logged during render, commit, or passive (including unmount functions). function onErrorOrWarning( - fiber: Fiber, type: 'error' | 'warn', args: $ReadOnlyArray, ): void { + if (getCurrentFiber === undefined) { + // Expected this to be part of the renderer. Ignore. + return; + } + const fiber = getCurrentFiber(); + if (fiber === null) { + // Outside of our render scope. + return; + } if (type === 'error') { - let fiberInstance = fiberToFiberInstanceMap.get(fiber); - if (fiberInstance === undefined && fiber.alternate !== null) { - fiberInstance = fiberToFiberInstanceMap.get(fiber.alternate); - } // if this is an error simulated by us to trigger error boundary, ignore - if (fiberInstance !== undefined && fiberInstance.flags & FORCE_ERROR) { + if ( + forceErrorForFibers.get(fiber) === true || + (fiber.alternate !== null && + forceErrorForFibers.get(fiber.alternate) === true) + ) { return; } } @@ -995,69 +1157,88 @@ export function attach( // [Warning: %o, {...}] and [Warning: %o, {...}] will be considered as the same message, // even if objects are different const message = formatConsoleArgumentsToSingleString(...args); - if (__DEBUG__) { - debug('onErrorOrWarning', fiber, null, `${type}: "${message}"`); - } - - // Mark this Fiber as needed its warning/error count updated during the next flush. - fibersWithChangedErrorOrWarningCounts.add(fiber); // Track the warning/error for later. - const fiberMap = - type === 'error' ? pendingFiberToErrorsMap : pendingFiberToWarningsMap; - const messageMap = fiberMap.get(fiber); - if (messageMap != null) { - const count = messageMap.get(message) || 0; - messageMap.set(message, count + 1); + let componentLogsEntry = fiberToComponentLogsMap.get(fiber); + if (componentLogsEntry === undefined && fiber.alternate !== null) { + componentLogsEntry = fiberToComponentLogsMap.get(fiber.alternate); + if (componentLogsEntry !== undefined) { + // Use the same set for both Fibers. + fiberToComponentLogsMap.set(fiber, componentLogsEntry); + } + } + if (componentLogsEntry === undefined) { + componentLogsEntry = { + errors: new Map(), + errorsCount: 0, + warnings: new Map(), + warningsCount: 0, + }; + fiberToComponentLogsMap.set(fiber, componentLogsEntry); + } + + const messageMap = + type === 'error' + ? componentLogsEntry.errors + : componentLogsEntry.warnings; + const count = messageMap.get(message) || 0; + messageMap.set(message, count + 1); + if (type === 'error') { + componentLogsEntry.errorsCount++; } else { - fiberMap.set(fiber, new Map([[message, 1]])); + componentLogsEntry.warningsCount++; } - // Passive effects may trigger errors or warnings too; - // In this case, we should wait until the rest of the passive effects have run, - // but we shouldn't wait until the next commit because that might be a long time. - // This would also cause "tearing" between an inspected Component and the tree view. - // Then again we don't want to flush too soon because this could be an error during async rendering. - // Use a debounce technique to ensure that we'll eventually flush. - flushPendingErrorsAndWarningsAfterDelay(); + // The changes will be flushed later when we commit. + + // If the log happened in a passive effect, then this happens after we've + // already committed the new tree so the change won't show up until we rerender + // that component again. We need to visit a Component with passive effects in + // handlePostCommitFiberRoot again to ensure that we flush the changes after passive. + needsToFlushComponentLogs = true; } // Patching the console enables DevTools to do a few useful things: // * Append component stacks to warnings and error messages // * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber) - registerRendererWithConsole(renderer, onErrorOrWarning); + registerRendererWithConsole(onErrorOrWarning, getComponentStack); // The renderer interface can't read these preferences directly, // because it is stored in localStorage within the context of the extension. // It relies on the extension to pass the preference through via the global. patchConsoleUsingWindowValues(); - const debug = ( + function debug( name: string, - fiber: Fiber, + instance: DevToolsInstance, parentInstance: null | DevToolsInstance, extraString: string = '', - ): void => { + ): void { if (__DEBUG__) { const displayName = - fiber.tag + ':' + (getDisplayNameForFiber(fiber) || 'null'); - - const maybeID = getFiberIDUnsafe(fiber) || ''; - - let parentDisplayName; - let maybeParentID; - if (parentInstance !== null && parentInstance.kind === FIBER_INSTANCE) { - const parentFiber = parentInstance.data; - parentDisplayName = - parentFiber.tag + - ':' + - (getDisplayNameForFiber(parentFiber) || 'null'); - maybeParentID = String(parentInstance.id); - } else { - // TODO: Handle VirtualInstance - parentDisplayName = ''; - maybeParentID = ''; - } + instance.kind === VIRTUAL_INSTANCE + ? instance.data.name || 'null' + : instance.data.tag + + ':' + + (getDisplayNameForFiber(instance.data) || 'null'); + + const maybeID = + instance.kind === FILTERED_FIBER_INSTANCE ? '' : instance.id; + + const parentDisplayName = + parentInstance === null + ? '' + : parentInstance.kind === VIRTUAL_INSTANCE + ? parentInstance.data.name || 'null' + : parentInstance.data.tag + + ':' + + (getDisplayNameForFiber(parentInstance.data) || 'null'); + + const maybeParentID = + parentInstance === null || + parentInstance.kind === FILTERED_FIBER_INSTANCE + ? '' + : parentInstance.id; console.groupCollapsed( `[renderer] %c${name} %c${displayName} (${maybeID}) %c${ @@ -1071,19 +1252,28 @@ export function attach( console.log(new Error().stack.split('\n').slice(1).join('\n')); console.groupEnd(); } - }; + } // eslint-disable-next-line no-unused-vars function debugTree(instance: DevToolsInstance, indent: number = 0) { if (__DEBUG__) { const name = - (instance.kind === FIBER_INSTANCE + (instance.kind !== VIRTUAL_INSTANCE ? getDisplayNameForFiber(instance.data) : instance.data.name) || ''; console.log( - ' '.repeat(indent) + '- ' + instance.id + ' (' + name + ')', + ' '.repeat(indent) + + '- ' + + (instance.kind === FILTERED_FIBER_INSTANCE ? 0 : instance.id) + + ' (' + + name + + ')', 'parent', - instance.parent === null ? ' ' : instance.parent.id, + instance.parent === null + ? ' ' + : instance.parent.kind === FILTERED_FIBER_INSTANCE + ? 0 + : instance.parent.id, 'next', instance.nextSibling === null ? ' ' : instance.nextSibling.id, ); @@ -1099,6 +1289,7 @@ export function attach( const hideElementsWithDisplayNames: Set = new Set(); const hideElementsWithPaths: Set = new Set(); const hideElementsWithTypes: Set = new Set(); + const hideElementsWithEnvs: Set = new Set(); // Highlight updates let traceUpdatesEnabled: boolean = false; @@ -1108,6 +1299,7 @@ export function attach( hideElementsWithTypes.clear(); hideElementsWithDisplayNames.clear(); hideElementsWithPaths.clear(); + hideElementsWithEnvs.clear(); componentFilters.forEach(componentFilter => { if (!componentFilter.isEnabled) { @@ -1133,6 +1325,9 @@ export function attach( case ComponentFilterHOC: hideElementsWithDisplayNames.add(new RegExp('\\(')); break; + case ComponentFilterEnvironmentName: + hideElementsWithEnvs.add(componentFilter.value); + break; default: console.warn( `Invalid component filter type "${componentFilter.type}"`, @@ -1155,7 +1350,7 @@ export function attach( // Unfortunately this feature is not expected to work for React Native for now. // It would be annoying for us to spam YellowBox warnings with unactionable stuff, // so for now just skip this message... - //console.warn('⚛️ DevTools: Could not locate saved component filters'); + //console.warn('⚛ DevTools: Could not locate saved component filters'); // Fallback to assuming the default filters in this case. applyComponentFilters(getDefaultComponentFilters()); @@ -1174,11 +1369,17 @@ export function attach( // Recursively unmount all roots. hook.getFiberRoots(rendererID).forEach(root => { - const rootInstance = getFiberInstanceThrows(root.current); - currentRootID = rootInstance.id; + const rootInstance = rootToFiberInstanceMap.get(root); + if (rootInstance === undefined) { + throw new Error( + 'Expected the root instance to already exist when applying filters', + ); + } + currentRoot = rootInstance; unmountInstanceRecursively(rootInstance); + rootToFiberInstanceMap.delete(root); flushPendingEvents(root); - currentRootID = -1; + currentRoot = (null: any); }); applyComponentFilters(componentFilters); @@ -1189,13 +1390,9 @@ export function attach( // Recursively re-mount all roots with new filter criteria applied. hook.getFiberRoots(rendererID).forEach(root => { const current = root.current; - const alternate = current.alternate; const newRoot = createFiberInstance(current); + rootToFiberInstanceMap.set(root, newRoot); idToDevToolsInstanceMap.set(newRoot.id, newRoot); - fiberToFiberInstanceMap.set(current, newRoot); - if (alternate) { - fiberToFiberInstanceMap.set(alternate, newRoot); - } // Before the traversals, remember to start tracking // our path in case we have selection to restore. @@ -1203,20 +1400,54 @@ export function attach( mightBeOnTrackedPath = true; } - currentRootID = newRoot.id; - setRootPseudoKey(currentRootID, root.current); + currentRoot = newRoot; + setRootPseudoKey(currentRoot.id, root.current); mountFiberRecursively(root.current, false); flushPendingEvents(root); - currentRootID = -1; + currentRoot = (null: any); }); - // Also re-evaluate all error and warning counts given the new filters. - reevaluateErrorsAndWarnings(); flushPendingEvents(); + + needsToFlushComponentLogs = false; } - function shouldFilterVirtual(data: ReactComponentInfo): boolean { - // TODO: Apply filters to VirtualInstances. + function getEnvironmentNames(): Array { + return Array.from(knownEnvironmentNames); + } + + function shouldFilterVirtual( + data: ReactComponentInfo, + secondaryEnv: null | string, + ): boolean { + // For purposes of filtering Server Components are always Function Components. + // Environment will be used to filter Server vs Client. + // Technically they can be forwardRef and memo too but those filters will go away + // as those become just plain user space function components like any HoC. + if (hideElementsWithTypes.has(ElementTypeFunction)) { + return true; + } + + if (hideElementsWithDisplayNames.size > 0) { + const displayName = data.name; + if (displayName != null) { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const displayNameRegExp of hideElementsWithDisplayNames) { + if (displayNameRegExp.test(displayName)) { + return true; + } + } + } + } + + if ( + (data.env == null || hideElementsWithEnvs.has(data.env)) && + (secondaryEnv === null || hideElementsWithEnvs.has(secondaryEnv)) + ) { + // If a Component has two environments, you have to filter both for it not to appear. + return true; + } + return false; } @@ -1275,6 +1506,26 @@ export function attach( } } + if (hideElementsWithEnvs.has('Client')) { + // If we're filtering out the Client environment we should filter out all + // "Client Components". Technically that also includes the built-ins but + // since that doesn't actually include any additional code loading it's + // useful to not filter out the built-ins. Those can be filtered separately. + // There's no other way to filter out just Function components on the Client. + // Therefore, this only filters Class and Function components. + switch (tag) { + case ClassComponent: + case IncompleteClassComponent: + case IncompleteFunctionComponent: + case FunctionComponent: + case IndeterminateComponent: + case ForwardRef: + case MemoComponent: + case SimpleMemoComponent: + return true; + } + } + /* DISABLED: https://github.com/facebook/react/pull/28417 if (hideElementsWithPaths.size > 0) { const source = getSourceForFiber(fiber); @@ -1354,98 +1605,50 @@ export function attach( } // When a mount or update is in progress, this value tracks the root that is being operated on. - let currentRootID: number = -1; - - // Returns a FiberInstance if one has already been generated for the Fiber or throws. - function getFiberInstanceThrows(fiber: Fiber): FiberInstance { - const fiberInstance = getFiberInstanceUnsafe(fiber); - if (fiberInstance !== null) { - return fiberInstance; - } - throw Error( - `Could not find ID for Fiber "${getDisplayNameForFiber(fiber) || ''}"`, - ); - } - - function getFiberIDThrows(fiber: Fiber): number { - const fiberInstance = getFiberInstanceUnsafe(fiber); - if (fiberInstance !== null) { - return fiberInstance.id; - } - throw Error( - `Could not find ID for Fiber "${getDisplayNameForFiber(fiber) || ''}"`, - ); - } - - // Returns a FiberInstance if one has already been generated for the Fiber or null if one has not been generated. - // Use this method while e.g. logging to avoid over-retaining Fibers. - function getFiberInstanceUnsafe(fiber: Fiber): FiberInstance | null { - const fiberInstance = fiberToFiberInstanceMap.get(fiber); - if (fiberInstance !== undefined) { - return fiberInstance; - } else { - const {alternate} = fiber; - if (alternate !== null) { - const alternateInstance = fiberToFiberInstanceMap.get(alternate); - if (alternateInstance !== undefined) { - return alternateInstance; - } - } - } - return null; - } - - function getFiberIDUnsafe(fiber: Fiber): number | null { - const fiberInstance = getFiberInstanceUnsafe(fiber); - return fiberInstance === null ? null : fiberInstance.id; - } + let currentRoot: FiberInstance = (null: any); // Removes a Fiber (and its alternate) from the Maps used to track their id. // This method should always be called when a Fiber is unmounting. - function untrackFiber(fiberInstance: FiberInstance) { - if (__DEBUG__) { - debug('untrackFiber()', fiberInstance.data, null); - } - - idToDevToolsInstanceMap.delete(fiberInstance.id); - - const fiber = fiberInstance.data; - - // Restore any errors/warnings associated with this fiber to the pending - // map. I.e. treat it as before we tracked the instances. This lets us - // restore them if we remount the same Fibers later. Otherwise we rely - // on the GC of the Fibers to clean them up. - if (fiberInstance.errors !== null) { - pendingFiberToErrorsMap.set(fiber, fiberInstance.errors); - fiberInstance.errors = null; - } - if (fiberInstance.warnings !== null) { - pendingFiberToWarningsMap.set(fiber, fiberInstance.warnings); - fiberInstance.warnings = null; - } - - if (fiberInstance.flags & FORCE_ERROR) { - fiberInstance.flags &= ~FORCE_ERROR; - forceErrorCount--; - if (forceErrorCount === 0 && setErrorHandler != null) { + function untrackFiber(nearestInstance: DevToolsInstance, fiber: Fiber) { + if (forceErrorForFibers.size > 0) { + forceErrorForFibers.delete(fiber); + if (fiber.alternate) { + forceErrorForFibers.delete(fiber.alternate); + } + if (forceErrorForFibers.size === 0 && setErrorHandler != null) { setErrorHandler(shouldErrorFiberAlwaysNull); } } - if (fiberInstance.flags & FORCE_SUSPENSE_FALLBACK) { - fiberInstance.flags &= ~FORCE_SUSPENSE_FALLBACK; - forceFallbackCount--; - if (forceFallbackCount === 0 && setSuspenseHandler != null) { + + if (forceFallbackForFibers.size > 0) { + forceFallbackForFibers.delete(fiber); + if (fiber.alternate) { + forceFallbackForFibers.delete(fiber.alternate); + } + if (forceFallbackForFibers.size === 0 && setSuspenseHandler != null) { setSuspenseHandler(shouldSuspendFiberAlwaysFalse); } } - if (fiberToFiberInstanceMap.get(fiber) === fiberInstance) { - fiberToFiberInstanceMap.delete(fiber); + // TODO: Consider using a WeakMap instead. The only thing where that doesn't work + // is React Native Paper which tracks tags but that support is eventually going away + // and can use the old findFiberByHostInstance strategy. + + if (fiber.tag === HostHoistable) { + releaseHostResource(nearestInstance, fiber.memoizedState); + } else if ( + fiber.tag === HostComponent || + fiber.tag === HostText || + fiber.tag === HostSingleton + ) { + releaseHostInstance(nearestInstance, fiber.stateNode); } - const {alternate} = fiber; - if (alternate !== null) { - if (fiberToFiberInstanceMap.get(alternate) === fiberInstance) { - fiberToFiberInstanceMap.delete(alternate); + + // Recursively clean up any filtered Fibers below this one as well since + // we won't recordUnmount on those. + for (let child = fiber.child; child !== null; child = child.sibling) { + if (shouldFilterFiber(child)) { + untrackFiber(nearestInstance, child); } } } @@ -1454,11 +1657,8 @@ export function attach( prevFiber: Fiber | null, nextFiber: Fiber, ): ChangeDescription | null { - switch (getElementTypeForFiber(nextFiber)) { - case ElementTypeClass: - case ElementTypeFunction: - case ElementTypeMemo: - case ElementTypeForwardRef: + switch (nextFiber.tag) { + case ClassComponent: if (prevFiber === null) { return { context: null, @@ -1469,7 +1669,7 @@ export function attach( }; } else { const data: ChangeDescription = { - context: getContextChangedKeys(nextFiber), + context: getContextChanged(prevFiber, nextFiber), didHooksChange: false, isFirstMount: false, props: getChangedKeys( @@ -1481,15 +1681,39 @@ export function attach( nextFiber.memoizedState, ), }; - - // Only traverse the hooks list once, depending on what info we're returning. + return data; + } + case IncompleteFunctionComponent: + case FunctionComponent: + case IndeterminateComponent: + case ForwardRef: + case MemoComponent: + case SimpleMemoComponent: + if (prevFiber === null) { + return { + context: null, + didHooksChange: false, + isFirstMount: true, + props: null, + state: null, + }; + } else { const indices = getChangedHooksIndices( prevFiber.memoizedState, nextFiber.memoizedState, ); - data.hooks = indices; - data.didHooksChange = indices !== null && indices.length > 0; - + const data: ChangeDescription = { + context: getContextChanged(prevFiber, nextFiber), + didHooksChange: indices !== null && indices.length > 0, + isFirstMount: false, + props: getChangedKeys( + prevFiber.memoizedProps, + nextFiber.memoizedProps, + ), + state: null, + hooks: indices, + }; + // Only traverse the hooks list once, depending on what info we're returning. return data; } default: @@ -1497,139 +1721,33 @@ export function attach( } } - function updateContextsForFiber(fiber: Fiber) { - switch (getElementTypeForFiber(fiber)) { - case ElementTypeClass: - case ElementTypeForwardRef: - case ElementTypeFunction: - case ElementTypeMemo: - if (idToContextsMap !== null) { - const id = getFiberIDThrows(fiber); - const contexts = getContextsForFiber(fiber); - if (contexts !== null) { - // $FlowFixMe[incompatible-use] found when upgrading Flow - idToContextsMap.set(id, contexts); - } - } - break; - default: - break; - } - } - - // Differentiates between a null context value and no context. - const NO_CONTEXT = {}; - - function getContextsForFiber(fiber: Fiber): [Object, any] | null { - let legacyContext = NO_CONTEXT; - let modernContext = NO_CONTEXT; - - switch (getElementTypeForFiber(fiber)) { - case ElementTypeClass: - const instance = fiber.stateNode; - if (instance != null) { - if ( - instance.constructor && - instance.constructor.contextType != null - ) { - modernContext = instance.context; - } else { - legacyContext = instance.context; - if (legacyContext && Object.keys(legacyContext).length === 0) { - legacyContext = NO_CONTEXT; - } - } - } - return [legacyContext, modernContext]; - case ElementTypeForwardRef: - case ElementTypeFunction: - case ElementTypeMemo: - const dependencies = fiber.dependencies; - if (dependencies && dependencies.firstContext) { - modernContext = dependencies.firstContext; - } - - return [legacyContext, modernContext]; - default: - return null; - } - } - - // Record all contexts at the time profiling is started. - // Fibers only store the current context value, - // so we need to track them separately in order to determine changed keys. - function crawlToInitializeContextsMap(fiber: Fiber) { - const id = getFiberIDUnsafe(fiber); - - // Not all Fibers in the subtree have mounted yet. - // For example, Offscreen (hidden) or Suspense (suspended) subtrees won't yet be tracked. - // We can safely skip these subtrees. - if (id !== null) { - updateContextsForFiber(fiber); - - let current = fiber.child; - while (current !== null) { - crawlToInitializeContextsMap(current); - current = current.sibling; + function getContextChanged(prevFiber: Fiber, nextFiber: Fiber): boolean { + let prevContext = + prevFiber.dependencies && prevFiber.dependencies.firstContext; + let nextContext = + nextFiber.dependencies && nextFiber.dependencies.firstContext; + + while (prevContext && nextContext) { + // Note this only works for versions of React that support this key (e.v. 18+) + // For older versions, there's no good way to read the current context value after render has completed. + // This is because React maintains a stack of context values during render, + // but by the time DevTools is called, render has finished and the stack is empty. + if (prevContext.context !== nextContext.context) { + // If the order of context has changed, then the later context values might have + // changed too but the main reason it rerendered was earlier. Either an earlier + // context changed value but then we would have exited already. If we end up here + // it's because a state or props change caused the order of contexts used to change. + // So the main cause is not the contexts themselves. + return false; } - } - } - - function getContextChangedKeys(fiber: Fiber): null | boolean | Array { - if (idToContextsMap !== null) { - const id = getFiberIDThrows(fiber); - // $FlowFixMe[incompatible-use] found when upgrading Flow - const prevContexts = idToContextsMap.has(id) - ? // $FlowFixMe[incompatible-use] found when upgrading Flow - idToContextsMap.get(id) - : null; - const nextContexts = getContextsForFiber(fiber); - - if (prevContexts == null || nextContexts == null) { - return null; + if (!is(prevContext.memoizedValue, nextContext.memoizedValue)) { + return true; } - const [prevLegacyContext, prevModernContext] = prevContexts; - const [nextLegacyContext, nextModernContext] = nextContexts; - - switch (getElementTypeForFiber(fiber)) { - case ElementTypeClass: - if (prevContexts && nextContexts) { - if (nextLegacyContext !== NO_CONTEXT) { - return getChangedKeys(prevLegacyContext, nextLegacyContext); - } else if (nextModernContext !== NO_CONTEXT) { - return prevModernContext !== nextModernContext; - } - } - break; - case ElementTypeForwardRef: - case ElementTypeFunction: - case ElementTypeMemo: - if (nextModernContext !== NO_CONTEXT) { - let prevContext = prevModernContext; - let nextContext = nextModernContext; - - while (prevContext && nextContext) { - // Note this only works for versions of React that support this key (e.v. 18+) - // For older versions, there's no good way to read the current context value after render has completed. - // This is because React maintains a stack of context values during render, - // but by the time DevTools is called, render has finished and the stack is empty. - if (!is(prevContext.memoizedValue, nextContext.memoizedValue)) { - return true; - } - - prevContext = prevContext.next; - nextContext = nextContext.next; - } - - return false; - } - break; - default: - break; - } + prevContext = prevContext.next; + nextContext = nextContext.next; } - return null; + return false; } function isHookThatCanScheduleUpdate(hookObject: any) { @@ -1674,20 +1792,13 @@ export function attach( const indices = []; let index = 0; - if ( - next.hasOwnProperty('baseState') && - next.hasOwnProperty('memoizedState') && - next.hasOwnProperty('next') && - next.hasOwnProperty('queue') - ) { - while (next !== null) { - if (didStatefulHookChange(prev, next)) { - indices.push(index); - } - next = next.next; - prev = prev.next; - index++; + while (next !== null) { + if (didStatefulHookChange(prev, next)) { + indices.push(index); } + next = next.next; + prev = prev.next; + index++; } return indices; @@ -1698,16 +1809,6 @@ export function attach( return null; } - // We can't report anything meaningful for hooks changes. - if ( - next.hasOwnProperty('baseState') && - next.hasOwnProperty('memoizedState') && - next.hasOwnProperty('next') && - next.hasOwnProperty('queue') - ) { - return null; - } - const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); const changedKeys = []; // eslint-disable-next-line no-for-of-loops/no-for-of-loops @@ -1803,159 +1904,40 @@ export function attach( } } - let flushPendingErrorsAndWarningsAfterDelayTimeoutID: null | TimeoutID = null; - - function clearPendingErrorsAndWarningsAfterDelay() { - if (flushPendingErrorsAndWarningsAfterDelayTimeoutID !== null) { - clearTimeout(flushPendingErrorsAndWarningsAfterDelayTimeoutID); - flushPendingErrorsAndWarningsAfterDelayTimeoutID = null; - } - } - - function flushPendingErrorsAndWarningsAfterDelay() { - clearPendingErrorsAndWarningsAfterDelay(); - - flushPendingErrorsAndWarningsAfterDelayTimeoutID = setTimeout(() => { - flushPendingErrorsAndWarningsAfterDelayTimeoutID = null; - - if (pendingOperations.length > 0) { - // On the off chance that something else has pushed pending operations, - // we should bail on warnings; it's probably not safe to push midway. - return; - } - - recordPendingErrorsAndWarnings(); - - if (shouldBailoutWithPendingOperations()) { - // No warnings or errors to flush; we can bail out early here too. - return; - } - - // We can create a smaller operations array than flushPendingEvents() - // because we only need to flush warning and error counts. - // Only a few pieces of fixed information are required up front. - const operations: OperationsArray = new Array( - 3 + pendingOperations.length, - ); - operations[0] = rendererID; - operations[1] = currentRootID; - operations[2] = 0; // String table size - for (let j = 0; j < pendingOperations.length; j++) { - operations[3 + j] = pendingOperations[j]; - } - - flushOrQueueOperations(operations); - - pendingOperations.length = 0; - }, 1000); - } - - function reevaluateErrorsAndWarnings() { - fibersWithChangedErrorOrWarningCounts.clear(); - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const devtoolsInstance of idToDevToolsInstanceMap.values()) { - if (devtoolsInstance.kind === FIBER_INSTANCE) { - fibersWithChangedErrorOrWarningCounts.add(devtoolsInstance.data); - } else { - // TODO: Handle VirtualInstance. - } - } - recordPendingErrorsAndWarnings(); - } - - function mergeMapsAndGetCountHelper( - fiber: Fiber, - fiberID: number, - pendingFiberToMessageCountMap: WeakMap>, - forError: boolean, - ): number { - let newCount = 0; - - const devtoolsInstance = idToDevToolsInstanceMap.get(fiberID); - - if (devtoolsInstance === undefined) { - return 0; - } - - let messageCountMap = forError - ? devtoolsInstance.errors - : devtoolsInstance.warnings; - - const pendingMessageCountMap = pendingFiberToMessageCountMap.get(fiber); - if (pendingMessageCountMap != null) { - if (messageCountMap === null) { - messageCountMap = pendingMessageCountMap; - if (forError) { - devtoolsInstance.errors = pendingMessageCountMap; - } else { - devtoolsInstance.warnings = pendingMessageCountMap; - } - } else { - // This Flow refinement should not be necessary and yet... - const refinedMessageCountMap = ((messageCountMap: any): Map< - string, - number, - >); - - pendingMessageCountMap.forEach((pendingCount, message) => { - const previousCount = refinedMessageCountMap.get(message) || 0; - refinedMessageCountMap.set(message, previousCount + pendingCount); - }); + function recordConsoleLogs( + instance: FiberInstance | VirtualInstance, + componentLogsEntry: void | ComponentLogs, + ): boolean { + if (componentLogsEntry === undefined) { + if (instance.logCount === 0) { + // Nothing has changed. + return false; } - } - - if (!shouldFilterFiber(fiber)) { - if (messageCountMap != null) { - messageCountMap.forEach(count => { - newCount += count; - }); + // Reset to zero. + instance.logCount = 0; + pushOperation(TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS); + pushOperation(instance.id); + pushOperation(0); + pushOperation(0); + return true; + } else { + const totalCount = + componentLogsEntry.errorsCount + componentLogsEntry.warningsCount; + if (instance.logCount === totalCount) { + // Nothing has changed. + return false; } + // Update counts. + instance.logCount = totalCount; + pushOperation(TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS); + pushOperation(instance.id); + pushOperation(componentLogsEntry.errorsCount); + pushOperation(componentLogsEntry.warningsCount); + return true; } - - pendingFiberToMessageCountMap.delete(fiber); - - return newCount; - } - - function recordPendingErrorsAndWarnings() { - clearPendingErrorsAndWarningsAfterDelay(); - - fibersWithChangedErrorOrWarningCounts.forEach(fiber => { - const fiberID = getFiberIDUnsafe(fiber); - if (fiberID === null) { - // Don't send updates for Fibers that didn't mount due to e.g. Suspense or an error boundary. - } else { - const errorCount = mergeMapsAndGetCountHelper( - fiber, - fiberID, - pendingFiberToErrorsMap, - true, - ); - const warningCount = mergeMapsAndGetCountHelper( - fiber, - fiberID, - pendingFiberToWarningsMap, - false, - ); - - pushOperation(TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS); - pushOperation(fiberID); - pushOperation(errorCount); - pushOperation(warningCount); - - // Only clear the ones that we've already shown. Leave others in case - // they mount later. - pendingFiberToErrorsMap.delete(fiber); - pendingFiberToWarningsMap.delete(fiber); - } - }); - fibersWithChangedErrorOrWarningCounts.clear(); } function flushPendingEvents(root: Object): void { - // Add any pending errors and warnings to the operations array. - recordPendingErrorsAndWarnings(); - if (shouldBailoutWithPendingOperations()) { // If we aren't profiling, we can just bail out here. // No use sending an empty update over the bridge. @@ -1991,7 +1973,12 @@ export function attach( // Which in turn enables fiber props, states, and hooks to be inspected. let i = 0; operations[i++] = rendererID; - operations[i++] = currentRootID; + if (currentRoot === null) { + // TODO: This is not always safe so this field is probably not needed. + operations[i++] = -1; + } else { + operations[i++] = currentRoot.id; + } // Now fill in the string table. // [stringTableLength, str1Length, ...str1, str2Length, ...str2, ...] @@ -2079,7 +2066,7 @@ export function attach( const isRoot = fiber.tag === HostRoot; let fiberInstance; if (isRoot) { - const entry = fiberToFiberInstanceMap.get(fiber); + const entry = rootToFiberInstanceMap.get(fiber.stateNode); if (entry === undefined) { throw new Error('The root should have been registered at this point'); } @@ -2087,20 +2074,12 @@ export function attach( } else { fiberInstance = createFiberInstance(fiber); } - // If this already exists behind a different FiberInstance, we intentionally - // override it here to claim the fiber as part of this new instance. - // E.g. if it was part of a reparenting. - fiberToFiberInstanceMap.set(fiber, fiberInstance); - const alternate = fiber.alternate; - if (alternate !== null && fiberToFiberInstanceMap.has(alternate)) { - fiberToFiberInstanceMap.set(alternate, fiberInstance); - } idToDevToolsInstanceMap.set(fiberInstance.id, fiberInstance); const id = fiberInstance.id; if (__DEBUG__) { - debug('recordMount()', fiber, parentInstance); + debug('recordMount()', fiberInstance, parentInstance); } const isProfilingSupported = fiber.hasOwnProperty('treeBaseDuration'); @@ -2162,7 +2141,12 @@ export function attach( ownerInstance.source = fiber._debugStack; } const ownerID = ownerInstance === null ? 0 : ownerInstance.id; - const parentID = parentInstance ? parentInstance.id : 0; + const parentID = parentInstance + ? parentInstance.kind === FILTERED_FIBER_INSTANCE + ? // A Filtered Fiber Instance will always have a Virtual Instance as a parent. + ((parentInstance.parent: any): VirtualInstance).id + : parentInstance.id + : 0; const displayNameStringID = getStringID(displayName); @@ -2198,8 +2182,14 @@ export function attach( } } + let componentLogsEntry = fiberToComponentLogsMap.get(fiber); + if (componentLogsEntry === undefined && fiber.alternate !== null) { + componentLogsEntry = fiberToComponentLogsMap.get(fiber.alternate); + } + recordConsoleLogs(fiberInstance, componentLogsEntry); + if (isProfilingSupported) { - recordProfilingDurations(fiberInstance); + recordProfilingDurations(fiberInstance, null); } return fiberInstance; } @@ -2207,6 +2197,7 @@ export function attach( function recordVirtualMount( instance: VirtualInstance, parentInstance: DevToolsInstance | null, + secondaryEnv: null | string, ): void { const id = instance.id; @@ -2220,6 +2211,9 @@ export function attach( let displayName = componentInfo.name || ''; if (typeof env === 'string') { // We model environment as an HoC name for now. + if (secondaryEnv !== null) { + displayName = secondaryEnv + '(' + displayName + ')'; + } displayName = env + '(' + displayName + ')'; } const elementType = ElementTypeVirtual; @@ -2242,7 +2236,12 @@ export function attach( ownerInstance.source = componentInfo.debugStack; } const ownerID = ownerInstance === null ? 0 : ownerInstance.id; - const parentID = parentInstance ? parentInstance.id : 0; + const parentID = parentInstance + ? parentInstance.kind === FILTERED_FIBER_INSTANCE + ? // A Filtered Fiber Instance will always have a Virtual Instance as a parent. + ((parentInstance.parent: any): VirtualInstance).id + : parentInstance.id + : 0; const displayNameStringID = getStringID(displayName); @@ -2258,24 +2257,23 @@ export function attach( pushOperation(ownerID); pushOperation(displayNameStringID); pushOperation(keyStringID); + + const componentLogsEntry = + componentInfoToComponentLogsMap.get(componentInfo); + recordConsoleLogs(instance, componentLogsEntry); } function recordUnmount(fiberInstance: FiberInstance): void { const fiber = fiberInstance.data; if (__DEBUG__) { - debug('recordUnmount()', fiber, null); + debug('recordUnmount()', fiberInstance, reconcilingParent); } - if (trackedPathMatchFiber !== null) { + if (trackedPathMatchInstance === fiberInstance) { // We're in the process of trying to restore previous selection. // If this fiber matched but is being unmounted, there's no use trying. // Reset the state so we don't keep holding onto it. - if ( - fiber === trackedPathMatchFiber || - fiber === trackedPathMatchFiber.alternate - ) { - setTrackedPath(null); - } + setTrackedPath(null); } const id = fiberInstance.id; @@ -2291,7 +2289,9 @@ export function attach( pendingRealUnmountedIDs.push(id); } - untrackFiber(fiberInstance); + idToDevToolsInstanceMap.delete(fiberInstance.id); + + untrackFiber(fiberInstance, fiber); } // Running state of the remaining children from the previous version of this parent that @@ -2386,6 +2386,14 @@ export function attach( traceNearestHostComponentUpdate: boolean, virtualLevel: number, // the nth level of virtual instances ): void { + // If we have the tree selection from previous reload, try to match this Instance. + // Also remember whether to do the same for siblings. + const mightSiblingsBeOnTrackedPath = + updateVirtualTrackedPathStateBeforeMount( + virtualInstance, + reconcilingParent, + ); + const stashedParent = reconcilingParent; const stashedPrevious = previouslyReconciledSibling; const stashedRemaining = remainingReconcilingChildren; @@ -2406,19 +2414,41 @@ export function attach( reconcilingParent = stashedParent; previouslyReconciledSibling = stashedPrevious; remainingReconcilingChildren = stashedRemaining; + updateTrackedPathStateAfterMount(mightSiblingsBeOnTrackedPath); } } function recordVirtualUnmount(instance: VirtualInstance) { - if (trackedPathMatchFiber !== null) { + if (trackedPathMatchInstance === instance) { // We're in the process of trying to restore previous selection. - // TODO: Handle virtual instances on the tracked path. + // If this fiber matched but is being unmounted, there's no use trying. + // Reset the state so we don't keep holding onto it. + setTrackedPath(null); } const id = instance.id; pendingRealUnmountedIDs.push(id); } + function getSecondaryEnvironmentName( + debugInfo: ?ReactDebugInfo, + index: number, + ): null | string { + if (debugInfo != null) { + const componentInfo: ReactComponentInfo = (debugInfo[index]: any); + for (let i = index + 1; i < debugInfo.length; i++) { + const debugEntry = debugInfo[i]; + if (typeof debugEntry.env === 'string') { + // If the next environment is different then this component was the boundary + // and it changed before entering the next component. So we assign this + // component a secondary environment. + return componentInfo.env !== debugEntry.env ? debugEntry.env : null; + } + } + } + return null; + } + function mountVirtualChildrenRecursively( firstChild: Fiber, lastChild: null | Fiber, // non-inclusive @@ -2439,7 +2469,19 @@ export function attach( // Not a Component. Some other Debug Info. continue; } + // Scan up until the next Component to see if this component changed environment. const componentInfo: ReactComponentInfo = (debugEntry: any); + const secondaryEnv = getSecondaryEnvironmentName(fiber._debugInfo, i); + if (componentInfo.env != null) { + knownEnvironmentNames.add(componentInfo.env); + } + if (secondaryEnv !== null) { + knownEnvironmentNames.add(secondaryEnv); + } + if (shouldFilterVirtual(componentInfo, secondaryEnv)) { + // Skip. + continue; + } if (level === virtualLevel) { if ( previousVirtualInstance === null || @@ -2458,7 +2500,11 @@ export function attach( ); } previousVirtualInstance = createVirtualInstance(componentInfo); - recordVirtualMount(previousVirtualInstance, reconcilingParent); + recordVirtualMount( + previousVirtualInstance, + reconcilingParent, + secondaryEnv, + ); insertChild(previousVirtualInstance); previousVirtualInstanceFirstFiber = fiber; } @@ -2517,25 +2563,49 @@ export function attach( fiber: Fiber, traceNearestHostComponentUpdate: boolean, ): void { - if (__DEBUG__) { - debug('mountFiberRecursively()', fiber, reconcilingParent); - } - - // If we have the tree selection from previous reload, try to match this Fiber. - // Also remember whether to do the same for siblings. - const mightSiblingsBeOnTrackedPath = - updateTrackedPathStateBeforeMount(fiber); - const shouldIncludeInTree = !shouldFilterFiber(fiber); let newInstance = null; if (shouldIncludeInTree) { newInstance = recordMount(fiber, reconcilingParent); insertChild(newInstance); + if (__DEBUG__) { + debug('mountFiberRecursively()', newInstance, reconcilingParent); + } + } else if ( + reconcilingParent !== null && + reconcilingParent.kind === VIRTUAL_INSTANCE + ) { + // If the parent is a Virtual Instance and we filtered this Fiber we include a + // hidden node. + + if ( + reconcilingParent.data === fiber._debugOwner && + fiber._debugStack != null && + reconcilingParent.source === null + ) { + // The new Fiber is directly owned by the parent. Therefore somewhere on the + // debugStack will be a stack frame inside parent that we can use as its soruce. + reconcilingParent.source = fiber._debugStack; + } + + newInstance = createFilteredFiberInstance(fiber); + insertChild(newInstance); + if (__DEBUG__) { + debug('mountFiberRecursively()', newInstance, reconcilingParent); + } } + + // If we have the tree selection from previous reload, try to match this Fiber. + // Also remember whether to do the same for siblings. + const mightSiblingsBeOnTrackedPath = updateTrackedPathStateBeforeMount( + fiber, + newInstance, + ); + const stashedParent = reconcilingParent; const stashedPrevious = previouslyReconciledSibling; const stashedRemaining = remainingReconcilingChildren; - if (shouldIncludeInTree) { + if (newInstance !== null) { // Push a new DevTools instance parent while reconciling this subtree. reconcilingParent = newInstance; previouslyReconciledSibling = null; @@ -2557,7 +2627,21 @@ export function attach( } if (fiber.tag === HostHoistable) { - aquireHostResource(fiber, fiber.memoizedState); + const nearestInstance = reconcilingParent; + if (nearestInstance === null) { + throw new Error('Did not expect a host hoistable to be the root'); + } + aquireHostResource(nearestInstance, fiber.memoizedState); + } else if ( + fiber.tag === HostComponent || + fiber.tag === HostText || + fiber.tag === HostSingleton + ) { + const nearestInstance = reconcilingParent; + if (nearestInstance === null) { + throw new Error('Did not expect a host hoistable to be the root'); + } + aquireHostInstance(nearestInstance, fiber.stateNode); } if (fiber.tag === SuspenseComponent) { @@ -2570,14 +2654,15 @@ export function attach( const fallbackChildFragment = primaryChildFragment ? primaryChildFragment.sibling : null; - const fallbackChild = fallbackChildFragment - ? fallbackChildFragment.child - : null; - if (fallbackChild !== null) { - mountChildrenRecursively( - fallbackChild, - traceNearestHostComponentUpdate, - ); + if (fallbackChildFragment) { + const fallbackChild = fallbackChildFragment.child; + if (fallbackChild !== null) { + updateTrackedPathStateBeforeMount(fallbackChildFragment, null); + mountChildrenRecursively( + fallbackChild, + traceNearestHostComponentUpdate, + ); + } } } else { let primaryChild: Fiber | null = null; @@ -2587,6 +2672,7 @@ export function attach( primaryChild = fiber.child; } else if (fiber.child !== null) { primaryChild = fiber.child.child; + updateTrackedPathStateBeforeMount(fiber.child, null); } if (primaryChild !== null) { mountChildrenRecursively( @@ -2604,7 +2690,7 @@ export function attach( } } } finally { - if (shouldIncludeInTree) { + if (newInstance !== null) { reconcilingParent = stashedParent; previouslyReconciledSibling = stashedPrevious; remainingReconcilingChildren = stashedRemaining; @@ -2620,9 +2706,7 @@ export function attach( // when we switch from primary to fallback, or deleting a subtree. function unmountInstanceRecursively(instance: DevToolsInstance) { if (__DEBUG__) { - if (instance.kind === FIBER_INSTANCE) { - debug('unmountInstanceRecursively()', instance.data, null); - } + debug('unmountInstanceRecursively()', instance, reconcilingParent); } const stashedParent = reconcilingParent; @@ -2644,13 +2728,18 @@ export function attach( } if (instance.kind === FIBER_INSTANCE) { recordUnmount(instance); - } else { + } else if (instance.kind === VIRTUAL_INSTANCE) { recordVirtualUnmount(instance); + } else { + untrackFiber(instance, instance.data); } removeChild(instance, null); } - function recordProfilingDurations(fiberInstance: FiberInstance) { + function recordProfilingDurations( + fiberInstance: FiberInstance, + prevFiber: null | Fiber, + ) { const id = fiberInstance.id; const fiber = fiberInstance.data; const {actualDuration, treeBaseDuration} = fiber; @@ -2658,13 +2747,11 @@ export function attach( fiberInstance.treeBaseDuration = treeBaseDuration || 0; if (isProfiling) { - const {alternate} = fiber; - // It's important to update treeBaseDuration even if the current Fiber did not render, // because it's possible that one of its descendants did. if ( - alternate == null || - treeBaseDuration !== alternate.treeBaseDuration + prevFiber == null || + treeBaseDuration !== prevFiber.treeBaseDuration ) { // Tree base duration updates are included in the operations typed array. // So we have to convert them from milliseconds to microseconds so we can send them as ints. @@ -2676,7 +2763,7 @@ export function attach( pushOperation(convertedTreeBaseDuration); } - if (alternate == null || didFiberRender(alternate, fiber)) { + if (prevFiber == null || didFiberRender(prevFiber, fiber)) { if (actualDuration != null) { // The actual duration reported by React includes time spent working on children. // This is useful information, but it's also useful to be able to exclude child durations. @@ -2704,17 +2791,34 @@ export function attach( ); if (recordChangeDescriptions) { - const changeDescription = getChangeDescription(alternate, fiber); + const changeDescription = getChangeDescription(prevFiber, fiber); if (changeDescription !== null) { if (metadata.changeDescriptions !== null) { metadata.changeDescriptions.set(id, changeDescription); } } - - updateContextsForFiber(fiber); } } } + + // If this Fiber was in the set of memoizedUpdaters we need to record + // it to be included in the description of the commit. + const fiberRoot: FiberRoot = currentRoot.data.stateNode; + const updaters = fiberRoot.memoizedUpdaters; + if ( + updaters != null && + (updaters.has(fiber) || + // We check the alternate here because we're matching identity and + // prevFiber might be same as fiber. + (fiber.alternate !== null && updaters.has(fiber.alternate))) + ) { + const metadata = + ((currentCommitProfilingMetadata: any): CommitProfilingData); + if (metadata.updaters === null) { + metadata.updaters = []; + } + metadata.updaters.push(instanceToSerializedElement(fiberInstance)); + } } } @@ -2750,15 +2854,14 @@ export function attach( virtualInstance.treeBaseDuration = treeBaseDuration; } - function recordResetChildren(parentInstance: DevToolsInstance) { + function recordResetChildren( + parentInstance: FiberInstance | VirtualInstance, + ) { if (__DEBUG__) { - if ( - parentInstance.firstChild !== null && - parentInstance.firstChild.kind === FIBER_INSTANCE - ) { + if (parentInstance.firstChild !== null) { debug( 'recordResetChildren()', - parentInstance.firstChild.data, + parentInstance.firstChild, parentInstance, ); } @@ -2770,7 +2873,17 @@ export function attach( let child: null | DevToolsInstance = parentInstance.firstChild; while (child !== null) { - nextChildren.push(child.id); + if (child.kind === FILTERED_FIBER_INSTANCE) { + for ( + let innerChild: null | DevToolsInstance = parentInstance.firstChild; + innerChild !== null; + innerChild = innerChild.nextSibling + ) { + nextChildren.push((innerChild: any).id); + } + } else { + nextChildren.push(child.id); + } child = child.nextSibling; } @@ -2817,6 +2930,14 @@ export function attach( ) { recordResetChildren(virtualInstance); } + // Update the errors/warnings count. If this Instance has switched to a different + // ReactComponentInfo instance, such as when refreshing Server Components, then + // we replace all the previous logs with the ones associated with the new ones rather + // than merging. Because deduping is expected to happen at the request level. + const componentLogsEntry = componentInfoToComponentLogsMap.get( + virtualInstance.data, + ); + recordConsoleLogs(virtualInstance, componentLogsEntry); // Must be called after all children have been appended. recordVirtualProfilingDurations(virtualInstance); } finally { @@ -2853,6 +2974,19 @@ export function attach( continue; } const componentInfo: ReactComponentInfo = (debugEntry: any); + const secondaryEnv = getSecondaryEnvironmentName( + nextChild._debugInfo, + i, + ); + if (componentInfo.env != null) { + knownEnvironmentNames.add(componentInfo.env); + } + if (secondaryEnv !== null) { + knownEnvironmentNames.add(secondaryEnv); + } + if (shouldFilterVirtual(componentInfo, secondaryEnv)) { + continue; + } if (level === virtualLevel) { if ( previousVirtualInstance === null || @@ -2914,7 +3048,11 @@ export function attach( } else { // Otherwise we create a new instance. const newVirtualInstance = createVirtualInstance(componentInfo); - recordVirtualMount(newVirtualInstance, reconcilingParent); + recordVirtualMount( + newVirtualInstance, + reconcilingParent, + secondaryEnv, + ); insertChild(newVirtualInstance); previousVirtualInstance = newVirtualInstance; previousVirtualInstanceWasMount = true; @@ -2998,11 +3136,6 @@ export function attach( shouldResetChildren = true; } - // Register the new alternate in case it's not already in. - fiberToFiberInstanceMap.set(nextChild, fiberInstance); - - // Update the Fiber so we that we always keep the current Fiber on the data. - fiberInstance.data = nextChild; moveChild(fiberInstance, previousSiblingOfExistingInstance); if ( @@ -3108,7 +3241,9 @@ export function attach( traceNearestHostComponentUpdate: boolean, ): boolean { if (__DEBUG__) { - debug('updateFiberRecursively()', nextFiber, reconcilingParent); + if (fiberInstance !== null) { + debug('updateFiberRecursively()', fiberInstance, reconcilingParent); + } } if (traceUpdatesEnabled) { @@ -3140,6 +3275,8 @@ export function attach( const stashedPrevious = previouslyReconciledSibling; const stashedRemaining = remainingReconcilingChildren; if (fiberInstance !== null) { + // Update the Fiber so we that we always keep the current Fiber on the data. + fiberInstance.data = nextFiber; if ( mostRecentlyInspectedElement !== null && mostRecentlyInspectedElement.id === fiberInstance.id && @@ -3159,8 +3296,12 @@ export function attach( } try { if (nextFiber.tag === HostHoistable) { - releaseHostResource(prevFiber, prevFiber.memoizedState); - aquireHostResource(nextFiber, nextFiber.memoizedState); + 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); } const isSuspense = nextFiber.tag === SuspenseComponent; @@ -3261,6 +3402,18 @@ export function attach( // I.e. we just restore them by undoing what we did above. fiberInstance.firstChild = remainingReconcilingChildren; remainingReconcilingChildren = null; + + if (traceUpdatesEnabled) { + // If we're tracing updates and we've bailed out before reaching a host node, + // we should fall back to recursively marking the nearest host descendants for highlight. + if (traceNearestHostComponentUpdate) { + const hostInstances = + findAllCurrentHostInstances(fiberInstance); + hostInstances.forEach(hostInstance => { + traceUpdatesForNodes.add(hostInstance); + }); + } + } } else { // If this fiber is filtered there might be changes to this set elsewhere so we have // to visit each child to place it back in the set. We let the child bail out instead. @@ -3272,27 +3425,24 @@ export function attach( ); } } - - if (traceUpdatesEnabled) { - // If we're tracing updates and we've bailed out before reaching a host node, - // we should fall back to recursively marking the nearest host descendants for highlight. - if (traceNearestHostComponentUpdate) { - const hostInstances = findAllCurrentHostInstances( - getFiberInstanceThrows(nextFiber), - ); - hostInstances.forEach(hostInstance => { - traceUpdatesForNodes.add(hostInstance); - }); - } - } } } if (fiberInstance !== null) { + let componentLogsEntry = fiberToComponentLogsMap.get( + fiberInstance.data, + ); + if (componentLogsEntry === undefined && fiberInstance.data.alternate) { + componentLogsEntry = fiberToComponentLogsMap.get( + fiberInstance.data.alternate, + ); + } + recordConsoleLogs(fiberInstance, componentLogsEntry); + const isProfilingSupported = nextFiber.hasOwnProperty('treeBaseDuration'); if (isProfilingSupported) { - recordProfilingDurations(fiberInstance); + recordProfilingDurations(fiberInstance, prevFiber); } } if (shouldResetChildren) { @@ -3363,15 +3513,11 @@ export function attach( // If we have not been profiling, then we can just walk the tree and build up its current state as-is. hook.getFiberRoots(rendererID).forEach(root => { const current = root.current; - const alternate = current.alternate; const newRoot = createFiberInstance(current); + rootToFiberInstanceMap.set(root, newRoot); idToDevToolsInstanceMap.set(newRoot.id, newRoot); - fiberToFiberInstanceMap.set(current, newRoot); - if (alternate) { - fiberToFiberInstanceMap.set(alternate, newRoot); - } - currentRootID = newRoot.id; - setRootPseudoKey(currentRootID, root.current); + currentRoot = newRoot; + setRootPseudoKey(currentRoot.id, root.current); // Handle multi-renderer edge-case where only some v16 renderers support profiling. if (isProfiling && rootSupportsProfiling(root)) { @@ -3383,33 +3529,20 @@ export function attach( commitTime: getCurrentTime() - profilingStartTime, maxActualDuration: 0, priorityLevel: null, - updaters: getUpdatersList(root), + updaters: null, effectDuration: null, passiveEffectDuration: null, }; } mountFiberRecursively(root.current, false); + flushPendingEvents(root); - currentRootID = -1; - }); - } - } - function getUpdatersList(root: any): Array | null { - const updaters = root.memoizedUpdaters; - if (updaters == null) { - return null; - } - const result = []; - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const updater of updaters) { - const inst = getFiberInstanceUnsafe(updater); - if (inst !== null) { - result.push(instanceToSerializedElement(inst)); - } + needsToFlushComponentLogs = false; + currentRoot = (null: any); + }); } - return result; } function handleCommitFiberUnmount(fiber: any) { @@ -3429,26 +3562,33 @@ export function attach( passiveEffectDuration; } } + + if (needsToFlushComponentLogs) { + // We received new logs after commit. I.e. in a passive effect. We need to + // traverse the tree to find the affected ones. If we just moved the whole + // tree traversal from handleCommitFiberRoot to handlePostCommitFiberRoot + // this wouldn't be needed. For now we just brute force check all instances. + // This is not that common of a case. + bruteForceFlushErrorsAndWarnings(); + } } - function handleCommitFiberRoot(root: any, priorityLevel: void | number) { + function handleCommitFiberRoot( + root: FiberRoot, + priorityLevel: void | number, + ) { const current = root.current; - const alternate = current.alternate; - let rootInstance = - fiberToFiberInstanceMap.get(current) || - (alternate && fiberToFiberInstanceMap.get(alternate)); + let prevFiber: null | Fiber = null; + let rootInstance = rootToFiberInstanceMap.get(root); if (!rootInstance) { rootInstance = createFiberInstance(current); + rootToFiberInstanceMap.set(root, rootInstance); idToDevToolsInstanceMap.set(rootInstance.id, rootInstance); - fiberToFiberInstanceMap.set(current, rootInstance); - if (alternate) { - fiberToFiberInstanceMap.set(alternate, rootInstance); - } - currentRootID = rootInstance.id; } else { - currentRootID = rootInstance.id; + prevFiber = rootInstance.data; } + currentRoot = rootInstance; // Before the traversals, remember to start tracking // our path in case we have selection to restore. @@ -3473,9 +3613,7 @@ export function attach( maxActualDuration: 0, priorityLevel: priorityLevel == null ? null : formatPriorityLevel(priorityLevel), - - updaters: getUpdatersList(root), - + updaters: null, // Initialize to null; if new enough React version is running, // these values will be read during separate handlePostCommitFiberRoot() call. effectDuration: null, @@ -3483,13 +3621,13 @@ export function attach( }; } - if (alternate) { + if (prevFiber !== null) { // TODO: relying on this seems a bit fishy. const wasMounted = - alternate.memoizedState != null && - alternate.memoizedState.element != null && + prevFiber.memoizedState != null && + prevFiber.memoizedState.element != null && // A dehydrated root is not considered mounted - alternate.memoizedState.isDehydrated !== true; + prevFiber.memoizedState.isDehydrated !== true; const isMounted = current.memoizedState != null && current.memoizedState.element != null && @@ -3497,19 +3635,20 @@ export function attach( current.memoizedState.isDehydrated !== true; if (!wasMounted && isMounted) { // Mount a new root. - setRootPseudoKey(currentRootID, current); + setRootPseudoKey(currentRoot.id, current); mountFiberRecursively(current, false); } else if (wasMounted && isMounted) { // Update an existing root. - updateFiberRecursively(rootInstance, current, alternate, false); + updateFiberRecursively(rootInstance, current, prevFiber, false); } else if (wasMounted && !isMounted) { // Unmount an existing root. - removeRootPseudoKey(currentRootID); unmountInstanceRecursively(rootInstance); + removeRootPseudoKey(currentRoot.id); + rootToFiberInstanceMap.delete(root); } } else { // Mount a new root. - setRootPseudoKey(currentRootID, current); + setRootPseudoKey(currentRoot.id, current); mountFiberRecursively(current, false); } @@ -3517,7 +3656,7 @@ export function attach( if (!shouldBailoutWithPendingOperations()) { const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( - currentRootID, + currentRoot.id, ); if (commitProfilingMetadata != null) { @@ -3526,7 +3665,7 @@ export function attach( ); } else { ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).set( - currentRootID, + currentRoot.id, [((currentCommitProfilingMetadata: any): CommitProfilingData)], ); } @@ -3536,11 +3675,13 @@ export function attach( // We're done here. flushPendingEvents(root); + needsToFlushComponentLogs = false; + if (traceUpdatesEnabled) { hook.emit('traceUpdates', traceUpdatesForNodes); } - currentRootID = -1; + currentRoot = (null: any); } function getResourceInstance(fiber: Fiber): HostInstance | null { @@ -3558,15 +3699,31 @@ export function attach( return null; } - function findAllCurrentHostInstances( - fiberInstance: FiberInstance, - ): $ReadOnlyArray { - const hostInstances = []; - const fiber = fiberInstance.data; - if (!fiber) { - return hostInstances; + function appendHostInstancesByDevToolsInstance( + devtoolsInstance: DevToolsInstance, + hostInstances: Array, + ) { + if (devtoolsInstance.kind !== VIRTUAL_INSTANCE) { + const fiber = devtoolsInstance.data; + appendHostInstancesByFiber(fiber, hostInstances); + return; + } + // Search the tree for the nearest child Fiber and add all its host instances. + // TODO: If the true nearest Fiber is filtered, we might skip it and instead include all + // the children below it. In the extreme case, searching the whole tree. + for ( + let child = devtoolsInstance.firstChild; + child !== null; + child = child.nextSibling + ) { + appendHostInstancesByDevToolsInstance(child, hostInstances); } + } + function appendHostInstancesByFiber( + fiber: Fiber, + hostInstances: Array, + ): void { // Next we'll drill down this component to find all HostComponent/Text. let node: Fiber = fiber; while (true) { @@ -3586,19 +3743,24 @@ export function attach( continue; } if (node === fiber) { - return hostInstances; + return; } while (!node.sibling) { if (!node.return || node.return === fiber) { - return hostInstances; + return; } node = node.return; } node.sibling.return = node.return; node = node.sibling; } - // Flow needs the return here, but ESLint complains about it. - // eslint-disable-next-line no-unreachable + } + + function findAllCurrentHostInstances( + devtoolsInstance: DevToolsInstance, + ): $ReadOnlyArray { + const hostInstances: Array = []; + appendHostInstancesByDevToolsInstance(devtoolsInstance, hostInstances); return hostInstances; } @@ -3609,17 +3771,7 @@ export function attach( console.warn(`Could not find DevToolsInstance with id "${id}"`); return null; } - if (devtoolsInstance.kind !== FIBER_INSTANCE) { - // TODO: Handle VirtualInstance. - return null; - } - const fiber = devtoolsInstance.data; - if (fiber === null) { - return null; - } - - const hostInstances = findAllCurrentHostInstances(devtoolsInstance); - return hostInstances; + return findAllCurrentHostInstances(devtoolsInstance); } catch (err) { // The fiber might have unmounted by now. return null; @@ -3638,85 +3790,25 @@ export function attach( } } - function getNearestMountedHostInstance( - hostInstance: HostInstance, - ): null | HostInstance { - const mountedFiber = renderer.findFiberByHostInstance(hostInstance); - if (mountedFiber != null) { - if (mountedFiber.stateNode !== hostInstance) { - // If it's not a perfect match the specific one might be a resource. - // We don't need to look at any parents because host resources don't have - // children so it won't be in any parent if it's not this one. - if (hostResourceToFiberMap.has(hostInstance)) { - return hostInstance; - } - } - return mountedFiber.stateNode; - } - if (hostResourceToFiberMap.has(hostInstance)) { - return hostInstance; - } - return null; - } - - function findNearestUnfilteredElementID(searchFiber: Fiber) { - let fiber: null | Fiber = searchFiber; - while (fiber !== null) { - const fiberInstance = getFiberInstanceUnsafe(fiber); - if (fiberInstance !== null) { - // TODO: Ideally we would not have any filtered FiberInstances which - // would make this logic much simpler. Unfortunately, we sometimes - // eagerly add to the map and some times don't eagerly clean it up. - // TODO: If the fiber is filtered, the FiberInstance wouldn't really - // exist which would mean that we also don't have a way to get to the - // VirtualInstances. - if (!shouldFilterFiber(fiberInstance.data)) { - return fiberInstance.id; - } - // We couldn't use this Fiber but we might have a VirtualInstance - // that is the nearest unfiltered instance. - let parentInstance = fiberInstance.parent; - while (parentInstance !== null) { - if (parentInstance.kind === FIBER_INSTANCE) { - // If we find a parent Fiber, it might not be the nearest parent - // so we break out and continue walking the Fiber tree instead. - break; - } else { - if (!shouldFilterVirtual(parentInstance.data)) { - return parentInstance.id; - } - } - parentInstance = parentInstance.parent; - } - } - fiber = fiber.return; + function getNearestMountedDOMNode(publicInstance: Element): null | Element { + let domNode: null | Element = publicInstance; + while (domNode && !publicInstanceToDevToolsInstanceMap.has(domNode)) { + // $FlowFixMe: In practice this is either null or Element. + domNode = domNode.parentNode; } - return null; + return domNode; } function getElementIDForHostInstance( - hostInstance: HostInstance, - findNearestUnfilteredAncestor: boolean = false, + publicInstance: HostInstance, ): number | null { - const resourceFibers = hostResourceToFiberMap.get(hostInstance); - if (resourceFibers !== undefined) { - // This is a resource. Find the first unfiltered instance. - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const resourceFiber of resourceFibers) { - const elementID = findNearestUnfilteredElementID(resourceFiber); - if (elementID !== null) { - return elementID; - } + const instance = publicInstanceToDevToolsInstanceMap.get(publicInstance); + if (instance !== undefined) { + if (instance.kind === FILTERED_FIBER_INSTANCE) { + // A Filtered Fiber Instance will always have a Virtual Instance as a parent. + return ((instance.parent: any): VirtualInstance).id; } - // If we don't find one, fallthrough to select the parent instead. - } - const fiber = renderer.findFiberByHostInstance(hostInstance); - if (fiber != null) { - if (!findNearestUnfilteredAncestor) { - // TODO: Remove this option. It's not used. - return getFiberIDThrows(fiber); - } - return findNearestUnfilteredElementID(fiber); + return instance.id; } return null; } @@ -3768,7 +3860,7 @@ export function attach( } function instanceToSerializedElement( - instance: DevToolsInstance, + instance: FiberInstance | VirtualInstance, ): SerializedElement { if (instance.kind === FIBER_INSTANCE) { const fiber = instance.data; @@ -3851,7 +3943,7 @@ export function attach( owner = ownerFiber._debugOwner; } else { const ownerInfo: ReactComponentInfo = (owner: any); // Refined - if (!shouldFilterVirtual(ownerInfo)) { + if (!shouldFilterVirtual(ownerInfo, null)) { return ownerInfo; } owner = ownerInfo.owner; @@ -3863,7 +3955,7 @@ export function attach( function findNearestOwnerInstance( parentInstance: null | DevToolsInstance, owner: void | null | ReactComponentInfo | Fiber, - ): null | DevToolsInstance { + ): null | FiberInstance | VirtualInstance { if (owner == null) { return null; } @@ -3878,6 +3970,9 @@ export function attach( // needs a duck type check anyway. parentInstance.data === (owner: any).alternate ) { + if (parentInstance.kind === FILTERED_FIBER_INSTANCE) { + return null; + } return parentInstance; } parentInstance = parentInstance.parent; @@ -3934,18 +4029,6 @@ export function attach( } } - function getNearestErrorBoundaryID(fiber: Fiber): number | null { - let parent = fiber.return; - while (parent !== null) { - if (isErrorBoundary(parent)) { - // TODO: If this boundary is filtered it won't have an ID. - return getFiberIDUnsafe(parent); - } - parent = parent.return; - } - return null; - } - function inspectElementRaw(id: number): InspectedElement | null { const devtoolsInstance = idToDevToolsInstanceMap.get(id); if (devtoolsInstance === undefined) { @@ -3955,7 +4038,11 @@ export function attach( if (devtoolsInstance.kind === VIRTUAL_INSTANCE) { return inspectVirtualInstanceRaw(devtoolsInstance); } - return inspectFiberInstanceRaw(devtoolsInstance); + if (devtoolsInstance.kind === FIBER_INSTANCE) { + return inspectFiberInstanceRaw(devtoolsInstance); + } + (devtoolsInstance: FilteredFiberInstance); // assert exhaustive + throw new Error('Unsupported instance kind'); } function inspectFiberInstanceRaw( @@ -4096,9 +4183,6 @@ export function attach( const owners: null | Array = getOwnersListFromInstance(fiberInstance); - const isTimedOutSuspense = - tag === SuspenseComponent && memoizedState !== null; - let hooks = null; if (usesHooks) { const originalConsoleMethods: {[string]: $FlowFixMe} = {}; @@ -4128,16 +4212,26 @@ export function attach( let rootType = null; let current = fiber; + let hasErrorBoundary = false; + let hasSuspenseBoundary = false; while (current.return !== null) { + const temp = current; current = current.return; + if (temp.tag === SuspenseComponent) { + hasSuspenseBoundary = true; + } else if (isErrorBoundary(temp)) { + hasErrorBoundary = true; + } } const fiberRoot = current.stateNode; if (fiberRoot != null && fiberRoot._debugRootType !== null) { rootType = fiberRoot._debugRootType; } + const isTimedOutSuspense = + tag === SuspenseComponent && memoizedState !== null; + let isErrored = false; - let targetErrorBoundaryID; if (isErrorBoundary(fiber)) { // if the current inspected element is an error boundary, // either that we want to use it to toggle off error state @@ -4150,12 +4244,9 @@ export function attach( const DidCapture = 0b000000000000000000010000000; isErrored = (fiber.flags & DidCapture) !== 0 || - (fiberInstance.flags & FORCE_ERROR) !== 0; - targetErrorBoundaryID = isErrored - ? fiberInstance.id - : getNearestErrorBoundaryID(fiber); - } else { - targetErrorBoundaryID = getNearestErrorBoundaryID(fiber); + forceErrorForFibers.get(fiber) === true || + (fiber.alternate !== null && + forceErrorForFibers.get(fiber.alternate) === true); } const plugins: Plugins = { @@ -4173,6 +4264,8 @@ export function attach( source = getSourceForFiberInstance(fiberInstance); } + const componentLogsEntry = fiberToComponentLogsMap.get(fiber); + return { id: fiberInstance.id, @@ -4190,18 +4283,20 @@ export function attach( canEditFunctionPropsRenamePaths: typeof overridePropsRenamePath === 'function', - canToggleError: supportsTogglingError && targetErrorBoundaryID != null, + canToggleError: supportsTogglingError && hasErrorBoundary, // Is this error boundary in error state. isErrored, - targetErrorBoundaryID, canToggleSuspense: supportsTogglingSuspense && + hasSuspenseBoundary && // If it's showing the real content, we can always flip fallback. (!isTimedOutSuspense || // If it's showing fallback because we previously forced it to, // allow toggling it back to remove the fallback override. - (fiberInstance.flags & FORCE_SUSPENSE_FALLBACK) !== 0), + forceFallbackForFibers.has(fiber) || + (fiber.alternate !== null && + forceFallbackForFibers.has(fiber.alternate))), // Can view component source location. canViewSource, @@ -4212,7 +4307,6 @@ export function attach( key: key != null ? key : null, - displayName: getDisplayNameForFiber(fiber), type: elementType, // Inspectable properties. @@ -4222,13 +4316,13 @@ export function attach( props: memoizedProps, state: showState ? memoizedState : null, errors: - fiberInstance.errors === null + componentLogsEntry === undefined ? [] - : Array.from(fiberInstance.errors.entries()), + : Array.from(componentLogsEntry.errors.entries()), warnings: - fiberInstance.warnings === null + componentLogsEntry === undefined ? [] - : Array.from(fiberInstance.warnings.entries()), + : Array.from(componentLogsEntry.warnings.entries()), // List of owners owners, @@ -4252,39 +4346,37 @@ export function attach( typeof componentInfo.key === 'string' ? componentInfo.key : null; const props = null; // TODO: Track props on ReactComponentInfo; - const env = componentInfo.env; - let displayName = componentInfo.name || ''; - if (typeof env === 'string') { - // We model environment as an HoC name for now. - displayName = env + '(' + displayName + ')'; - } - const owners: null | Array = getOwnersListFromInstance(virtualInstance); let rootType = null; - let targetErrorBoundaryID = null; - let parent = virtualInstance.parent; - while (parent !== null) { - if (parent.kind === FIBER_INSTANCE) { - targetErrorBoundaryID = getNearestErrorBoundaryID(parent.data); - let current = parent.data; - while (current.return !== null) { - current = current.return; - } - const fiberRoot = current.stateNode; - if (fiberRoot != null && fiberRoot._debugRootType !== null) { - rootType = fiberRoot._debugRootType; + let hasErrorBoundary = false; + let hasSuspenseBoundary = false; + const nearestFiber = getNearestFiber(virtualInstance); + if (nearestFiber !== null) { + let current = nearestFiber; + while (current.return !== null) { + const temp = current; + current = current.return; + if (temp.tag === SuspenseComponent) { + hasSuspenseBoundary = true; + } else if (isErrorBoundary(temp)) { + hasErrorBoundary = true; } - break; } - parent = parent.parent; + const fiberRoot = current.stateNode; + if (fiberRoot != null && fiberRoot._debugRootType !== null) { + rootType = fiberRoot._debugRootType; + } } const plugins: Plugins = { stylex: null, }; + const componentLogsEntry = + componentInfoToComponentLogsMap.get(componentInfo); + return { id: virtualInstance.id, @@ -4296,11 +4388,10 @@ export function attach( canEditFunctionPropsDeletePaths: false, canEditFunctionPropsRenamePaths: false, - canToggleError: supportsTogglingError && targetErrorBoundaryID != null, + canToggleError: supportsTogglingError && hasErrorBoundary, isErrored: false, - targetErrorBoundaryID, - canToggleSuspense: supportsTogglingSuspense, + canToggleSuspense: supportsTogglingSuspense && hasSuspenseBoundary, // Can view component source location. canViewSource, @@ -4311,7 +4402,6 @@ export function attach( key: key, - displayName: displayName, type: ElementTypeVirtual, // Inspectable properties. @@ -4321,14 +4411,13 @@ export function attach( props: props, state: null, errors: - virtualInstance.errors === null + componentLogsEntry === undefined ? [] - : Array.from(virtualInstance.errors.entries()), + : Array.from(componentLogsEntry.errors.entries()), warnings: - virtualInstance.warnings === null + componentLogsEntry === undefined ? [] - : Array.from(virtualInstance.warnings.entries()), - + : Array.from(componentLogsEntry.warnings.entries()), // List of owners owners, @@ -4675,10 +4764,12 @@ export function attach( return; } + const displayName = getDisplayNameForElementID(id); + const supportsGroup = typeof console.groupCollapsed === 'function'; if (supportsGroup) { console.groupCollapsed( - `[Click to expand] %c<${result.displayName || 'Component'} />`, + `[Click to expand] %c<${displayName || 'Component'} />`, // --dom-tag-name-color is the CSS variable Chrome styles HTML elements with in the console. 'color: var(--dom-tag-name-color); font-weight: normal;', ); @@ -4934,7 +5025,6 @@ export function attach( let currentCommitProfilingMetadata: CommitProfilingData | null = null; let displayNamesByRootID: DisplayNamesByRootID | null = null; - let idToContextsMap: Map | null = null; let initialTreeBaseDurationsMap: Map> | null = null; let isProfiling: boolean = false; @@ -5056,7 +5146,9 @@ export function attach( ) { // We don't need to convert milliseconds to microseconds in this case, // because the profiling summary is JSON serialized. - target.push([instance.id, instance.treeBaseDuration]); + if (instance.kind !== FILTERED_FIBER_INSTANCE) { + target.push([instance.id, instance.treeBaseDuration]); + } for ( let child = instance.firstChild; child !== null; @@ -5079,10 +5171,14 @@ export function attach( // (e.g. when a fiber is re-rendered or when a fiber gets removed). displayNamesByRootID = new Map(); initialTreeBaseDurationsMap = new Map(); - idToContextsMap = new Map(); hook.getFiberRoots(rendererID).forEach(root => { - const rootInstance = getFiberInstanceThrows(root.current); + const rootInstance = rootToFiberInstanceMap.get(root); + if (rootInstance === undefined) { + throw new Error( + 'Expected the root instance to already exist when starting profiling', + ); + } const rootID = rootInstance.id; ((displayNamesByRootID: any): DisplayNamesByRootID).set( rootID, @@ -5091,13 +5187,6 @@ export function attach( const initialTreeBaseDurations: Array<[number, number]> = []; snapshotTreeBaseDurations(rootInstance, initialTreeBaseDurations); (initialTreeBaseDurationsMap: any).set(rootID, initialTreeBaseDurations); - - if (shouldRecordChangeDescriptions) { - // Record all contexts at the time profiling is started. - // Fibers only store the current context value, - // so we need to track them separately in order to determine changed keys. - crawlToInitializeContextsMap(root.current); - } }); isProfiling = true; @@ -5128,30 +5217,43 @@ export function attach( ); } + function getNearestFiber(devtoolsInstance: DevToolsInstance): null | Fiber { + if (devtoolsInstance.kind === VIRTUAL_INSTANCE) { + let inst: DevToolsInstance = devtoolsInstance; + while (inst.kind === VIRTUAL_INSTANCE) { + // For virtual instances, we search deeper until we find a Fiber instance. + // Then we search upwards from that Fiber. That's because Virtual Instances + // will always have an Fiber child filtered or not. If we searched its parents + // we might skip through a filtered Error Boundary before we hit a FiberInstance. + if (inst.firstChild === null) { + return null; + } + inst = inst.firstChild; + } + return inst.data.return; + } else { + return devtoolsInstance.data; + } + } + // React will switch between these implementations depending on whether // we have any manually suspended/errored-out Fibers or not. function shouldErrorFiberAlwaysNull() { return null; } - let forceErrorCount = 0; + // Map of Fiber and its force error status: true (error), false (toggled off) + const forceErrorForFibers = new Map(); - function shouldErrorFiberAccordingToMap(fiber: any): null | boolean { + function shouldErrorFiberAccordingToMap(fiber: any): boolean { if (typeof setErrorHandler !== 'function') { throw new Error( 'Expected overrideError() to not get called for earlier React versions.', ); } - let fiberInstance = fiberToFiberInstanceMap.get(fiber); - if (fiberInstance === undefined && fiber.alternate !== null) { - fiberInstance = fiberToFiberInstanceMap.get(fiber.alternate); - } - if (fiberInstance === undefined) { - return null; - } - - if (fiberInstance.flags & FORCE_ERROR_RESET) { + let status = forceErrorForFibers.get(fiber); + if (status === false) { // TRICKY overrideError adds entries to this Map, // so ideally it would be the method that clears them too, // but that would break the functionality of the feature, @@ -5161,18 +5263,27 @@ export function attach( // Technically this is premature and we should schedule it for later, // since the render could always fail without committing the updated error boundary, // but since this is a DEV-only feature, the simplicity is worth the trade off. - forceErrorCount--; - fiberInstance.flags &= ~FORCE_ERROR_RESET; - if (forceErrorCount === 0) { + forceErrorForFibers.delete(fiber); + if (forceErrorForFibers.size === 0) { // Last override is gone. Switch React back to fast path. setErrorHandler(shouldErrorFiberAlwaysNull); } return false; - } else if (fiberInstance.flags & FORCE_ERROR) { - return true; - } else { - return null; } + if (status === undefined && fiber.alternate !== null) { + status = forceErrorForFibers.get(fiber.alternate); + if (status === false) { + forceErrorForFibers.delete(fiber.alternate); + if (forceErrorForFibers.size === 0) { + // Last override is gone. Switch React back to fast path. + setErrorHandler(shouldErrorFiberAlwaysNull); + } + } + } + if (status === undefined) { + return false; + } + return status; } function overrideError(id: number, forceError: boolean) { @@ -5189,38 +5300,39 @@ export function attach( if (devtoolsInstance === undefined) { return; } - if ((devtoolsInstance.flags & (FORCE_ERROR | FORCE_ERROR_RESET)) === 0) { - forceErrorCount++; - if (forceErrorCount === 1) { - // First override is added. Switch React to slower path. - setErrorHandler(shouldErrorFiberAccordingToMap); + const nearestFiber = getNearestFiber(devtoolsInstance); + if (nearestFiber === null) { + return; + } + let fiber = nearestFiber; + while (!isErrorBoundary(fiber)) { + if (fiber.return === null) { + return; } + fiber = fiber.return; } - devtoolsInstance.flags &= forceError ? ~FORCE_ERROR_RESET : ~FORCE_ERROR; - devtoolsInstance.flags |= forceError ? FORCE_ERROR : FORCE_ERROR_RESET; - - if (devtoolsInstance.kind === FIBER_INSTANCE) { - const fiber = devtoolsInstance.data; - scheduleUpdate(fiber); - } else { - // TODO: Handle VirtualInstance. + forceErrorForFibers.set(fiber, forceError); + if (fiber.alternate !== null) { + // We only need one of the Fibers in the set. + forceErrorForFibers.delete(fiber.alternate); + } + if (forceErrorForFibers.size === 1) { + // First override is added. Switch React to slower path. + setErrorHandler(shouldErrorFiberAccordingToMap); } + scheduleUpdate(fiber); } function shouldSuspendFiberAlwaysFalse() { return false; } - let forceFallbackCount = 0; + const forceFallbackForFibers = new Set(); - function shouldSuspendFiberAccordingToSet(fiber: any) { - let fiberInstance = fiberToFiberInstanceMap.get(fiber); - if (fiberInstance === undefined && fiber.alternate !== null) { - fiberInstance = fiberToFiberInstanceMap.get(fiber.alternate); - } + function shouldSuspendFiberAccordingToSet(fiber: Fiber): boolean { return ( - fiberInstance !== undefined && - (fiberInstance.flags & FORCE_SUSPENSE_FALLBACK) !== 0 + forceFallbackForFibers.has(fiber) || + (fiber.alternate !== null && forceFallbackForFibers.has(fiber.alternate)) ); } @@ -5237,45 +5349,50 @@ export function attach( if (devtoolsInstance === undefined) { return; } + const nearestFiber = getNearestFiber(devtoolsInstance); + if (nearestFiber === null) { + return; + } + let fiber = nearestFiber; + while (fiber.tag !== SuspenseComponent) { + if (fiber.return === null) { + return; + } + fiber = fiber.return; + } + if (fiber.alternate !== null) { + // We only need one of the Fibers in the set. + forceFallbackForFibers.delete(fiber.alternate); + } if (forceFallback) { - if ((devtoolsInstance.flags & FORCE_SUSPENSE_FALLBACK) === 0) { - devtoolsInstance.flags |= FORCE_SUSPENSE_FALLBACK; - forceFallbackCount++; - if (forceFallbackCount === 1) { - // First override is added. Switch React to slower path. - setSuspenseHandler(shouldSuspendFiberAccordingToSet); - } + forceFallbackForFibers.add(fiber); + if (forceFallbackForFibers.size === 1) { + // First override is added. Switch React to slower path. + setSuspenseHandler(shouldSuspendFiberAccordingToSet); } } else { - if ((devtoolsInstance.flags & FORCE_SUSPENSE_FALLBACK) !== 0) { - devtoolsInstance.flags &= ~FORCE_SUSPENSE_FALLBACK; - forceFallbackCount--; - if (forceFallbackCount === 0) { - // Last override is gone. Switch React back to fast path. - setSuspenseHandler(shouldSuspendFiberAlwaysFalse); - } + forceFallbackForFibers.delete(fiber); + if (forceFallbackForFibers.size === 0) { + // Last override is gone. Switch React back to fast path. + setSuspenseHandler(shouldSuspendFiberAlwaysFalse); } } - - if (devtoolsInstance.kind === FIBER_INSTANCE) { - const fiber = devtoolsInstance.data; - scheduleUpdate(fiber); - } else { - // TODO: Handle VirtualInstance. - } + scheduleUpdate(fiber); } // Remember if we're trying to restore the selection after reload. // In that case, we'll do some extra checks for matching mounts. let trackedPath: Array | null = null; - let trackedPathMatchFiber: Fiber | null = null; + let trackedPathMatchFiber: Fiber | null = null; // This is the deepest unfiltered match of a Fiber. + let trackedPathMatchInstance: FiberInstance | VirtualInstance | null = null; // This is the deepest matched filtered Instance. let trackedPathMatchDepth = -1; let mightBeOnTrackedPath = false; function setTrackedPath(path: Array | null) { if (path === null) { trackedPathMatchFiber = null; + trackedPathMatchInstance = null; trackedPathMatchDepth = -1; mightBeOnTrackedPath = false; } @@ -5285,7 +5402,10 @@ export function attach( // We call this before traversing a new mount. // It remembers whether this Fiber is the next best match for tracked path. // The return value signals whether we should keep matching siblings or not. - function updateTrackedPathStateBeforeMount(fiber: Fiber): boolean { + function updateTrackedPathStateBeforeMount( + fiber: Fiber, + fiberInstance: null | FiberInstance | FilteredFiberInstance, + ): boolean { if (trackedPath === null || !mightBeOnTrackedPath) { // Fast path: there's nothing to track so do nothing and ignore siblings. return false; @@ -5313,6 +5433,9 @@ export function attach( ) { // We have our next match. trackedPathMatchFiber = fiber; + if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) { + trackedPathMatchInstance = fiberInstance; + } trackedPathMatchDepth++; // Are we out of frames to match? // $FlowFixMe[incompatible-use] found when upgrading Flow @@ -5329,6 +5452,11 @@ export function attach( return false; } } + if (trackedPathMatchFiber === null && fiberInstance === null) { + // We're now looking for a Virtual Instance. It might be inside filtered Fibers + // so we keep looking below. + return true; + } // This Fiber's parent is on the path, but this Fiber itself isn't. // There's no need to check its children--they won't be on the path either. mightBeOnTrackedPath = false; @@ -5336,6 +5464,57 @@ export function attach( return true; } + function updateVirtualTrackedPathStateBeforeMount( + virtualInstance: VirtualInstance, + parentInstance: null | DevToolsInstance, + ): boolean { + if (trackedPath === null || !mightBeOnTrackedPath) { + // Fast path: there's nothing to track so do nothing and ignore siblings. + return false; + } + // Check if we've matched our nearest unfiltered parent so far. + if (trackedPathMatchInstance === parentInstance) { + const actualFrame = getVirtualPathFrame(virtualInstance); + // $FlowFixMe[incompatible-use] found when upgrading Flow + const expectedFrame = trackedPath[trackedPathMatchDepth + 1]; + if (expectedFrame === undefined) { + throw new Error('Expected to see a frame at the next depth.'); + } + if ( + actualFrame.index === expectedFrame.index && + actualFrame.key === expectedFrame.key && + actualFrame.displayName === expectedFrame.displayName + ) { + // We have our next match. + trackedPathMatchFiber = null; // Don't bother looking in Fibers anymore. We're deeper now. + trackedPathMatchInstance = virtualInstance; + trackedPathMatchDepth++; + // Are we out of frames to match? + // $FlowFixMe[incompatible-use] found when upgrading Flow + if (trackedPathMatchDepth === trackedPath.length - 1) { + // There's nothing that can possibly match afterwards. + // Don't check the children. + mightBeOnTrackedPath = false; + } else { + // Check the children, as they might reveal the next match. + mightBeOnTrackedPath = true; + } + // In either case, since we have a match, we don't need + // to check the siblings. They'll never match. + return false; + } + } + if (trackedPathMatchFiber !== null) { + // We're still looking for a Fiber which might be underneath this instance. + return true; + } + // This Instance's parent is on the path, but this Instance itself isn't. + // There's no need to check its children--they won't be on the path either. + mightBeOnTrackedPath = false; + // However, one of its siblings may be on the path so keep searching. + return true; + } + function updateTrackedPathStateAfterMount( mightSiblingsBeOnTrackedPath: boolean, ) { @@ -5415,8 +5594,13 @@ export function attach( case HostRoot: // Roots don't have a real displayName, index, or key. // Instead, we'll use the pseudo key (childDisplayName:indexWithThatName). - const id = getFiberIDThrows(fiber); - const pseudoKey = rootPseudoKeys.get(id); + const rootInstance = rootToFiberInstanceMap.get(fiber.stateNode); + if (rootInstance === undefined) { + throw new Error( + 'Expected the root instance to exist when computing a path', + ); + } + const pseudoKey = rootPseudoKeys.get(rootInstance.id); if (pseudoKey === undefined) { throw new Error('Expected mounted root to have known pseudo key.'); } @@ -5435,6 +5619,14 @@ export function attach( }; } + function getVirtualPathFrame(virtualInstance: VirtualInstance): PathFrame { + return { + displayName: virtualInstance.data.name || '', + key: virtualInstance.data.key == null ? null : virtualInstance.data.key, + index: -1, // We use -1 to indicate that this is a virtual path frame. + }; + } + // Produces a serializable representation that does a best effort // of identifying a particular Fiber between page reloads. // The return path will contain Fibers that are "invisible" to the store @@ -5444,13 +5636,20 @@ export function attach( if (devtoolsInstance === undefined) { return null; } - if (devtoolsInstance.kind !== FIBER_INSTANCE) { - // TODO: Handle VirtualInstance. - return null; - } - let fiber: null | Fiber = devtoolsInstance.data; const keyPath = []; + + let inst: DevToolsInstance = devtoolsInstance; + while (inst.kind === VIRTUAL_INSTANCE) { + keyPath.push(getVirtualPathFrame(inst)); + if (inst.parent === null) { + // This is a bug but non-essential. We should've found a root instance. + return null; + } + inst = inst.parent; + } + + let fiber: null | Fiber = inst.data; while (fiber !== null) { // $FlowFixMe[incompatible-call] found when upgrading Flow keyPath.push(getPathFrame(fiber)); @@ -5466,20 +5665,12 @@ export function attach( // Nothing to match. return null; } - if (trackedPathMatchFiber === null) { + if (trackedPathMatchInstance === null) { // We didn't find anything. return null; } - // Find the closest Fiber store is aware of. - let fiber: null | Fiber = trackedPathMatchFiber; - while (fiber !== null && shouldFilterFiber(fiber)) { - fiber = fiber.return; - } - if (fiber === null) { - return null; - } return { - id: getFiberIDThrows(fiber), + id: trackedPathMatchInstance.id, // $FlowFixMe[incompatible-use] found when upgrading Flow isFullMatch: trackedPathMatchDepth === trackedPath.length - 1, }; @@ -5585,7 +5776,7 @@ export function attach( flushInitialOperations, getBestMatchForTrackedPath, getDisplayNameForElementID, - getNearestMountedHostInstance, + getNearestMountedDOMNode, getElementIDForHostInstance, getInstanceAndStyle, getOwnersList, @@ -5598,8 +5789,10 @@ export function attach( inspectElement, logElementToConsole, patchConsoleForStrictMode, + getComponentStack, getElementAttributeByPath, getElementSourceFunctionById, + onErrorOrWarning, overrideError, overrideSuspense, overrideValueAtPath, @@ -5612,5 +5805,6 @@ export function attach( storeAsGlobal, unpatchConsoleForStrictMode, updateComponentFilters, + getEnvironmentNames, }; } diff --git a/packages/react-devtools-shared/src/backend/flight/DevToolsComponentInfoStack.js b/packages/react-devtools-shared/src/backend/flight/DevToolsComponentInfoStack.js new file mode 100644 index 0000000000000..05864ed281674 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/flight/DevToolsComponentInfoStack.js @@ -0,0 +1,55 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// This is a DevTools fork of ReactComponentInfoStack. +// This fork enables DevTools to use the same "native" component stack format, +// while still maintaining support for multiple renderer versions +// (which use different values for ReactTypeOfWork). + +import type {ReactComponentInfo} from 'shared/ReactTypes'; + +import {describeBuiltInComponentFrame} from '../shared/DevToolsComponentStackFrame'; + +import {formatOwnerStack} from '../shared/DevToolsOwnerStack'; + +export function getOwnerStackByComponentInfoInDev( + componentInfo: ReactComponentInfo, +): string { + try { + let info = ''; + + // The owner stack of the current component will be where it was created, i.e. inside its owner. + // There's no actual name of the currently executing component. Instead, that is available + // on the regular stack that's currently executing. However, if there is no owner at all, then + // there's no stack frame so we add the name of the root component to the stack to know which + // component is currently executing. + if (!componentInfo.owner && typeof componentInfo.name === 'string') { + return describeBuiltInComponentFrame(componentInfo.name); + } + + let owner: void | null | ReactComponentInfo = componentInfo; + + while (owner) { + const ownerStack: ?Error = owner.debugStack; + if (ownerStack != null) { + // Server Component + owner = owner.owner; + if (owner) { + // TODO: Should we stash this somewhere for caching purposes? + info += '\n' + formatOwnerStack(ownerStack); + } + } else { + break; + } + } + return info; + } catch (x) { + return '\nError generating stack: ' + x.message + '\n' + x.stack; + } +} diff --git a/packages/react-devtools-shared/src/backend/flight/renderer.js b/packages/react-devtools-shared/src/backend/flight/renderer.js new file mode 100644 index 0000000000000..065dc81a071a7 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/flight/renderer.js @@ -0,0 +1,228 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactComponentInfo} from 'shared/ReactTypes'; + +import type {DevToolsHook, ReactRenderer, RendererInterface} from '../types'; + +import {getOwnerStackByComponentInfoInDev} from './DevToolsComponentInfoStack'; + +import {formatOwnerStack} from '../shared/DevToolsOwnerStack'; + +import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponentLogs'; + +import {formatConsoleArgumentsToSingleString} from 'react-devtools-shared/src/backend/utils'; + +import { + patchConsoleUsingWindowValues, + registerRenderer as registerRendererWithConsole, +} from '../console'; + +function supportsConsoleTasks(componentInfo: ReactComponentInfo): boolean { + // If this ReactComponentInfo supports native console.createTask then we are already running + // inside a native async stack trace if it's active - meaning the DevTools is open. + // Ideally we'd detect if this task was created while the DevTools was open or not. + return !!componentInfo.debugTask; +} + +export function attach( + hook: DevToolsHook, + rendererID: number, + renderer: ReactRenderer, + global: Object, +): RendererInterface { + const {getCurrentComponentInfo} = renderer; + + function getComponentStack( + topFrame: Error, + ): null | {enableOwnerStacks: boolean, componentStack: string} { + if (getCurrentComponentInfo === undefined) { + // Expected this to be part of the renderer. Ignore. + return null; + } + const current = getCurrentComponentInfo(); + if (current === null) { + // Outside of our render scope. + return null; + } + + if (supportsConsoleTasks(current)) { + // This will be handled natively by console.createTask. No need for + // DevTools to add it. + return null; + } + + const enableOwnerStacks = current.debugStack != null; + let componentStack = ''; + if (enableOwnerStacks) { + // Prefix the owner stack with the current stack. I.e. what called + // console.error. While this will also be part of the native stack, + // it is hidden and not presented alongside this argument so we print + // them all together. + const topStackFrames = formatOwnerStack(topFrame); + if (topStackFrames) { + componentStack += '\n' + topStackFrames; + } + componentStack += getOwnerStackByComponentInfoInDev(current); + } + return {enableOwnerStacks, componentStack}; + } + + // Called when an error or warning is logged during render, commit, or passive (including unmount functions). + function onErrorOrWarning( + type: 'error' | 'warn', + args: $ReadOnlyArray, + ): void { + if (getCurrentComponentInfo === undefined) { + // Expected this to be part of the renderer. Ignore. + return; + } + const componentInfo = getCurrentComponentInfo(); + if (componentInfo === null) { + // Outside of our render scope. + return; + } + + if ( + args.length > 3 && + typeof args[0] === 'string' && + args[0].startsWith('%c%s%c ') && + typeof args[1] === 'string' && + typeof args[2] === 'string' && + typeof args[3] === 'string' + ) { + // This looks like the badge we prefixed to the log. Our UI doesn't support formatted logs. + // We remove the formatting. If the environment of the log is the same as the environment of + // the component (the common case) we remove the badge completely otherwise leave it plain + const format = args[0].slice(7); + const env = args[2].trim(); + args = args.slice(4); + if (env !== componentInfo.env) { + args.unshift('[' + env + '] ' + format); + } else { + args.unshift(format); + } + } + + // We can't really use this message as a unique key, since we can't distinguish + // different objects in this implementation. We have to delegate displaying of the objects + // to the environment, the browser console, for example, so this is why this should be kept + // as an array of arguments, instead of the plain string. + // [Warning: %o, {...}] and [Warning: %o, {...}] will be considered as the same message, + // even if objects are different + const message = formatConsoleArgumentsToSingleString(...args); + + // Track the warning/error for later. + let componentLogsEntry = componentInfoToComponentLogsMap.get(componentInfo); + if (componentLogsEntry === undefined) { + componentLogsEntry = { + errors: new Map(), + errorsCount: 0, + warnings: new Map(), + warningsCount: 0, + }; + componentInfoToComponentLogsMap.set(componentInfo, componentLogsEntry); + } + + const messageMap = + type === 'error' + ? componentLogsEntry.errors + : componentLogsEntry.warnings; + const count = messageMap.get(message) || 0; + messageMap.set(message, count + 1); + if (type === 'error') { + componentLogsEntry.errorsCount++; + } else { + componentLogsEntry.warningsCount++; + } + + // The changes will be flushed later when we commit this tree to Fiber. + } + + patchConsoleUsingWindowValues(); + registerRendererWithConsole(onErrorOrWarning, getComponentStack); + + return { + cleanup() {}, + clearErrorsAndWarnings() {}, + clearErrorsForElementID() {}, + clearWarningsForElementID() {}, + getSerializedElementValueByPath() {}, + deletePath() {}, + findHostInstancesForElementID() { + return null; + }, + flushInitialOperations() {}, + getBestMatchForTrackedPath() { + return null; + }, + getComponentStack, + getDisplayNameForElementID() { + return null; + }, + getNearestMountedDOMNode() { + return null; + }, + getElementIDForHostInstance() { + return null; + }, + getInstanceAndStyle() { + return { + instance: null, + style: null, + }; + }, + getOwnersList() { + return null; + }, + getPathForElement() { + return null; + }, + getProfilingData() { + throw new Error('getProfilingData not supported by this renderer'); + }, + handleCommitFiberRoot() {}, + handleCommitFiberUnmount() {}, + handlePostCommitFiberRoot() {}, + hasElementWithId() { + return false; + }, + inspectElement( + requestID: number, + id: number, + path: Array | null, + ) { + return { + id, + responseID: requestID, + type: 'not-found', + }; + }, + logElementToConsole() {}, + patchConsoleForStrictMode() {}, + getElementAttributeByPath() {}, + getElementSourceFunctionById() {}, + onErrorOrWarning, + overrideError() {}, + overrideSuspense() {}, + overrideValueAtPath() {}, + renamePath() {}, + renderer, + setTraceUpdatesEnabled() {}, + setTrackedPath() {}, + startProfiling() {}, + stopProfiling() {}, + storeAsGlobal() {}, + unpatchConsoleForStrictMode() {}, + updateComponentFilters() {}, + getEnvironmentNames() { + return []; + }, + }; +} diff --git a/packages/react-devtools-shared/src/backend/index.js b/packages/react-devtools-shared/src/backend/index.js index 5c398ebf75869..0ac7ecc468aa5 100644 --- a/packages/react-devtools-shared/src/backend/index.js +++ b/packages/react-devtools-shared/src/backend/index.js @@ -9,16 +9,7 @@ import Agent from './agent'; -import {attach} from './fiber/renderer'; -import {attach as attachLegacy} from './legacy/renderer'; -import {hasAssignedBackend} from './utils'; - -import type {DevToolsHook, ReactRenderer, RendererInterface} from './types'; - -// this is the backend that is compatible with all older React versions -function isMatchingRender(version: string): boolean { - return !hasAssignedBackend(version); -} +import type {DevToolsHook, RendererID, RendererInterface} from './types'; export type InitBackend = typeof initBackend; @@ -32,29 +23,32 @@ export function initBackend( return () => {}; } + function registerRendererInterface( + id: RendererID, + rendererInterface: RendererInterface, + ) { + agent.registerRendererInterface(id, rendererInterface); + + // Now that the Store and the renderer interface are connected, + // it's time to flush the pending operation codes to the frontend. + rendererInterface.flushInitialOperations(); + } + const subs = [ hook.sub( 'renderer-attached', ({ id, - renderer, rendererInterface, }: { id: number, - renderer: ReactRenderer, rendererInterface: RendererInterface, - ... }) => { - agent.setRendererInterface(id, rendererInterface); - - // Now that the Store and the renderer interface are connected, - // it's time to flush the pending operation codes to the frontend. - rendererInterface.flushInitialOperations(); + registerRendererInterface(id, rendererInterface); }, ), - - hook.sub('unsupported-renderer-version', (id: number) => { - agent.onUnsupportedRenderer(id); + hook.sub('unsupported-renderer-version', () => { + agent.onUnsupportedRenderer(); }), hook.sub('fastRefreshScheduled', agent.onFastRefreshScheduled), @@ -64,60 +58,19 @@ export function initBackend( // TODO Add additional subscriptions required for profiling mode ]; - const attachRenderer = (id: number, renderer: ReactRenderer) => { - // only attach if the renderer is compatible with the current version of the backend - if (!isMatchingRender(renderer.reconcilerVersion || renderer.version)) { - return; - } - let rendererInterface = hook.rendererInterfaces.get(id); - - // Inject any not-yet-injected renderers (if we didn't reload-and-profile) - if (rendererInterface == null) { - if (typeof renderer.findFiberByHostInstance === 'function') { - // react-reconciler v16+ - rendererInterface = attach(hook, id, renderer, global); - } else if (renderer.ComponentTree) { - // react-dom v15 - rendererInterface = attachLegacy(hook, id, renderer, global); - } else { - // Older react-dom or other unsupported renderer version - } - - if (rendererInterface != null) { - hook.rendererInterfaces.set(id, rendererInterface); - } + agent.addListener('getIfHasUnsupportedRendererVersion', () => { + if (hook.hasUnsupportedRendererAttached) { + agent.onUnsupportedRenderer(); } - - // Notify the DevTools frontend about new renderers. - // This includes any that were attached early (via __REACT_DEVTOOLS_ATTACH__). - if (rendererInterface != null) { - hook.emit('renderer-attached', { - id, - renderer, - rendererInterface, - }); - } else { - hook.emit('unsupported-renderer-version', id); - } - }; - - // Connect renderers that have already injected themselves. - hook.renderers.forEach((renderer, id) => { - attachRenderer(id, renderer); }); - // Connect any new renderers that injected themselves. - subs.push( - hook.sub( - 'renderer', - ({id, renderer}: {id: number, renderer: ReactRenderer, ...}) => { - attachRenderer(id, renderer); - }, - ), - ); + hook.rendererInterfaces.forEach((rendererInterface, id) => { + registerRendererInterface(id, rendererInterface); + }); hook.emit('react-devtools', agent); hook.reactDevtoolsAgent = agent; + const onAgentShutdown = () => { subs.forEach(fn => fn()); hook.rendererInterfaces.forEach(rendererInterface => { diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index f8aa548a0573a..828d05fc27f81 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -145,15 +145,13 @@ export function attach( let getElementIDForHostInstance: GetElementIDForHostInstance = ((null: any): GetElementIDForHostInstance); let findHostInstanceForInternalID: (id: number) => ?HostInstance; - let getNearestMountedHostInstance = ( - node: HostInstance, - ): null | HostInstance => { + let getNearestMountedDOMNode = (node: Element): null | Element => { // Not implemented. return null; }; if (renderer.ComponentTree) { - getElementIDForHostInstance = (node, findNearestUnfilteredAncestor) => { + getElementIDForHostInstance = node => { const internalInstance = renderer.ComponentTree.getClosestInstanceFromNode(node); return internalInstanceToIDMap.get(internalInstance) || null; @@ -162,9 +160,7 @@ export function attach( const internalInstance = idToInternalInstanceMap.get(id); return renderer.ComponentTree.getNodeFromInstance(internalInstance); }; - getNearestMountedHostInstance = ( - node: HostInstance, - ): null | HostInstance => { + getNearestMountedDOMNode = (node: Element): null | Element => { const internalInstance = renderer.ComponentTree.getClosestInstanceFromNode(node); if (internalInstance != null) { @@ -173,7 +169,7 @@ export function attach( return null; }; } else if (renderer.Mount.getID && renderer.Mount.getNode) { - getElementIDForHostInstance = (node, findNearestUnfilteredAncestor) => { + getElementIDForHostInstance = node => { // Not implemented. return null; }; @@ -775,7 +771,7 @@ export function attach( return null; } - const {displayName, key} = getData(internalInstance); + const {key} = getData(internalInstance); const type = getElementType(internalInstance); let context = null; @@ -830,7 +826,6 @@ export function attach( // Toggle error boundary did not exist in legacy versions canToggleError: false, isErrored: false, - targetErrorBoundaryID: null, // Suspense did not exist in legacy versions canToggleSuspense: false, @@ -842,8 +837,6 @@ export function attach( // Only legacy context exists in legacy versions. hasLegacyContext: true, - displayName: displayName, - type: type, key: key != null ? key : null, @@ -876,10 +869,12 @@ export function attach( return; } + const displayName = getDisplayNameForElementID(id); + const supportsGroup = typeof console.groupCollapsed === 'function'; if (supportsGroup) { console.groupCollapsed( - `[Click to expand] %c<${result.displayName || 'Component'} />`, + `[Click to expand] %c<${displayName || 'Component'} />`, // --dom-tag-name-color is the CSS variable Chrome styles HTML elements with in the console. 'color: var(--dom-tag-name-color); font-weight: normal;', ); @@ -1078,6 +1073,11 @@ export function attach( // Not implemented. } + function getEnvironmentNames(): Array { + // No RSC support. + return []; + } + function setTraceUpdatesEnabled(enabled: boolean) { // Not implemented. } @@ -1121,7 +1121,7 @@ export function attach( flushInitialOperations, getBestMatchForTrackedPath, getDisplayNameForElementID, - getNearestMountedHostInstance, + getNearestMountedDOMNode, getElementIDForHostInstance, getInstanceAndStyle, findHostInstancesForElementID: (id: number) => { @@ -1152,5 +1152,6 @@ export function attach( storeAsGlobal, unpatchConsoleForStrictMode, updateComponentFilters, + getEnvironmentNames, }; } diff --git a/packages/react-devtools-shared/src/backend/profilingHooks.js b/packages/react-devtools-shared/src/backend/profilingHooks.js index 71907fc5c4021..47a01035308e2 100644 --- a/packages/react-devtools-shared/src/backend/profilingHooks.js +++ b/packages/react-devtools-shared/src/backend/profilingHooks.js @@ -274,16 +274,19 @@ export function createProfilingHooks({ } const top = currentReactMeasuresStack.pop(); + // $FlowFixMe[incompatible-type] if (top.type !== type) { console.error( 'Unexpected type "%s" completed at %sms before "%s" completed.', type, currentTime, + // $FlowFixMe[incompatible-use] top.type, ); } // $FlowFixMe[cannot-write] This property should not be writable outside of this function. + // $FlowFixMe[incompatible-use] top.duration = currentTime - top.timestamp; if (currentTimelineData) { diff --git a/packages/react-devtools-shared/src/backend/shared/DevToolsServerComponentLogs.js b/packages/react-devtools-shared/src/backend/shared/DevToolsServerComponentLogs.js new file mode 100644 index 0000000000000..0eee05b536492 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/shared/DevToolsServerComponentLogs.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// This keeps track of Server Component logs which may come from. +// This is in a shared module because Server Component logs don't come from a specific renderer +// but can become associated with a Virtual Instance of any renderer. + +import type {ReactComponentInfo} from 'shared/ReactTypes'; + +type ComponentLogs = { + errors: Map, + errorsCount: number, + warnings: Map, + warningsCount: number, +}; + +// This keeps it around as long as the ComponentInfo is alive which +// lets the Fiber get reparented/remounted and still observe the previous errors/warnings. +// Unless we explicitly clear the logs from a Fiber. +export const componentInfoToComponentLogsMap: WeakMap< + ReactComponentInfo, + ComponentLogs, +> = new WeakMap(); diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 87b0f2048b9db..e3c062fefaaf3 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -14,7 +14,11 @@ * Be mindful of backwards compatibility when making changes. */ -import type {ReactContext, Wakeable} from 'shared/ReactTypes'; +import type { + ReactContext, + Wakeable, + ReactComponentInfo, +} from 'shared/ReactTypes'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type { ComponentFilter, @@ -90,7 +94,6 @@ export type GetDisplayNameForElementID = (id: number) => string | null; export type GetElementIDForHostInstance = ( component: HostInstance, - findNearestUnfilteredAncestor?: boolean, ) => number | null; export type FindHostInstancesForElementID = ( id: number, @@ -106,10 +109,11 @@ export type Lane = number; export type Lanes = number; export type ReactRenderer = { - findFiberByHostInstance: (hostInstance: HostInstance) => Fiber | null, version: string, rendererPackageName: string, bundleType: BundleType, + // 16.0+ - To be removed in future versions. + findFiberByHostInstance?: (hostInstance: HostInstance) => Fiber | null, // 16.9+ overrideHookState?: ?( fiber: Object, @@ -155,6 +159,9 @@ export type ReactRenderer = { // Only injected by React v16.9+ in DEV mode. // Enables DevTools to append owners-only component stack to error messages. getCurrentFiber?: () => Fiber | null, + // Only injected by React Flight Clients in DEV mode. + // Enables DevTools to append owners-only component stack to error messages from Server Components. + getCurrentComponentInfo?: () => ReactComponentInfo | null, // 17.0.2+ reconcilerVersion?: string, // Uniquely identifies React DOM v15. @@ -239,8 +246,6 @@ export type OwnersList = { export type InspectedElement = { id: number, - displayName: string | null, - // Does the current renderer support editable hooks and function props? canEditHooks: boolean, canEditFunctionProps: boolean, @@ -254,7 +259,6 @@ export type InspectedElement = { // Is this Error, and can its value be overridden now? canToggleError: boolean, isErrored: boolean, - targetErrorBoundaryID: ?number, // Is this Suspense, and can its value be overridden now? canToggleSuspense: boolean, @@ -346,6 +350,14 @@ export type InstanceAndStyle = { type Type = 'props' | 'hooks' | 'state' | 'context'; +export type OnErrorOrWarning = ( + type: 'error' | 'warn', + args: Array, +) => void; +export type GetComponentStack = ( + topFrame: Error, +) => null | {enableOwnerStacks: boolean, componentStack: string}; + export type RendererInterface = { cleanup: () => void, clearErrorsAndWarnings: () => void, @@ -360,9 +372,8 @@ export type RendererInterface = { findHostInstancesForElementID: FindHostInstancesForElementID, flushInitialOperations: () => void, getBestMatchForTrackedPath: () => PathMatch | null, - getNearestMountedHostInstance: ( - component: HostInstance, - ) => HostInstance | null, + getComponentStack?: GetComponentStack, + getNearestMountedDOMNode: (component: Element) => Element | null, getElementIDForHostInstance: GetElementIDForHostInstance, getDisplayNameForElementID: GetDisplayNameForElementID, getInstanceAndStyle(id: number): InstanceAndStyle, @@ -384,6 +395,7 @@ export type RendererInterface = { forceFullData: boolean, ) => InspectedElementPayload, logElementToConsole: (id: number) => void, + onErrorOrWarning?: OnErrorOrWarning, overrideError: (id: number, forceError: boolean) => void, overrideSuspense: (id: number, forceFallback: boolean) => void, overrideValueAtPath: ( @@ -418,6 +430,7 @@ export type RendererInterface = { ) => void, unpatchConsoleForStrictMode: () => void, updateComponentFilters: (componentFilters: Array) => void, + getEnvironmentNames: () => Array, // Timeline profiler interface @@ -479,6 +492,7 @@ export type DevToolsHook = { listeners: {[key: string]: Array, ...}, rendererInterfaces: Map, renderers: Map, + hasUnsupportedRendererAttached: boolean, backends: Map, emit: (event: string, data: any) => void, diff --git a/packages/react-devtools-shared/src/backend/utils.js b/packages/react-devtools-shared/src/backend/utils.js index bd762467b6481..e763bdd759759 100644 --- a/packages/react-devtools-shared/src/backend/utils.js +++ b/packages/react-devtools-shared/src/backend/utils.js @@ -299,10 +299,12 @@ export function formatConsoleArgumentsToSingleString( if (args.length) { const REGEXP = /(%?)(%([jds]))/g; + // $FlowFixMe[incompatible-call] formatted = formatted.replace(REGEXP, (match, escaped, ptn, flag) => { let arg = args.shift(); switch (flag) { case 's': + // $FlowFixMe[unsafe-addition] arg += ''; break; case 'd': diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js b/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js index cdaf64ed8c7a3..fdd059cc23486 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js @@ -194,6 +194,7 @@ export default class Overlay { while (this.rects.length > elements.length) { const rect = this.rects.pop(); + // $FlowFixMe[incompatible-use] rect.remove(); } if (elements.length === 0) { diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index d0d5c7951683a..7a8bb801445c1 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -221,7 +221,6 @@ export function convertInspectedElementBackendToFrontend( canEditHooksAndRenamePaths, canToggleError, isErrored, - targetErrorBoundaryID, canToggleSuspense, canViewSource, hasLegacyContext, @@ -251,7 +250,6 @@ export function convertInspectedElementBackendToFrontend( canEditHooksAndRenamePaths, canToggleError, isErrored, - targetErrorBoundaryID, canToggleSuspense, canViewSource, hasLegacyContext, diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index f4da08be6bdbe..65a52b571a680 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -178,6 +178,7 @@ type SavedPreferencesParams = { }; export type BackendEvents = { + backendInitialized: [], backendVersion: [string], bridgeProtocol: [BridgeProtocol], extensionBackendInitialized: [], @@ -189,6 +190,7 @@ export type BackendEvents = { operations: [Array], ownersList: [OwnersList], overrideComponentFilters: [Array], + environmentNames: [Array], profilingData: [ProfilingDataBackend], profilingStatus: [boolean], reloadAppForProfiling: [], @@ -198,7 +200,7 @@ export type BackendEvents = { stopInspectingHost: [boolean], syncSelectionFromBuiltinElementsPanel: [], syncSelectionToBuiltinElementsPanel: [], - unsupportedRendererVersion: [RendererID], + unsupportedRendererVersion: [], // React Native style editor plug-in. isNativeStyleEditorSupported: [ @@ -216,6 +218,7 @@ type FrontendEvents = { deletePath: [DeletePath], getBackendVersion: [], getBridgeProtocol: [], + getIfHasUnsupportedRendererVersion: [], getOwnersList: [ElementAndRendererID], getProfilingData: [{rendererID: RendererID}], getProfilingStatus: [], @@ -237,6 +240,7 @@ type FrontendEvents = { stopProfiling: [], storeAsGlobal: [StoreAsGlobalParams], updateComponentFilters: [Array], + getEnvironmentNames: [], updateConsolePatchSettings: [ConsolePatchSettings], viewAttributeSource: [ViewAttributeSourceParams], viewElementSource: [ElementAndRendererID], diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index ef6f720346246..d351306a44546 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -191,6 +191,8 @@ export default class Store extends EventEmitter<{ // Used for windowing purposes. _weightAcrossRoots: number = 0; + _shouldCheckBridgeProtocolCompatibility: boolean = false; + constructor(bridge: FrontendBridge, config?: Config) { super(); @@ -218,6 +220,7 @@ export default class Store extends EventEmitter<{ supportsReloadAndProfile, supportsTimeline, supportsTraceUpdates, + checkBridgeProtocolCompatibility, } = config; if (supportsInspectMatchingDOMElement) { this._supportsInspectMatchingDOMElement = true; @@ -234,6 +237,9 @@ export default class Store extends EventEmitter<{ if (supportsTraceUpdates) { this._supportsTraceUpdates = true; } + if (checkBridgeProtocolCompatibility) { + this._shouldCheckBridgeProtocolCompatibility = true; + } } this._bridge = bridge; @@ -262,24 +268,9 @@ export default class Store extends EventEmitter<{ this._profilerStore = new ProfilerStore(bridge, this, isProfiling); - // Verify that the frontend version is compatible with the connected backend. - // See github.com/facebook/react/issues/21326 - if (config != null && config.checkBridgeProtocolCompatibility) { - // Older backends don't support an explicit bridge protocol, - // so we should timeout eventually and show a downgrade message. - this._onBridgeProtocolTimeoutID = setTimeout( - this.onBridgeProtocolTimeout, - 10000, - ); - - bridge.addListener('bridgeProtocol', this.onBridgeProtocol); - bridge.send('getBridgeProtocol'); - } - bridge.addListener('backendVersion', this.onBridgeBackendVersion); - bridge.send('getBackendVersion'); - bridge.addListener('saveToClipboard', this.onSaveToClipboard); + bridge.addListener('backendInitialized', this.onBackendInitialized); } // This is only used in tests to avoid memory leaks. @@ -1493,6 +1484,25 @@ export default class Store extends EventEmitter<{ copy(text); }; + onBackendInitialized: () => void = () => { + // Verify that the frontend version is compatible with the connected backend. + // See github.com/facebook/react/issues/21326 + if (this._shouldCheckBridgeProtocolCompatibility) { + // Older backends don't support an explicit bridge protocol, + // so we should timeout eventually and show a downgrade message. + this._onBridgeProtocolTimeoutID = setTimeout( + this.onBridgeProtocolTimeout, + 10000, + ); + + this._bridge.addListener('bridgeProtocol', this.onBridgeProtocol); + this._bridge.send('getBridgeProtocol'); + } + + this._bridge.send('getBackendVersion'); + this._bridge.send('getIfHasUnsupportedRendererVersion'); + }; + // The Store should never throw an Error without also emitting an event. // Otherwise Store errors will be invisible to users, // but the downstream errors they cause will be reported as bugs. diff --git a/packages/react-devtools-shared/src/devtools/views/Components/CannotSuspendWarningMessage.js b/packages/react-devtools-shared/src/devtools/views/Components/CannotSuspendWarningMessage.js deleted file mode 100644 index 71fce90b5b80b..0000000000000 --- a/packages/react-devtools-shared/src/devtools/views/Components/CannotSuspendWarningMessage.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import * as React from 'react'; -import {useContext} from 'react'; -import {StoreContext} from '../context'; -import { - ComponentFilterElementType, - ElementTypeSuspense, -} from 'react-devtools-shared/src/frontend/types'; - -export default function CannotSuspendWarningMessage(): React.Node { - const store = useContext(StoreContext); - const areSuspenseElementsHidden = !!store.componentFilters.find( - filter => - filter.type === ComponentFilterElementType && - filter.value === ElementTypeSuspense && - filter.isEnabled, - ); - - // Has the user filtered out Suspense nodes from the tree? - // If so, the selected element might actually be in a Suspense tree after all. - if (areSuspenseElementsHidden) { - return ( -
- Suspended state cannot be toggled while Suspense components are hidden. - Disable the filter and try again. -
- ); - } else { - return ( -
- The selected element is not within a Suspense container. Suspending it - would cause an error. -
- ); - } -} 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 5dd887ba59666..de9f3490190d9 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -9,15 +9,13 @@ import * as React from 'react'; import {useCallback, useContext, useSyncExternalStore} from 'react'; -import {TreeDispatcherContext, TreeStateContext} from './TreeContext'; +import {TreeStateContext} from './TreeContext'; import {BridgeContext, StoreContext, OptionsContext} from '../context'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; import Icon from '../Icon'; -import {ModalDialogContext} from '../ModalDialog'; import Toggle from '../Toggle'; import {ElementTypeSuspense} from 'react-devtools-shared/src/frontend/types'; -import CannotSuspendWarningMessage from './CannotSuspendWarningMessage'; import InspectedElementView from './InspectedElementView'; import {InspectedElementContext} from './InspectedElementContext'; import {getOpenInEditorURL} from '../../../utils'; @@ -38,7 +36,6 @@ export type Props = {}; export default function InspectedElementWrapper(_: Props): React.Node { const {inspectedElementID} = useContext(TreeStateContext); - const dispatch = useContext(TreeDispatcherContext); const bridge = useContext(BridgeContext); const store = useContext(StoreContext); const { @@ -47,7 +44,6 @@ export default function InspectedElementWrapper(_: Props): React.Node { hideLogAction, hideViewSourceAction, } = useContext(OptionsContext); - const {dispatch: modalDialogDispatch} = useContext(ModalDialogContext); const {hookNames, inspectedElement, parseHookNames, toggleParseHookNames} = useContext(InspectedElementContext); @@ -105,8 +101,6 @@ export default function InspectedElementWrapper(_: Props): React.Node { }, [bridge, inspectedElementID, store]); const isErrored = inspectedElement != null && inspectedElement.isErrored; - const targetErrorBoundaryID = - inspectedElement != null ? inspectedElement.targetErrorBoundaryID : null; const isSuspended = element !== null && @@ -137,79 +131,43 @@ export default function InspectedElementWrapper(_: Props): React.Node { ); const toggleErrored = useCallback(() => { - if (inspectedElement == null || targetErrorBoundaryID == null) { + if (inspectedElement == null) { return; } - const rendererID = store.getRendererIDForElement(targetErrorBoundaryID); + const rendererID = store.getRendererIDForElement(inspectedElement.id); if (rendererID !== null) { - if (targetErrorBoundaryID !== inspectedElement.id) { - // Update tree selection so that if we cause a component to error, - // the nearest error boundary will become the newly selected thing. - dispatch({ - type: 'SELECT_ELEMENT_BY_ID', - payload: targetErrorBoundaryID, - }); - } - // Toggle error. + // Because triggering an error will always delete the children, we'll + // automatically select the nearest still mounted instance which will be + // the error boundary. bridge.send('overrideError', { - id: targetErrorBoundaryID, + id: inspectedElement.id, rendererID, forceError: !isErrored, }); } - }, [bridge, dispatch, isErrored, targetErrorBoundaryID]); + }, [bridge, store, isErrored, inspectedElement]); // TODO (suspense toggle) Would be nice to eventually use a two setState pattern here as well. const toggleSuspended = useCallback(() => { - let nearestSuspenseElement = null; - let currentElement = element; - while (currentElement !== null) { - if (currentElement.type === ElementTypeSuspense) { - nearestSuspenseElement = currentElement; - break; - } else if (currentElement.parentID > 0) { - currentElement = store.getElementByID(currentElement.parentID); - } else { - currentElement = null; - } + if (inspectedElement == null) { + return; } - // If we didn't find a Suspense ancestor, we can't suspend. - // Instead we can show a warning to the user. - if (nearestSuspenseElement === null) { - modalDialogDispatch({ - id: 'InspectedElement', - type: 'SHOW', - content: , - }); - } else { - const nearestSuspenseElementID = nearestSuspenseElement.id; - - // If we're suspending from an arbitrary (non-Suspense) component, select the nearest Suspense element in the Tree. - // This way when the fallback UI is shown and the current element is hidden, something meaningful is selected. - if (nearestSuspenseElement !== element) { - dispatch({ - type: 'SELECT_ELEMENT_BY_ID', - payload: nearestSuspenseElementID, - }); - } - - const rendererID = store.getRendererIDForElement( - nearestSuspenseElementID, - ); - + const rendererID = store.getRendererIDForElement(inspectedElement.id); + if (rendererID !== null) { // Toggle suspended - if (rendererID !== null) { - bridge.send('overrideSuspense', { - id: nearestSuspenseElementID, - rendererID, - forceFallback: !isSuspended, - }); - } + // Because suspending or unsuspending always delete the children or fallback, + // we'll automatically select the nearest still mounted instance which will be + // the Suspense boundary. + bridge.send('overrideSuspense', { + id: inspectedElement.id, + rendererID, + forceFallback: !isSuspended, + }); } - }, [bridge, dispatch, element, isSuspended, modalDialogDispatch, store]); + }, [bridge, store, isSuspended, inspectedElement]); if (element === null) { return ( diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js index 7e9df12a1efb8..75c9b8a6d9cc6 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -391,18 +391,22 @@ const __printTree = (commitTree: CommitTree) => { const id = queue.shift(); const depth = queue.shift(); + // $FlowFixMe[incompatible-call] const node = nodes.get(id); if (node == null) { + // $FlowFixMe[incompatible-type] throw Error(`Could not find node with id "${id}" in commit tree`); } console.log( + // $FlowFixMe[incompatible-call] `${'•'.repeat(depth)}${node.id}:${node.displayName || ''} ${ node.key ? `key:"${node.key}"` : '' } (${node.treeBaseDuration})`, ); node.children.forEach(childID => { + // $FlowFixMe[unsafe-addition] queue.push(childID, depth + 1); }); } diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js index a2a6a1d681819..33552daa262d8 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js @@ -15,6 +15,7 @@ import { useMemo, useRef, useState, + use, } from 'react'; import { LOCAL_STORAGE_OPEN_IN_EDITOR_URL, @@ -31,6 +32,7 @@ import { ComponentFilterElementType, ComponentFilterHOC, ComponentFilterLocation, + ComponentFilterEnvironmentName, ElementTypeClass, ElementTypeContext, ElementTypeFunction, @@ -52,11 +54,16 @@ import type { ElementType, ElementTypeComponentFilter, RegExpComponentFilter, + EnvironmentNameComponentFilter, } from 'react-devtools-shared/src/frontend/types'; const vscodeFilepath = 'vscode://file/{path}:{line}'; -export default function ComponentsSettings(_: {}): React.Node { +export default function ComponentsSettings({ + environmentNames, +}: { + environmentNames: Promise>, +}): React.Node { const store = useContext(StoreContext); const {parseHookNames, setParseHookNames} = useContext(SettingsContext); @@ -101,6 +108,30 @@ export default function ComponentsSettings(_: {}): React.Node { Array, >(() => [...store.componentFilters]); + const usedEnvironmentNames = use(environmentNames); + + const resolvedEnvironmentNames = useMemo(() => { + const set = new Set(usedEnvironmentNames); + // If there are other filters already specified but are not currently + // on the page, we still allow them as options. + for (let i = 0; i < componentFilters.length; i++) { + const filter = componentFilters[i]; + if (filter.type === ComponentFilterEnvironmentName) { + set.add(filter.value); + } + } + // Client is special and is always available as a default. + if (set.size > 0) { + // Only show any options at all if there's any other option already + // used by a filter or if any environments are used by the page. + // Note that "Client" can have been added above which would mean + // that we should show it as an option regardless if it's the only + // option. + set.add('Client'); + } + return Array.from(set).sort(); + }, [usedEnvironmentNames, componentFilters]); + const addFilter = useCallback(() => { setComponentFilters(prevComponentFilters => { return [ @@ -146,6 +177,13 @@ export default function ComponentsSettings(_: {}): React.Node { isEnabled: componentFilter.isEnabled, isValid: true, }; + } else if (type === ComponentFilterEnvironmentName) { + cloned[index] = { + type: ComponentFilterEnvironmentName, + isEnabled: componentFilter.isEnabled, + isValid: true, + value: 'Client', + }; } } return cloned; @@ -210,6 +248,29 @@ export default function ComponentsSettings(_: {}): React.Node { [], ); + const updateFilterValueEnvironmentName = useCallback( + (componentFilter: ComponentFilter, value: string) => { + if (componentFilter.type !== ComponentFilterEnvironmentName) { + throw Error('Invalid value for environment name filter'); + } + + setComponentFilters(prevComponentFilters => { + const cloned: Array = [...prevComponentFilters]; + if (componentFilter.type === ComponentFilterEnvironmentName) { + const index = prevComponentFilters.indexOf(componentFilter); + if (index >= 0) { + cloned[index] = { + ...componentFilter, + value, + }; + } + } + return cloned; + }); + }, + [], + ); + const removeFilter = useCallback((index: number) => { setComponentFilters(prevComponentFilters => { const cloned: Array = [...prevComponentFilters]; @@ -246,6 +307,11 @@ export default function ComponentsSettings(_: {}): React.Node { ...((cloned[index]: any): BooleanComponentFilter), isEnabled, }; + } else if (componentFilter.type === ComponentFilterEnvironmentName) { + cloned[index] = { + ...((cloned[index]: any): EnvironmentNameComponentFilter), + isEnabled, + }; } } return cloned; @@ -380,10 +446,16 @@ export default function ComponentsSettings(_: {}): React.Node { + {resolvedEnvironmentNames.length > 0 && ( + + )} - {componentFilter.type === ComponentFilterElementType && + {(componentFilter.type === ComponentFilterElementType || + componentFilter.type === ComponentFilterEnvironmentName) && 'equals'} {(componentFilter.type === ComponentFilterLocation || componentFilter.type === ComponentFilterDisplayName) && @@ -428,6 +500,23 @@ export default function ComponentsSettings(_: {}): React.Node { value={componentFilter.value} /> )} + {componentFilter.type === ComponentFilterEnvironmentName && ( + + )}