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 2f6d8a94021ab..aef18c90c2e5e 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -101,6 +101,7 @@ import {propagatePhiTypes} from '../TypeInference/PropagatePhiTypes'; import {lowerContextAccess} from '../Optimization/LowerContextAccess'; import {validateNoSetStateInPassiveEffects} from '../Validation/ValidateNoSetStateInPassiveEffects'; import {validateNoJSXInTryStatement} from '../Validation/ValidateNoJSXInTryStatement'; +import {propagateScopeDependenciesHIR} from '../HIR/PropagateScopeDependenciesHIR'; export type CompilerPipelineValue = | {kind: 'ast'; name: string; value: CodegenFunction} @@ -341,6 +342,14 @@ function* runWithEnvironment( }); assertTerminalSuccessorsExist(hir); assertTerminalPredsExist(hir); + if (env.config.enablePropagateDepsInHIR) { + propagateScopeDependenciesHIR(hir); + yield log({ + kind: 'hir', + name: 'PropagateScopeDependenciesHIR', + value: hir, + }); + } const reactiveFunction = buildReactiveFunction(hir); yield log({ @@ -359,12 +368,14 @@ function* runWithEnvironment( }); assertScopeInstructionsWithinScopes(reactiveFunction); - propagateScopeDependencies(reactiveFunction); - yield log({ - kind: 'reactive', - name: 'PropagateScopeDependencies', - value: reactiveFunction, - }); + if (!env.config.enablePropagateDepsInHIR) { + propagateScopeDependencies(reactiveFunction); + yield log({ + kind: 'reactive', + name: 'PropagateScopeDependencies', + value: reactiveFunction, + }); + } pruneNonEscapingScopes(reactiveFunction); yield log({ diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectHoistablePropertyLoads.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectHoistablePropertyLoads.ts new file mode 100644 index 0000000000000..941c60dea9d4f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectHoistablePropertyLoads.ts @@ -0,0 +1,469 @@ +import {CompilerError} from '../CompilerError'; +import {inRange} from '../ReactiveScopes/InferReactiveScopeVariables'; +import {Set_intersect, Set_union, getOrInsertDefault} from '../Utils/utils'; +import { + BasicBlock, + BlockId, + GeneratedSource, + HIRFunction, + Identifier, + IdentifierId, + Place, + ReactiveScopeDependency, + ScopeId, +} from './HIR'; + +/** + * Helper function for `PropagateScopeDependencies`. + * Uses control flow graph analysis to determine which `Identifier`s can + * be assumed to be non-null objects, on a per-block basis. + * + * Here is an example: + * ```js + * function useFoo(x, y, z) { + * // NOT safe to hoist PropertyLoads here + * if (...) { + * // safe to hoist loads from x + * read(x.a); + * return; + * } + * // safe to hoist loads from y, z + * read(y.b); + * if (...) { + * // safe to hoist loads from y, z + * read(z.a); + * } else { + * // safe to hoist loads from y, z + * read(z.b); + * } + * // safe to hoist loads from y, z + * return; + * } + * ``` + * + * Note that we currently do NOT account for mutable / declaration range + * when doing the CFG-based traversal, producing results that are technically + * incorrect but filtered by PropagateScopeDeps (which only takes dependencies + * on constructed value -- i.e. a scope's dependencies must have mutable ranges + * ending earlier than the scope start). + * + * Take this example, this function will infer x.foo.bar as non-nullable for bb0, + * via the intersection of bb1 & bb2 which in turn comes from bb3. This is technically + * incorrect bb0 is before / during x's mutable range. + * bb0: + * const x = ...; + * if cond then bb1 else bb2 + * bb1: + * ... + * goto bb3 + * bb2: + * ... + * goto bb3: + * bb3: + * x.foo.bar + */ +export function collectHoistablePropertyLoads( + fn: HIRFunction, + temporaries: ReadonlyMap, +): ReadonlyMap { + const nodes = collectPropertyLoadsInBlocks(fn, temporaries); + propagateNonNull(fn, nodes); + + const nodesKeyedByScopeId = new Map(); + for (const [_, block] of fn.body.blocks) { + if (block.terminal.kind === 'scope') { + nodesKeyedByScopeId.set( + block.terminal.scope.id, + nodes.get(block.terminal.block)!, + ); + } + } + + return nodesKeyedByScopeId; +} + +export type BlockInfo = { + block: BasicBlock; + assumedNonNullObjects: ReadonlySet; +}; + +export function getProperty( + object: Place, + propertyName: string, + temporaries: ReadonlyMap, +): ReactiveScopeDependency { + /* + * (1) Get the base object either from the temporary sidemap (e.g. a LoadLocal) + * or a deep copy of an existing property dependency. + * Example 1: + * $0 = LoadLocal x + * $1 = PropertyLoad $0.y + * getProperty($0, ...) -> resolvedObject = x, resolvedDependency = null + * + * Example 2: + * $0 = LoadLocal x + * $1 = PropertyLoad $0.y + * $2 = PropertyLoad $1.z + * getProperty($1, ...) -> resolvedObject = null, resolvedDependency = x.y + * + * Example 3: + * $0 = Call(...) + * $1 = PropertyLoad $0.y + * getProperty($0, ...) -> resolvedObject = null, resolvedDependency = null + */ + const resolvedDependency = temporaries.get(object.identifier.id); + + /** + * (2) Push the last PropertyLoad + * TODO(mofeiZ): understand optional chaining + */ + let property: ReactiveScopeDependency; + if (resolvedDependency == null) { + property = { + identifier: object.identifier, + path: [{property: propertyName, optional: false}], + }; + } else { + property = { + identifier: resolvedDependency.identifier, + path: [ + ...resolvedDependency.path, + {property: propertyName, optional: false}, + ], + }; + } + return property; +} + +export function resolveTemporary( + place: Place, + temporaries: ReadonlyMap, +): Identifier { + return temporaries.get(place.identifier.id) ?? place.identifier; +} + +/** + * Tree data structure to dedupe property loads (e.g. a.b.c) + * and make computing sets intersections simpler. + */ +type RootNode = { + properties: Map; + parent: null; + // Recorded to make later computations simpler + fullPath: ReactiveScopeDependency; + root: Identifier; +}; + +type PropertyLoadNode = + | { + properties: Map; + parent: PropertyLoadNode; + fullPath: ReactiveScopeDependency; + } + | RootNode; + +class Tree { + roots: Map = new Map(); + + #getOrCreateRoot(identifier: Identifier): PropertyLoadNode { + /** + * Reads from a statically scoped variable are always safe in JS, + * with the exception of TDZ (not addressed by this pass). + */ + let rootNode = this.roots.get(identifier); + + if (rootNode === undefined) { + rootNode = { + root: identifier, + properties: new Map(), + fullPath: { + identifier, + path: [], + }, + parent: null, + }; + this.roots.set(identifier, rootNode); + } + return rootNode; + } + + static #getOrCreateProperty( + node: PropertyLoadNode, + property: string, + ): PropertyLoadNode { + let child = node.properties.get(property); + if (child == null) { + child = { + properties: new Map(), + parent: node, + fullPath: { + identifier: node.fullPath.identifier, + path: node.fullPath.path.concat([{property, optional: false}]), + }, + }; + node.properties.set(property, child); + } + return child; + } + + getPropertyLoadNode(n: ReactiveScopeDependency): PropertyLoadNode { + CompilerError.invariant(n.path.length > 0, { + reason: + '[CollectHoistablePropertyLoads] Expected property node, found root node', + loc: GeneratedSource, + }); + /** + * We add ReactiveScopeDependencies according to instruction ordering, + * so all subpaths of a PropertyLoad should already exist + * (e.g. a.b is added before a.b.c), + */ + let currNode = this.#getOrCreateRoot(n.identifier); + for (let i = 0; i < n.path.length - 1; i++) { + currNode = assertNonNull(currNode.properties.get(n.path[i].property)); + } + + return Tree.#getOrCreateProperty(currNode, n.path.at(-1)!.property); + } +} + +function collectPropertyLoadsInBlocks( + fn: HIRFunction, + temporaries: ReadonlyMap, +): ReadonlyMap { + /** + * Due to current limitations of mutable range inference, there are edge cases in + * which we infer known-immutable values (e.g. props or hook params) to have a + * mutable range and scope. + * (see `destructure-array-declaration-to-context-var` fixture) + * We track known immutable identifiers to reduce regressions (as PropagateScopeDeps + * is being rewritten to HIR). + */ + const knownImmutableIdentifiers = new Set(); + if (fn.fnType === 'Component' || fn.fnType === 'Hook') { + for (const p of fn.params) { + if (p.kind === 'Identifier') { + knownImmutableIdentifiers.add(p.identifier); + } + } + } + const tree = new Tree(); + const nodes = new Map(); + for (const [_, block] of fn.body.blocks) { + const assumedNonNullObjects = new Set(); + for (const instr of block.instructions) { + if (instr.value.kind === 'PropertyLoad') { + const property = getProperty( + instr.value.object, + instr.value.property, + temporaries, + ); + const propertyNode = tree.getPropertyLoadNode(property); + const object = instr.value.object.identifier; + /** + * Since this runs *after* buildReactiveScopeTerminals, identifier mutable ranges + * are not valid with respect to current instruction id numbering. + * We use attached reactive scope ranges as a proxy for mutable range, but this + * is an overestimate as (1) scope ranges merge and align to form valid program + * blocks and (2) passes like MemoizeFbtAndMacroOperands may assign scopes to + * non-mutable identifiers. + * + * See comment at top of function for why we track known immutable identifiers. + */ + const isMutableAtInstr = + object.mutableRange.end > object.mutableRange.start + 1 && + object.scope != null && + inRange(instr, object.scope.range); + if ( + !isMutableAtInstr || + knownImmutableIdentifiers.has(propertyNode.fullPath.identifier) + ) { + let curr = propertyNode.parent; + while (curr != null) { + assumedNonNullObjects.add(curr); + curr = curr.parent; + } + } + } + // TODO handle destructuring + } + + nodes.set(block.id, { + block, + assumedNonNullObjects, + }); + } + return nodes; +} + +function propagateNonNull( + fn: HIRFunction, + nodes: ReadonlyMap, +): void { + const blockSuccessors = new Map>(); + const terminalPreds = new Set(); + + for (const [blockId, block] of fn.body.blocks) { + for (const pred of block.preds) { + getOrInsertDefault(blockSuccessors, pred, new Set()).add(blockId); + } + if (block.terminal.kind === 'throw' || block.terminal.kind === 'return') { + terminalPreds.add(blockId); + } + } + + /** + * In the context of a control flow graph, the identifiers that a block + * can assume are non-null can be calculated from the following: + * X = Union(Intersect(X_neighbors), X) + */ + function recursivelyPropagateNonNull( + nodeId: BlockId, + direction: 'forward' | 'backward', + traversalState: Map, + nonNullObjectsByBlock: Map>, + ): boolean { + /** + * Avoid re-visiting computed or currently active nodes, which can + * occur when the control flow graph has backedges. + */ + if (traversalState.has(nodeId)) { + return false; + } + traversalState.set(nodeId, 'active'); + + const node = nodes.get(nodeId); + if (node == null) { + CompilerError.invariant(false, { + reason: `Bad node ${nodeId}, kind: ${direction}`, + loc: GeneratedSource, + }); + } + const neighbors = Array.from( + direction === 'backward' + ? (blockSuccessors.get(nodeId) ?? []) + : node.block.preds, + ); + + let changed = false; + for (const pred of neighbors) { + if (!traversalState.has(pred)) { + const neighborChanged = recursivelyPropagateNonNull( + pred, + direction, + traversalState, + nonNullObjectsByBlock, + ); + changed ||= neighborChanged; + } + } + /** + * Note that a predecessor / successor can only be active (status != 'done') + * if it is a self-loop or other transitive cycle. Active neighbors can be + * filtered out (i.e. not included in the intersection) + * Example: self loop. + * X = Union(Intersect(X, ...X_other_neighbors), X) + * + * Example: transitive cycle through node Y, for some Y that is a + * predecessor / successor of X. + * X = Union( + * Intersect( + * Union(Intersect(X, ...Y_other_neighbors), Y), + * ...X_neighbors + * ), + * X + * ) + * + * Non-active neighbors with no recorded results can occur due to backedges. + * it's not safe to assume they can be filtered out (e.g. not included in + * the intersection) + */ + const neighborAccesses = Set_intersect( + Array.from(neighbors) + .filter(n => traversalState.get(n) === 'done') + .map(n => assertNonNull(nonNullObjectsByBlock.get(n))), + ); + + const prevObjects = assertNonNull(nonNullObjectsByBlock.get(nodeId)); + const newObjects = Set_union(prevObjects, neighborAccesses); + + nonNullObjectsByBlock.set(nodeId, newObjects); + traversalState.set(nodeId, 'done'); + changed ||= prevObjects.size !== newObjects.size; + return changed; + } + const fromEntry = new Map>(); + const fromExit = new Map>(); + for (const [blockId, blockInfo] of nodes) { + fromEntry.set(blockId, blockInfo.assumedNonNullObjects); + fromExit.set(blockId, blockInfo.assumedNonNullObjects); + } + const traversalState = new Map(); + const reversedBlocks = [...fn.body.blocks]; + reversedBlocks.reverse(); + + let i = 0; + let changed; + do { + i++; + changed = false; + for (const [blockId] of fn.body.blocks) { + const forwardChanged = recursivelyPropagateNonNull( + blockId, + 'forward', + traversalState, + fromEntry, + ); + changed ||= forwardChanged; + } + traversalState.clear(); + for (const [blockId] of reversedBlocks) { + const backwardChanged = recursivelyPropagateNonNull( + blockId, + 'backward', + traversalState, + fromExit, + ); + changed ||= backwardChanged; + } + traversalState.clear(); + } while (changed); + + /** + * TODO: validate against meta internal code, then remove in future PR. + * Currently cannot come up with a case that requires fixed-point iteration. + */ + CompilerError.invariant(i <= 2, { + reason: 'require fixed-point iteration', + description: `#iterations = ${i}`, + loc: GeneratedSource, + }); + + CompilerError.invariant( + fromEntry.size === fromExit.size && fromEntry.size === nodes.size, + { + reason: + 'bad sizes after calculating fromEntry + fromExit ' + + `${fromEntry.size} ${fromExit.size} ${nodes.size}`, + loc: GeneratedSource, + }, + ); + + for (const [id, node] of nodes) { + node.assumedNonNullObjects = Set_union( + assertNonNull(fromEntry.get(id)), + assertNonNull(fromExit.get(id)), + ); + } +} + +function assertNonNull, U>( + value: T | null | undefined, + source?: string, +): T { + CompilerError.invariant(value != null, { + reason: 'Unexpected null', + description: source != null ? `(from ${source})` : null, + loc: GeneratedSource, + }); + return value; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/DeriveMinimalDependenciesHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/DeriveMinimalDependenciesHIR.ts new file mode 100644 index 0000000000000..ecc1844b006aa --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/DeriveMinimalDependenciesHIR.ts @@ -0,0 +1,267 @@ +/** + * 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 {GeneratedSource, Identifier, ReactiveScopeDependency} from '../HIR'; +import {printIdentifier} from '../HIR/PrintHIR'; +import {ReactiveScopePropertyDependency} from '../ReactiveScopes/DeriveMinimalDependencies'; + +const ENABLE_DEBUG_INVARIANTS = true; + +/** + * Simpler fork of DeriveMinimalDependencies, see PropagateScopeDependenciesHIR + * for detailed explanation. + */ +export class ReactiveScopeDependencyTreeHIR { + #roots: Map = new Map(); + + #getOrCreateRoot(identifier: Identifier, isNonNull: boolean): DependencyNode { + // roots can always be accessed unconditionally in JS + let rootNode = this.#roots.get(identifier); + + if (rootNode === undefined) { + rootNode = { + properties: new Map(), + accessType: isNonNull + ? PropertyAccessType.NonNullAccess + : PropertyAccessType.Access, + }; + this.#roots.set(identifier, rootNode); + } + return rootNode; + } + + addDependency(dep: ReactiveScopePropertyDependency): void { + const {path} = dep; + let currNode = this.#getOrCreateRoot(dep.identifier, false); + + const accessType = PropertyAccessType.Access; + + currNode.accessType = merge(currNode.accessType, accessType); + + for (const property of path) { + // all properties read 'on the way' to a dependency are marked as 'access' + let currChild = getOrMakeProperty(currNode, property.property); + currChild.accessType = merge(currChild.accessType, accessType); + currNode = currChild; + } + + /* + * 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. + */ + currNode.accessType = merge( + currNode.accessType, + PropertyAccessType.Dependency, + ); + } + + markNodesNonNull(dep: ReactiveScopePropertyDependency): void { + const accessType = PropertyAccessType.NonNullAccess; + let currNode = this.#roots.get(dep.identifier); + + let cursor = 0; + while (currNode != null && cursor < dep.path.length) { + currNode.accessType = merge(currNode.accessType, accessType); + currNode = currNode.properties.get(dep.path[cursor++].property); + } + if (currNode != null) { + currNode.accessType = merge(currNode.accessType, accessType); + } + } + + /** + * Derive a set of minimal dependencies that are safe to + * access unconditionally (with respect to nullthrows behavior) + */ + deriveMinimalDependencies(): Set { + const results = new Set(); + for (const [rootId, rootNode] of this.#roots.entries()) { + if (ENABLE_DEBUG_INVARIANTS) { + assertWellFormedTree(rootNode); + } + const deps = deriveMinimalDependenciesInSubtree(rootNode, []); + + for (const dep of deps) { + results.add({ + identifier: rootId, + path: dep.path.map(s => ({property: s, optional: false})), + }); + } + } + + return results; + } + + /* + * Prints dependency tree to string for debugging. + * @param includeAccesses + * @returns string representation of DependencyTree + */ + printDeps(includeAccesses: boolean): string { + let res: Array> = []; + + for (const [rootId, rootNode] of this.#roots.entries()) { + const rootResults = printSubtree(rootNode, includeAccesses).map( + result => `${printIdentifier(rootId)}.${result}`, + ); + res.push(rootResults); + } + return res.flat().join('\n'); + } +} + +enum PropertyAccessType { + Access = 'Access', + NonNullAccess = 'NonNullAccess', + Dependency = 'Dependency', + NonNullDependency = 'NonNullDependency', +} + +const MIN_ACCESS_TYPE = PropertyAccessType.Access; +/** + * "NonNull" means that PropertyReads from a node are side-effect free, + * as the node is (1) immutable and (2) has unconditional propertyloads + * somewhere in the cfg. + */ +function isNonNull(access: PropertyAccessType): boolean { + return ( + access === PropertyAccessType.NonNullAccess || + access === PropertyAccessType.NonNullDependency + ); +} +function isDependency(access: PropertyAccessType): boolean { + return ( + access === PropertyAccessType.Dependency || + access === PropertyAccessType.NonNullDependency + ); +} + +function merge( + access1: PropertyAccessType, + access2: PropertyAccessType, +): PropertyAccessType { + const resultisNonNull = isNonNull(access1) || isNonNull(access2); + const resultIsDependency = isDependency(access1) || isDependency(access2); + + /* + * Straightforward merge. + * This can be represented as bitwise OR, but is written out for readability + * + * Observe that `NonNullAccess | Dependency` produces an + * unconditionally accessed conditional dependency. We currently use these + * as we use unconditional dependencies. (i.e. to codegen change variables) + */ + if (resultisNonNull) { + if (resultIsDependency) { + return PropertyAccessType.NonNullDependency; + } else { + return PropertyAccessType.NonNullAccess; + } + } else { + if (resultIsDependency) { + return PropertyAccessType.Dependency; + } else { + return PropertyAccessType.Access; + } + } +} + +type DependencyNode = { + properties: Map; + accessType: PropertyAccessType; +}; + +type ReduceResultNode = { + path: Array; +}; + +function assertWellFormedTree(node: DependencyNode): void { + let nonNullInChildren = false; + for (const childNode of node.properties.values()) { + assertWellFormedTree(childNode); + nonNullInChildren ||= isNonNull(childNode.accessType); + } + if (nonNullInChildren) { + CompilerError.invariant(isNonNull(node.accessType), { + reason: + '[DeriveMinimialDependencies] Not well formed tree, unexpected non-null node', + description: node.accessType, + loc: GeneratedSource, + }); + } +} + +function deriveMinimalDependenciesInSubtree( + node: DependencyNode, + path: Array, +): Array { + if (isDependency(node.accessType)) { + /** + * If this node is a dependency, we truncate the subtree + * and return this node. e.g. deps=[`obj.a`, `obj.a.b`] + * reduces to deps=[`obj.a`] + */ + return [{path}]; + } else { + if (isNonNull(node.accessType)) { + /* + * Only recurse into subtree dependencies if this node + * is known to be non-null. + */ + const result: Array = []; + for (const [childName, childNode] of node.properties) { + result.push( + ...deriveMinimalDependenciesInSubtree(childNode, [ + ...path, + childName, + ]), + ); + } + return result; + } else { + /* + * This only occurs when this subtree contains a dependency, + * but this node is potentially nullish. As we currently + * don't record optional property paths as scope dependencies, + * we truncate and record this node as a dependency. + */ + return [{path}]; + } + } +} + +function printSubtree( + node: DependencyNode, + includeAccesses: boolean, +): Array { + const results: Array = []; + for (const [propertyName, propertyNode] of node.properties) { + if (includeAccesses || isDependency(propertyNode.accessType)) { + results.push(`${propertyName} (${propertyNode.accessType})`); + } + const propertyResults = printSubtree(propertyNode, includeAccesses); + results.push(...propertyResults.map(result => `${propertyName}.${result}`)); + } + return results; +} + +function getOrMakeProperty( + node: DependencyNode, + property: string, +): DependencyNode { + let child = node.properties.get(property); + if (child == null) { + child = { + properties: new Map(), + accessType: MIN_ACCESS_TYPE, + }; + node.properties.set(property, child); + } + return child; +} 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 dfcb11b28bc8b..75f3086011fd0 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,7 @@ const EnvironmentConfigSchema = z.object({ */ enableUseTypeAnnotations: z.boolean().default(false), - enablePropagateDepsInHIR: z.boolean().default(true), + enablePropagateDepsInHIR: z.boolean().default(false), /** * Enables inference of optional dependency chains. Without this flag @@ -737,6 +738,8 @@ export class Environment { this.#globals, this.#shapes, moduleConfig, + moduleName, + loc, ); } else { moduleType = null; @@ -794,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; } } @@ -822,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/PropagateScopeDependenciesHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PropagateScopeDependenciesHIR.ts new file mode 100644 index 0000000000000..4c7dac004d80a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PropagateScopeDependenciesHIR.ts @@ -0,0 +1,557 @@ +import { + ScopeId, + HIRFunction, + Place, + Instruction, + ReactiveScopeDependency, + Identifier, + ReactiveScope, + isObjectMethodType, + isRefValueType, + isUseRefType, + makeInstructionId, + InstructionId, + InstructionKind, + GeneratedSource, + DeclarationId, + areEqualPaths, + IdentifierId, +} from './HIR'; +import { + BlockInfo, + collectHoistablePropertyLoads, + getProperty, +} from './CollectHoistablePropertyLoads'; +import { + ScopeBlockTraversal, + eachInstructionOperand, + eachInstructionValueOperand, + eachPatternOperand, + eachTerminalOperand, +} from './visitors'; +import {Stack, empty} from '../Utils/Stack'; +import {CompilerError} from '../CompilerError'; +import {Iterable_some} from '../Utils/utils'; +import {ReactiveScopeDependencyTreeHIR} from './DeriveMinimalDependenciesHIR'; + +export function propagateScopeDependenciesHIR(fn: HIRFunction): void { + const usedOutsideDeclaringScope = + findTemporariesUsedOutsideDeclaringScope(fn); + const temporaries = collectTemporariesSidemap(fn, usedOutsideDeclaringScope); + + const hoistablePropertyLoads = collectHoistablePropertyLoads(fn, temporaries); + + const scopeDeps = collectDependencies( + fn, + usedOutsideDeclaringScope, + temporaries, + ); + + /** + * Derive the minimal set of hoistable dependencies for each scope. + */ + for (const [scope, deps] of scopeDeps) { + const tree = new ReactiveScopeDependencyTreeHIR(); + + /** + * Step 1: Add every dependency used by this scope (e.g. `a.b.c`) + */ + for (const dep of deps) { + tree.addDependency({...dep}); + } + /** + * Step 2: Mark hoistable dependencies, given the basic block in + * which the scope begins. + */ + recordHoistablePropertyReads(hoistablePropertyLoads, scope.id, tree); + const candidates = tree.deriveMinimalDependencies(); + for (const candidateDep of candidates) { + if ( + !Iterable_some( + scope.dependencies, + existingDep => + existingDep.identifier.declarationId === + candidateDep.identifier.declarationId && + areEqualPaths(existingDep.path, candidateDep.path), + ) + ) + scope.dependencies.add(candidateDep); + } + } +} + +function findTemporariesUsedOutsideDeclaringScope( + fn: HIRFunction, +): ReadonlySet { + /* + * tracks all relevant LoadLocal and PropertyLoad lvalues + * and the scope where they are defined + */ + const declarations = new Map(); + const prunedScopes = new Set(); + const scopeTraversal = new ScopeBlockTraversal(); + const usedOutsideDeclaringScope = new Set(); + + function handlePlace(place: Place): void { + const declaringScope = declarations.get(place.identifier.declarationId); + if ( + declaringScope != null && + !scopeTraversal.isScopeActive(declaringScope) && + !prunedScopes.has(declaringScope) + ) { + // Declaring scope is not active === used outside declaring scope + usedOutsideDeclaringScope.add(place.identifier.declarationId); + } + } + + function handleInstruction(instr: Instruction): void { + const scope = scopeTraversal.currentScope; + if (scope == null || prunedScopes.has(scope)) { + return; + } + switch (instr.value.kind) { + case 'LoadLocal': + case 'LoadContext': + case 'PropertyLoad': { + declarations.set(instr.lvalue.identifier.declarationId, scope); + break; + } + default: { + break; + } + } + } + + for (const [blockId, block] of fn.body.blocks) { + scopeTraversal.recordScopes(block); + const scopeStartInfo = scopeTraversal.blockInfos.get(blockId); + if (scopeStartInfo?.kind === 'begin' && scopeStartInfo.pruned) { + prunedScopes.add(scopeStartInfo.scope.id); + } + for (const instr of block.instructions) { + for (const place of eachInstructionOperand(instr)) { + handlePlace(place); + } + handleInstruction(instr); + } + + for (const place of eachTerminalOperand(block.terminal)) { + handlePlace(place); + } + } + return usedOutsideDeclaringScope; +} + +/** + * @returns mapping of LoadLocal and PropertyLoad to the source of the load. + * ```js + * // source + * foo(a.b); + * + * // HIR: a potential sidemap is {0: a, 1: a.b, 2: foo} + * $0 = LoadLocal 'a' + * $1 = PropertyLoad $0, 'b' + * $2 = LoadLocal 'foo' + * $3 = CallExpression $2($1) + * ``` + * Only map LoadLocal and PropertyLoad lvalues to their source if we know that + * reordering the read (from the time-of-load to time-of-use) is valid. + * + * If a LoadLocal or PropertyLoad instruction is within the reactive scope range + * (a proxy for mutable range) of the load source, later instructions may + * reassign / mutate the source value. Since it's incorrect to reorder these + * load instructions to after their scope ranges, we also do not store them in + * identifier sidemaps. + * + * Take this example (from fixture + * `evaluation-order-mutate-call-after-dependency-load`) + * ```js + * // source + * function useFoo(arg) { + * const arr = [1, 2, 3, ...arg]; + * return [ + * arr.length, + * arr.push(0) + * ]; + * } + * + * // IR pseudocode + * scope @0 { + * $0 = arr = ArrayExpression [1, 2, 3, ...arg] + * $1 = arr.length + * $2 = arr.push(0) + * } + * scope @1 { + * $3 = ArrayExpression [$1, $2] + * } + * ``` + * Here, it's invalid for scope@1 to take `arr.length` as a dependency instead + * of $1, as the evaluation of `arr.length` changes between instructions $1 and + * $3. We do not track $1 -> arr.length in this case. + */ +function collectTemporariesSidemap( + fn: HIRFunction, + usedOutsideDeclaringScope: ReadonlySet, +): ReadonlyMap { + const temporaries = new Map(); + for (const [_, block] of fn.body.blocks) { + for (const instr of block.instructions) { + const {value, lvalue} = instr; + const usedOutside = usedOutsideDeclaringScope.has( + lvalue.identifier.declarationId, + ); + + if (value.kind === 'PropertyLoad' && !usedOutside) { + const property = getProperty(value.object, value.property, temporaries); + temporaries.set(lvalue.identifier.id, property); + } else if ( + value.kind === 'LoadLocal' && + lvalue.identifier.name == null && + value.place.identifier.name !== null && + !usedOutside + ) { + temporaries.set(lvalue.identifier.id, { + identifier: value.place.identifier, + path: [], + }); + } + } + } + return temporaries; +} + +type Decl = { + id: InstructionId; + scope: Stack; +}; + +class Context { + #declarations: Map = new Map(); + #reassignments: Map = new Map(); + + #scopes: Stack = empty(); + // Reactive dependencies used in the current reactive scope. + #dependencies: Stack> = empty(); + deps: Map> = new Map(); + + #temporaries: ReadonlyMap; + #temporariesUsedOutsideScope: ReadonlySet; + + constructor( + temporariesUsedOutsideScope: ReadonlySet, + temporaries: ReadonlyMap, + ) { + this.#temporariesUsedOutsideScope = temporariesUsedOutsideScope; + this.#temporaries = temporaries; + } + + enterScope(scope: ReactiveScope): void { + // Set context for new scope + this.#dependencies = this.#dependencies.push([]); + this.#scopes = this.#scopes.push(scope); + } + + exitScope(scope: ReactiveScope, pruned: boolean): void { + // Save dependencies we collected from the exiting scope + const scopedDependencies = this.#dependencies.value; + CompilerError.invariant(scopedDependencies != null, { + reason: '[PropagateScopeDeps]: Unexpected scope mismatch', + loc: scope.loc, + }); + + // Restore context of previous scope + this.#scopes = this.#scopes.pop(); + this.#dependencies = this.#dependencies.pop(); + + /* + * Collect dependencies we recorded for the exiting scope and propagate + * them upward using the same rules as normal dependency collection. + * Child scopes may have dependencies on values created within the outer + * scope, which necessarily cannot be dependencies of the outer scope. + */ + for (const dep of scopedDependencies) { + if (this.#checkValidDependency(dep)) { + this.#dependencies.value?.push(dep); + } + } + + if (!pruned) { + this.deps.set(scope, scopedDependencies); + } + } + + isUsedOutsideDeclaringScope(place: Place): boolean { + return this.#temporariesUsedOutsideScope.has( + place.identifier.declarationId, + ); + } + + /* + * Records where a value was declared, and optionally, the scope where the value originated from. + * This is later used to determine if a dependency should be added to a scope; if the current + * scope we are visiting is the same scope where the value originates, it can't be a dependency + * on itself. + */ + declare(identifier: Identifier, decl: Decl): void { + if (!this.#declarations.has(identifier.declarationId)) { + this.#declarations.set(identifier.declarationId, decl); + } + this.#reassignments.set(identifier, decl); + } + + // Checks if identifier is a valid dependency in the current scope + #checkValidDependency(maybeDependency: ReactiveScopeDependency): boolean { + // ref.current access is not a valid dep + if ( + isUseRefType(maybeDependency.identifier) && + maybeDependency.path.at(0)?.property === 'current' + ) { + return false; + } + + // ref value is not a valid dep + if (isRefValueType(maybeDependency.identifier)) { + return false; + } + + /* + * object methods are not deps because they will be codegen'ed back in to + * the object literal. + */ + if (isObjectMethodType(maybeDependency.identifier)) { + return false; + } + + const identifier = maybeDependency.identifier; + /* + * If this operand is used in a scope, has a dynamic value, and was defined + * before this scope, then its a dependency of the scope. + */ + const currentDeclaration = + this.#reassignments.get(identifier) ?? + this.#declarations.get(identifier.declarationId); + const currentScope = this.currentScope.value; + return ( + currentScope != null && + currentDeclaration !== undefined && + currentDeclaration.id < currentScope.range.start + ); + } + + #isScopeActive(scope: ReactiveScope): boolean { + if (this.#scopes === null) { + return false; + } + return this.#scopes.find(state => state === scope); + } + + get currentScope(): Stack { + return this.#scopes; + } + + visitOperand(place: Place): void { + /* + * if this operand is a temporary created for a property load, try to resolve it to + * the expanded Place. Fall back to using the operand as-is. + */ + this.visitDependency( + this.#temporaries.get(place.identifier.id) ?? { + identifier: place.identifier, + path: [], + }, + ); + } + + visitProperty(object: Place, property: string): void { + const nextDependency = getProperty(object, property, this.#temporaries); + this.visitDependency(nextDependency); + } + + visitDependency(maybeDependency: ReactiveScopeDependency): void { + /* + * Any value used after its originally defining scope has concluded must be added as an + * output of its defining scope. Regardless of whether its a const or not, + * some later code needs access to the value. If the current + * scope we are visiting is the same scope where the value originates, it can't be a dependency + * on itself. + */ + + /* + * if originalDeclaration is undefined here, then this is not a local var + * (all decls e.g. `let x;` should be initialized in BuildHIR) + */ + const originalDeclaration = this.#declarations.get( + maybeDependency.identifier.declarationId, + ); + if ( + originalDeclaration !== undefined && + originalDeclaration.scope.value !== null + ) { + originalDeclaration.scope.each(scope => { + if ( + !this.#isScopeActive(scope) && + !Iterable_some( + scope.declarations.values(), + decl => + decl.identifier.declarationId === + maybeDependency.identifier.declarationId, + ) + ) { + scope.declarations.set(maybeDependency.identifier.id, { + identifier: maybeDependency.identifier, + scope: originalDeclaration.scope.value!, + }); + } + }); + } + + if (this.#checkValidDependency(maybeDependency)) { + this.#dependencies.value!.push(maybeDependency); + } + } + + /* + * Record a variable that is declared in some other scope and that is being reassigned in the + * current one as a {@link ReactiveScope.reassignments} + */ + visitReassignment(place: Place): void { + const currentScope = this.currentScope.value; + if ( + currentScope != null && + !Iterable_some( + currentScope.reassignments, + identifier => + identifier.declarationId === place.identifier.declarationId, + ) && + this.#checkValidDependency({identifier: place.identifier, path: []}) + ) { + currentScope.reassignments.add(place.identifier); + } + } +} + +function handleInstruction(instr: Instruction, context: Context): void { + const {id, value, lvalue} = instr; + if (value.kind === 'LoadLocal') { + if ( + value.place.identifier.name === null || + lvalue.identifier.name !== null || + context.isUsedOutsideDeclaringScope(lvalue) + ) { + context.visitOperand(value.place); + } + } else if (value.kind === 'PropertyLoad') { + if (context.isUsedOutsideDeclaringScope(lvalue)) { + context.visitProperty(value.object, value.property); + } + } else if (value.kind === 'StoreLocal') { + context.visitOperand(value.value); + if (value.lvalue.kind === InstructionKind.Reassign) { + context.visitReassignment(value.lvalue.place); + } + context.declare(value.lvalue.place.identifier, { + id, + scope: context.currentScope, + }); + } else if (value.kind === 'DeclareLocal' || value.kind === 'DeclareContext') { + /* + * Some variables may be declared and never initialized. We need + * to retain (and hoist) these declarations if they are included + * in a reactive scope. One approach is to simply add all `DeclareLocal`s + * as scope declarations. + */ + + /* + * We add context variable declarations here, not at `StoreContext`, since + * context Store / Loads are modeled as reads and mutates to the underlying + * variable reference (instead of through intermediate / inlined temporaries) + */ + context.declare(value.lvalue.place.identifier, { + id, + scope: context.currentScope, + }); + } else if (value.kind === 'Destructure') { + context.visitOperand(value.value); + for (const place of eachPatternOperand(value.lvalue.pattern)) { + if (value.lvalue.kind === InstructionKind.Reassign) { + context.visitReassignment(place); + } + context.declare(place.identifier, { + id, + scope: context.currentScope, + }); + } + } else { + for (const operand of eachInstructionValueOperand(value)) { + context.visitOperand(operand); + } + } + + context.declare(lvalue.identifier, { + id, + scope: context.currentScope, + }); +} + +function collectDependencies( + fn: HIRFunction, + usedOutsideDeclaringScope: ReadonlySet, + temporaries: ReadonlyMap, +): Map> { + const context = new Context(usedOutsideDeclaringScope, temporaries); + + for (const param of fn.params) { + if (param.kind === 'Identifier') { + context.declare(param.identifier, { + id: makeInstructionId(0), + scope: empty(), + }); + } else { + context.declare(param.place.identifier, { + id: makeInstructionId(0), + scope: empty(), + }); + } + } + + const scopeTraversal = new ScopeBlockTraversal(); + + for (const [blockId, block] of fn.body.blocks) { + scopeTraversal.recordScopes(block); + const scopeBlockInfo = scopeTraversal.blockInfos.get(blockId); + if (scopeBlockInfo?.kind === 'begin') { + context.enterScope(scopeBlockInfo.scope); + } else if (scopeBlockInfo?.kind === 'end') { + context.exitScope(scopeBlockInfo.scope, scopeBlockInfo?.pruned); + } + + for (const instr of block.instructions) { + handleInstruction(instr, context); + } + for (const place of eachTerminalOperand(block.terminal)) { + context.visitOperand(place); + } + } + return context.deps; +} + +/** + * Compute the set of hoistable property reads. + */ +function recordHoistablePropertyReads( + nodes: ReadonlyMap, + scopeId: ScopeId, + tree: ReactiveScopeDependencyTreeHIR, +): void { + const node = nodes.get(scopeId); + CompilerError.invariant(node != null, { + reason: '[PropagateScopeDependencies] Scope not found in tracked blocks', + loc: GeneratedSource, + }); + + for (const item of node.assumedNonNullObjects) { + tree.markNodesNonNull({ + ...item.fullPath, + }); + } +} 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 904b7a4038dec..217bc3132bd14 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts @@ -6,7 +6,9 @@ */ import {assertExhaustive} from '../Utils/utils'; +import {CompilerError} from '..'; import { + BasicBlock, BlockId, Instruction, InstructionValue, @@ -14,7 +16,9 @@ import { Pattern, Place, ReactiveInstruction, + ReactiveScope, ReactiveValue, + ScopeId, SpreadPattern, Terminal, } from './HIR'; @@ -1149,3 +1153,72 @@ export function* eachTerminalOperand(terminal: Terminal): Iterable { } } } + +/** + * Helper class for traversing scope blocks in HIR-form. + */ +export class ScopeBlockTraversal { + // Live stack of active scopes + #activeScopes: Array = []; + blockInfos: Map< + BlockId, + | { + kind: 'end'; + scope: ReactiveScope; + pruned: boolean; + } + | { + kind: 'begin'; + scope: ReactiveScope; + pruned: boolean; + fallthrough: BlockId; + } + > = new Map(); + + recordScopes(block: BasicBlock): void { + const blockInfo = this.blockInfos.get(block.id); + if (blockInfo?.kind === 'begin') { + this.#activeScopes.push(blockInfo.scope.id); + } else if (blockInfo?.kind === 'end') { + const top = this.#activeScopes.at(-1); + CompilerError.invariant(blockInfo.scope.id === top, { + reason: + 'Expected traversed block fallthrough to match top-most active scope', + loc: block.instructions[0]?.loc ?? block.terminal.id, + }); + this.#activeScopes.pop(); + } + + if ( + block.terminal.kind === 'scope' || + block.terminal.kind === 'pruned-scope' + ) { + CompilerError.invariant( + !this.blockInfos.has(block.terminal.block) && + !this.blockInfos.has(block.terminal.fallthrough), + { + reason: 'Expected unique scope blocks and fallthroughs', + loc: block.terminal.loc, + }, + ); + this.blockInfos.set(block.terminal.block, { + kind: 'begin', + scope: block.terminal.scope, + pruned: block.terminal.kind === 'pruned-scope', + fallthrough: block.terminal.fallthrough, + }); + this.blockInfos.set(block.terminal.fallthrough, { + kind: 'end', + scope: block.terminal.scope, + pruned: block.terminal.kind === 'pruned-scope', + }); + } + } + + isScopeActive(scopeId: ScopeId): boolean { + return this.#activeScopes.indexOf(scopeId) !== -1; + } + get currentScope(): ScopeId | null { + return this.#activeScopes.at(-1) ?? null; + } +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferFunctionEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferFunctionEffects.ts new file mode 100644 index 0000000000000..0ae54839b6fa3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferFunctionEffects.ts @@ -0,0 +1,335 @@ +/** + * 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, ErrorSeverity, ValueKind} from '..'; +import { + AbstractValue, + BasicBlock, + Effect, + Environment, + FunctionEffect, + Instruction, + InstructionValue, + Place, + ValueReason, + getHookKind, + isRefOrRefValue, +} from '../HIR'; +import {eachInstructionOperand, eachTerminalOperand} from '../HIR/visitors'; +import {assertExhaustive} from '../Utils/utils'; + +interface State { + kind(place: Place): AbstractValue; + values(place: Place): Array; + isDefined(place: Place): boolean; +} + +function inferOperandEffect(state: State, place: Place): null | FunctionEffect { + const value = state.kind(place); + CompilerError.invariant(value != null, { + reason: 'Expected operand to have a kind', + loc: null, + }); + + switch (place.effect) { + case Effect.Store: + case Effect.Mutate: { + if (isRefOrRefValue(place.identifier)) { + break; + } else if (value.kind === ValueKind.Context) { + return { + kind: 'ContextMutation', + loc: place.loc, + effect: place.effect, + places: value.context.size === 0 ? new Set([place]) : value.context, + }; + } else if ( + value.kind !== ValueKind.Mutable && + // We ignore mutations of primitives since this is not a React-specific problem + value.kind !== ValueKind.Primitive + ) { + let reason = getWriteErrorReason(value); + return { + kind: + value.reason.size === 1 && value.reason.has(ValueReason.Global) + ? 'GlobalMutation' + : 'ReactMutation', + error: { + reason, + description: + place.identifier.name !== null && + place.identifier.name.kind === 'named' + ? `Found mutation of \`${place.identifier.name.value}\`` + : null, + loc: place.loc, + suggestions: null, + severity: ErrorSeverity.InvalidReact, + }, + }; + } + break; + } + } + return null; +} + +function inheritFunctionEffects( + state: State, + place: Place, +): Array { + const effects = inferFunctionInstrEffects(state, place); + + return effects + .flatMap(effect => { + if (effect.kind === 'GlobalMutation' || effect.kind === 'ReactMutation') { + return [effect]; + } else { + const effects: Array = []; + CompilerError.invariant(effect.kind === 'ContextMutation', { + reason: 'Expected ContextMutation', + loc: null, + }); + /** + * Contextual effects need to be replayed against the current inference + * state, which may know more about the value to which the effect applied. + * The main cases are: + * 1. The mutated context value is _still_ a context value in the current scope, + * so we have to continue propagating the original context mutation. + * 2. The mutated context value is a mutable value in the current scope, + * so the context mutation was fine and we can skip propagating the effect. + * 3. The mutated context value is an immutable value in the current scope, + * resulting in a non-ContextMutation FunctionEffect. We propagate that new, + * more detailed effect to the current function context. + */ + for (const place of effect.places) { + if (state.isDefined(place)) { + const replayedEffect = inferOperandEffect(state, { + ...place, + loc: effect.loc, + effect: effect.effect, + }); + if (replayedEffect != null) { + if (replayedEffect.kind === 'ContextMutation') { + // Case 1, still a context value so propagate the original effect + effects.push(effect); + } else { + // Case 3, immutable value so propagate the more precise effect + effects.push(replayedEffect); + } + } // else case 2, local mutable value so this effect was fine + } + } + return effects; + } + }) + .filter((effect): effect is FunctionEffect => effect != null); +} + +function inferFunctionInstrEffects( + state: State, + place: Place, +): Array { + const effects: Array = []; + const instrs = state.values(place); + CompilerError.invariant(instrs != null, { + reason: 'Expected operand to have instructions', + loc: null, + }); + + for (const instr of instrs) { + if ( + (instr.kind === 'FunctionExpression' || instr.kind === 'ObjectMethod') && + instr.loweredFunc.func.effects != null + ) { + effects.push(...instr.loweredFunc.func.effects); + } + } + + return effects; +} + +function operandEffects( + state: State, + place: Place, + filterRenderSafe: boolean, +): Array { + const functionEffects: Array = []; + const effect = inferOperandEffect(state, place); + effect && functionEffects.push(effect); + functionEffects.push(...inheritFunctionEffects(state, place)); + if (filterRenderSafe) { + return functionEffects.filter(effect => !isEffectSafeOutsideRender(effect)); + } else { + return functionEffects; + } +} + +export function inferInstructionFunctionEffects( + env: Environment, + state: State, + instr: Instruction, +): Array { + const functionEffects: Array = []; + switch (instr.value.kind) { + case 'JsxExpression': { + if (instr.value.tag.kind === 'Identifier') { + functionEffects.push(...operandEffects(state, instr.value.tag, false)); + } + instr.value.children?.forEach(child => + functionEffects.push(...operandEffects(state, child, false)), + ); + for (const attr of instr.value.props) { + if (attr.kind === 'JsxSpreadAttribute') { + functionEffects.push(...operandEffects(state, attr.argument, false)); + } else { + functionEffects.push(...operandEffects(state, attr.place, true)); + } + } + break; + } + case 'ObjectMethod': + case 'FunctionExpression': { + /** + * If this function references other functions, propagate the referenced function's + * effects to this function. + * + * ``` + * let f = () => global = true; + * let g = () => f(); + * g(); + * ``` + * + * In this example, because `g` references `f`, we propagate the GlobalMutation from + * `f` to `g`. Thus, referencing `g` in `g()` will evaluate the GlobalMutation in the outer + * function effect context and report an error. But if instead we do: + * + * ``` + * let f = () => global = true; + * let g = () => f(); + * useEffect(() => g(), [g]) + * ``` + * + * Now `g`'s effects will be discarded since they're in a useEffect. + */ + for (const operand of eachInstructionOperand(instr)) { + instr.value.loweredFunc.func.effects ??= []; + instr.value.loweredFunc.func.effects.push( + ...inferFunctionInstrEffects(state, operand), + ); + } + break; + } + case 'MethodCall': + case 'CallExpression': { + let callee; + if (instr.value.kind === 'MethodCall') { + callee = instr.value.property; + functionEffects.push( + ...operandEffects(state, instr.value.receiver, false), + ); + } else { + callee = instr.value.callee; + } + functionEffects.push(...operandEffects(state, callee, false)); + let isHook = getHookKind(env, callee.identifier) != null; + for (const arg of instr.value.args) { + const place = arg.kind === 'Identifier' ? arg : arg.place; + /* + * Join the effects of the argument with the effects of the enclosing function, + * unless the we're detecting a global mutation inside a useEffect hook + */ + functionEffects.push(...operandEffects(state, place, isHook)); + } + break; + } + case 'StartMemoize': + case 'FinishMemoize': + case 'LoadLocal': + case 'StoreLocal': { + break; + } + case 'StoreGlobal': { + functionEffects.push({ + kind: 'GlobalMutation', + error: { + reason: + 'Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)', + loc: instr.loc, + suggestions: null, + severity: ErrorSeverity.InvalidReact, + }, + }); + break; + } + default: { + for (const operand of eachInstructionOperand(instr)) { + functionEffects.push(...operandEffects(state, operand, false)); + } + } + } + return functionEffects; +} + +export function inferTerminalFunctionEffects( + state: State, + block: BasicBlock, +): Array { + const functionEffects: Array = []; + for (const operand of eachTerminalOperand(block.terminal)) { + functionEffects.push(...operandEffects(state, operand, true)); + } + return functionEffects; +} + +export function raiseFunctionEffectErrors( + functionEffects: Array, +): void { + functionEffects.forEach(eff => { + switch (eff.kind) { + case 'ReactMutation': + case 'GlobalMutation': { + CompilerError.throw(eff.error); + } + case 'ContextMutation': { + CompilerError.throw({ + severity: ErrorSeverity.Invariant, + reason: `Unexpected ContextMutation in top-level function effects`, + loc: eff.loc, + }); + } + default: + assertExhaustive( + eff, + `Unexpected function effect kind \`${(eff as any).kind}\``, + ); + } + }); +} + +function isEffectSafeOutsideRender(effect: FunctionEffect): boolean { + return effect.kind === 'GlobalMutation'; +} + +function getWriteErrorReason(abstractValue: AbstractValue): string { + if (abstractValue.reason.has(ValueReason.Global)) { + return 'Writing to a variable defined outside a component or hook is not allowed. Consider using an effect'; + } else if (abstractValue.reason.has(ValueReason.JsxCaptured)) { + return 'Updating a value used previously in JSX is not allowed. Consider moving the mutation before the JSX'; + } else if (abstractValue.reason.has(ValueReason.Context)) { + return `Mutating a value returned from 'useContext()', which should not be mutated`; + } else if (abstractValue.reason.has(ValueReason.KnownReturnSignature)) { + return 'Mutating a value returned from a function whose return value should not be mutated'; + } else if (abstractValue.reason.has(ValueReason.ReactiveFunctionArgument)) { + return 'Mutating component props or hook arguments is not allowed. Consider using a local variable instead'; + } else if (abstractValue.reason.has(ValueReason.State)) { + return "Mutating a value returned from 'useState()', which should not be mutated. Use the setter function to update instead"; + } else if (abstractValue.reason.has(ValueReason.ReducerState)) { + return "Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead"; + } else { + return 'This mutates a variable that React considers immutable'; + } +} 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 1604f4813967a..5231b7aef631c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import {CompilerError, ErrorSeverity} from '../CompilerError'; +import {CompilerError} from '../CompilerError'; import {Environment} from '../HIR'; import { AbstractValue, @@ -26,11 +26,9 @@ import { Type, ValueKind, ValueReason, - getHookKind, isArrayType, isMutableEffect, isObjectType, - isRefOrRefValue, } from '../HIR/HIR'; import {FunctionSignature} from '../HIR/ObjectShape'; import { @@ -48,6 +46,11 @@ import { eachTerminalSuccessor, } from '../HIR/visitors'; import {assertExhaustive} from '../Utils/utils'; +import { + inferTerminalFunctionEffects, + inferInstructionFunctionEffects, + raiseFunctionEffectErrors, +} from './InferFunctionEffects'; const UndefinedValue: InstructionValue = { kind: 'Primitive', @@ -228,7 +231,7 @@ export default function inferReferenceEffects( statesByBlock.set(blockId, incomingState); const state = incomingState.clone(); - inferBlock(fn.env, functionEffects, state, block); + inferBlock(fn.env, state, block, functionEffects); for (const nextBlockId of eachTerminalSuccessor(block.terminal)) { queue(nextBlockId, state); @@ -236,37 +239,20 @@ export default function inferReferenceEffects( } } - if (!options.isFunctionExpression) { - functionEffects.forEach(eff => { - switch (eff.kind) { - case 'ReactMutation': - case 'GlobalMutation': { - CompilerError.throw(eff.error); - } - case 'ContextMutation': { - CompilerError.throw({ - severity: ErrorSeverity.Invariant, - reason: `Unexpected ContextMutation in top-level function effects`, - loc: eff.loc, - }); - } - default: - assertExhaustive( - eff, - `Unexpected function effect kind \`${(eff as any).kind}\``, - ); - } - }); - } else { + if (options.isFunctionExpression) { fn.effects = functionEffects; + } else { + raiseFunctionEffectErrors(functionEffects); } } +type FreezeAction = {values: Set; reason: Set}; + // Maintains a mapping of top-level variables to the kind of value they hold class InferenceState { #env: Environment; - // The kind of reach value, based on its allocation site + // The kind of each value, based on its allocation site #values: Map; /* * The set of values pointed to by each identifier. This is a set @@ -378,10 +364,10 @@ class InferenceState { * value is already frozen or is immutable. */ referenceAndRecordEffects( + freezeActions: Array, place: Place, effectKind: Effect, reason: ValueReason, - functionEffects: Array, ): void { const values = this.#variables.get(place.identifier.id); if (values === undefined) { @@ -398,59 +384,8 @@ class InferenceState { return; } - // Propagate effects of function expressions to the outer (ie current) effect context - for (const value of values) { - if ( - (value.kind === 'FunctionExpression' || - value.kind === 'ObjectMethod') && - value.loweredFunc.func.effects != null - ) { - for (const effect of value.loweredFunc.func.effects) { - if ( - effect.kind === 'GlobalMutation' || - effect.kind === 'ReactMutation' - ) { - // Known effects are always propagated upwards - functionEffects.push(effect); - } else { - /** - * Contextual effects need to be replayed against the current inference - * state, which may know more about the value to which the effect applied. - * The main cases are: - * 1. The mutated context value is _still_ a context value in the current scope, - * so we have to continue propagating the original context mutation. - * 2. The mutated context value is a mutable value in the current scope, - * so the context mutation was fine and we can skip propagating the effect. - * 3. The mutated context value is an immutable value in the current scope, - * resulting in a non-ContextMutation FunctionEffect. We propagate that new, - * more detailed effect to the current function context. - */ - for (const place of effect.places) { - if (this.isDefined(place)) { - const replayedEffect = this.reference( - {...place, loc: effect.loc}, - effect.effect, - reason, - ); - if (replayedEffect != null) { - if (replayedEffect.kind === 'ContextMutation') { - // Case 1, still a context value so propagate the original effect - functionEffects.push(effect); - } else { - // Case 3, immutable value so propagate the more precise effect - functionEffects.push(replayedEffect); - } - } // else case 2, local mutable value so this effect was fine - } - } - } - } - } - } - const functionEffect = this.reference(place, effectKind, reason); - if (functionEffect !== null) { - functionEffects.push(functionEffect); - } + const action = this.reference(place, effectKind, reason); + action && freezeActions.push(action); } freezeValues(values: Set, reason: Set): void { @@ -488,7 +423,7 @@ class InferenceState { place: Place, effectKind: Effect, reason: ValueReason, - ): FunctionEffect | null { + ): null | FreezeAction { const values = this.#variables.get(place.identifier.id); CompilerError.invariant(values !== undefined, { reason: '[InferReferenceEffects] Expected value to be initialized', @@ -498,7 +433,7 @@ class InferenceState { }); let valueKind: AbstractValue | null = this.kind(place); let effect: Effect | null = null; - let functionEffect: FunctionEffect | null = null; + let freeze: null | FreezeAction = null; switch (effectKind) { case Effect.Freeze: { if ( @@ -513,7 +448,7 @@ class InferenceState { reason: reasonSet, context: new Set(), }; - this.freezeValues(values, reasonSet); + freeze = {values, reason: reasonSet}; } else { effect = Effect.Read; } @@ -531,85 +466,10 @@ class InferenceState { break; } case Effect.Mutate: { - if (isRefOrRefValue(place.identifier)) { - // no-op: refs are validate via ValidateNoRefAccessInRender - } else if (valueKind.kind === ValueKind.Context) { - functionEffect = { - kind: 'ContextMutation', - loc: place.loc, - effect: effectKind, - places: - valueKind.context.size === 0 - ? new Set([place]) - : valueKind.context, - }; - } else if ( - valueKind.kind !== ValueKind.Mutable && - // We ignore mutations of primitives since this is not a React-specific problem - valueKind.kind !== ValueKind.Primitive - ) { - let reason = getWriteErrorReason(valueKind); - functionEffect = { - kind: - valueKind.reason.size === 1 && - valueKind.reason.has(ValueReason.Global) - ? 'GlobalMutation' - : 'ReactMutation', - error: { - reason, - description: - place.identifier.name !== null && - place.identifier.name.kind === 'named' - ? `Found mutation of \`${place.identifier.name.value}\`` - : null, - loc: place.loc, - suggestions: null, - severity: ErrorSeverity.InvalidReact, - }, - }; - } effect = Effect.Mutate; break; } case Effect.Store: { - if (isRefOrRefValue(place.identifier)) { - // no-op: refs are validate via ValidateNoRefAccessInRender - } else if (valueKind.kind === ValueKind.Context) { - functionEffect = { - kind: 'ContextMutation', - loc: place.loc, - effect: effectKind, - places: - valueKind.context.size === 0 - ? new Set([place]) - : valueKind.context, - }; - } else if ( - valueKind.kind !== ValueKind.Mutable && - // We ignore mutations of primitives since this is not a React-specific problem - valueKind.kind !== ValueKind.Primitive - ) { - let reason = getWriteErrorReason(valueKind); - functionEffect = { - kind: - valueKind.reason.size === 1 && - valueKind.reason.has(ValueReason.Global) - ? 'GlobalMutation' - : 'ReactMutation', - error: { - reason, - description: - place.identifier.name !== null && - place.identifier.name.kind === 'named' - ? `Found mutation of \`${place.identifier.name.value}\`` - : null, - loc: place.loc, - suggestions: null, - severity: ErrorSeverity.InvalidReact, - }, - }; - } - /* * TODO(gsn): This should be bailout once we add bailout infra. * @@ -661,7 +521,7 @@ class InferenceState { suggestions: null, }); place.effect = effect; - return functionEffect; + return freeze; } /* @@ -952,15 +812,24 @@ function mergeAbstractValues( return {kind, reason, context}; } +type Continuation = + | { + kind: 'initialize'; + valueKind: AbstractValue; + effect: {kind: Effect; reason: ValueReason} | null; + lvalueEffect?: Effect; + } + | {kind: 'funeffects'}; + /* * Iterates over the given @param block, defining variables and * recording references on the @param state according to JS semantics. */ function inferBlock( env: Environment, - functionEffects: Array, state: InferenceState, block: BasicBlock, + functionEffects: Array, ): void { for (const phi of block.phis) { state.inferPhi(phi); @@ -968,24 +837,27 @@ function inferBlock( for (const instr of block.instructions) { const instrValue = instr.value; - let effect: {kind: Effect; reason: ValueReason} | null = null; - let lvalueEffect = Effect.ConditionallyMutate; - let valueKind: AbstractValue; + const defaultLvalueEffect = Effect.ConditionallyMutate; + let continuation: Continuation; + const freezeActions: Array = []; switch (instrValue.kind) { case 'BinaryExpression': { - valueKind = { - kind: ValueKind.Primitive, - reason: new Set([ValueReason.Other]), - context: new Set(), - }; - effect = { - kind: Effect.Read, - reason: ValueReason.Other, + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Primitive, + reason: new Set([ValueReason.Other]), + context: new Set(), + }, + effect: { + kind: Effect.Read, + reason: ValueReason.Other, + }, }; break; } case 'ArrayExpression': { - valueKind = hasContextRefOperand(state, instrValue) + const valueKind: AbstractValue = hasContextRefOperand(state, instrValue) ? { kind: ValueKind.Context, reason: new Set([ValueReason.Other]), @@ -996,8 +868,12 @@ function inferBlock( reason: new Set([ValueReason.Other]), context: new Set(), }; - effect = {kind: Effect.Capture, reason: ValueReason.Other}; - lvalueEffect = Effect.Store; + continuation = { + kind: 'initialize', + valueKind, + effect: {kind: Effect.Capture, reason: ValueReason.Other}, + lvalueEffect: Effect.Store, + }; break; } case 'NewExpression': { @@ -1014,34 +890,35 @@ function inferBlock( * Classes / functions created during render could technically capture and * mutate their enclosing scope, which we currently do not detect. */ - valueKind = { + const valueKind: AbstractValue = { kind: ValueKind.Mutable, reason: new Set([ValueReason.Other]), context: new Set(), }; state.referenceAndRecordEffects( + freezeActions, instrValue.callee, Effect.Read, ValueReason.Other, - functionEffects, ); for (const operand of eachCallArgument(instrValue.args)) { state.referenceAndRecordEffects( + freezeActions, operand, Effect.ConditionallyMutate, ValueReason.Other, - functionEffects, ); } state.initialize(instrValue, valueKind); state.define(instr.lvalue, instrValue); - instr.lvalue.effect = lvalueEffect; - continue; + instr.lvalue.effect = Effect.ConditionallyMutate; + continuation = {kind: 'funeffects'}; + break; } case 'ObjectExpression': { - valueKind = hasContextRefOperand(state, instrValue) + const valueKind: AbstractValue = hasContextRefOperand(state, instrValue) ? { kind: ValueKind.Context, reason: new Set([ValueReason.Other]), @@ -1059,28 +936,28 @@ function inferBlock( if (property.key.kind === 'computed') { // Object keys must be primitives, so we know they're frozen at this point state.referenceAndRecordEffects( + freezeActions, property.key.name, Effect.Freeze, ValueReason.Other, - functionEffects, ); } // Object construction captures but does not modify the key/property values state.referenceAndRecordEffects( + freezeActions, property.place, Effect.Capture, ValueReason.Other, - functionEffects, ); break; } case 'Spread': { // Object construction captures but does not modify the key/property values state.referenceAndRecordEffects( + freezeActions, property.place, Effect.Capture, ValueReason.Other, - functionEffects, ); break; } @@ -1096,65 +973,67 @@ function inferBlock( state.initialize(instrValue, valueKind); state.define(instr.lvalue, instrValue); instr.lvalue.effect = Effect.Store; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'UnaryExpression': { - valueKind = { - kind: ValueKind.Primitive, - reason: new Set([ValueReason.Other]), - context: new Set(), + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Primitive, + reason: new Set([ValueReason.Other]), + context: new Set(), + }, + effect: {kind: Effect.Read, reason: ValueReason.Other}, }; - effect = {kind: Effect.Read, reason: ValueReason.Other}; break; } case 'UnsupportedNode': { // TODO: handle other statement kinds - valueKind = { - kind: ValueKind.Mutable, - reason: new Set([ValueReason.Other]), - context: new Set(), + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Mutable, + reason: new Set([ValueReason.Other]), + context: new Set(), + }, + effect: null, }; break; } case 'JsxExpression': { if (instrValue.tag.kind === 'Identifier') { state.referenceAndRecordEffects( + freezeActions, instrValue.tag, Effect.Freeze, ValueReason.JsxCaptured, - functionEffects, ); } if (instrValue.children !== null) { for (const child of instrValue.children) { state.referenceAndRecordEffects( + freezeActions, child, Effect.Freeze, ValueReason.JsxCaptured, - functionEffects, ); } } for (const attr of instrValue.props) { if (attr.kind === 'JsxSpreadAttribute') { state.referenceAndRecordEffects( + freezeActions, attr.argument, Effect.Freeze, ValueReason.JsxCaptured, - functionEffects, ); } else { - const propEffects: Array = []; state.referenceAndRecordEffects( + freezeActions, attr.place, Effect.Freeze, ValueReason.JsxCaptured, - propEffects, - ); - functionEffects.push( - ...propEffects.filter( - effect => !isEffectSafeOutsideRender(effect), - ), ); } } @@ -1166,17 +1045,21 @@ function inferBlock( }); state.define(instr.lvalue, instrValue); instr.lvalue.effect = Effect.ConditionallyMutate; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'JsxFragment': { - valueKind = { - kind: ValueKind.Frozen, - reason: new Set([ValueReason.Other]), - context: new Set(), - }; - effect = { - kind: Effect.Freeze, - reason: ValueReason.Other, + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Frozen, + reason: new Set([ValueReason.Other]), + context: new Set(), + }, + effect: { + kind: Effect.Freeze, + reason: ValueReason.Other, + }, }; break; } @@ -1185,53 +1068,71 @@ function inferBlock( * template literal (with no tag function) always produces * an immutable string */ - valueKind = { - kind: ValueKind.Primitive, - reason: new Set([ValueReason.Other]), - context: new Set(), + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Primitive, + reason: new Set([ValueReason.Other]), + context: new Set(), + }, + effect: {kind: Effect.Read, reason: ValueReason.Other}, }; - effect = {kind: Effect.Read, reason: ValueReason.Other}; break; } case 'RegExpLiteral': { // RegExp instances are mutable objects - valueKind = { - kind: ValueKind.Mutable, - reason: new Set([ValueReason.Other]), - context: new Set(), - }; - effect = { - kind: Effect.ConditionallyMutate, - reason: ValueReason.Other, + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Mutable, + reason: new Set([ValueReason.Other]), + context: new Set(), + }, + effect: { + kind: Effect.ConditionallyMutate, + reason: ValueReason.Other, + }, }; break; } case 'MetaProperty': { if (instrValue.meta !== 'import' || instrValue.property !== 'meta') { - continue; + continuation = {kind: 'funeffects'}; + break; } - - valueKind = { - kind: ValueKind.Global, - reason: new Set([ValueReason.Global]), - context: new Set(), + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Global, + reason: new Set([ValueReason.Global]), + context: new Set(), + }, + effect: null, }; break; } case 'LoadGlobal': - valueKind = { - kind: ValueKind.Global, - reason: new Set([ValueReason.Global]), - context: new Set(), + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Global, + reason: new Set([ValueReason.Global]), + context: new Set(), + }, + effect: null, }; break; case 'Debugger': case 'JSXText': case 'Primitive': { - valueKind = { - kind: ValueKind.Primitive, - reason: new Set([ValueReason.Other]), - context: new Set(), + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Primitive, + reason: new Set([ValueReason.Other]), + context: new Set(), + }, + effect: null, }; break; } @@ -1241,51 +1142,15 @@ function inferBlock( const mutableOperands: Array = []; for (const operand of eachInstructionOperand(instr)) { state.referenceAndRecordEffects( + freezeActions, operand, operand.effect === Effect.Unknown ? Effect.Read : operand.effect, ValueReason.Other, - [], ); if (isMutableEffect(operand.effect, operand.loc)) { mutableOperands.push(operand); } hasMutableOperand ||= isMutableEffect(operand.effect, operand.loc); - - /** - * If this function references other functions, propagate the referenced function's - * effects to this function. - * - * ``` - * let f = () => global = true; - * let g = () => f(); - * g(); - * ``` - * - * In this example, because `g` references `f`, we propagate the GlobalMutation from - * `f` to `g`. Thus, referencing `g` in `g()` will evaluate the GlobalMutation in the outer - * function effect context and report an error. But if instead we do: - * - * ``` - * let f = () => global = true; - * let g = () => f(); - * useEffect(() => g(), [g]) - * ``` - * - * Now `g`'s effects will be discarded since they're in a useEffect. - */ - const values = state.values(operand); - for (const value of values) { - if ( - (value.kind === 'ObjectMethod' || - value.kind === 'FunctionExpression') && - value.loweredFunc.func.effects !== null - ) { - instrValue.loweredFunc.func.effects ??= []; - instrValue.loweredFunc.func.effects.push( - ...value.loweredFunc.func.effects, - ); - } - } } /* * If a closure did not capture any mutable values, then we can consider it to be @@ -1298,7 +1163,8 @@ function inferBlock( }); state.define(instr.lvalue, instrValue); instr.lvalue.effect = Effect.Store; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'TaggedTemplateExpression': { const operands = [...eachInstructionValueOperand(instrValue)]; @@ -1331,15 +1197,16 @@ function inferBlock( context: new Set(), }; state.referenceAndRecordEffects( + freezeActions, instrValue.tag, calleeEffect, ValueReason.Other, - functionEffects, ); state.initialize(instrValue, returnValueKind); state.define(instr.lvalue, instrValue); instr.lvalue.effect = Effect.ConditionallyMutate; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'CallExpression': { const signature = getFunctionCallSignature( @@ -1365,50 +1232,39 @@ function inferBlock( context: new Set(), }; let hasCaptureArgument = false; - let isHook = getHookKind(env, instrValue.callee.identifier) != null; for (let i = 0; i < instrValue.args.length; i++) { - const argumentEffects: Array = []; const arg = instrValue.args[i]; const place = arg.kind === 'Identifier' ? arg : arg.place; if (effects !== null) { state.referenceAndRecordEffects( + freezeActions, place, effects[i], ValueReason.Other, - argumentEffects, ); } else { state.referenceAndRecordEffects( + freezeActions, place, Effect.ConditionallyMutate, ValueReason.Other, - argumentEffects, ); } - /* - * Join the effects of the argument with the effects of the enclosing function, - * unless the we're detecting a global mutation inside a useEffect hook - */ - functionEffects.push( - ...argumentEffects.filter( - argEffect => !isHook || !isEffectSafeOutsideRender(argEffect), - ), - ); hasCaptureArgument ||= place.effect === Effect.Capture; } if (signature !== null) { state.referenceAndRecordEffects( + freezeActions, instrValue.callee, signature.calleeEffect, ValueReason.Other, - functionEffects, ); } else { state.referenceAndRecordEffects( + freezeActions, instrValue.callee, Effect.ConditionallyMutate, ValueReason.Other, - functionEffects, ); } hasCaptureArgument ||= instrValue.callee.effect === Effect.Capture; @@ -1418,7 +1274,8 @@ function inferBlock( instr.lvalue.effect = hasCaptureArgument ? Effect.Store : Effect.ConditionallyMutate; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'MethodCall': { CompilerError.invariant(state.isDefined(instrValue.receiver), { @@ -1429,10 +1286,10 @@ function inferBlock( suggestions: null, }); state.referenceAndRecordEffects( + freezeActions, instrValue.property, Effect.Read, ValueReason.Other, - functionEffects, ); const signature = getFunctionCallSignature( @@ -1465,17 +1322,17 @@ function inferBlock( for (const arg of instrValue.args) { const place = arg.kind === 'Identifier' ? arg : arg.place; state.referenceAndRecordEffects( + freezeActions, place, Effect.Read, ValueReason.Other, - functionEffects, ); } state.referenceAndRecordEffects( + freezeActions, instrValue.receiver, Effect.Capture, ValueReason.Other, - functionEffects, ); state.initialize(instrValue, returnValueKind); state.define(instr.lvalue, instrValue); @@ -1483,15 +1340,14 @@ function inferBlock( instrValue.receiver.effect === Effect.Capture ? Effect.Store : Effect.ConditionallyMutate; - continue; + continuation = {kind: 'funeffects'}; + break; } const effects = signature !== null ? getFunctionEffects(instrValue, signature) : null; let hasCaptureArgument = false; - let isHook = getHookKind(env, instrValue.property.identifier) != null; for (let i = 0; i < instrValue.args.length; i++) { - const argumentEffects: Array = []; const arg = instrValue.args[i]; const place = arg.kind === 'Identifier' ? arg : arg.place; if (effects !== null) { @@ -1500,43 +1356,34 @@ function inferBlock( * mutating effects */ state.referenceAndRecordEffects( + freezeActions, place, effects[i], ValueReason.Other, - argumentEffects, ); } else { state.referenceAndRecordEffects( + freezeActions, place, Effect.ConditionallyMutate, ValueReason.Other, - argumentEffects, ); } - /* - * Join the effects of the argument with the effects of the enclosing function, - * unless the we're detecting a global mutation inside a useEffect hook - */ - functionEffects.push( - ...argumentEffects.filter( - argEffect => !isHook || !isEffectSafeOutsideRender(argEffect), - ), - ); hasCaptureArgument ||= place.effect === Effect.Capture; } if (signature !== null) { state.referenceAndRecordEffects( + freezeActions, instrValue.receiver, signature.calleeEffect, ValueReason.Other, - functionEffects, ); } else { state.referenceAndRecordEffects( + freezeActions, instrValue.receiver, Effect.ConditionallyMutate, ValueReason.Other, - functionEffects, ); } hasCaptureArgument ||= instrValue.receiver.effect === Effect.Capture; @@ -1546,7 +1393,8 @@ function inferBlock( instr.lvalue.effect = hasCaptureArgument ? Effect.Store : Effect.ConditionallyMutate; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'PropertyStore': { const effect = @@ -1554,45 +1402,50 @@ function inferBlock( ? Effect.ConditionallyMutate : Effect.Capture; state.referenceAndRecordEffects( + freezeActions, instrValue.value, effect, ValueReason.Other, - functionEffects, ); state.referenceAndRecordEffects( + freezeActions, instrValue.object, Effect.Store, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; state.alias(lvalue, instrValue.value); lvalue.effect = Effect.Store; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'PropertyDelete': { // `delete` returns a boolean (immutable) and modifies the object - valueKind = { - kind: ValueKind.Primitive, - reason: new Set([ValueReason.Other]), - context: new Set(), + continuation = { + kind: 'initialize', + valueKind: { + kind: ValueKind.Primitive, + reason: new Set([ValueReason.Other]), + context: new Set(), + }, + effect: {kind: Effect.Mutate, reason: ValueReason.Other}, }; - effect = {kind: Effect.Mutate, reason: ValueReason.Other}; break; } case 'PropertyLoad': { state.referenceAndRecordEffects( + freezeActions, instrValue.object, Effect.Read, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; lvalue.effect = Effect.ConditionallyMutate; state.initialize(instrValue, state.kind(instrValue.object)); state.define(lvalue, instrValue); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'ComputedStore': { const effect = @@ -1600,41 +1453,42 @@ function inferBlock( ? Effect.ConditionallyMutate : Effect.Capture; state.referenceAndRecordEffects( + freezeActions, instrValue.value, effect, ValueReason.Other, - functionEffects, ); state.referenceAndRecordEffects( + freezeActions, instrValue.property, Effect.Capture, ValueReason.Other, - functionEffects, ); state.referenceAndRecordEffects( + freezeActions, instrValue.object, Effect.Store, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; state.alias(lvalue, instrValue.value); lvalue.effect = Effect.Store; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'ComputedDelete': { state.referenceAndRecordEffects( + freezeActions, instrValue.object, Effect.Mutate, ValueReason.Other, - functionEffects, ); state.referenceAndRecordEffects( + freezeActions, instrValue.property, Effect.Read, ValueReason.Other, - functionEffects, ); state.initialize(instrValue, { kind: ValueKind.Primitive, @@ -1643,26 +1497,28 @@ function inferBlock( }); state.define(instr.lvalue, instrValue); instr.lvalue.effect = Effect.Mutate; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'ComputedLoad': { state.referenceAndRecordEffects( + freezeActions, instrValue.object, Effect.Read, ValueReason.Other, - functionEffects, ); state.referenceAndRecordEffects( + freezeActions, instrValue.property, Effect.Read, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; lvalue.effect = Effect.ConditionallyMutate; state.initialize(instrValue, state.kind(instrValue.object)); state.define(lvalue, instrValue); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'Await': { state.initialize(instrValue, state.kind(instrValue.value)); @@ -1672,15 +1528,16 @@ function inferBlock( * will occur. */ state.referenceAndRecordEffects( + freezeActions, instrValue.value, Effect.ConditionallyMutate, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; lvalue.effect = Effect.ConditionallyMutate; state.alias(lvalue, instrValue.value); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'TypeCastExpression': { /* @@ -1693,32 +1550,33 @@ function inferBlock( */ state.initialize(instrValue, state.kind(instrValue.value)); state.referenceAndRecordEffects( + freezeActions, instrValue.value, Effect.Read, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; lvalue.effect = Effect.ConditionallyMutate; state.alias(lvalue, instrValue.value); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'StartMemoize': case 'FinishMemoize': { for (const val of eachInstructionValueOperand(instrValue)) { if (env.config.enablePreserveExistingMemoizationGuarantees) { state.referenceAndRecordEffects( + freezeActions, val, Effect.Freeze, ValueReason.Other, - [], ); } else { state.referenceAndRecordEffects( + freezeActions, val, Effect.Read, ValueReason.Other, - [], ); } } @@ -1730,7 +1588,8 @@ function inferBlock( context: new Set(), }); state.define(lvalue, instrValue); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'LoadLocal': { const lvalue = instr.lvalue; @@ -1740,29 +1599,31 @@ function inferBlock( ? Effect.ConditionallyMutate : Effect.Capture; state.referenceAndRecordEffects( + freezeActions, instrValue.place, effect, ValueReason.Other, - [], ); lvalue.effect = Effect.ConditionallyMutate; // direct aliasing: `a = b`; state.alias(lvalue, instrValue.place); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'LoadContext': { state.referenceAndRecordEffects( + freezeActions, instrValue.place, Effect.Capture, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; lvalue.effect = Effect.ConditionallyMutate; const valueKind = state.kind(instrValue.place); state.initialize(instrValue, valueKind); state.define(lvalue, instrValue); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'DeclareLocal': { const value = UndefinedValue; @@ -1782,7 +1643,8 @@ function inferBlock( }, ); state.define(instrValue.lvalue.place, value); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'DeclareContext': { state.initialize(instrValue, { @@ -1791,7 +1653,8 @@ function inferBlock( context: new Set(), }); state.define(instrValue.lvalue.place, instrValue); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'PostfixUpdate': case 'PrefixUpdate': { @@ -1801,10 +1664,10 @@ function inferBlock( ? Effect.ConditionallyMutate : Effect.Capture; state.referenceAndRecordEffects( + freezeActions, instrValue.value, effect, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; @@ -1818,7 +1681,8 @@ function inferBlock( * replacing it */ instrValue.lvalue.effect = Effect.Store; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'StoreLocal': { const effect = @@ -1827,10 +1691,10 @@ function inferBlock( ? Effect.ConditionallyMutate : Effect.Capture; state.referenceAndRecordEffects( + freezeActions, instrValue.value, effect, ValueReason.Other, - [], ); const lvalue = instr.lvalue; @@ -1844,48 +1708,40 @@ function inferBlock( * replacing it */ instrValue.lvalue.place.effect = Effect.Store; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'StoreContext': { state.referenceAndRecordEffects( + freezeActions, instrValue.value, Effect.ConditionallyMutate, ValueReason.Other, - functionEffects, ); state.referenceAndRecordEffects( + freezeActions, instrValue.lvalue.place, Effect.Mutate, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; state.alias(lvalue, instrValue.value); lvalue.effect = Effect.Store; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'StoreGlobal': { state.referenceAndRecordEffects( + freezeActions, instrValue.value, Effect.Capture, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; lvalue.effect = Effect.Store; - - functionEffects.push({ - kind: 'GlobalMutation', - error: { - reason: - 'Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)', - loc: instr.loc, - suggestions: null, - severity: ErrorSeverity.InvalidReact, - }, - }); - continue; + continuation = {kind: 'funeffects'}; + break; } case 'Destructure': { let effect: Effect = Effect.Capture; @@ -1899,10 +1755,10 @@ function inferBlock( } } state.referenceAndRecordEffects( + freezeActions, instrValue.value, effect, ValueReason.Other, - functionEffects, ); const lvalue = instr.lvalue; @@ -1918,7 +1774,8 @@ function inferBlock( */ place.effect = Effect.Store; } - continue; + continuation = {kind: 'funeffects'}; + break; } case 'GetIterator': { /** @@ -1938,6 +1795,8 @@ function inferBlock( const kind = state.kind(instrValue.collection).kind; const isMutable = kind === ValueKind.Mutable || kind === ValueKind.Context; + let effect; + let valueKind: AbstractValue; if (!isMutable || isArrayType(instrValue.collection.identifier)) { // Case 1, assume iterator is a separate mutable object effect = { @@ -1957,7 +1816,12 @@ function inferBlock( }; valueKind = state.kind(instrValue.collection); } - lvalueEffect = Effect.Store; + continuation = { + kind: 'initialize', + effect, + valueKind, + lvalueEffect: Effect.Store, + }; break; } case 'IteratorNext': { @@ -1972,10 +1836,10 @@ function inferBlock( * ConditionallyMutate reflects this "mutate if mutable" semantic. */ state.referenceAndRecordEffects( + freezeActions, instrValue.iterator, Effect.ConditionallyMutate, ValueReason.Other, - functionEffects, ); /** * Regardless of the effect on the iterator, the *result* of advancing the iterator @@ -1984,23 +1848,27 @@ function inferBlock( * ensure that the item is mutable or frozen if the collection is mutable/frozen. */ state.referenceAndRecordEffects( + freezeActions, instrValue.collection, Effect.Capture, ValueReason.Other, - functionEffects, ); state.initialize(instrValue, state.kind(instrValue.collection)); state.define(instr.lvalue, instrValue); instr.lvalue.effect = Effect.Store; - continue; + continuation = {kind: 'funeffects'}; + break; } case 'NextPropertyOf': { - effect = {kind: Effect.Read, reason: ValueReason.Other}; - lvalueEffect = Effect.Store; - valueKind = { - kind: ValueKind.Primitive, - reason: new Set([ValueReason.Other]), - context: new Set(), + continuation = { + kind: 'initialize', + effect: {kind: Effect.Read, reason: ValueReason.Other}, + lvalueEffect: Effect.Store, + valueKind: { + kind: ValueKind.Primitive, + reason: new Set([ValueReason.Other]), + context: new Set(), + }, }; break; } @@ -2009,26 +1877,34 @@ function inferBlock( } } - for (const operand of eachInstructionOperand(instr)) { - CompilerError.invariant(effect != null, { - reason: `effectKind must be set for instruction value \`${instrValue.kind}\``, - description: null, - loc: instrValue.loc, - suggestions: null, - }); - state.referenceAndRecordEffects( - operand, - effect.kind, - effect.reason, - functionEffects, - ); + if (continuation.kind === 'initialize') { + for (const operand of eachInstructionOperand(instr)) { + CompilerError.invariant(continuation.effect != null, { + reason: `effectKind must be set for instruction value \`${instrValue.kind}\``, + description: null, + loc: instrValue.loc, + suggestions: null, + }); + state.referenceAndRecordEffects( + freezeActions, + operand, + continuation.effect.kind, + continuation.effect.reason, + ); + } + + state.initialize(instrValue, continuation.valueKind); + state.define(instr.lvalue, instrValue); + instr.lvalue.effect = continuation.lvalueEffect ?? defaultLvalueEffect; } - state.initialize(instrValue, valueKind); - state.define(instr.lvalue, instrValue); - instr.lvalue.effect = lvalueEffect; + functionEffects.push(...inferInstructionFunctionEffects(env, state, instr)); + freezeActions.forEach(({values, reason}) => + state.freezeValues(values, reason), + ); } + const terminalFreezeActions: Array = []; for (const operand of eachTerminalOperand(block.terminal)) { let effect; if (block.terminal.kind === 'return' || block.terminal.kind === 'throw') { @@ -2043,17 +1919,17 @@ function inferBlock( } else { effect = Effect.Read; } - const propEffects: Array = []; state.referenceAndRecordEffects( + terminalFreezeActions, operand, effect, ValueReason.Other, - propEffects, - ); - functionEffects.push( - ...propEffects.filter(effect => !isEffectSafeOutsideRender(effect)), ); } + functionEffects.push(...inferTerminalFunctionEffects(state, block)); + terminalFreezeActions.forEach(({values, reason}) => + state.freezeValues(values, reason), + ); } function hasContextRefOperand( @@ -2089,7 +1965,7 @@ export function getFunctionCallSignature( * @param sig * @returns Inferred effects of function arguments, or null if inference fails. */ -function getFunctionEffects( +export function getFunctionEffects( fn: MethodCall | CallExpression, sig: FunctionSignature, ): Array | null { @@ -2164,27 +2040,3 @@ function areArgumentsImmutableAndNonMutating( } return true; } - -function isEffectSafeOutsideRender(effect: FunctionEffect): boolean { - return effect.kind === 'GlobalMutation'; -} - -function getWriteErrorReason(abstractValue: AbstractValue): string { - if (abstractValue.reason.has(ValueReason.Global)) { - return 'Writing to a variable defined outside a component or hook is not allowed. Consider using an effect'; - } else if (abstractValue.reason.has(ValueReason.JsxCaptured)) { - return 'Updating a value used previously in JSX is not allowed. Consider moving the mutation before the JSX'; - } else if (abstractValue.reason.has(ValueReason.Context)) { - return `Mutating a value returned from 'useContext()', which should not be mutated`; - } else if (abstractValue.reason.has(ValueReason.KnownReturnSignature)) { - return 'Mutating a value returned from a function whose return value should not be mutated'; - } else if (abstractValue.reason.has(ValueReason.ReactiveFunctionArgument)) { - return 'Mutating component props or hook arguments is not allowed. Consider using a local variable instead'; - } else if (abstractValue.reason.has(ValueReason.State)) { - return "Mutating a value returned from 'useState()', which should not be mutated. Use the setter function to update instead"; - } else if (abstractValue.reason.has(ValueReason.ReducerState)) { - return "Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead"; - } else { - return 'This mutates a variable that React considers immutable'; - } -} 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 126772f591b41..0d0b37ce58afe 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts @@ -13,6 +13,8 @@ import { HIRFunction, Identifier, Instruction, + InstructionId, + MutableRange, Place, ReactiveScope, makeInstructionId, @@ -186,8 +188,14 @@ function mergeLocation(l: SourceLocation, r: SourceLocation): SourceLocation { } // Is the operand mutable at this given instruction -export function isMutable({id}: Instruction, place: Place): boolean { - const range = place.identifier.mutableRange; +export function isMutable(instr: {id: InstructionId}, place: Place): boolean { + return inRange(instr, place.identifier.mutableRange); +} + +export function inRange( + {id}: {id: InstructionId}, + range: MutableRange, +): boolean { return id >= range.start && id < range.end; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts b/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts index 6e07e14a8d0e6..6b813d597560c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts @@ -83,16 +83,33 @@ export function getOrInsertDefault( } } -export function Set_union(a: Set, b: Set): Set { - const union = new Set(); - for (const item of a) { - if (b.has(item)) { - union.add(item); - } +export function Set_union(a: ReadonlySet, b: ReadonlySet): Set { + const union = new Set(a); + for (const item of b) { + union.add(item); } return union; } +export function Set_intersect(sets: Array>): Set { + if (sets.length === 0 || sets.some(s => s.size === 0)) { + return new Set(); + } else if (sets.length === 1) { + return new Set(sets[0]); + } + const result: Set = new Set(); + const first = sets[0]; + outer: for (const e of first) { + for (let i = 1; i < sets.length; i++) { + if (!sets[i].has(e)) { + continue outer; + } + } + result.add(e); + } + return result; +} + export function Iterable_some( iter: Iterable, pred: (item: T) => boolean, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccesInRender.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccesInRender.ts index 12449fd275ab1..8fee651f8d1ab 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccesInRender.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccesInRender.ts @@ -66,45 +66,8 @@ class Env extends Map { } override set(key: IdentifierId, value: RefAccessType): this { - function tyEqual(a: RefAccessType, b: RefAccessType): boolean { - if (a.kind !== b.kind) { - return false; - } - switch (a.kind) { - case 'None': - return true; - case 'Ref': - return true; - case 'RefValue': - CompilerError.invariant(b.kind === 'RefValue', { - reason: 'Expected ref value', - loc: null, - }); - return a.loc == b.loc; - case 'Structure': { - CompilerError.invariant(b.kind === 'Structure', { - reason: 'Expected structure', - loc: null, - }); - const fnTypesEqual = - (a.fn === null && b.fn === null) || - (a.fn !== null && - b.fn !== null && - a.fn.readRefEffect === b.fn.readRefEffect && - tyEqual(a.fn.returnType, b.fn.returnType)); - return ( - fnTypesEqual && - (a.value === b.value || - (a.value !== null && - b.value !== null && - tyEqual(a.value, b.value))) - ); - } - } - } - const cur = this.get(key); - const widenedValue = joinRefTypes(value, cur ?? {kind: 'None'}); + const widenedValue = joinRefAccessTypes(value, cur ?? {kind: 'None'}); if ( !(cur == null && widenedValue.kind === 'None') && (cur == null || !tyEqual(cur, widenedValue)) @@ -130,8 +93,43 @@ function refTypeOfType(identifier: Identifier): RefAccessType { } } -function joinRefTypes(...types: Array): RefAccessType { - function joinRefRefTypes( +function tyEqual(a: RefAccessType, b: RefAccessType): boolean { + if (a.kind !== b.kind) { + return false; + } + switch (a.kind) { + case 'None': + return true; + case 'Ref': + return true; + case 'RefValue': + CompilerError.invariant(b.kind === 'RefValue', { + reason: 'Expected ref value', + loc: null, + }); + return a.loc == b.loc; + case 'Structure': { + CompilerError.invariant(b.kind === 'Structure', { + reason: 'Expected structure', + loc: null, + }); + const fnTypesEqual = + (a.fn === null && b.fn === null) || + (a.fn !== null && + b.fn !== null && + a.fn.readRefEffect === b.fn.readRefEffect && + tyEqual(a.fn.returnType, b.fn.returnType)); + return ( + fnTypesEqual && + (a.value === b.value || + (a.value !== null && b.value !== null && tyEqual(a.value, b.value))) + ); + } + } +} + +function joinRefAccessTypes(...types: Array): RefAccessType { + function joinRefAccessRefTypes( a: RefAccessRefType, b: RefAccessRefType, ): RefAccessRefType { @@ -156,14 +154,17 @@ function joinRefTypes(...types: Array): RefAccessType { ? a.fn : { readRefEffect: a.fn.readRefEffect || b.fn.readRefEffect, - returnType: joinRefTypes(a.fn.returnType, b.fn.returnType), + returnType: joinRefAccessTypes( + a.fn.returnType, + b.fn.returnType, + ), }; const value = a.value === null ? b.value : b.value === null ? a.value - : joinRefRefTypes(a.value, b.value); + : joinRefAccessRefTypes(a.value, b.value); return { kind: 'Structure', fn, @@ -179,7 +180,7 @@ function joinRefTypes(...types: Array): RefAccessType { } else if (b.kind === 'None') { return a; } else { - return joinRefRefTypes(a, b); + return joinRefAccessRefTypes(a, b); } }, {kind: 'None'}, @@ -210,7 +211,7 @@ function validateNoRefAccessInRenderImpl( for (const phi of block.phis) { env.set( phi.id.id, - joinRefTypes( + joinRefAccessTypes( ...Array(...phi.operands.values()).map( operand => env.get(operand.id) ?? ({kind: 'None'} as const), ), @@ -375,7 +376,7 @@ function validateNoRefAccessInRenderImpl( validateNoDirectRefValueAccess(errors, operand, env); types.push(env.get(operand.identifier.id) ?? {kind: 'None'}); } - const value = joinRefTypes(...types); + const value = joinRefAccessTypes(...types); if (value.kind === 'None') { env.set(instr.lvalue.identifier.id, {kind: 'None'}); } else { @@ -413,7 +414,7 @@ function validateNoRefAccessInRenderImpl( if (isUseRefType(instr.lvalue.identifier)) { env.set( instr.lvalue.identifier.id, - joinRefTypes( + joinRefAccessTypes( env.get(instr.lvalue.identifier.id) ?? {kind: 'None'}, {kind: 'Ref'}, ), @@ -422,7 +423,7 @@ function validateNoRefAccessInRenderImpl( if (isRefValueType(instr.lvalue.identifier)) { env.set( instr.lvalue.identifier.id, - joinRefTypes( + joinRefAccessTypes( env.get(instr.lvalue.identifier.id) ?? {kind: 'None'}, {kind: 'RefValue', loc: instr.loc}, ), @@ -451,7 +452,7 @@ function validateNoRefAccessInRenderImpl( }); return Ok( - joinRefTypes( + joinRefAccessTypes( ...returnValues.filter((env): env is RefAccessType => env !== undefined), ), ); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/alias-capture-in-method-receiver-and-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/alias-capture-in-method-receiver-and-mutate.expect.md index ee8e4f0d3670a..0b03ac9978951 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/alias-capture-in-method-receiver-and-mutate.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/alias-capture-in-method-receiver-and-mutate.expect.md @@ -57,4 +57,4 @@ export const FIXTURE_ENTRYPOINT = { ``` ### Eval output -(kind: ok) [[{"a":0,"b":"value1","c":true}],"[[ cyclic ref *2 ]]"] \ No newline at end of file +(kind: ok) [[{"a":0,"b":"value1","c":true},"joe"],"[[ cyclic ref *2 ]]"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md index 6d55ba736486a..1bc6b9b51ebf6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md @@ -60,6 +60,6 @@ export const FIXTURE_ENTRYPOINT = { ``` ### Eval output -(kind: ok) [2] -[2] -[3] \ No newline at end of file +(kind: ok) [2,"joe"] +[2,"joe"] +[3,"joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructure-array-declaration-to-context-var.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructure-array-declaration-to-context-var.expect.md index fdb7203785716..26b56ea2a4f4d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructure-array-declaration-to-context-var.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructure-array-declaration-to-context-var.expect.md @@ -10,7 +10,7 @@ function Component(props) { x = identity(props.value[0]); }; foo(); - return {x}; + return
{x}
; } export const FIXTURE_ENTRYPOINT = { @@ -45,7 +45,7 @@ function Component(props) { const t0 = x; let t1; if ($[2] !== t0) { - t1 = { x: t0 }; + t1 =
{t0}
; $[2] = t0; $[3] = t1; } else { @@ -62,4 +62,4 @@ export const FIXTURE_ENTRYPOINT = { ``` ### Eval output -(kind: ok) {"x":42} \ No newline at end of file +(kind: ok)
42
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructure-array-declaration-to-context-var.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructure-array-declaration-to-context-var.js index 55675de9abb40..bc9324a35f06d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructure-array-declaration-to-context-var.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/destructure-array-declaration-to-context-var.js @@ -6,7 +6,7 @@ function Component(props) { x = identity(props.value[0]); }; foo(); - return {x}; + return
{x}
; } export const FIXTURE_ENTRYPOINT = { 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/evaluation-order-mutate-call-after-dependency-load.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-call-after-dependency-load.expect.md new file mode 100644 index 0000000000000..acad3c3092126 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-call-after-dependency-load.expect.md @@ -0,0 +1,83 @@ + +## Input + +```javascript +/** + * Test that we preserve order of evaluation on the following case scope@0 + * ```js + * // simplified HIR + * scope@0 + * ... + * $0 = arr.length + * $1 = arr.push(...) + * + * scope@1 <-- here we should depend on $0 (the value of the property load before the + * mutable call) + * [$0, $1] + * ``` + */ +function useFoo(source: Array): [number, number] { + const arr = [1, 2, 3, ...source]; + return [arr.length, arr.push(0)]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [[5, 6]], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; /** + * Test that we preserve order of evaluation on the following case scope@0 + * ```js + * // simplified HIR + * scope@0 + * ... + * $0 = arr.length + * $1 = arr.push(...) + * + * scope@1 <-- here we should depend on $0 (the value of the property load before the + * mutable call) + * [$0, $1] + * ``` + */ +function useFoo(source) { + const $ = _c(6); + let t0; + let t1; + if ($[0] !== source) { + const arr = [1, 2, 3, ...source]; + t0 = arr.length; + t1 = arr.push(0); + $[0] = source; + $[1] = t0; + $[2] = t1; + } else { + t0 = $[1]; + t1 = $[2]; + } + let t2; + if ($[3] !== t0 || $[4] !== t1) { + t2 = [t0, t1]; + $[3] = t0; + $[4] = t1; + $[5] = t2; + } else { + t2 = $[5]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [[5, 6]], +}; + +``` + +### Eval output +(kind: ok) [5,6] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-call-after-dependency-load.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-call-after-dependency-load.ts new file mode 100644 index 0000000000000..c2fa617f51c73 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-call-after-dependency-load.ts @@ -0,0 +1,23 @@ +/** + * Test that we preserve order of evaluation on the following case scope@0 + * ```js + * // simplified HIR + * scope@0 + * ... + * $0 = arr.length + * $1 = arr.push(...) + * + * scope@1 <-- here we should depend on $0 (the value of the property load before the + * mutable call) + * [$0, $1] + * ``` + */ +function useFoo(source: Array): [number, number] { + const arr = [1, 2, 3, ...source]; + return [arr.length, arr.push(0)]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [[5, 6]], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-store-after-dependency-load.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-store-after-dependency-load.expect.md new file mode 100644 index 0000000000000..b2bf1e36a4878 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-store-after-dependency-load.expect.md @@ -0,0 +1,83 @@ + +## Input + +```javascript +/** + * Test that we preserve order of evaluation on the following case scope@0 + * ```js + * // simplified HIR + * scope@0 + * ... + * $0 = arr.length + * $1 = arr.length = 0 + * + * scope@1 <-- here we should depend on $0 (the value of the property load before the + * property store) + * [$0, $1] + * ``` + */ +function useFoo(source: Array): [number, number] { + const arr = [1, 2, 3, ...source]; + return [arr.length, (arr.length = 0)]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [[5, 6]], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; /** + * Test that we preserve order of evaluation on the following case scope@0 + * ```js + * // simplified HIR + * scope@0 + * ... + * $0 = arr.length + * $1 = arr.length = 0 + * + * scope@1 <-- here we should depend on $0 (the value of the property load before the + * property store) + * [$0, $1] + * ``` + */ +function useFoo(source) { + const $ = _c(6); + let t0; + let t1; + if ($[0] !== source) { + const arr = [1, 2, 3, ...source]; + t0 = arr.length; + t1 = arr.length = 0; + $[0] = source; + $[1] = t0; + $[2] = t1; + } else { + t0 = $[1]; + t1 = $[2]; + } + let t2; + if ($[3] !== t0 || $[4] !== t1) { + t2 = [t0, t1]; + $[3] = t0; + $[4] = t1; + $[5] = t2; + } else { + t2 = $[5]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [[5, 6]], +}; + +``` + +### Eval output +(kind: ok) [5,0] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-store-after-dependency-load.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-store-after-dependency-load.ts new file mode 100644 index 0000000000000..8798cd99c7f77 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/evaluation-order-mutate-store-after-dependency-load.ts @@ -0,0 +1,23 @@ +/** + * Test that we preserve order of evaluation on the following case scope@0 + * ```js + * // simplified HIR + * scope@0 + * ... + * $0 = arr.length + * $1 = arr.length = 0 + * + * scope@1 <-- here we should depend on $0 (the value of the property load before the + * property store) + * [$0, $1] + * ``` + */ +function useFoo(source: Array): [number, number] { + const arr = [1, 2, 3, ...source]; + return [arr.length, (arr.length = 0)]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [[5, 6]], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr-conditional-access-2.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr-conditional-access-2.expect.md new file mode 100644 index 0000000000000..8cbaeb3f89465 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr-conditional-access-2.expect.md @@ -0,0 +1,67 @@ + +## Input + +```javascript +// @enableTreatFunctionDepsAsConditional @enablePropagateDepsInHIR:false +import {Stringify} from 'shared-runtime'; + +function Component({props}) { + const f = () => props.a.b; + + return {} : f} />; +} +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{props: null}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableTreatFunctionDepsAsConditional @enablePropagateDepsInHIR:false +import { Stringify } from "shared-runtime"; + +function Component(t0) { + const $ = _c(7); + const { props } = t0; + let t1; + if ($[0] !== props) { + t1 = () => props.a.b; + $[0] = props; + $[1] = t1; + } else { + t1 = $[1]; + } + const f = t1; + let t2; + if ($[2] !== props || $[3] !== f) { + t2 = props == null ? _temp : f; + $[2] = props; + $[3] = f; + $[4] = t2; + } else { + t2 = $[4]; + } + let t3; + if ($[5] !== t2) { + t3 = ; + $[5] = t2; + $[6] = t3; + } else { + t3 = $[6]; + } + return t3; +} +function _temp() {} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ props: null }], +}; + +``` + +### Eval output +(kind: ok)
{"f":"[[ function params=0 ]]"}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr-conditional-access-2.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr-conditional-access-2.tsx new file mode 100644 index 0000000000000..2ede54db5f364 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr-conditional-access-2.tsx @@ -0,0 +1,12 @@ +// @enableTreatFunctionDepsAsConditional @enablePropagateDepsInHIR:false +import {Stringify} from 'shared-runtime'; + +function Component({props}) { + const f = () => props.a.b; + + return {} : f} />; +} +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{props: null}], +}; diff --git "a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr\342\200\223conditional-access.expect.md" "b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr\342\200\223conditional-access.expect.md" index eb05286fa29a5..f2fa20feb5477 100644 --- "a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr\342\200\223conditional-access.expect.md" +++ "b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr\342\200\223conditional-access.expect.md" @@ -2,7 +2,7 @@ ## Input ```javascript -// @enableTreatFunctionDepsAsConditional +// @enableTreatFunctionDepsAsConditional @enablePropagateDepsInHIR:false function Component(props) { function getLength() { return props.bar.length; @@ -21,7 +21,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @enableTreatFunctionDepsAsConditional +import { c as _c } from "react/compiler-runtime"; // @enableTreatFunctionDepsAsConditional @enablePropagateDepsInHIR:false function Component(props) { const $ = _c(5); let t0; diff --git "a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr\342\200\223conditional-access.js" "b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr\342\200\223conditional-access.js" index 6e59fb947d150..9bff3e5cdb53b 100644 --- "a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr\342\200\223conditional-access.js" +++ "b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/functionexpr\342\200\223conditional-access.js" @@ -1,4 +1,4 @@ -// @enableTreatFunctionDepsAsConditional +// @enableTreatFunctionDepsAsConditional @enablePropagateDepsInHIR:false function Component(props) { function getLength() { return props.bar.length; 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 index 2623806ba9534..c34b79a848ba8 100644 --- 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 @@ -2,7 +2,7 @@ ## Input ```javascript -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies function Component(props) { const data = useMemo(() => { return props?.items.edges?.nodes.map(); @@ -15,7 +15,7 @@ function Component(props) { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies function Component(props) { const $ = _c(4); 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 index bbccbab90e2dd..d82d36b547970 100644 --- 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 @@ -1,4 +1,4 @@ -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies function Component(props) { const data = useMemo(() => { return props?.items.edges?.nodes.map(); 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 index e61d8d5b3919d..98fcfbe7f0f6f 100644 --- 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 @@ -2,7 +2,7 @@ ## Input ```javascript -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { @@ -19,7 +19,7 @@ function Component(props) { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import { ValidateMemoization } from "shared-runtime"; function Component(props) { const $ = _c(2); 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 index cf8d17b60fe0d..563b0bbf0f418 100644 --- 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 @@ -1,4 +1,4 @@ -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { 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 index a153c3d046595..46767056bdcdf 100644 --- 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 @@ -2,7 +2,7 @@ ## Input ```javascript -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { @@ -19,7 +19,7 @@ function Component(props) { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import { ValidateMemoization } from "shared-runtime"; function Component(props) { const $ = _c(7); 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 index 0a9d9bd9af247..8e6275bf921eb 100644 --- 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 @@ -1,4 +1,4 @@ -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { 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 index f7091dc8dcd6a..a4cf6d767d1c3 100644 --- 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 @@ -2,7 +2,7 @@ ## Input ```javascript -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { @@ -18,7 +18,7 @@ function Component(props) { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import { ValidateMemoization } from "shared-runtime"; function Component(props) { const $ = _c(7); 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 index 4e7268e1bf9e1..5750d7af3a0e0 100644 --- 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 @@ -1,4 +1,4 @@ -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { 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 index c8069ea47d27c..77ded20d939bd 100644 --- 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 @@ -2,7 +2,7 @@ ## Input ```javascript -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { @@ -23,7 +23,7 @@ function Component(props) { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import { ValidateMemoization } from "shared-runtime"; function Component(props) { const $ = _c(9); 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 index 2245a700f2e6c..760f345e90210 100644 --- 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 @@ -1,4 +1,4 @@ -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { 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 index df498e0ad06aa..10c23085d8e6b 100644 --- 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 @@ -2,7 +2,7 @@ ## Input ```javascript -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { @@ -23,7 +23,7 @@ function Component(props) { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import { ValidateMemoization } from "shared-runtime"; function Component(props) { const $ = _c(9); 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 index 006e516ae546e..3f773f4fe4e4b 100644 --- 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 @@ -1,4 +1,4 @@ -// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR:false +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies import {ValidateMemoization} from 'shared-runtime'; function Component(props) { const data = useMemo(() => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-as-memo-dep.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-as-memo-dep.expect.md new file mode 100644 index 0000000000000..e885982310117 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-as-memo-dep.expect.md @@ -0,0 +1,32 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +function Component(props) { + const data = useMemo(() => { + return props?.items.edges?.nodes.map(); + }, [props?.items.edges?.nodes]); + return ; +} + +``` + + +## Error + +``` + 1 | // @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR + 2 | function Component(props) { +> 3 | const data = useMemo(() => { + | ^^^^^^^ +> 4 | return props?.items.edges?.nodes.map(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 5 | }, [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:5) + 6 | return ; + 7 | } + 8 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-as-memo-dep.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-as-memo-dep.js new file mode 100644 index 0000000000000..6ff87d0c46329 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-as-memo-dep.js @@ -0,0 +1,7 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single-with-unconditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single-with-unconditional.expect.md new file mode 100644 index 0000000000000..3559b2bd58b28 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single-with-unconditional.expect.md @@ -0,0 +1,42 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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 ; +} + +``` + + +## Error + +``` + 2 | import {ValidateMemoization} from 'shared-runtime'; + 3 | function Component(props) { +> 4 | const data = useMemo(() => { + | ^^^^^^^ +> 5 | const x = []; + | ^^^^^^^^^^^^^^^^^ +> 6 | x.push(props?.items); + | ^^^^^^^^^^^^^^^^^ +> 7 | x.push(props.items); + | ^^^^^^^^^^^^^^^^^ +> 8 | return x; + | ^^^^^^^^^^^^^^^^^ +> 9 | }, [props.items]); + | ^^^^ 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 (4:9) + 10 | return ; + 11 | } + 12 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single-with-unconditional.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single-with-unconditional.js new file mode 100644 index 0000000000000..a3f8ba41bd886 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single-with-unconditional.js @@ -0,0 +1,11 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single.expect.md new file mode 100644 index 0000000000000..429f168836b82 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single.expect.md @@ -0,0 +1,39 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +import {ValidateMemoization} from 'shared-runtime'; +function Component(props) { + const data = useMemo(() => { + const x = []; + x.push(props?.items); + return x; + }, [props?.items]); + return ; +} + +``` + + +## Error + +``` + 2 | import {ValidateMemoization} from 'shared-runtime'; + 3 | function Component(props) { +> 4 | const data = useMemo(() => { + | ^^^^^^^ +> 5 | const x = []; + | ^^^^^^^^^^^^^^^^^ +> 6 | x.push(props?.items); + | ^^^^^^^^^^^^^^^^^ +> 7 | return x; + | ^^^^^^^^^^^^^^^^^ +> 8 | }, [props?.items]); + | ^^^^ 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 (4:8) + 9 | return ; + 10 | } + 11 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single.js new file mode 100644 index 0000000000000..535a0ce074419 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-single.js @@ -0,0 +1,10 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional-optional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional-optional.expect.md new file mode 100644 index 0000000000000..d54ba8d0ecfe6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional-optional.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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 ( + + ); +} + +``` + + +## Error + +``` + 2 | import {ValidateMemoization} from 'shared-runtime'; + 3 | function Component(props) { +> 4 | const data = useMemo(() => { + | ^^^^^^^ +> 5 | const x = []; + | ^^^^^^^^^^^^^^^^^ +> 6 | x.push(props?.items); + | ^^^^^^^^^^^^^^^^^ +> 7 | if (props.cond) { + | ^^^^^^^^^^^^^^^^^ +> 8 | x.push(props?.items); + | ^^^^^^^^^^^^^^^^^ +> 9 | } + | ^^^^^^^^^^^^^^^^^ +> 10 | return x; + | ^^^^^^^^^^^^^^^^^ +> 11 | }, [props?.items, props.cond]); + | ^^^^ 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 (4:11) + 12 | return ( + 13 | + 14 | ); +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional-optional.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional-optional.js new file mode 100644 index 0000000000000..b2ae1032b27a1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional-optional.js @@ -0,0 +1,15 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional.expect.md new file mode 100644 index 0000000000000..1b2c10a9ac777 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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 ( + + ); +} + +``` + + +## Error + +``` + 2 | import {ValidateMemoization} from 'shared-runtime'; + 3 | function Component(props) { +> 4 | const data = useMemo(() => { + | ^^^^^^^ +> 5 | const x = []; + | ^^^^^^^^^^^^^^^^^ +> 6 | x.push(props?.items); + | ^^^^^^^^^^^^^^^^^ +> 7 | if (props.cond) { + | ^^^^^^^^^^^^^^^^^ +> 8 | x.push(props.items); + | ^^^^^^^^^^^^^^^^^ +> 9 | } + | ^^^^^^^^^^^^^^^^^ +> 10 | return x; + | ^^^^^^^^^^^^^^^^^ +> 11 | }, [props?.items, props.cond]); + | ^^^^ 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 (4:11) + 12 | return ( + 13 | + 14 | ); +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional.js new file mode 100644 index 0000000000000..aa1997022207a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/error.todo-optional-member-expression-with-conditional.js @@ -0,0 +1,15 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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/propagate-scope-deps-hir-fork/optional-member-expression-inverted-optionals-parallel-paths.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/optional-member-expression-inverted-optionals-parallel-paths.expect.md new file mode 100644 index 0000000000000..60ae4e49d328c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/optional-member-expression-inverted-optionals-parallel-paths.expect.md @@ -0,0 +1,46 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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 +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/propagate-scope-deps-hir-fork/optional-member-expression-inverted-optionals-parallel-paths.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/optional-member-expression-inverted-optionals-parallel-paths.js new file mode 100644 index 0000000000000..091912f957370 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/optional-member-expression-inverted-optionals-parallel-paths.js @@ -0,0 +1,11 @@ +// @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enablePropagateDepsInHIR +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/propagate-scope-deps-hir-fork/reduce-reactive-deps/conditional-member-expr.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/conditional-member-expr.expect.md new file mode 100644 index 0000000000000..d56dcb63aed18 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/conditional-member-expr.expect.md @@ -0,0 +1,53 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +// To preserve the nullthrows behavior and reactive deps of this code, +// Forget needs to add `props.a` as a dependency (since `props.a.b` is +// a conditional dependency, i.e. gated behind control flow) + +function Component(props) { + let x = []; + x.push(props.a?.b); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: null}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +// To preserve the nullthrows behavior and reactive deps of this code, +// Forget needs to add `props.a` as a dependency (since `props.a.b` is +// a conditional dependency, i.e. gated behind control flow) + +function Component(props) { + const $ = _c(2); + let x; + if ($[0] !== props.a) { + x = []; + x.push(props.a?.b); + $[0] = props.a; + $[1] = x; + } else { + x = $[1]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: null }], +}; + +``` + +### Eval output +(kind: ok) [null] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/conditional-member-expr.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/conditional-member-expr.js new file mode 100644 index 0000000000000..447665425e7d7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/conditional-member-expr.js @@ -0,0 +1,15 @@ +// @enablePropagateDepsInHIR +// To preserve the nullthrows behavior and reactive deps of this code, +// Forget needs to add `props.a` as a dependency (since `props.a.b` is +// a conditional dependency, i.e. gated behind control flow) + +function Component(props) { + let x = []; + x.push(props.a?.b); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: null}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/join-uncond-scopes-cond-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/join-uncond-scopes-cond-deps.expect.md new file mode 100644 index 0000000000000..af49da4b64854 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/join-uncond-scopes-cond-deps.expect.md @@ -0,0 +1,93 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +// This tests an optimization, NOT a correctness property. +// When propagating reactive dependencies of an inner scope up to its parent, +// we prefer to retain granularity. +// +// In this test, we check that Forget propagates the inner scope's conditional +// dependencies (e.g. props.a.b) instead of only its derived minimal +// unconditional dependencies (e.g. props). +// ```javascript +// scope @0 (deps=[???] decls=[x, y]) { +// let y = {}; +// scope @1 (deps=[props] decls=[x]) { +// let x = {}; +// if (foo) mutate1(x, props.a.b); +// } +// mutate2(y, props.a.b); +// } + +import {CONST_TRUE, setProperty} from 'shared-runtime'; + +function useJoinCondDepsInUncondScopes(props) { + let y = {}; + let x = {}; + if (CONST_TRUE) { + setProperty(x, props.a.b); + } + setProperty(y, props.a.b); + return [x, y]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useJoinCondDepsInUncondScopes, + params: [{a: {b: 3}}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +// This tests an optimization, NOT a correctness property. +// When propagating reactive dependencies of an inner scope up to its parent, +// we prefer to retain granularity. +// +// In this test, we check that Forget propagates the inner scope's conditional +// dependencies (e.g. props.a.b) instead of only its derived minimal +// unconditional dependencies (e.g. props). +// ```javascript +// scope @0 (deps=[???] decls=[x, y]) { +// let y = {}; +// scope @1 (deps=[props] decls=[x]) { +// let x = {}; +// if (foo) mutate1(x, props.a.b); +// } +// mutate2(y, props.a.b); +// } + +import { CONST_TRUE, setProperty } from "shared-runtime"; + +function useJoinCondDepsInUncondScopes(props) { + const $ = _c(2); + let t0; + if ($[0] !== props.a.b) { + const y = {}; + const x = {}; + if (CONST_TRUE) { + setProperty(x, props.a.b); + } + + setProperty(y, props.a.b); + t0 = [x, y]; + $[0] = props.a.b; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useJoinCondDepsInUncondScopes, + params: [{ a: { b: 3 } }], +}; + +``` + +### Eval output +(kind: ok) [{"wat0":3},{"wat0":3}] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/join-uncond-scopes-cond-deps.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/join-uncond-scopes-cond-deps.js new file mode 100644 index 0000000000000..950dbd187edba --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/join-uncond-scopes-cond-deps.js @@ -0,0 +1,34 @@ +// @enablePropagateDepsInHIR +// This tests an optimization, NOT a correctness property. +// When propagating reactive dependencies of an inner scope up to its parent, +// we prefer to retain granularity. +// +// In this test, we check that Forget propagates the inner scope's conditional +// dependencies (e.g. props.a.b) instead of only its derived minimal +// unconditional dependencies (e.g. props). +// ```javascript +// scope @0 (deps=[???] decls=[x, y]) { +// let y = {}; +// scope @1 (deps=[props] decls=[x]) { +// let x = {}; +// if (foo) mutate1(x, props.a.b); +// } +// mutate2(y, props.a.b); +// } + +import {CONST_TRUE, setProperty} from 'shared-runtime'; + +function useJoinCondDepsInUncondScopes(props) { + let y = {}; + let x = {}; + if (CONST_TRUE) { + setProperty(x, props.a.b); + } + setProperty(y, props.a.b); + return [x, y]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useJoinCondDepsInUncondScopes, + params: [{a: {b: 3}}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain.expect.md new file mode 100644 index 0000000000000..0f155c79dea93 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain.expect.md @@ -0,0 +1,69 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +// To preserve the nullthrows behavior and reactive deps of this code, +// Forget needs to add `props.a.b` or a subpath as a dependency. +// +// (1) Since the reactive block producing x unconditionally read props.a.<...>, +// reading `props.a.b` outside of the block would still preserve nullthrows +// semantics of source code +// (2) Technically, props.a, props.a.b, and props.a.b.c are all reactive deps. +// However, `props.a?.b` is only dependent on whether `props.a` is nullish, +// not its actual value. Since we already preserve nullthrows on `props.a`, +// we technically do not need to add `props.a` as a dependency. + +function Component(props) { + let x = []; + x.push(props.a?.b); + x.push(props.a.b.c); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: {b: {c: 1}}}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +// To preserve the nullthrows behavior and reactive deps of this code, +// Forget needs to add `props.a.b` or a subpath as a dependency. +// +// (1) Since the reactive block producing x unconditionally read props.a.<...>, +// reading `props.a.b` outside of the block would still preserve nullthrows +// semantics of source code +// (2) Technically, props.a, props.a.b, and props.a.b.c are all reactive deps. +// However, `props.a?.b` is only dependent on whether `props.a` is nullish, +// not its actual value. Since we already preserve nullthrows on `props.a`, +// we technically do not need to add `props.a` as a dependency. + +function Component(props) { + const $ = _c(2); + let x; + if ($[0] !== props.a) { + x = []; + x.push(props.a?.b); + x.push(props.a.b.c); + $[0] = props.a; + $[1] = x; + } else { + x = $[1]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: { b: { c: 1 } } }], +}; + +``` + +### Eval output +(kind: ok) [{"c":1},1] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain.ts new file mode 100644 index 0000000000000..6f1d99761dd46 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain.ts @@ -0,0 +1,23 @@ +// @enablePropagateDepsInHIR +// To preserve the nullthrows behavior and reactive deps of this code, +// Forget needs to add `props.a.b` or a subpath as a dependency. +// +// (1) Since the reactive block producing x unconditionally read props.a.<...>, +// reading `props.a.b` outside of the block would still preserve nullthrows +// semantics of source code +// (2) Technically, props.a, props.a.b, and props.a.b.c are all reactive deps. +// However, `props.a?.b` is only dependent on whether `props.a` is nullish, +// not its actual value. Since we already preserve nullthrows on `props.a`, +// we technically do not need to add `props.a` as a dependency. + +function Component(props) { + let x = []; + x.push(props.a?.b); + x.push(props.a.b.c); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: {b: {c: 1}}}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain2.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain2.expect.md new file mode 100644 index 0000000000000..cf2d1d413741e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain2.expect.md @@ -0,0 +1,47 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +function Component(props) { + const x = []; + x.push(props.items?.length); + x.push(props.items?.edges?.map?.(render)?.filter?.(Boolean) ?? []); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{items: {edges: null, length: 0}}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +function Component(props) { + const $ = _c(2); + let x; + if ($[0] !== props.items) { + x = []; + x.push(props.items?.length); + x.push(props.items?.edges?.map?.(render)?.filter?.(Boolean) ?? []); + $[0] = props.items; + $[1] = x; + } else { + x = $[1]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ items: { edges: null, length: 0 } }], +}; + +``` + +### Eval output +(kind: ok) [0,[]] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain2.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain2.ts new file mode 100644 index 0000000000000..cc696e15d05df --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/memberexpr-join-optional-chain2.ts @@ -0,0 +1,12 @@ +// @enablePropagateDepsInHIR +function Component(props) { + const x = []; + x.push(props.items?.length); + x.push(props.items?.edges?.map?.(render)?.filter?.(Boolean) ?? []); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{items: {edges: null, length: 0}}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/promote-uncond.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/promote-uncond.expect.md new file mode 100644 index 0000000000000..902a1578c8870 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/promote-uncond.expect.md @@ -0,0 +1,65 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +// When a conditional dependency `props.a.b.c` has no unconditional dependency +// in its subpath or superpath, we should find the nearest unconditional access + +import {identity} from 'shared-runtime'; + +// and promote it to an unconditional dependency. +function usePromoteUnconditionalAccessToDependency(props, other) { + const x = {}; + x.a = props.a.a.a; + if (identity(other)) { + x.c = props.a.b.c; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: usePromoteUnconditionalAccessToDependency, + params: [{a: {a: {a: 3}}}, false], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +// When a conditional dependency `props.a.b.c` has no unconditional dependency +// in its subpath or superpath, we should find the nearest unconditional access + +import { identity } from "shared-runtime"; + +// and promote it to an unconditional dependency. +function usePromoteUnconditionalAccessToDependency(props, other) { + const $ = _c(4); + let x; + if ($[0] !== props.a.a.a || $[1] !== props.a.b || $[2] !== other) { + x = {}; + x.a = props.a.a.a; + if (identity(other)) { + x.c = props.a.b.c; + } + $[0] = props.a.a.a; + $[1] = props.a.b; + $[2] = other; + $[3] = x; + } else { + x = $[3]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: usePromoteUnconditionalAccessToDependency, + params: [{ a: { a: { a: 3 } } }, false], +}; + +``` + +### Eval output +(kind: ok) {"a":3} \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/promote-uncond.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/promote-uncond.js new file mode 100644 index 0000000000000..ef585f19ea565 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/reduce-reactive-deps/promote-uncond.js @@ -0,0 +1,20 @@ +// @enablePropagateDepsInHIR +// When a conditional dependency `props.a.b.c` has no unconditional dependency +// in its subpath or superpath, we should find the nearest unconditional access + +import {identity} from 'shared-runtime'; + +// and promote it to an unconditional dependency. +function usePromoteUnconditionalAccessToDependency(props, other) { + const x = {}; + x.a = props.a.a.a; + if (identity(other)) { + x.c = props.a.b.c; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: usePromoteUnconditionalAccessToDependency, + params: [{a: {a: {a: 3}}}, false], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/repro-scope-missing-mutable-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/repro-scope-missing-mutable-range.expect.md new file mode 100644 index 0000000000000..cf4e4f93274bb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/repro-scope-missing-mutable-range.expect.md @@ -0,0 +1,55 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +function HomeDiscoStoreItemTileRating(props) { + const item = useFragment(); + let count = 0; + const aggregates = item?.aggregates || []; + aggregates.forEach(aggregate => { + count += aggregate.count || 0; + }); + + return {count}; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +function HomeDiscoStoreItemTileRating(props) { + const $ = _c(4); + const item = useFragment(); + let count; + if ($[0] !== item) { + count = 0; + const aggregates = item?.aggregates || []; + aggregates.forEach((aggregate) => { + count = count + (aggregate.count || 0); + count; + }); + $[0] = item; + $[1] = count; + } else { + count = $[1]; + } + + const t0 = count; + let t1; + if ($[2] !== t0) { + t1 = {t0}; + $[2] = t0; + $[3] = t1; + } else { + t1 = $[3]; + } + 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/propagate-scope-deps-hir-fork/repro-scope-missing-mutable-range.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/repro-scope-missing-mutable-range.js new file mode 100644 index 0000000000000..71933018ccb28 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/repro-scope-missing-mutable-range.js @@ -0,0 +1,11 @@ +// @enablePropagateDepsInHIR +function HomeDiscoStoreItemTileRating(props) { + const item = useFragment(); + let count = 0; + const aggregates = item?.aggregates || []; + aggregates.forEach(aggregate => { + count += aggregate.count || 0; + }); + + return {count}; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-cascading-eliminated-phis.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-cascading-eliminated-phis.expect.md new file mode 100644 index 0000000000000..6277ffbf404fe --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-cascading-eliminated-phis.expect.md @@ -0,0 +1,94 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +function Component(props) { + let x = 0; + const values = []; + const y = props.a || props.b; + values.push(y); + if (props.c) { + x = 1; + } + values.push(x); + if (props.d) { + x = 2; + } + values.push(x); + return values; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 1, c: true, d: true}], + sequentialRenders: [ + {a: 0, b: 1, c: true, d: true}, + {a: 4, b: 1, c: true, d: true}, + {a: 4, b: 1, c: false, d: true}, + {a: 4, b: 1, c: false, d: false}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +function Component(props) { + const $ = _c(7); + let x = 0; + let values; + if ( + $[0] !== props.a || + $[1] !== props.b || + $[2] !== props.c || + $[3] !== props.d || + $[4] !== x + ) { + values = []; + const y = props.a || props.b; + values.push(y); + if (props.c) { + x = 1; + } + + values.push(x); + if (props.d) { + x = 2; + } + + values.push(x); + $[0] = props.a; + $[1] = props.b; + $[2] = props.c; + $[3] = props.d; + $[4] = x; + $[5] = values; + $[6] = x; + } else { + values = $[5]; + x = $[6]; + } + return values; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 1, c: true, d: true }], + sequentialRenders: [ + { a: 0, b: 1, c: true, d: true }, + { a: 4, b: 1, c: true, d: true }, + { a: 4, b: 1, c: false, d: true }, + { a: 4, b: 1, c: false, d: false }, + ], +}; + +``` + +### Eval output +(kind: ok) [1,1,2] +[4,1,2] +[4,0,2] +[4,0,0] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-cascading-eliminated-phis.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-cascading-eliminated-phis.js new file mode 100644 index 0000000000000..eb1dde9a9d607 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-cascading-eliminated-phis.js @@ -0,0 +1,27 @@ +// @enablePropagateDepsInHIR +function Component(props) { + let x = 0; + const values = []; + const y = props.a || props.b; + values.push(y); + if (props.c) { + x = 1; + } + values.push(x); + if (props.d) { + x = 2; + } + values.push(x); + return values; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 1, c: true, d: true}], + sequentialRenders: [ + {a: 0, b: 1, c: true, d: true}, + {a: 4, b: 1, c: true, d: true}, + {a: 4, b: 1, c: false, d: true}, + {a: 4, b: 1, c: false, d: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-leave-case.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-leave-case.expect.md new file mode 100644 index 0000000000000..cc6713b29503e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-leave-case.expect.md @@ -0,0 +1,85 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +import {Stringify} from 'shared-runtime'; + +function Component(props) { + let x = []; + let y; + if (props.p0) { + x.push(props.p1); + y = x; + } + return ( + + {x} + {y} + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{p0: false, p1: 2}], + sequentialRenders: [ + {p0: false, p1: 2}, + {p0: false, p1: 2}, + {p0: true, p1: 2}, + {p0: true, p1: 3}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +import { Stringify } from "shared-runtime"; + +function Component(props) { + const $ = _c(3); + let t0; + if ($[0] !== props.p0 || $[1] !== props.p1) { + const x = []; + let y; + if (props.p0) { + x.push(props.p1); + y = x; + } + + t0 = ( + + {x} + {y} + + ); + $[0] = props.p0; + $[1] = props.p1; + $[2] = t0; + } else { + t0 = $[2]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ p0: false, p1: 2 }], + sequentialRenders: [ + { p0: false, p1: 2 }, + { p0: false, p1: 2 }, + { p0: true, p1: 2 }, + { p0: true, p1: 3 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"children":[[],null]}
+
{"children":[[],null]}
+
{"children":[[2],"[[ cyclic ref *2 ]]"]}
+
{"children":[[3],"[[ cyclic ref *2 ]]"]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-leave-case.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-leave-case.js new file mode 100644 index 0000000000000..f13f66c590ab7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-leave-case.js @@ -0,0 +1,28 @@ +// @enablePropagateDepsInHIR +import {Stringify} from 'shared-runtime'; + +function Component(props) { + let x = []; + let y; + if (props.p0) { + x.push(props.p1); + y = x; + } + return ( + + {x} + {y} + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{p0: false, p1: 2}], + sequentialRenders: [ + {p0: false, p1: 2}, + {p0: false, p1: 2}, + {p0: true, p1: 2}, + {p0: true, p1: 3}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction-with-mutation.expect.md new file mode 100644 index 0000000000000..eb623477049be --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction-with-mutation.expect.md @@ -0,0 +1,67 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +import {mutate} from 'shared-runtime'; + +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; + mutate(x); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +import { mutate } from "shared-runtime"; + +function useFoo(props) { + const $ = _c(4); + let x; + if ($[0] !== props.bar || $[1] !== props.cond || $[2] !== props.foo) { + x = []; + x.push(props.bar); + props.cond ? (([x] = [[]]), x.push(props.foo)) : null; + mutate(x); + $[0] = props.bar; + $[1] = props.cond; + $[2] = props.foo; + $[3] = x; + } else { + x = $[3]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], +}; + +``` + +### Eval output +(kind: ok) [55,"joe"] +[55,"joe"] +[3,"joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction-with-mutation.js new file mode 100644 index 0000000000000..1ea8b35addd9d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction-with-mutation.js @@ -0,0 +1,20 @@ +// @enablePropagateDepsInHIR +import {mutate} from 'shared-runtime'; + +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; + mutate(x); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction.expect.md new file mode 100644 index 0000000000000..a737b1809469c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +function useFoo(props) { + const $ = _c(5); + let x; + if ($[0] !== props.bar) { + x = []; + x.push(props.bar); + $[0] = props.bar; + $[1] = x; + } else { + x = $[1]; + } + if ($[2] !== props.cond || $[3] !== props.foo) { + props.cond ? (([x] = [[]]), x.push(props.foo)) : null; + $[2] = props.cond; + $[3] = props.foo; + $[4] = x; + } else { + x = $[4]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], +}; + +``` + +### Eval output +(kind: ok) [55] +[55] +[3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction.js new file mode 100644 index 0000000000000..2f37cdabb47f5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-destruction.js @@ -0,0 +1,17 @@ +// @enablePropagateDepsInHIR +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-with-mutation.expect.md new file mode 100644 index 0000000000000..6a2184712a812 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-with-mutation.expect.md @@ -0,0 +1,67 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +import {mutate} from 'shared-runtime'; + +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond ? ((x = {}), (x = []), x.push(props.foo)) : null; + mutate(x); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +import { mutate } from "shared-runtime"; + +function useFoo(props) { + const $ = _c(4); + let x; + if ($[0] !== props.bar || $[1] !== props.cond || $[2] !== props.foo) { + x = []; + x.push(props.bar); + props.cond ? ((x = []), x.push(props.foo)) : null; + mutate(x); + $[0] = props.bar; + $[1] = props.cond; + $[2] = props.foo; + $[3] = x; + } else { + x = $[3]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], +}; + +``` + +### Eval output +(kind: ok) [55,"joe"] +[55,"joe"] +[3,"joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-with-mutation.js new file mode 100644 index 0000000000000..a8fce44135419 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary-with-mutation.js @@ -0,0 +1,20 @@ +// @enablePropagateDepsInHIR +import {mutate} from 'shared-runtime'; + +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond ? ((x = {}), (x = []), x.push(props.foo)) : null; + mutate(x); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary.expect.md new file mode 100644 index 0000000000000..a6750238dc303 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond ? ((x = {}), (x = []), x.push(props.foo)) : null; + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +function useFoo(props) { + const $ = _c(5); + let x; + if ($[0] !== props.bar) { + x = []; + x.push(props.bar); + $[0] = props.bar; + $[1] = x; + } else { + x = $[1]; + } + if ($[2] !== props.cond || $[3] !== props.foo) { + props.cond ? ((x = []), x.push(props.foo)) : null; + $[2] = props.cond; + $[3] = props.foo; + $[4] = x; + } else { + x = $[4]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], +}; + +``` + +### Eval output +(kind: ok) [55] +[55] +[3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary.js new file mode 100644 index 0000000000000..3cafcd9f0bda2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-ternary.js @@ -0,0 +1,17 @@ +// @enablePropagateDepsInHIR +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond ? ((x = {}), (x = []), x.push(props.foo)) : null; + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary-with-mutation.expect.md new file mode 100644 index 0000000000000..062c7ac8fca30 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary-with-mutation.expect.md @@ -0,0 +1,67 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +import {arrayPush} from 'shared-runtime'; +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond + ? ((x = {}), (x = []), x.push(props.foo)) + : ((x = []), (x = []), x.push(props.bar)); + arrayPush(x, 4); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +import { arrayPush } from "shared-runtime"; +function useFoo(props) { + const $ = _c(4); + let x; + if ($[0] !== props.bar || $[1] !== props.cond || $[2] !== props.foo) { + x = []; + x.push(props.bar); + props.cond ? ((x = []), x.push(props.foo)) : ((x = []), x.push(props.bar)); + arrayPush(x, 4); + $[0] = props.bar; + $[1] = props.cond; + $[2] = props.foo; + $[3] = x; + } else { + x = $[3]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], +}; + +``` + +### Eval output +(kind: ok) [55,4] +[55,4] +[3,4] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary-with-mutation.js new file mode 100644 index 0000000000000..2b7134fa20863 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary-with-mutation.js @@ -0,0 +1,21 @@ +// @enablePropagateDepsInHIR +import {arrayPush} from 'shared-runtime'; +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond + ? ((x = {}), (x = []), x.push(props.foo)) + : ((x = []), (x = []), x.push(props.bar)); + arrayPush(x, 4); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary.expect.md new file mode 100644 index 0000000000000..c5cf366fb6101 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary.expect.md @@ -0,0 +1,69 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond + ? ((x = {}), (x = []), x.push(props.foo)) + : ((x = []), (x = []), x.push(props.bar)); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +function useFoo(props) { + const $ = _c(6); + let x; + if ($[0] !== props.bar) { + x = []; + x.push(props.bar); + $[0] = props.bar; + $[1] = x; + } else { + x = $[1]; + } + if ($[2] !== props.cond || $[3] !== props.foo || $[4] !== props.bar) { + props.cond ? ((x = []), x.push(props.foo)) : ((x = []), x.push(props.bar)); + $[2] = props.cond; + $[3] = props.foo; + $[4] = props.bar; + $[5] = x; + } else { + x = $[5]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], +}; + +``` + +### Eval output +(kind: ok) [55] +[55] +[3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary.js new file mode 100644 index 0000000000000..d131c3bbc0ac2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-unconditional-ternary.js @@ -0,0 +1,19 @@ +// @enablePropagateDepsInHIR +function useFoo(props) { + let x = []; + x.push(props.bar); + props.cond + ? ((x = {}), (x = []), x.push(props.foo)) + : ((x = []), (x = []), x.push(props.bar)); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-via-destructuring-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-via-destructuring-with-mutation.expect.md new file mode 100644 index 0000000000000..42288dda64247 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-via-destructuring-with-mutation.expect.md @@ -0,0 +1,75 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +import {mutate} from 'shared-runtime'; + +function useFoo(props) { + let {x} = {x: []}; + x.push(props.bar); + if (props.cond) { + ({x} = {x: {}}); + ({x} = {x: []}); + x.push(props.foo); + } + mutate(x); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +import { mutate } from "shared-runtime"; + +function useFoo(props) { + const $ = _c(4); + let x; + if ($[0] !== props.bar || $[1] !== props.cond || $[2] !== props.foo) { + ({ x } = { x: [] }); + x.push(props.bar); + if (props.cond) { + ({ x } = { x: [] }); + x.push(props.foo); + } + + mutate(x); + $[0] = props.bar; + $[1] = props.cond; + $[2] = props.foo; + $[3] = x; + } else { + x = $[3]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ bar: "bar", foo: "foo", cond: true }], + sequentialRenders: [ + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: false }, + ], +}; + +``` + +### Eval output +(kind: ok) ["foo","joe"] +["foo","joe"] +["bar","joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-via-destructuring-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-via-destructuring-with-mutation.js new file mode 100644 index 0000000000000..e83596e91b6a4 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-via-destructuring-with-mutation.js @@ -0,0 +1,24 @@ +// @enablePropagateDepsInHIR +import {mutate} from 'shared-runtime'; + +function useFoo(props) { + let {x} = {x: []}; + x.push(props.bar); + if (props.cond) { + ({x} = {x: {}}); + ({x} = {x: []}); + x.push(props.foo); + } + mutate(x); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-with-mutation.expect.md new file mode 100644 index 0000000000000..bac466cd6e1e1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-with-mutation.expect.md @@ -0,0 +1,75 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +import {mutate} from 'shared-runtime'; + +function useFoo(props) { + let x = []; + x.push(props.bar); + if (props.cond) { + x = {}; + x = []; + x.push(props.foo); + } + mutate(x); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +import { mutate } from "shared-runtime"; + +function useFoo(props) { + const $ = _c(4); + let x; + if ($[0] !== props.bar || $[1] !== props.cond || $[2] !== props.foo) { + x = []; + x.push(props.bar); + if (props.cond) { + x = []; + x.push(props.foo); + } + + mutate(x); + $[0] = props.bar; + $[1] = props.cond; + $[2] = props.foo; + $[3] = x; + } else { + x = $[3]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ bar: "bar", foo: "foo", cond: true }], + sequentialRenders: [ + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: false }, + ], +}; + +``` + +### Eval output +(kind: ok) ["foo","joe"] +["foo","joe"] +["bar","joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-with-mutation.js new file mode 100644 index 0000000000000..ac7b8007d8c4c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/ssa-renaming-with-mutation.js @@ -0,0 +1,24 @@ +// @enablePropagateDepsInHIR +import {mutate} from 'shared-runtime'; + +function useFoo(props) { + let x = []; + x.push(props.bar); + if (props.cond) { + x = {}; + x = []; + x.push(props.foo); + } + mutate(x); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch-non-final-default.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch-non-final-default.expect.md new file mode 100644 index 0000000000000..37846215b1d9e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch-non-final-default.expect.md @@ -0,0 +1,91 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +function Component(props) { + let x = []; + let y; + switch (props.p0) { + case 1: { + break; + } + case true: { + x.push(props.p2); + y = []; + } + default: { + break; + } + case false: { + y = x; + break; + } + } + const child = ; + y.push(props.p4); + return {child}; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +function Component(props) { + const $ = _c(8); + let y; + let t0; + if ($[0] !== props.p0 || $[1] !== props.p2) { + const x = []; + bb0: switch (props.p0) { + case 1: { + break bb0; + } + case true: { + x.push(props.p2); + let t1; + if ($[4] === Symbol.for("react.memo_cache_sentinel")) { + t1 = []; + $[4] = t1; + } else { + t1 = $[4]; + } + y = t1; + } + default: { + break bb0; + } + case false: { + y = x; + } + } + + t0 = ; + $[0] = props.p0; + $[1] = props.p2; + $[2] = y; + $[3] = t0; + } else { + y = $[2]; + t0 = $[3]; + } + const child = t0; + y.push(props.p4); + let t1; + if ($[5] !== y || $[6] !== child) { + t1 = {child}; + $[5] = y; + $[6] = child; + $[7] = t1; + } else { + t1 = $[7]; + } + 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/propagate-scope-deps-hir-fork/switch-non-final-default.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch-non-final-default.js new file mode 100644 index 0000000000000..7a73d054d54e8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch-non-final-default.js @@ -0,0 +1,24 @@ +// @enablePropagateDepsInHIR +function Component(props) { + let x = []; + let y; + switch (props.p0) { + case 1: { + break; + } + case true: { + x.push(props.p2); + y = []; + } + default: { + break; + } + case false: { + y = x; + break; + } + } + const child = ; + y.push(props.p4); + return {child}; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch.expect.md new file mode 100644 index 0000000000000..1be4143849266 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch.expect.md @@ -0,0 +1,74 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +function Component(props) { + let x = []; + let y; + switch (props.p0) { + case true: { + x.push(props.p2); + x.push(props.p3); + y = []; + } + case false: { + y = x; + break; + } + } + const child = ; + y.push(props.p4); + return {child}; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +function Component(props) { + const $ = _c(8); + let y; + let t0; + if ($[0] !== props.p0 || $[1] !== props.p2 || $[2] !== props.p3) { + const x = []; + switch (props.p0) { + case true: { + x.push(props.p2); + x.push(props.p3); + } + case false: { + y = x; + } + } + + t0 = ; + $[0] = props.p0; + $[1] = props.p2; + $[2] = props.p3; + $[3] = y; + $[4] = t0; + } else { + y = $[3]; + t0 = $[4]; + } + const child = t0; + y.push(props.p4); + let t1; + if ($[5] !== y || $[6] !== child) { + t1 = {child}; + $[5] = y; + $[6] = child; + $[7] = t1; + } else { + t1 = $[7]; + } + 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/propagate-scope-deps-hir-fork/switch.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch.js new file mode 100644 index 0000000000000..187fffd39ec82 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/switch.js @@ -0,0 +1,19 @@ +// @enablePropagateDepsInHIR +function Component(props) { + let x = []; + let y; + switch (props.p0) { + case true: { + x.push(props.p2); + x.push(props.p3); + y = []; + } + case false: { + y = x; + break; + } + } + const child = ; + y.push(props.p4); + return {child}; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.expect.md new file mode 100644 index 0000000000000..4fef6820554e1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.expect.md @@ -0,0 +1,72 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +const {shallowCopy, throwErrorWithMessage} = require('shared-runtime'); + +function Component(props) { + const x = []; + try { + x.push(throwErrorWithMessage('oops')); + } catch { + x.push(shallowCopy({a: props.a})); + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 1}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +const { shallowCopy, throwErrorWithMessage } = require("shared-runtime"); + +function Component(props) { + const $ = _c(5); + let x; + if ($[0] !== props) { + x = []; + try { + let t0; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t0 = throwErrorWithMessage("oops"); + $[2] = t0; + } else { + t0 = $[2]; + } + x.push(t0); + } catch { + let t0; + if ($[3] !== props.a) { + t0 = shallowCopy({ a: props.a }); + $[3] = props.a; + $[4] = t0; + } else { + t0 = $[4]; + } + x.push(t0); + } + $[0] = props; + $[1] = x; + } else { + x = $[1]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 1 }], +}; + +``` + +### Eval output +(kind: ok) [{"a":1}] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.js new file mode 100644 index 0000000000000..97e4250b2277a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.js @@ -0,0 +1,17 @@ +// @enablePropagateDepsInHIR +const {shallowCopy, throwErrorWithMessage} = require('shared-runtime'); + +function Component(props) { + const x = []; + try { + x.push(throwErrorWithMessage('oops')); + } catch { + x.push(shallowCopy({a: props.a})); + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 1}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch-escaping.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch-escaping.expect.md new file mode 100644 index 0000000000000..914001f3737bd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch-escaping.expect.md @@ -0,0 +1,63 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +const {throwInput} = require('shared-runtime'); + +function Component(props) { + let x; + try { + const y = []; + y.push(props.y); + throwInput(y); + } catch (e) { + e.push(props.e); + x = e; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{y: 'foo', e: 'bar'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +const { throwInput } = require("shared-runtime"); + +function Component(props) { + const $ = _c(2); + let x; + if ($[0] !== props) { + try { + const y = []; + y.push(props.y); + throwInput(y); + } catch (t0) { + const e = t0; + e.push(props.e); + x = e; + } + $[0] = props; + $[1] = x; + } else { + x = $[1]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ y: "foo", e: "bar" }], +}; + +``` + +### Eval output +(kind: ok) ["foo","bar"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch-escaping.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch-escaping.js new file mode 100644 index 0000000000000..5a0864118ba8f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch-escaping.js @@ -0,0 +1,20 @@ +// @enablePropagateDepsInHIR +const {throwInput} = require('shared-runtime'); + +function Component(props) { + let x; + try { + const y = []; + y.push(props.y); + throwInput(y); + } catch (e) { + e.push(props.e); + x = e; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{y: 'foo', e: 'bar'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch.expect.md new file mode 100644 index 0000000000000..30ecdf6d59e9d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch.expect.md @@ -0,0 +1,69 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +const {throwInput} = require('shared-runtime'); + +function Component(props) { + try { + const y = []; + y.push(props.y); + throwInput(y); + } catch (e) { + e.push(props.e); + return e; + } + return null; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{y: 'foo', e: 'bar'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +const { throwInput } = require("shared-runtime"); + +function Component(props) { + const $ = _c(2); + let t0; + if ($[0] !== props) { + t0 = Symbol.for("react.early_return_sentinel"); + bb0: { + try { + const y = []; + y.push(props.y); + throwInput(y); + } catch (t1) { + const e = t1; + e.push(props.e); + t0 = e; + break bb0; + } + } + $[0] = props; + $[1] = t0; + } else { + t0 = $[1]; + } + if (t0 !== Symbol.for("react.early_return_sentinel")) { + return t0; + } + return null; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ y: "foo", e: "bar" }], +}; + +``` + +### Eval output +(kind: ok) ["foo","bar"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch.js new file mode 100644 index 0000000000000..97d650453c175 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-try-value-modified-in-catch.js @@ -0,0 +1,19 @@ +// @enablePropagateDepsInHIR +const {throwInput} = require('shared-runtime'); + +function Component(props) { + try { + const y = []; + y.push(props.y); + throwInput(y); + } catch (e) { + e.push(props.e); + return e; + } + return null; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{y: 'foo', e: 'bar'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/useMemo-multiple-if-else.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/useMemo-multiple-if-else.expect.md new file mode 100644 index 0000000000000..d654221dc837e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/useMemo-multiple-if-else.expect.md @@ -0,0 +1,81 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR +import {useMemo} from 'react'; + +function Component(props) { + const x = useMemo(() => { + let y = []; + if (props.cond) { + y.push(props.a); + } + if (props.cond2) { + return y; + } + y.push(props.b); + return y; + }); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 1, b: 2, cond2: false}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR +import { useMemo } from "react"; + +function Component(props) { + const $ = _c(6); + let t0; + bb0: { + let y; + if ( + $[0] !== props.cond || + $[1] !== props.a || + $[2] !== props.cond2 || + $[3] !== props.b + ) { + y = []; + if (props.cond) { + y.push(props.a); + } + if (props.cond2) { + t0 = y; + break bb0; + } + + y.push(props.b); + $[0] = props.cond; + $[1] = props.a; + $[2] = props.cond2; + $[3] = props.b; + $[4] = y; + $[5] = t0; + } else { + y = $[4]; + t0 = $[5]; + } + t0 = y; + } + const x = t0; + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 1, b: 2, cond2: false }], +}; + +``` + +### Eval output +(kind: ok) [2] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/useMemo-multiple-if-else.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/useMemo-multiple-if-else.js new file mode 100644 index 0000000000000..7075ecaac53c4 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/useMemo-multiple-if-else.js @@ -0,0 +1,22 @@ +// @enablePropagateDepsInHIR +import {useMemo} from 'react'; + +function Component(props) { + const x = useMemo(() => { + let y = []; + if (props.cond) { + y.push(props.a); + } + if (props.cond2) { + return y; + } + y.push(props.b); + return y; + }); + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 1, b: 2, cond2: false}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance.expect.md new file mode 100644 index 0000000000000..701702f9dd7ff --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance.expect.md @@ -0,0 +1,107 @@ + +## Input + +```javascript +import {makeObject_Primitives, setPropertyByKey} from 'shared-runtime'; + +function useFoo({value, cond}) { + let x: any = makeObject_Primitives(); + if (cond) { + setPropertyByKey(x, 'a', null); + } else { + setPropertyByKey(x, 'a', {b: 2}); + } + + /** + * y should take a dependency on `x`, not `x.a.b` here + */ + const y = []; + if (!cond) { + y.push(x.a.b); + } + + x = makeObject_Primitives(); + setPropertyByKey(x, 'a', {b: value}); + + return [y, x.a.b]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{value: 3, cond: true}], + sequentialRenders: [ + {value: 3, cond: true}, + {value: 3, cond: false}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { makeObject_Primitives, setPropertyByKey } from "shared-runtime"; + +function useFoo(t0) { + const $ = _c(10); + const { value, cond } = t0; + let x; + if ($[0] !== cond) { + x = makeObject_Primitives(); + if (cond) { + setPropertyByKey(x, "a", null); + } else { + setPropertyByKey(x, "a", { b: 2 }); + } + $[0] = cond; + $[1] = x; + } else { + x = $[1]; + } + let y; + if ($[2] !== cond || $[3] !== x) { + y = []; + if (!cond) { + y.push(x.a.b); + } + $[2] = cond; + $[3] = x; + $[4] = y; + } else { + y = $[4]; + } + if ($[5] !== value) { + x = makeObject_Primitives(); + setPropertyByKey(x, "a", { b: value }); + $[5] = value; + $[6] = x; + } else { + x = $[6]; + } + let t1; + if ($[7] !== y || $[8] !== x.a.b) { + t1 = [y, x.a.b]; + $[7] = y; + $[8] = x.a.b; + $[9] = t1; + } else { + t1 = $[9]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ value: 3, cond: true }], + sequentialRenders: [ + { value: 3, cond: true }, + { value: 3, cond: false }, + ], +}; + +``` + +### Eval output +(kind: ok) [[],3] +[[2],3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance.tsx new file mode 100644 index 0000000000000..3f75571bd700c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance.tsx @@ -0,0 +1,32 @@ +import {makeObject_Primitives, setPropertyByKey} from 'shared-runtime'; + +function useFoo({value, cond}) { + let x: any = makeObject_Primitives(); + if (cond) { + setPropertyByKey(x, 'a', null); + } else { + setPropertyByKey(x, 'a', {b: 2}); + } + + /** + * y should take a dependency on `x`, not `x.a.b` here + */ + const y = []; + if (!cond) { + y.push(x.a.b); + } + + x = makeObject_Primitives(); + setPropertyByKey(x, 'a', {b: value}); + + return [y, x.a.b]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{value: 3, cond: true}], + sequentialRenders: [ + {value: 3, cond: true}, + {value: 3, cond: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance1.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance1.expect.md new file mode 100644 index 0000000000000..54ee5676b3f6d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance1.expect.md @@ -0,0 +1,96 @@ + +## Input + +```javascript +import {identity, shallowCopy, Stringify, useIdentity} from 'shared-runtime'; + +type HasA = {kind: 'hasA'; a: {value: number}}; +type HasC = {kind: 'hasC'; c: {value: number}}; +function Foo({cond}: {cond: boolean}) { + let x: HasA | HasC = shallowCopy({kind: 'hasA', a: {value: 2}}); + /** + * This read of x.a.value is outside of x's identifier mutable + * range + scope range. We mark this ssa instance (x_@0) as having + * a non-null object property `x.a`. + */ + Math.max(x.a.value, 2); + if (cond) { + x = shallowCopy({kind: 'hasC', c: {value: 3}}); + } + + /** + * Since this x (x_@2 = phi(x_@0, x_@1)) is a different ssa instance, + * we cannot safely hoist a read of `x.a.value` + */ + return ; +} +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{cond: false}], + sequentialRenders: [{cond: false}, {cond: true}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { identity, shallowCopy, Stringify, useIdentity } from "shared-runtime"; + +type HasA = { kind: "hasA"; a: { value: number } }; +type HasC = { kind: "hasC"; c: { value: number } }; +function Foo(t0) { + const $ = _c(7); + const { cond } = t0; + let t1; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t1 = shallowCopy({ kind: "hasA", a: { value: 2 } }); + $[0] = t1; + } else { + t1 = $[0]; + } + let x = t1; + + Math.max(x.a.value, 2); + if (cond) { + let t2; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t2 = shallowCopy({ kind: "hasC", c: { value: 3 } }); + $[1] = t2; + } else { + t2 = $[1]; + } + x = t2; + } + let t2; + if ($[2] !== cond || $[3] !== x) { + t2 = !cond && [(x as HasA).a.value + 2]; + $[2] = cond; + $[3] = x; + $[4] = t2; + } else { + t2 = $[4]; + } + let t3; + if ($[5] !== t2) { + t3 = ; + $[5] = t2; + $[6] = t3; + } else { + t3 = $[6]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ cond: false }], + sequentialRenders: [{ cond: false }, { cond: true }], +}; + +``` + +### Eval output +(kind: ok)
{"val":[4]}
+
{"val":false}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance1.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance1.tsx new file mode 100644 index 0000000000000..147ca85809355 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/hoist-deps-diff-ssa-instance1.tsx @@ -0,0 +1,27 @@ +import {identity, shallowCopy, Stringify, useIdentity} from 'shared-runtime'; + +type HasA = {kind: 'hasA'; a: {value: number}}; +type HasC = {kind: 'hasC'; c: {value: number}}; +function Foo({cond}: {cond: boolean}) { + let x: HasA | HasC = shallowCopy({kind: 'hasA', a: {value: 2}}); + /** + * This read of x.a.value is outside of x's identifier mutable + * range + scope range. We mark this ssa instance (x_@0) as having + * a non-null object property `x.a`. + */ + Math.max(x.a.value, 2); + if (cond) { + x = shallowCopy({kind: 'hasC', c: {value: 3}}); + } + + /** + * Since this x (x_@2 = phi(x_@0, x_@1)) is a different ssa instance, + * we cannot safely hoist a read of `x.a.value` + */ + return ; +} +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{cond: false}], + sequentialRenders: [{cond: false}, {cond: true}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/todo-merge-ssa-phi-access-nodes.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/todo-merge-ssa-phi-access-nodes.expect.md new file mode 100644 index 0000000000000..ccd81b3e14425 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/todo-merge-ssa-phi-access-nodes.expect.md @@ -0,0 +1,114 @@ + +## Input + +```javascript +import { + identity, + makeObject_Primitives, + setPropertyByKey, +} from 'shared-runtime'; + +/** + * A bit of an edge case, but we could further optimize here by merging + * re-orderability of nodes across phis. + */ +function useFoo(cond) { + let x; + if (cond) { + /** start of scope for x_@0 */ + x = {}; + setPropertyByKey(x, 'a', {b: 2}); + /** end of scope for x_@0 */ + Math.max(x.a.b, 0); + } else { + /** start of scope for x_@1 */ + x = makeObject_Primitives(); + setPropertyByKey(x, 'a', {b: 3}); + /** end of scope for x_@1 */ + Math.max(x.a.b, 0); + } + /** + * At this point, we have a phi node. + * x_@2 = phi(x_@0, x_@1) + * + * We can assume that both x_@0 and x_@1 both have non-null `x.a` properties, + * so we can infer that x_@2 does as well. + */ + + // Here, y should take a dependency on `x.a.b` + const y = []; + if (identity(cond)) { + y.push(x.a.b); + } + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [true], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { + identity, + makeObject_Primitives, + setPropertyByKey, +} from "shared-runtime"; + +/** + * A bit of an edge case, but we could further optimize here by merging + * re-orderability of nodes across phis. + */ +function useFoo(cond) { + const $ = _c(5); + let x; + if (cond) { + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + x = {}; + setPropertyByKey(x, "a", { b: 2 }); + $[0] = x; + } else { + x = $[0]; + } + + Math.max(x.a.b, 0); + } else { + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + x = makeObject_Primitives(); + setPropertyByKey(x, "a", { b: 3 }); + $[1] = x; + } else { + x = $[1]; + } + + Math.max(x.a.b, 0); + } + let y; + if ($[2] !== cond || $[3] !== x) { + y = []; + if (identity(cond)) { + y.push(x.a.b); + } + $[2] = cond; + $[3] = x; + $[4] = y; + } else { + y = $[4]; + } + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [true], +}; + +``` + +### Eval output +(kind: ok) [2] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/todo-merge-ssa-phi-access-nodes.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/todo-merge-ssa-phi-access-nodes.ts new file mode 100644 index 0000000000000..749b6c0a831c0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/todo-merge-ssa-phi-access-nodes.ts @@ -0,0 +1,45 @@ +import { + identity, + makeObject_Primitives, + setPropertyByKey, +} from 'shared-runtime'; + +/** + * A bit of an edge case, but we could further optimize here by merging + * re-orderability of nodes across phis. + */ +function useFoo(cond) { + let x; + if (cond) { + /** start of scope for x_@0 */ + x = {}; + setPropertyByKey(x, 'a', {b: 2}); + /** end of scope for x_@0 */ + Math.max(x.a.b, 0); + } else { + /** start of scope for x_@1 */ + x = makeObject_Primitives(); + setPropertyByKey(x, 'a', {b: 3}); + /** end of scope for x_@1 */ + Math.max(x.a.b, 0); + } + /** + * At this point, we have a phi node. + * x_@2 = phi(x_@0, x_@1) + * + * We can assume that both x_@0 and x_@1 both have non-null `x.a` properties, + * so we can infer that x_@2 does as well. + */ + + // Here, y should take a dependency on `x.a.b` + const y = []; + if (identity(cond)) { + y.push(x.a.b); + } + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [true], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/uncond-access-in-mutable-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/uncond-access-in-mutable-range.expect.md new file mode 100644 index 0000000000000..34979e9de9485 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/uncond-access-in-mutable-range.expect.md @@ -0,0 +1,105 @@ + +## Input + +```javascript +// x.a.b was accessed unconditionally within the mutable range of x. +// As a result, we cannot infer anything about whether `x` or `x.a` +// may be null. This means that it's not safe to hoist reads from x +// (e.g. take `x.a` or `x.a.b` as a dependency). + +import {identity, makeObject_Primitives, setProperty} from 'shared-runtime'; + +function Component({cond, other}) { + const x = makeObject_Primitives(); + setProperty(x, {b: 3, other}, 'a'); + identity(x.a.b); + if (!cond) { + x.a = null; + } + + const y = [identity(cond) && x.a.b]; + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{cond: false}], + sequentialRenders: [ + {cond: false}, + {cond: false}, + {cond: false, other: 8}, + {cond: true}, + {cond: true}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // x.a.b was accessed unconditionally within the mutable range of x. +// As a result, we cannot infer anything about whether `x` or `x.a` +// may be null. This means that it's not safe to hoist reads from x +// (e.g. take `x.a` or `x.a.b` as a dependency). + +import { identity, makeObject_Primitives, setProperty } from "shared-runtime"; + +function Component(t0) { + const $ = _c(8); + const { cond, other } = t0; + let x; + if ($[0] !== other || $[1] !== cond) { + x = makeObject_Primitives(); + setProperty(x, { b: 3, other }, "a"); + identity(x.a.b); + if (!cond) { + x.a = null; + } + $[0] = other; + $[1] = cond; + $[2] = x; + } else { + x = $[2]; + } + let t1; + if ($[3] !== cond || $[4] !== x) { + t1 = identity(cond) && x.a.b; + $[3] = cond; + $[4] = x; + $[5] = t1; + } else { + t1 = $[5]; + } + let t2; + if ($[6] !== t1) { + t2 = [t1]; + $[6] = t1; + $[7] = t2; + } else { + t2 = $[7]; + } + const y = t2; + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ cond: false }], + sequentialRenders: [ + { cond: false }, + { cond: false }, + { cond: false, other: 8 }, + { cond: true }, + { cond: true }, + ], +}; + +``` + +### Eval output +(kind: ok) [false] +[false] +[false] +[null] +[null] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/uncond-access-in-mutable-range.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/uncond-access-in-mutable-range.js new file mode 100644 index 0000000000000..c4e4819f6b8fe --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reduce-reactive-deps/uncond-access-in-mutable-range.js @@ -0,0 +1,30 @@ +// x.a.b was accessed unconditionally within the mutable range of x. +// As a result, we cannot infer anything about whether `x` or `x.a` +// may be null. This means that it's not safe to hoist reads from x +// (e.g. take `x.a` or `x.a.b` as a dependency). + +import {identity, makeObject_Primitives, setProperty} from 'shared-runtime'; + +function Component({cond, other}) { + const x = makeObject_Primitives(); + setProperty(x, {b: 3, other}, 'a'); + identity(x.a.b); + if (!cond) { + x.a = null; + } + + const y = [identity(cond) && x.a.b]; + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{cond: false}], + sequentialRenders: [ + {cond: false}, + {cond: false}, + {cond: false, other: 8}, + {cond: true}, + {cond: true}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-cascading-eliminated-phis.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-cascading-eliminated-phis.expect.md index cd123dbdba56e..6af0cf0af7bca 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-cascading-eliminated-phis.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-cascading-eliminated-phis.expect.md @@ -20,8 +20,13 @@ function Component(props) { export const FIXTURE_ENTRYPOINT = { fn: Component, - params: ['TodoAdd'], - isComponent: 'TodoAdd', + params: [{a: 0, b: 1, c: true, d: true}], + sequentialRenders: [ + {a: 0, b: 1, c: true, d: true}, + {a: 4, b: 1, c: true, d: true}, + {a: 4, b: 1, c: false, d: true}, + {a: 4, b: 1, c: false, d: false}, + ], }; ``` @@ -61,9 +66,19 @@ function Component(props) { export const FIXTURE_ENTRYPOINT = { fn: Component, - params: ["TodoAdd"], - isComponent: "TodoAdd", + params: [{ a: 0, b: 1, c: true, d: true }], + sequentialRenders: [ + { a: 0, b: 1, c: true, d: true }, + { a: 4, b: 1, c: true, d: true }, + { a: 4, b: 1, c: false, d: true }, + { a: 4, b: 1, c: false, d: false }, + ], }; ``` - \ No newline at end of file + +### Eval output +(kind: ok) [1,1,2] +[4,1,2] +[4,0,2] +[4,0,0] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-cascading-eliminated-phis.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-cascading-eliminated-phis.js index 53f30fa7aae93..8d5a4e2ee91e1 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-cascading-eliminated-phis.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-cascading-eliminated-phis.js @@ -16,6 +16,11 @@ function Component(props) { export const FIXTURE_ENTRYPOINT = { fn: Component, - params: ['TodoAdd'], - isComponent: 'TodoAdd', + params: [{a: 0, b: 1, c: true, d: true}], + sequentialRenders: [ + {a: 0, b: 1, c: true, d: true}, + {a: 4, b: 1, c: true, d: true}, + {a: 4, b: 1, c: false, d: true}, + {a: 4, b: 1, c: false, d: false}, + ], }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-leave-case.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-leave-case.expect.md index a7389041bb12d..a10ad5fae4108 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-leave-case.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-leave-case.expect.md @@ -2,6 +2,8 @@ ## Input ```javascript +import {Stringify} from 'shared-runtime'; + function Component(props) { let x = []; let y; @@ -10,19 +12,32 @@ function Component(props) { y = x; } return ( - + {x} {y} - + ); } +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{p0: false, p1: 2}], + sequentialRenders: [ + {p0: false, p1: 2}, + {p0: false, p1: 2}, + {p0: true, p1: 2}, + {p0: true, p1: 3}, + ], +}; + ``` ## Code ```javascript import { c as _c } from "react/compiler-runtime"; +import { Stringify } from "shared-runtime"; + function Component(props) { const $ = _c(2); let t0; @@ -35,10 +50,10 @@ function Component(props) { } t0 = ( - + {x} {y} - +
); $[0] = props; $[1] = t0; @@ -48,5 +63,21 @@ function Component(props) { return t0; } +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ p0: false, p1: 2 }], + sequentialRenders: [ + { p0: false, p1: 2 }, + { p0: false, p1: 2 }, + { p0: true, p1: 2 }, + { p0: true, p1: 3 }, + ], +}; + ``` - \ No newline at end of file + +### Eval output +(kind: ok)
{"children":[[],null]}
+
{"children":[[],null]}
+
{"children":[[2],"[[ cyclic ref *2 ]]"]}
+
{"children":[[3],"[[ cyclic ref *2 ]]"]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-leave-case.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-leave-case.js index fce3a15209d6e..54d489225797d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-leave-case.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-leave-case.js @@ -1,3 +1,5 @@ +import {Stringify} from 'shared-runtime'; + function Component(props) { let x = []; let y; @@ -6,9 +8,20 @@ function Component(props) { y = x; } return ( - + {x} {y} - +
); } + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{p0: false, p1: 2}], + sequentialRenders: [ + {p0: false, p1: 2}, + {p0: false, p1: 2}, + {p0: true, p1: 2}, + {p0: true, p1: 3}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction-with-mutation.expect.md index 9ce43a45b4c22..3e7fd4bf5f859 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction-with-mutation.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction-with-mutation.expect.md @@ -2,28 +2,42 @@ ## Input ```javascript -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let x = []; x.push(props.bar); props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; - mut(x); + mutate(x); return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; + ``` ## Code ```javascript import { c as _c } from "react/compiler-runtime"; -function foo(props) { +import { mutate } from "shared-runtime"; + +function useFoo(props) { const $ = _c(2); let x; if ($[0] !== props) { x = []; x.push(props.bar); props.cond ? (([x] = [[]]), x.push(props.foo)) : null; - mut(x); + mutate(x); $[0] = props; $[1] = x; } else { @@ -32,5 +46,19 @@ function foo(props) { return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], +}; + ``` - \ No newline at end of file + +### Eval output +(kind: ok) [55,"joe"] +[55,"joe"] +[3,"joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction-with-mutation.js index 4d4f4381ca6f0..4f7e4163e3962 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction-with-mutation.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction-with-mutation.js @@ -1,7 +1,19 @@ -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let x = []; x.push(props.bar); props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; - mut(x); + mutate(x); return x; } + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction.expect.md index e20dc2606fa37..9b3aad524ce14 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -function foo(props) { +function useFoo(props) { let x = []; x.push(props.bar); props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; @@ -10,9 +10,13 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, - params: ['TodoAdd'], - isComponent: 'TodoAdd', + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], }; ``` @@ -21,7 +25,7 @@ export const FIXTURE_ENTRYPOINT = { ```javascript import { c as _c } from "react/compiler-runtime"; -function foo(props) { +function useFoo(props) { const $ = _c(4); let x; if ($[0] !== props.bar) { @@ -43,10 +47,18 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, - params: ["TodoAdd"], - isComponent: "TodoAdd", + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], }; ``` - \ No newline at end of file + +### Eval output +(kind: ok) [55] +[55] +[3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction.js index ce21e08292d70..3d2f7f86e48dc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-destruction.js @@ -1,4 +1,4 @@ -function foo(props) { +function useFoo(props) { let x = []; x.push(props.bar); props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; @@ -6,7 +6,11 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, - params: ['TodoAdd'], - isComponent: 'TodoAdd', + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-with-mutation.expect.md index 3ec90ca8879bc..de9466c4daa9d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-with-mutation.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-with-mutation.expect.md @@ -2,28 +2,42 @@ ## Input ```javascript -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let x = []; x.push(props.bar); props.cond ? ((x = {}), (x = []), x.push(props.foo)) : null; - mut(x); + mutate(x); return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; + ``` ## Code ```javascript import { c as _c } from "react/compiler-runtime"; -function foo(props) { +import { mutate } from "shared-runtime"; + +function useFoo(props) { const $ = _c(2); let x; if ($[0] !== props) { x = []; x.push(props.bar); props.cond ? ((x = []), x.push(props.foo)) : null; - mut(x); + mutate(x); $[0] = props; $[1] = x; } else { @@ -32,5 +46,19 @@ function foo(props) { return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], +}; + ``` - \ No newline at end of file + +### Eval output +(kind: ok) [55,"joe"] +[55,"joe"] +[3,"joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-with-mutation.js index 5dde2fea8c0df..7016a5bf07ebd 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-with-mutation.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary-with-mutation.js @@ -1,7 +1,19 @@ -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let x = []; x.push(props.bar); props.cond ? ((x = {}), (x = []), x.push(props.foo)) : null; - mut(x); + mutate(x); return x; } + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary.expect.md index 1b9453341153b..e199863257bfe 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -function foo(props) { +function useFoo(props) { let x = []; x.push(props.bar); props.cond ? ((x = {}), (x = []), x.push(props.foo)) : null; @@ -10,9 +10,13 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, - params: ['TodoAdd'], - isComponent: 'TodoAdd', + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], }; ``` @@ -21,7 +25,7 @@ export const FIXTURE_ENTRYPOINT = { ```javascript import { c as _c } from "react/compiler-runtime"; -function foo(props) { +function useFoo(props) { const $ = _c(4); let x; if ($[0] !== props.bar) { @@ -43,10 +47,18 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, - params: ["TodoAdd"], - isComponent: "TodoAdd", + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], }; ``` - \ No newline at end of file + +### Eval output +(kind: ok) [55] +[55] +[3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary.js index 407efdbcc0e3d..6057fcbb35c6f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-ternary.js @@ -1,4 +1,4 @@ -function foo(props) { +function useFoo(props) { let x = []; x.push(props.bar); props.cond ? ((x = {}), (x = []), x.push(props.foo)) : null; @@ -6,7 +6,11 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, - params: ['TodoAdd'], - isComponent: 'TodoAdd', + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary-with-mutation.expect.md index bd77cc9c173a0..16981f69cda90 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary-with-mutation.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary-with-mutation.expect.md @@ -3,7 +3,7 @@ ```javascript import {arrayPush} from 'shared-runtime'; -function foo(props) { +function useFoo(props) { let x = []; x.push(props.bar); props.cond @@ -14,7 +14,7 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, + fn: useFoo, params: [{cond: false, foo: 2, bar: 55}], sequentialRenders: [ {cond: false, foo: 2, bar: 55}, @@ -30,7 +30,7 @@ export const FIXTURE_ENTRYPOINT = { ```javascript import { c as _c } from "react/compiler-runtime"; import { arrayPush } from "shared-runtime"; -function foo(props) { +function useFoo(props) { const $ = _c(2); let x; if ($[0] !== props) { @@ -47,7 +47,7 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, + fn: useFoo, params: [{ cond: false, foo: 2, bar: 55 }], sequentialRenders: [ { cond: false, foo: 2, bar: 55 }, diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary-with-mutation.js index d37e4736a5189..c2829b33e6e26 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary-with-mutation.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary-with-mutation.js @@ -1,5 +1,5 @@ import {arrayPush} from 'shared-runtime'; -function foo(props) { +function useFoo(props) { let x = []; x.push(props.bar); props.cond @@ -10,7 +10,7 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, + fn: useFoo, params: [{cond: false, foo: 2, bar: 55}], sequentialRenders: [ {cond: false, foo: 2, bar: 55}, diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary.expect.md index b518df5ab8e8f..99b50ac231342 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -function foo(props) { +function useFoo(props) { let x = []; x.push(props.bar); props.cond @@ -12,9 +12,13 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, - params: ['TodoAdd'], - isComponent: 'TodoAdd', + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], }; ``` @@ -23,7 +27,7 @@ export const FIXTURE_ENTRYPOINT = { ```javascript import { c as _c } from "react/compiler-runtime"; -function foo(props) { +function useFoo(props) { const $ = _c(4); let x; if ($[0] !== props.bar) { @@ -45,10 +49,18 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, - params: ["TodoAdd"], - isComponent: "TodoAdd", + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], }; ``` - \ No newline at end of file + +### Eval output +(kind: ok) [55] +[55] +[3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary.js index 3cf63f389d3be..7e34aa5683f5c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-ternary.js @@ -1,4 +1,4 @@ -function foo(props) { +function useFoo(props) { let x = []; x.push(props.bar); props.cond @@ -8,7 +8,11 @@ function foo(props) { } export const FIXTURE_ENTRYPOINT = { - fn: foo, - params: ['TodoAdd'], - isComponent: 'TodoAdd', + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-with-mutation.expect.md index e27e00db70f05..f4689e5795552 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-with-mutation.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-with-mutation.expect.md @@ -2,7 +2,9 @@ ## Input ```javascript -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let x = []; x.push(props.bar); if (props.cond) { @@ -14,17 +16,29 @@ function foo(props) { x = []; x.push(props.bar); } - mut(x); + mutate(x); return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; + ``` ## Code ```javascript import { c as _c } from "react/compiler-runtime"; -function foo(props) { +import { mutate } from "shared-runtime"; + +function useFoo(props) { const $ = _c(2); let x; if ($[0] !== props) { @@ -38,7 +52,7 @@ function foo(props) { x.push(props.bar); } - mut(x); + mutate(x); $[0] = props; $[1] = x; } else { @@ -47,5 +61,19 @@ function foo(props) { return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ bar: "bar", foo: "foo", cond: true }], + sequentialRenders: [ + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: false }, + ], +}; + ``` - \ No newline at end of file + +### Eval output +(kind: ok) ["foo","joe"] +["foo","joe"] +["bar","joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-with-mutation.js index f5d09dbf30d10..3e7078cfc7999 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-with-mutation.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-unconditional-with-mutation.js @@ -1,4 +1,6 @@ -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let x = []; x.push(props.bar); if (props.cond) { @@ -10,6 +12,16 @@ function foo(props) { x = []; x.push(props.bar); } - mut(x); + mutate(x); return x; } + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-via-destructuring-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-via-destructuring-with-mutation.expect.md index 7ad946b2f494f..ed1056c47c0f8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-via-destructuring-with-mutation.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-via-destructuring-with-mutation.expect.md @@ -2,7 +2,9 @@ ## Input ```javascript -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let {x} = {x: []}; x.push(props.bar); if (props.cond) { @@ -10,17 +12,29 @@ function foo(props) { ({x} = {x: []}); x.push(props.foo); } - mut(x); + mutate(x); return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; + ``` ## Code ```javascript import { c as _c } from "react/compiler-runtime"; -function foo(props) { +import { mutate } from "shared-runtime"; + +function useFoo(props) { const $ = _c(2); let x; if ($[0] !== props) { @@ -31,7 +45,7 @@ function foo(props) { x.push(props.foo); } - mut(x); + mutate(x); $[0] = props; $[1] = x; } else { @@ -40,5 +54,19 @@ function foo(props) { return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ bar: "bar", foo: "foo", cond: true }], + sequentialRenders: [ + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: false }, + ], +}; + ``` - \ No newline at end of file + +### Eval output +(kind: ok) ["foo","joe"] +["foo","joe"] +["bar","joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-via-destructuring-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-via-destructuring-with-mutation.js index 303a9f92743c8..a72e15eebaf18 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-via-destructuring-with-mutation.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-via-destructuring-with-mutation.js @@ -1,4 +1,6 @@ -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let {x} = {x: []}; x.push(props.bar); if (props.cond) { @@ -6,6 +8,16 @@ function foo(props) { ({x} = {x: []}); x.push(props.foo); } - mut(x); + mutate(x); return x; } + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-with-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-with-mutation.expect.md index 76ccbae06a937..26cd73a82b53f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-with-mutation.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-with-mutation.expect.md @@ -2,7 +2,9 @@ ## Input ```javascript -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let x = []; x.push(props.bar); if (props.cond) { @@ -10,17 +12,29 @@ function foo(props) { x = []; x.push(props.foo); } - mut(x); + mutate(x); return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; + ``` ## Code ```javascript import { c as _c } from "react/compiler-runtime"; -function foo(props) { +import { mutate } from "shared-runtime"; + +function useFoo(props) { const $ = _c(2); let x; if ($[0] !== props) { @@ -31,7 +45,7 @@ function foo(props) { x.push(props.foo); } - mut(x); + mutate(x); $[0] = props; $[1] = x; } else { @@ -40,5 +54,19 @@ function foo(props) { return x; } +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ bar: "bar", foo: "foo", cond: true }], + sequentialRenders: [ + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: true }, + { bar: "bar", foo: "foo", cond: false }, + ], +}; + ``` - \ No newline at end of file + +### Eval output +(kind: ok) ["foo","joe"] +["foo","joe"] +["bar","joe"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-with-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-with-mutation.js index 30f71933538ef..a4d22684f2440 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-with-mutation.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ssa-renaming-with-mutation.js @@ -1,4 +1,6 @@ -function foo(props) { +import {mutate} from 'shared-runtime'; + +function useFoo(props) { let x = []; x.push(props.bar); if (props.cond) { @@ -6,6 +8,16 @@ function foo(props) { x = []; x.push(props.foo); } - mut(x); + mutate(x); return x; } + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{bar: 'bar', foo: 'foo', cond: true}], + sequentialRenders: [ + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: true}, + {bar: 'bar', foo: 'foo', cond: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-load-cached.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-load-cached.expect.md new file mode 100644 index 0000000000000..a2a0e3bef9b76 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-load-cached.expect.md @@ -0,0 +1,73 @@ + +## Input + +```javascript +import {Stringify} from 'shared-runtime'; +import {makeArray} from 'shared-runtime'; + +/** + * Here, we don't need to memoize Stringify as it is a read off of a global. + * TODO: in PropagateScopeDeps (hir), we should produce a sidemap of global rvals + * and avoid adding them to `temporariesUsedOutsideDefiningScope`. + */ +function Component({num}: {num: number}) { + const arr = makeArray(num); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{num: 2}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { Stringify } from "shared-runtime"; +import { makeArray } from "shared-runtime"; + +/** + * Here, we don't need to memoize Stringify as it is a read off of a global. + * TODO: in PropagateScopeDeps (hir), we should produce a sidemap of global rvals + * and avoid adding them to `temporariesUsedOutsideDefiningScope`. + */ +function Component(t0) { + const $ = _c(6); + const { num } = t0; + let T0; + let t1; + if ($[0] !== num) { + const arr = makeArray(num); + T0 = Stringify; + t1 = arr.push(num); + $[0] = num; + $[1] = T0; + $[2] = t1; + } else { + T0 = $[1]; + t1 = $[2]; + } + let t2; + if ($[3] !== T0 || $[4] !== t1) { + t2 = ; + $[3] = T0; + $[4] = t1; + $[5] = t2; + } else { + t2 = $[5]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ num: 2 }], +}; + +``` + +### Eval output +(kind: ok)
{"value":2}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-load-cached.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-load-cached.tsx new file mode 100644 index 0000000000000..ff000fd86699d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-load-cached.tsx @@ -0,0 +1,17 @@ +import {Stringify} from 'shared-runtime'; +import {makeArray} from 'shared-runtime'; + +/** + * Here, we don't need to memoize Stringify as it is a read off of a global. + * TODO: in PropagateScopeDeps (hir), we should produce a sidemap of global rvals + * and avoid adding them to `temporariesUsedOutsideDefiningScope`. + */ +function Component({num}: {num: number}) { + const arr = makeArray(num); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{num: 2}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-property-load-cached.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-property-load-cached.expect.md new file mode 100644 index 0000000000000..cf2ad80e7ac51 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-property-load-cached.expect.md @@ -0,0 +1,78 @@ + +## Input + +```javascript +import * as SharedRuntime from 'shared-runtime'; +import {makeArray} from 'shared-runtime'; + +/** + * Here, we don't need to memoize SharedRuntime.Stringify as it is a PropertyLoad + * off of a global. + * TODO: in PropagateScopeDeps (hir), we should produce a sidemap of global rvals + * and avoid adding them to `temporariesUsedOutsideDefiningScope`. + */ +function Component({num}: {num: number}) { + const arr = makeArray(num); + return ( + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{num: 2}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import * as SharedRuntime from "shared-runtime"; +import { makeArray } from "shared-runtime"; + +/** + * Here, we don't need to memoize SharedRuntime.Stringify as it is a PropertyLoad + * off of a global. + * TODO: in PropagateScopeDeps (hir), we should produce a sidemap of global rvals + * and avoid adding them to `temporariesUsedOutsideDefiningScope`. + */ +function Component(t0) { + const $ = _c(6); + const { num } = t0; + let T0; + let t1; + if ($[0] !== num) { + const arr = makeArray(num); + + T0 = SharedRuntime.Stringify; + t1 = arr.push(num); + $[0] = num; + $[1] = T0; + $[2] = t1; + } else { + T0 = $[1]; + t1 = $[2]; + } + let t2; + if ($[3] !== T0 || $[4] !== t1) { + t2 = ; + $[3] = T0; + $[4] = t1; + $[5] = t2; + } else { + t2 = $[5]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ num: 2 }], +}; + +``` + +### Eval output +(kind: ok)
{"value":2}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-property-load-cached.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-property-load-cached.tsx new file mode 100644 index 0000000000000..be9f3e7ab9ce1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo-global-property-load-cached.tsx @@ -0,0 +1,20 @@ +import * as SharedRuntime from 'shared-runtime'; +import {makeArray} from 'shared-runtime'; + +/** + * Here, we don't need to memoize SharedRuntime.Stringify as it is a PropertyLoad + * off of a global. + * TODO: in PropagateScopeDeps (hir), we should produce a sidemap of global rvals + * and avoid adding them to `temporariesUsedOutsideDefiningScope`. + */ +function Component({num}: {num: number}) { + const arr = makeArray(num); + return ( + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{num: 2}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unmemoized-nonreactive-dependency-is-pruned-as-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unmemoized-nonreactive-dependency-is-pruned-as-dependency.expect.md index 05b86335272e5..72b0eeda8480f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unmemoized-nonreactive-dependency-is-pruned-as-dependency.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/unmemoized-nonreactive-dependency-is-pruned-as-dependency.expect.md @@ -42,4 +42,4 @@ export const FIXTURE_ENTRYPOINT = { ``` ### Eval output -(kind: ok)
\ No newline at end of file +(kind: ok)
joe
\ No newline at end of file diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index 2d875430263c2..7d7476dc74739 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -134,7 +134,6 @@ const skipFilter = new Set([ 'same-variable-as-dep-and-redeclare', 'simple-scope', 'ssa-arrayexpression', - 'ssa-cascading-eliminated-phis', 'ssa-for-of', 'ssa-multiple-phis', 'ssa-nested-loops-no-reassign', @@ -144,9 +143,6 @@ const skipFilter = new Set([ 'ssa-objectexpression', 'ssa-property-alias-if', 'ssa-reassign', - 'ssa-renaming-ternary-destruction', - 'ssa-renaming-ternary', - 'ssa-renaming-unconditional-ternary', 'ssa-renaming-via-destructuring', 'ssa-renaming', 'ssa-sibling-phis', @@ -325,15 +321,9 @@ const skipFilter = new Set([ 'repro-scope-missing-mutable-range', 'repro', 'simple', - 'ssa-leave-case', 'ssa-property-alias-alias-mutate-if', 'ssa-property-alias-mutate-if', 'ssa-property-alias-mutate-inside-if', - 'ssa-renaming-ternary-destruction-with-mutation', - 'ssa-renaming-ternary-with-mutation', - 'ssa-renaming-unconditional-with-mutation', - 'ssa-renaming-via-destructuring-with-mutation', - 'ssa-renaming-with-mutation', 'switch-global-propertyload-case-test', 'switch-non-final-default', 'switch', 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 10aa87c32b39e..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,60 +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, - }, - 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, + 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, + }, }, - typedLog: { - kind: 'function', - calleeEffect: EffectEnum.Read, - positionalParams: [], - restParam: EffectEnum.Read, - returnType: {kind: 'type', name: 'Primitive'}, - returnValueKind: ValueKindEnum.Primitive, + }; + } 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'}, + }, }, - useFreeze: { - kind: 'hook', - returnType: {kind: 'type', name: 'Any'}, + }; + } 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', + }, }, - useFragment: { - kind: 'hook', - returnType: {kind: 'type', name: 'MixedReadonly'}, - noAlias: true, - }, - useNoAlias: { - kind: 'hook', - returnType: {kind: 'type', name: 'Any'}, - returnValueKind: ValueKindEnum.Mutable, - noAlias: true, - }, - }, - }; + }; + } + return null; }; } diff --git a/compiler/packages/snap/src/sprout/shared-runtime.ts b/compiler/packages/snap/src/sprout/shared-runtime.ts index bb1c65a6574ac..0f3e09b12e127 100644 --- a/compiler/packages/snap/src/sprout/shared-runtime.ts +++ b/compiler/packages/snap/src/sprout/shared-runtime.ts @@ -54,6 +54,8 @@ export function mutate(arg: any): void { // don't mutate primitive if (arg == null || typeof arg !== 'object') { return; + } else if (Array.isArray(arg)) { + arg.push('joe'); } let count: number = 0; @@ -96,6 +98,15 @@ export function setProperty(arg: any, property: any): void { } } +export function setPropertyByKey< + T, + TKey extends keyof T, + TProperty extends T[TKey], +>(arg: T, key: TKey, property: TProperty): T { + arg[key] = property; + return arg; +} + export function arrayPush(arr: Array, ...values: Array): void { arr.push(...values); } @@ -123,7 +134,7 @@ export function calculateExpensiveNumber(x: number): number { /** * Functions that do not mutate their parameters */ -export function shallowCopy(obj: object): object { +export function shallowCopy(obj: T): T { return Object.assign({}, obj); } 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 7589463c49e7b..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; @@ -148,15 +160,7 @@ describe('ReactCache', () => { error = e; } expect(error.message).toMatch('Failed to load: Hi'); - assertLog([ - 'Promise rejected [Hi]', - 'Error! [Hi]', - 'Error! [Hi]', - - ...(gate('enableSiblingPrerendering') - ? ['Error! [Hi]', 'Error! [Hi]'] - : []), - ]); + assertLog(['Promise rejected [Hi]', 'Error! [Hi]', 'Error! [Hi]']); // Should throw again on a subsequent read root.render(); @@ -187,15 +191,27 @@ 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'] : []), + ]); } }); @@ -212,13 +228,17 @@ describe('ReactCache', () => { , ); - await waitForAll(['Suspend! [1]', 'Loading...']); + await waitForPaint(['Suspend! [1]', 'Loading...']); jest.advanceTimersByTime(100); assertLog(['Promise resolved [1]']); - await waitForAll([1, 'Suspend! [2]', 1, 'Suspend! [2]', 'Suspend! [3]']); + await waitForAll([1, 'Suspend! [2]']); jest.advanceTimersByTime(100); - assertLog(['Promise resolved [2]', 'Promise resolved [3]']); + assertLog(['Promise resolved [2]']); + await waitForAll([1, 2, 'Suspend! [3]']); + + jest.advanceTimersByTime(100); + assertLog(['Promise resolved [3]']); await waitForAll([1, 2, 3]); await act(() => jest.advanceTimersByTime(100)); @@ -233,23 +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]', + 'Suspend! [4]', + 'Loading...', 1, - 4, + 'Suspend! [4]', 'Suspend! [5]', - 'Promise resolved [5]', - 1, - 4, - 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 @@ -269,22 +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! [3]', 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'); }); @@ -359,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..597536c961333 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,18 +78,27 @@ 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'; +import type {SharedStateServer} from 'react/src/ReactSharedInternalsServer'; +import type {SharedStateClient} from 'react/src/ReactSharedInternalsClient'; + // TODO: This is an unfortunate hack. We shouldn't feature detect the internals // like this. It's just that for now we support the same build of the Flight // client both in the RSC environment, in the SSR environments as well as the // browser client. We should probably have a separate RSC build. This is DEV // only though. -const ReactSharedInternals = +const ReactSharedInteralsServer: void | SharedStateServer = (React: any) + .__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; +const ReactSharedInternals: SharedStateServer | SharedStateClient = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE || - React.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; + ReactSharedInteralsServer; export type {CallServerCallback, EncodeFormActionCallback}; @@ -271,6 +282,8 @@ export type Response = { _rowLength: number, // remaining bytes in the row. 0 indicates that we're looking for a newline. _buffer: Array, // chunks received so far as part of this row _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from + _debugRootOwner?: null | ReactComponentInfo, // DEV-only + _debugRootStack?: null | Error, // DEV-only _debugRootTask?: null | ConsoleTask, // DEV-only _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only _replayConsole: boolean, // DEV-only @@ -666,7 +679,7 @@ function createElement( type, key, props, - _owner: owner, + _owner: __DEV__ && owner === null ? response._debugRootOwner : owner, }: any); Object.defineProperty(element, 'ref', { enumerable: false, @@ -693,7 +706,7 @@ function createElement( props, // Record the component responsible for creating this element. - _owner: owner, + _owner: __DEV__ && owner === null ? response._debugRootOwner : owner, }: any); } @@ -727,7 +740,11 @@ function createElement( env = owner.env; } let normalizedStackTrace: null | Error = null; - if (stack !== null) { + if (owner === null && response._debugRootStack != null) { + // We override the stack if we override the owner since the stack where the root JSX + // was created on the server isn't very useful but where the request was made is. + normalizedStackTrace = response._debugRootStack; + } else if (stack !== null) { // We create a fake stack and then create an Error object inside of it. // This means that the stack trace is now normalized into the native format // of the browser and the stack frames will have been registered with @@ -815,8 +832,10 @@ function createElement( if (enableOwnerStacks) { // $FlowFixMe[cannot-write] erroredComponent.debugStack = element._debugStack; - // $FlowFixMe[cannot-write] - erroredComponent.debugTask = element._debugTask; + if (supportsCreateTask) { + // $FlowFixMe[cannot-write] + erroredComponent.debugTask = element._debugTask; + } } erroredChunk._debugInfo = [erroredComponent]; } @@ -992,8 +1011,10 @@ function waitForReference( if (enableOwnerStacks) { // $FlowFixMe[cannot-write] erroredComponent.debugStack = element._debugStack; - // $FlowFixMe[cannot-write] - erroredComponent.debugTask = element._debugTask; + if (supportsCreateTask) { + // $FlowFixMe[cannot-write] + erroredComponent.debugTask = element._debugTask; + } } const chunkDebugInfo: ReactDebugInfo = chunk._debugInfo || (chunk._debugInfo = []); @@ -1051,8 +1072,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 +1083,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) { @@ -1402,6 +1423,25 @@ function ResponseInstance( this._buffer = []; this._tempRefs = temporaryReferences; if (__DEV__) { + // TODO: The Flight Client can be used in a Client Environment too and we should really support + // getting the owner there as well, but currently the owner of ReactComponentInfo is typed as only + // supporting other ReactComponentInfo as owners (and not Fiber or Fizz's ComponentStackNode). + // We need to update all the callsites consuming ReactComponentInfo owners to support those. + // In the meantime we only check ReactSharedInteralsServer since we know that in an RSC environment + // the only owners will be ReactComponentInfo. + const rootOwner: null | ReactComponentInfo = + ReactSharedInteralsServer === undefined || + ReactSharedInteralsServer.A === null + ? null + : (ReactSharedInteralsServer.A.getOwner(): any); + + this._debugRootOwner = rootOwner; + this._debugRootStack = + rootOwner !== null + ? // TODO: Consider passing the top frame in so we can avoid internals showing up. + new Error('react-stack-top-frame') + : null; + const rootEnv = environmentName === undefined ? 'Server' : environmentName; if (supportsCreateTask) { // Any stacks that appear on the server need to be rooted somehow on the client @@ -2302,7 +2342,16 @@ function resolveDebugInfo( const env = debugInfo.env === undefined ? response._rootEnvironmentName : debugInfo.env; initializeFakeTask(response, debugInfo, env); - initializeFakeStack(response, debugInfo); + if (debugInfo.owner === null && response._debugRootOwner != null) { + // $FlowFixMe + debugInfo.owner = response._debugRootOwner; + // We override the stack if we override the owner since the stack where the root JSX + // was created on the server isn't very useful but where the request was made is. + // $FlowFixMe + debugInfo.debugStack = response._debugRootStack; + } else { + initializeFakeStack(response, debugInfo); + } const chunk = getChunk(response, id); const chunkDebugInfo: ReactDebugInfo = @@ -2326,6 +2375,63 @@ 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 === null ? (response._debugRootOwner: any) : 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 +2461,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 +3005,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/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 0c3b8798dc8c3..34986dc623de8 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -24,6 +24,10 @@ function normalizeCodeLocInfo(str) { return ( str && str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { + const dot = name.lastIndexOf('.'); + if (dot !== -1) { + name = name.slice(dot + 1); + } return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); }) ); @@ -3124,6 +3128,69 @@ describe('ReactFlight', () => { ); }); + // @gate __DEV__ && enableOwnerStacks + it('can track owner for a flight response created in another render', async () => { + jest.resetModules(); + jest.mock('react', () => ReactServer); + // For this to work the Flight Client needs to be the react-server version. + const ReactNoopFlightClienOnTheServer = require('react-noop-renderer/flight-client'); + jest.resetModules(); + jest.mock('react', () => React); + + let stack; + + function Component() { + stack = ReactServer.captureOwnerStack(); + return ReactServer.createElement('span', null, 'hi'); + } + + const ClientComponent = clientReference(Component); + + function ThirdPartyComponent() { + return ReactServer.createElement(ClientComponent); + } + + // This is rendered outside the render to ensure we don't inherit anything accidental + // by being in the same environment which would make it seem like it works when it doesn't. + const thirdPartyTransport = ReactNoopFlightServer.render( + {children: ReactServer.createElement(ThirdPartyComponent)}, + { + environmentName: 'third-party', + }, + ); + + async function fetchThirdParty() { + return ReactNoopFlightClienOnTheServer.read(thirdPartyTransport); + } + + async function FirstPartyComponent() { + // This component fetches from a third party + const thirdParty = await fetchThirdParty(); + return thirdParty.children; + } + function App() { + return ReactServer.createElement(FirstPartyComponent); + } + + const transport = ReactNoopFlightServer.render( + ReactServer.createElement(App), + ); + + await act(async () => { + const root = await ReactNoopFlightClient.read(transport); + ReactNoop.render(root); + }); + + expect(normalizeCodeLocInfo(stack)).toBe( + '\n in ThirdPartyComponent (at **)' + + '\n in createResponse (at **)' + // These two internal frames should + '\n in read (at **)' + // ideally not be included. + '\n in fetchThirdParty (at **)' + + '\n in FirstPartyComponent (at **)' + + '\n in App (at **)', + ); + }); + // @gate __DEV__ && enableOwnerStacks it('can get the component owner stacks for onError in dev', async () => { const thrownError = new Error('hi'); 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 c54e591321dee..55a1454142235 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -1033,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-extensions/src/background/dynamicallyInjectContentScripts.js b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js index b1888b4e7cc59..9398d71a54e7c 100644 --- a/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js +++ b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js @@ -25,14 +25,6 @@ const contentScriptsToInject = [ 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, - }, ]; async function dynamicallyInjectContentScripts() { 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/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/src/__tests__/TimelineProfiler-test.js b/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js index 6861e1aac3309..b4cc9bbc0bafc 100644 --- a/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js +++ b/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js @@ -15,11 +15,32 @@ import { normalizeCodeLocInfo, } from './utils'; +import {ReactVersion} from '../../../../ReactVersions'; +import semver from 'semver'; + +let React = require('react'); +let Scheduler; +let store; +let utils; + +// 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'); + +// This flag is on experimental which disables timeline profiler. +const enableComponentPerformanceTrack = + React.version.startsWith('19') && React.version.includes('experimental'); + describe('Timeline profiler', () => { - let React; - let Scheduler; - let store; - let utils; + if (enableComponentPerformanceTrack) { + test('no tests', () => {}); + // Ignore all tests. + return; + } beforeEach(() => { utils = require('./utils'); @@ -1651,7 +1672,11 @@ describe('Timeline profiler', () => { , ); - await waitForAll(['suspended']); + await waitForAll([ + 'suspended', + + ...(enableSiblingPrerendering ? ['suspended'] : []), + ]); Scheduler.unstable_advanceTime(10); resolveFn(); @@ -1662,9 +1687,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 +1732,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 +1765,11 @@ describe('Timeline profiler', () => { , ); - await waitForAll(['suspended']); + await waitForAll([ + 'suspended', + + ...(enableSiblingPrerendering ? ['suspended'] : []), + ]); Scheduler.unstable_advanceTime(10); rejectFn(); @@ -1719,9 +1780,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 +1826,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__/preprocessData-test.js b/packages/react-devtools-shared/src/__tests__/preprocessData-test.js index 06595b67ca1a0..7a77ab20eb9cf 100644 --- a/packages/react-devtools-shared/src/__tests__/preprocessData-test.js +++ b/packages/react-devtools-shared/src/__tests__/preprocessData-test.js @@ -16,16 +16,29 @@ import {ReactVersion} from '../../../../ReactVersions'; const ReactVersionTestingAgainst = process.env.REACT_VERSION || ReactVersion; +let React = require('react'); +let ReactDOM; +let ReactDOMClient; +let Scheduler; +let utils; +let assertLog; +let waitFor; + +// This flag is on experimental which disables timeline profiler. +const enableComponentPerformanceTrack = + React.version.startsWith('19') && React.version.includes('experimental'); + describe('Timeline profiler', () => { - let React; - let ReactDOM; - let ReactDOMClient; - let Scheduler; - let utils; - let assertLog; - let waitFor; + if (enableComponentPerformanceTrack) { + test('no tests', () => {}); + // Ignore all tests. + return; + } describe('User Timing API', () => { + if (enableComponentPerformanceTrack) { + return; + } let currentlyNotClearedMarks; let registeredMarks; let featureDetectionMarkName = null; 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/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 92db4c062d5a2..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; @@ -221,6 +222,10 @@ export default class Agent extends EventEmitter<{ ); 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, @@ -230,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; @@ -251,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, ...} { @@ -714,7 +714,7 @@ export default class Agent extends EventEmitter<{ } } - setRendererInterface( + registerRendererInterface( rendererID: RendererID, rendererInterface: RendererInterface, ) { @@ -945,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 05d9055d0b021..61d2e490eb966 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,23 +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, version} = renderer; - - // 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 = { @@ -219,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 @@ -275,11 +227,21 @@ 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' - : 'Component Stack'; // This gets printed + if (__IS_CHROME__ || __IS_EDGE__) { + // Before sending the stack to Chrome DevTools for formatting, + // V8 will reconstruct this according to the template : + // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/inspector/value-mirror.cc;l=252-311;drc=bdc48d1b1312cc40c00282efb1c9c5f41dcdca9a + // It has to start with ^[\w.]*Error\b to trigger stack formatting. + fakeError.name = enableOwnerStacks + ? 'Error Stack' + : 'Error Component Stack'; // This gets printed + } else { + fakeError.name = enableOwnerStacks + ? 'Stack' + : 'Component Stack'; // This gets printed + } // In Chromium, the stack property needs to start with ^[\w.]*Error\b to trigger stack // formatting. Otherwise it is left alone. So we prefix it. Otherwise we just override it // to our own stack. @@ -289,6 +251,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. @@ -324,15 +287,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 4fada3907f0ee..2fd768b5d01c3 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -100,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; @@ -157,8 +167,7 @@ type FiberInstance = { 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 }; @@ -171,8 +180,7 @@ function createFiberInstance(fiber: Fiber): FiberInstance { firstChild: null, nextSibling: null, source: null, - errors: null, - warnings: null, + logCount: 0, treeBaseDuration: 0, data: fiber, }; @@ -187,8 +195,7 @@ type FilteredFiberInstance = { firstChild: null | DevToolsInstance, nextSibling: null | DevToolsInstance, source: null | string | Error | Source, // always null here. - errors: null, // error messages and count - warnings: null, // 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 }; @@ -201,9 +208,8 @@ function createFilteredFiberInstance(fiber: Fiber): FilteredFiberInstance { parent: null, firstChild: null, nextSibling: null, - componentStack: null, - errors: null, - warnings: null, + source: null, + logCount: 0, treeBaseDuration: 0, data: fiber, }: any); @@ -221,11 +227,7 @@ type VirtualInstance = { 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. @@ -242,8 +244,7 @@ function createVirtualInstance( firstChild: null, nextSibling: null, source: null, - errors: null, - warnings: null, + logCount: 0, treeBaseDuration: 0, data: debugEntry, }; @@ -753,13 +754,7 @@ const knownEnvironmentNames: Set = new Set(); // Map of FiberRoot to their root FiberInstance. const rootToFiberInstanceMap: Map = new Map(); -// 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(); - -// Map of id to one (arbitrary) Fiber in a pair. +// 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 = @@ -924,6 +919,7 @@ export function attach( setErrorHandler, setSuspenseHandler, scheduleUpdate, + getCurrentFiber, } = renderer; const supportsTogglingError = typeof setErrorHandler === 'function' && @@ -968,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( @@ -1062,12 +1075,70 @@ export function attach( } } + function getComponentStack( + topFrame: Error, + ): null | {enableOwnerStacks: boolean, componentStack: string} { + if (getCurrentFiber == null) { + // 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 == null) { + // 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') { // if this is an error simulated by us to trigger error boundary, ignore if ( @@ -1086,40 +1157,51 @@ 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__) { - const fiberInstance = fiberToFiberInstanceMap.get(fiber); - if (fiberInstance !== undefined) { - debug('onErrorOrWarning', fiberInstance, 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. @@ -1268,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()); @@ -1293,11 +1375,11 @@ export function attach( 'Expected the root instance to already exist when applying filters', ); } - currentRootID = rootInstance.id; + currentRoot = rootInstance; unmountInstanceRecursively(rootInstance); rootToFiberInstanceMap.delete(root); flushPendingEvents(root); - currentRootID = -1; + currentRoot = (null: any); }); applyComponentFilters(componentFilters); @@ -1308,14 +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. @@ -1323,16 +1400,16 @@ 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 getEnvironmentNames(): Array { @@ -1528,40 +1605,7 @@ 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; - - 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. @@ -1613,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, @@ -1628,7 +1669,7 @@ export function attach( }; } else { const data: ChangeDescription = { - context: getContextChangedKeys(nextFiber), + context: getContextChanged(prevFiber, nextFiber), didHooksChange: false, isFirstMount: false, props: getChangedKeys( @@ -1640,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: @@ -1656,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) { @@ -1833,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; @@ -1857,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 @@ -1962,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. @@ -2150,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, ...] @@ -2246,14 +2074,6 @@ 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; @@ -2362,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; } @@ -2431,6 +2257,10 @@ export function attach( pushOperation(ownerID); pushOperation(displayNameStringID); pushOperation(keyStringID); + + const componentLogsEntry = + componentInfoToComponentLogsMap.get(componentInfo); + recordConsoleLogs(instance, componentLogsEntry); } function recordUnmount(fiberInstance: FiberInstance): void { @@ -2461,29 +2291,6 @@ export function attach( idToDevToolsInstanceMap.delete(fiberInstance.id); - // 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 (fiberToFiberInstanceMap.get(fiber) === fiberInstance) { - fiberToFiberInstanceMap.delete(fiber); - } - const {alternate} = fiber; - if (alternate !== null) { - if (fiberToFiberInstanceMap.get(alternate) === fiberInstance) { - fiberToFiberInstanceMap.delete(alternate); - } - } - untrackFiber(fiberInstance, fiber); } @@ -2929,7 +2736,10 @@ export function attach( 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; @@ -2937,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. @@ -2955,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. @@ -2983,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)); + } } } @@ -3105,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 { @@ -3303,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 ( @@ -3447,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 && @@ -3599,10 +3429,20 @@ export function attach( } 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) { @@ -3673,16 +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)) { @@ -3694,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) { @@ -3740,6 +3562,15 @@ 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( @@ -3747,21 +3578,17 @@ export function attach( priorityLevel: void | number, ) { const current = root.current; - const alternate = current.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. @@ -3786,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, @@ -3796,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 && @@ -3810,20 +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. unmountInstanceRecursively(rootInstance); - removeRootPseudoKey(currentRootID); + removeRootPseudoKey(currentRoot.id); rootToFiberInstanceMap.delete(root); } } else { // Mount a new root. - setRootPseudoKey(currentRootID, current); + setRootPseudoKey(currentRoot.id, current); mountFiberRecursively(current, false); } @@ -3831,7 +3656,7 @@ export function attach( if (!shouldBailoutWithPendingOperations()) { const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( - currentRootID, + currentRoot.id, ); if (commitProfilingMetadata != null) { @@ -3840,7 +3665,7 @@ export function attach( ); } else { ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).set( - currentRootID, + currentRoot.id, [((currentCommitProfilingMetadata: any): CommitProfilingData)], ); } @@ -3850,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 { @@ -4437,6 +4264,8 @@ export function attach( source = getSourceForFiberInstance(fiberInstance); } + const componentLogsEntry = fiberToComponentLogsMap.get(fiber); + return { id: fiberInstance.id, @@ -4487,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, @@ -4545,6 +4374,9 @@ export function attach( stylex: null, }; + const componentLogsEntry = + componentInfoToComponentLogsMap.get(componentInfo); + return { id: virtualInstance.id, @@ -4579,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, @@ -5194,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; @@ -5341,7 +5171,6 @@ 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 = rootToFiberInstanceMap.get(root); @@ -5358,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; @@ -5967,8 +5789,10 @@ export function attach( inspectElement, logElementToConsole, patchConsoleForStrictMode, + getComponentStack, getElementAttributeByPath, getElementSourceFunctionById, + onErrorOrWarning, overrideError, overrideSuspense, overrideValueAtPath, 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 f264134b4bb53..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,65 +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 ( - // v16-19 - typeof renderer.findFiberByHostInstance === 'function' || - // v16.8+ - renderer.currentDispatcherRef != null - ) { - // 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/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 3ceec410803e3..7d816d403af07 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, @@ -154,7 +158,10 @@ export type ReactRenderer = { currentDispatcherRef?: LegacyDispatcherRef | CurrentDispatcherRef, // Only injected by React v16.9+ in DEV mode. // Enables DevTools to append owners-only component stack to error messages. - getCurrentFiber?: () => Fiber | null, + getCurrentFiber?: (() => Fiber | null) | 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. @@ -343,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, @@ -357,6 +372,7 @@ export type RendererInterface = { findHostInstancesForElementID: FindHostInstancesForElementID, flushInitialOperations: () => void, getBestMatchForTrackedPath: () => PathMatch | null, + getComponentStack?: GetComponentStack, getNearestMountedDOMNode: (component: Element) => Element | null, getElementIDForHostInstance: GetElementIDForHostInstance, getDisplayNameForElementID: GetDisplayNameForElementID, @@ -379,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: ( @@ -475,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/bridge.js b/packages/react-devtools-shared/src/bridge.js index 1e9b3c222d623..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: [], @@ -199,7 +200,7 @@ export type BackendEvents = { stopInspectingHost: [boolean], syncSelectionFromBuiltinElementsPanel: [], syncSelectionToBuiltinElementsPanel: [], - unsupportedRendererVersion: [RendererID], + unsupportedRendererVersion: [], // React Native style editor plug-in. isNativeStyleEditorSupported: [ @@ -217,6 +218,7 @@ type FrontendEvents = { deletePath: [DeletePath], getBackendVersion: [], getBridgeProtocol: [], + getIfHasUnsupportedRendererVersion: [], getOwnersList: [ElementAndRendererID], getProfilingData: [{rendererID: RendererID}], getProfilingStatus: [], 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/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/hooks.js b/packages/react-devtools-shared/src/devtools/views/hooks.js index f2debdfa8f580..68143d331561f 100644 --- a/packages/react-devtools-shared/src/devtools/views/hooks.js +++ b/packages/react-devtools-shared/src/devtools/views/hooks.js @@ -186,7 +186,7 @@ export function useLocalStorage( ); // Listen for changes to this local storage value made from other windows. - // This enables the e.g. "⚛️ Elements" tab to update in response to changes from "⚛️ Settings". + // This enables the e.g. "⚛ Elements" tab to update in response to changes from "⚛ Settings". useLayoutEffect(() => { // $FlowFixMe[missing-local-annot] const onStorage = event => { diff --git a/packages/react-devtools-shared/src/devtools/views/utils.js b/packages/react-devtools-shared/src/devtools/views/utils.js index 7b7ac10e13ebd..2759a3f452c86 100644 --- a/packages/react-devtools-shared/src/devtools/views/utils.js +++ b/packages/react-devtools-shared/src/devtools/views/utils.js @@ -142,10 +142,14 @@ export function serializeHooksForCopy(hooks: HooksTree | null): string { const current = queue.pop(); // These aren't meaningful + // $FlowFixMe[incompatible-use] delete current.id; + // $FlowFixMe[incompatible-use] delete current.isStateEditable; + // $FlowFixMe[incompatible-use] if (current.subHooks.length > 0) { + // $FlowFixMe[incompatible-use] queue.push(...current.subHooks); } } diff --git a/packages/react-devtools-shared/src/hook.js b/packages/react-devtools-shared/src/hook.js index c16409e3d9279..eb81e81d4615e 100644 --- a/packages/react-devtools-shared/src/hook.js +++ b/packages/react-devtools-shared/src/hook.js @@ -21,6 +21,7 @@ import { FIREFOX_CONSOLE_DIMMING_COLOR, ANSI_STYLE_DIMMING_TEMPLATE, } from 'react-devtools-shared/src/constants'; +import attachRenderer from './attachRenderer'; declare var window: any; @@ -358,7 +359,6 @@ export function installHook(target: any): DevToolsHook | null { } let uidCounter = 0; - function inject(renderer: ReactRenderer): number { const id = ++uidCounter; renderers.set(id, renderer); @@ -367,39 +367,21 @@ export function installHook(target: any): DevToolsHook | null { ? 'deadcode' : detectReactBuildType(renderer); - // Patching the console enables DevTools to do a few useful things: - // * Append component stacks to warnings and error messages - // * Disabling or marking logs during a double render in Strict Mode - // * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber) - // - // Allow patching console early (during injection) to - // provide developers with components stacks even if they don't run DevTools. - if (target.hasOwnProperty('__REACT_DEVTOOLS_CONSOLE_FUNCTIONS__')) { - const {registerRendererWithConsole, patchConsoleUsingWindowValues} = - target.__REACT_DEVTOOLS_CONSOLE_FUNCTIONS__; - if ( - typeof registerRendererWithConsole === 'function' && - typeof patchConsoleUsingWindowValues === 'function' - ) { - registerRendererWithConsole(renderer); - patchConsoleUsingWindowValues(); - } - } - - // If we have just reloaded to profile, we need to inject the renderer interface before the app loads. - // Otherwise the renderer won't yet exist and we can skip this step. - const attach = target.__REACT_DEVTOOLS_ATTACH__; - if (typeof attach === 'function') { - const rendererInterface = attach(hook, id, renderer, target); - hook.rendererInterfaces.set(id, rendererInterface); - } - hook.emit('renderer', { id, renderer, reactBuildType, }); + const rendererInterface = attachRenderer(hook, id, renderer, target); + if (rendererInterface != null) { + hook.rendererInterfaces.set(id, rendererInterface); + hook.emit('renderer-attached', {id, rendererInterface}); + } else { + hook.hasUnsupportedRendererAttached = true; + hook.emit('unsupported-renderer-version'); + } + return id; } @@ -532,6 +514,7 @@ export function installHook(target: any): DevToolsHook | null { const startStackFrame = openModuleRangesStack.pop(); const stopStackFrame = getTopStackFrameString(error); if (stopStackFrame !== null) { + // $FlowFixMe[incompatible-call] moduleRanges.push([startStackFrame, stopStackFrame]); } } @@ -552,6 +535,7 @@ export function installHook(target: any): DevToolsHook | null { // Fast Refresh for web relies on this. renderers, + hasUnsupportedRendererAttached: false, emit, getFiberRoots, @@ -564,6 +548,9 @@ export function installHook(target: any): DevToolsHook | null { // React v16 checks the hook for this to ensure DevTools is new enough. supportsFiber: true, + // React Flight Client checks the hook for this to ensure DevTools is new enough. + supportsFlight: true, + // React calls these methods. checkDCE, onCommitFiberUnmount, diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index da605ba4e8b95..9b927c19219fb 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -475,6 +475,7 @@ export function parseElementDisplayNameFromBackend( if (displayName.indexOf('(') >= 0) { const matches = displayName.match(/[^()]+/g); if (matches != null) { + // $FlowFixMe[incompatible-type] displayName = matches.pop(); hocDisplayNames = matches; } @@ -485,6 +486,7 @@ export function parseElementDisplayNameFromBackend( } return { + // $FlowFixMe[incompatible-return] formattedDisplayName: displayName, hocDisplayNames, compiledWithForget: false, diff --git a/packages/react-devtools-timeline/src/import-worker/preprocessData.js b/packages/react-devtools-timeline/src/import-worker/preprocessData.js index d6fe8faa6e3f6..5dc0423b08b87 100644 --- a/packages/react-devtools-timeline/src/import-worker/preprocessData.js +++ b/packages/react-devtools-timeline/src/import-worker/preprocessData.js @@ -202,6 +202,7 @@ function markWorkCompleted( ); } + // $FlowFixMe[incompatible-use] const {measure, startTime} = stack.pop(); if (!measure) { console.error('Could not find matching measure for type "%s".', type); diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index d70d1fdbb381c..0e39f988f813c 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -325,7 +325,7 @@ export function getPublicInstance(instance: Instance): Instance { export function prepareForCommit(containerInfo: Container): Object | null { eventsEnabled = ReactBrowserEventEmitterIsEnabled(); - selectionInformation = getSelectionInformation(); + selectionInformation = getSelectionInformation(containerInfo); let activeInstance = null; if (enableCreateEventHandleAPI) { const focusedElem = selectionInformation.focusedElem; @@ -357,7 +357,7 @@ export function afterActiveInstanceBlur(): void { } export function resetAfterCommit(containerInfo: Container): void { - restoreSelection(selectionInformation); + restoreSelection(selectionInformation, containerInfo); ReactBrowserEventEmitterSetEnabled(eventsEnabled); eventsEnabled = null; selectionInformation = null; @@ -3520,7 +3520,7 @@ function insertStylesheetIntoRoot( for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if ( - node.nodeName === 'link' || + node.nodeName === 'LINK' || // We omit style tags with media="not all" because they are not in the right position // and will be hoisted by the Fizz runtime imminently. node.getAttribute('media') !== 'not all' diff --git a/packages/react-dom-bindings/src/client/ReactInputSelection.js b/packages/react-dom-bindings/src/client/ReactInputSelection.js index 1a3d63b367f6f..0f3dfe11cd9f9 100644 --- a/packages/react-dom-bindings/src/client/ReactInputSelection.js +++ b/packages/react-dom-bindings/src/client/ReactInputSelection.js @@ -56,9 +56,9 @@ function isSameOriginFrame(iframe) { } } -function getActiveElementDeep() { - let win = window; - let element = getActiveElement(); +function getActiveElementDeep(containerInfo) { + let win = containerInfo?.ownerDocument?.defaultView ?? window; + let element = getActiveElement(win.document); while (element instanceof win.HTMLIFrameElement) { if (isSameOriginFrame(element)) { win = element.contentWindow; @@ -97,8 +97,8 @@ export function hasSelectionCapabilities(elem) { ); } -export function getSelectionInformation() { - const focusedElem = getActiveElementDeep(); +export function getSelectionInformation(containerInfo) { + const focusedElem = getActiveElementDeep(containerInfo); return { focusedElem: focusedElem, selectionRange: hasSelectionCapabilities(focusedElem) @@ -112,8 +112,8 @@ export function getSelectionInformation() { * restore it. This is useful when performing operations that could remove dom * nodes and place them back in, resulting in focus being lost. */ -export function restoreSelection(priorSelectionInformation) { - const curFocusedElem = getActiveElementDeep(); +export function restoreSelection(priorSelectionInformation, containerInfo) { + const curFocusedElem = getActiveElementDeep(containerInfo); const priorFocusedElem = priorSelectionInformation.focusedElem; const priorSelectionRange = priorSelectionInformation.selectionRange; if (curFocusedElem !== priorFocusedElem && isInDocument(priorFocusedElem)) { diff --git a/packages/react-dom/npm/static.browser.js b/packages/react-dom/npm/static.browser.js index 6d3f52b0e6c1e..ddfe2b20896dd 100644 --- a/packages/react-dom/npm/static.browser.js +++ b/packages/react-dom/npm/static.browser.js @@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') { exports.version = s.version; exports.prerender = s.prerender; +exports.resumeAndPrerender = s.resumeAndPrerender; diff --git a/packages/react-dom/npm/static.edge.js b/packages/react-dom/npm/static.edge.js index de57d7a4c022e..ff770374b314a 100644 --- a/packages/react-dom/npm/static.edge.js +++ b/packages/react-dom/npm/static.edge.js @@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') { exports.version = s.version; exports.prerender = s.prerender; +exports.resumeAndPrerender = s.resumeAndPrerender; diff --git a/packages/react-dom/npm/static.node.js b/packages/react-dom/npm/static.node.js index 0a7cc8dfd7855..5dc47d472ba4b 100644 --- a/packages/react-dom/npm/static.node.js +++ b/packages/react-dom/npm/static.node.js @@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') { exports.version = s.version; exports.prerenderToNodeStream = s.prerenderToNodeStream; +exports.resumeAndPrerenderToNodeStream = s.resumeAndPrerenderToNodeStream; diff --git a/packages/react-dom/src/__tests__/ReactDOMFiber-test.js b/packages/react-dom/src/__tests__/ReactDOMFiber-test.js index 7c701219ec3de..cf0526fd61bf0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFiber-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFiber-test.js @@ -19,12 +19,23 @@ let act; let assertConsoleErrorDev; let assertLog; let root; +let JSDOM; describe('ReactDOMFiber', () => { let container; beforeEach(() => { jest.resetModules(); + + // JSDOM needs to be setup with a TextEncoder and TextDecoder when used standalone + // https://github.com/jsdom/jsdom/issues/2524 + (() => { + const {TextEncoder, TextDecoder} = require('util'); + global.TextEncoder = TextEncoder; + global.TextDecoder = TextDecoder; + JSDOM = require('jsdom').JSDOM; + })(); + React = require('react'); ReactDOM = require('react-dom'); PropTypes = require('prop-types'); @@ -1272,4 +1283,48 @@ describe('ReactDOMFiber', () => { }); expect(didCallOnChange).toBe(true); }); + + it('should restore selection in the correct window', async () => { + // creating new JSDOM instance to get a second window as window.open is not implemented + // https://github.com/jsdom/jsdom/blob/c53efc81e75f38a0558fbf3ed75d30b78b4c4898/lib/jsdom/browser/Window.js#L987 + const {window: newWindow} = new JSDOM(''); + // creating a new container since the default cleanup expects the existing container to be in the document + const newContainer = newWindow.document.createElement('div'); + newWindow.document.body.appendChild(newContainer); + root = ReactDOMClient.createRoot(newContainer); + + const Test = () => { + const [reverse, setReverse] = React.useState(false); + const [items] = React.useState(() => ['a', 'b', 'c']); + const onClick = () => { + setReverse(true); + }; + + // shuffle the items so that the react commit needs to restore focus + // to the correct element after commit + const itemsToRender = reverse ? items.reverse() : items; + + return ( +
+ {itemsToRender.map(item => ( + + ))} +
+ ); + }; + + await act(() => { + root.render(); + }); + + newWindow.document.getElementById('a').focus(); + await act(() => { + newWindow.document.getElementById('a').click(); + }); + + expect(newWindow.document.activeElement).not.toBe(newWindow.document.body); + expect(newWindow.document.activeElement.innerHTML).toBe('a'); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js index 027099d54707c..ee843996bef1c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js @@ -744,7 +744,7 @@ describe('ReactDOMFiberAsync', () => { // Because it suspended, it remains on the current path expect(div.textContent).toBe('/path/a'); }); - assertLog(gate('enableSiblingPrerendering') ? ['Suspend! [/path/b]'] : []); + assertLog([]); await act(async () => { resolvePromise(); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 357ef1dcb478e..1512e1d4c7e99 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -1758,4 +1758,90 @@ describe('ReactDOMFizzStaticBrowser', () => { await readIntoContainer(dynamic); expect(getVisibleChildren(container)).toEqual('hello'); }); + + // @gate enableHalt + it('can resume render of a prerender', async () => { + const errors = []; + + let resolveA; + const promiseA = new Promise(r => (resolveA = r)); + let resolveB; + const promiseB = new Promise(r => (resolveB = r)); + + async function ComponentA() { + await promiseA; + return ( + + + + ); + } + + async function ComponentB() { + await promiseB; + return 'Hello'; + } + + function App() { + return ( + + + + ); + } + + const controller = new AbortController(); + let pendingResult; + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }); + }); + + controller.abort(); + + const prerendered = await pendingResult; + const postponedState = JSON.stringify(prerendered.postponed); + + await readIntoContainer(prerendered.prelude); + expect(getVisibleChildren(container)).toEqual('Loading A'); + + await resolveA(); + + expect(prerendered.postponed).not.toBe(null); + + const controller2 = new AbortController(); + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.resumeAndPrerender( + , + JSON.parse(postponedState), + { + signal: controller2.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + }); + + controller2.abort(); + + const prerendered2 = await pendingResult; + const postponedState2 = JSON.stringify(prerendered2.postponed); + + await readIntoContainer(prerendered2.prelude); + expect(getVisibleChildren(container)).toEqual('Loading B'); + + await resolveB(); + + const dynamic = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedState2)), + ); + + await readIntoContainer(dynamic); + expect(getVisibleChildren(container)).toEqual('Hello'); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index b2a45a4c71b1b..168d44722d473 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -699,15 +699,7 @@ describe('ReactDOMForm', () => { // This should suspend because form actions are implicitly wrapped // in startTransition. await submit(formRef.current); - assertLog([ - 'Pending...', - 'Suspend! [Updated]', - 'Loading...', - - ...(gate('enableSiblingPrerendering') - ? ['Suspend! [Updated]', 'Loading...'] - : []), - ]); + assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']); expect(container.textContent).toBe('Pending...Initial'); await act(() => resolveText('Updated')); @@ -744,15 +736,7 @@ describe('ReactDOMForm', () => { // Update await submit(formRef.current); - assertLog([ - 'Pending...', - 'Suspend! [Count: 1]', - 'Loading...', - - ...(gate('enableSiblingPrerendering') - ? ['Suspend! [Count: 1]', 'Loading...'] - : []), - ]); + assertLog(['Pending...', 'Suspend! [Count: 1]', 'Loading...']); expect(container.textContent).toBe('Pending...Count: 0'); await act(() => resolveText('Count: 1')); @@ -761,15 +745,7 @@ describe('ReactDOMForm', () => { // Update again await submit(formRef.current); - assertLog([ - 'Pending...', - 'Suspend! [Count: 2]', - 'Loading...', - - ...(gate('enableSiblingPrerendering') - ? ['Suspend! [Count: 2]', 'Loading...'] - : []), - ]); + assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']); expect(container.textContent).toBe('Pending...Count: 1'); await act(() => resolveText('Count: 2')); @@ -813,14 +789,7 @@ describe('ReactDOMForm', () => { assertLog(['Async action started', 'Pending...']); await act(() => resolveText('Wait')); - assertLog([ - 'Suspend! [Updated]', - 'Loading...', - - ...(gate('enableSiblingPrerendering') - ? ['Suspend! [Updated]', 'Loading...'] - : []), - ]); + assertLog(['Suspend! [Updated]', 'Loading...']); expect(container.textContent).toBe('Pending...Initial'); await act(() => resolveText('Updated')); @@ -1490,13 +1459,23 @@ describe('ReactDOMForm', () => { , ), ); - assertLog(['Suspend! [Count: 0]', 'Loading...']); + assertLog([ + 'Suspend! [Count: 0]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 0]'] : []), + ]); await act(() => resolveText('Count: 0')); assertLog(['Count: 0']); // Dispatch outside of a transition. This will trigger a loading state. await act(() => dispatch()); - assertLog(['Suspend! [Count: 1]', 'Loading...']); + assertLog([ + 'Suspend! [Count: 1]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 1]'] : []), + ]); expect(container.textContent).toBe('Loading...'); await act(() => resolveText('Count: 1')); @@ -1506,15 +1485,7 @@ describe('ReactDOMForm', () => { // Now dispatch inside of a transition. This one does not trigger a // loading state. await act(() => startTransition(() => dispatch())); - assertLog([ - 'Count: 1', - 'Suspend! [Count: 2]', - 'Loading...', - - ...(gate('enableSiblingPrerendering') - ? ['Suspend! [Count: 2]', 'Loading...'] - : []), - ]); + assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']); expect(container.textContent).toBe('Count: 1'); await act(() => resolveText('Count: 2')); diff --git a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js index 50ba64a478888..fa22142702527 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js @@ -160,7 +160,13 @@ describe('ReactDOMSuspensePlaceholder', () => { }); expect(container.textContent).toEqual('Loading...'); - assertLog(['A', 'Suspend! [B]', 'Loading...']); + assertLog([ + 'A', + 'Suspend! [B]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['A', 'Suspend! [B]', 'C'] : []), + ]); await act(() => { resolveText('B'); }); diff --git a/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js b/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js index 468d5b54e1ae2..796d429625a70 100644 --- a/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js +++ b/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js @@ -192,7 +192,13 @@ test('regression (#20932): return pointer is correct before entering deleted tre await act(() => { root.render(); }); - assertLog(['Suspend! [0]', 'Loading Async...', 'Loading Tail...']); + assertLog([ + 'Suspend! [0]', + 'Loading Async...', + 'Loading Tail...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [0]'] : []), + ]); await act(() => { resolveText(0); }); @@ -205,5 +211,7 @@ test('regression (#20932): return pointer is correct before entering deleted tre 'Loading Async...', 'Suspend! [1]', 'Loading Async...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [1]'] : []), ]); }); diff --git a/packages/react-dom/src/client/ReactDOMClientFB.js b/packages/react-dom/src/client/ReactDOMClientFB.js index aa0bafac01ad3..8aa17df05dd0e 100644 --- a/packages/react-dom/src/client/ReactDOMClientFB.js +++ b/packages/react-dom/src/client/ReactDOMClientFB.js @@ -100,6 +100,7 @@ function flushSyncFromReconciler(fn: (() => R) | void): R | void { ); } } + // $FlowFixMe[incompatible-call] return flushSyncWithoutWarningIfAlreadyRendering(fn); } diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index f5d6a45a18c8c..6785515bbebe7 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -23,6 +23,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createPrerenderRequest, + resumeAndPrerenderRequest, startWork, startFlowing, stopFlowing, @@ -33,6 +34,7 @@ import { import { createResumableState, createRenderState, + resumeRenderState, createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; @@ -141,4 +143,73 @@ function prerender( }); } -export {prerender, ReactVersion as version}; +type ResumeOptions = { + nonce?: string, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, + onPostpone?: (reason: string) => void, + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, +}; + +function resumeAndPrerender( + children: ReactNodeList, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + pull: (controller): ?Promise => { + startFlowing(request, controller); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + + const result = { + postponed: getPostponedState(request), + prelude: stream, + }; + resolve(result); + } + + const request = resumeAndPrerenderRequest( + children, + postponedState, + resumeRenderState( + postponedState.resumableState, + options ? options.nonce : undefined, + ), + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + options ? options.onPostpone : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export {prerender, resumeAndPrerender, ReactVersion as version}; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js index 1a2eb1e599afe..5a7467002cb5c 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -23,6 +23,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createPrerenderRequest, + resumeAndPrerenderRequest, startWork, startFlowing, stopFlowing, @@ -33,6 +34,7 @@ import { import { createResumableState, createRenderState, + resumeRenderState, createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; @@ -140,4 +142,73 @@ function prerender( }); } -export {prerender, ReactVersion as version}; +type ResumeOptions = { + nonce?: string, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, + onPostpone?: (reason: string) => void, + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, +}; + +function resumeAndPrerender( + children: ReactNodeList, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + pull: (controller): ?Promise => { + startFlowing(request, controller); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + + const result = { + postponed: getPostponedState(request), + prelude: stream, + }; + resolve(result); + } + + const request = resumeAndPrerenderRequest( + children, + postponedState, + resumeRenderState( + postponedState.resumableState, + options ? options.nonce : undefined, + ), + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + options ? options.onPostpone : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export {prerender, resumeAndPrerender, ReactVersion as version}; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index fc25aa75c190a..9b9cd680c16b9 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -25,6 +25,7 @@ import ReactVersion from 'shared/ReactVersion'; import { createPrerenderRequest, + resumeAndPrerenderRequest, startWork, startFlowing, abort, @@ -34,6 +35,7 @@ import { import { createResumableState, createRenderState, + resumeRenderState, createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; @@ -141,4 +143,67 @@ function prerenderToNodeStream( }); } -export {prerenderToNodeStream, ReactVersion as version}; +type ResumeOptions = { + nonce?: string, + signal?: AbortSignal, + onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void, +}; + +function resumeAndPrerenderToNodeStream( + children: ReactNodeList, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + const readable: Readable = new Readable({ + read() { + startFlowing(request, writable); + }, + }); + const writable = createFakeWritable(readable); + + const result = { + postponed: getPostponedState(request), + prelude: readable, + }; + resolve(result); + } + const request = resumeAndPrerenderRequest( + children, + postponedState, + resumeRenderState( + postponedState.resumableState, + options ? options.nonce : undefined, + ), + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + options ? options.onPostpone : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export { + prerenderToNodeStream, + resumeAndPrerenderToNodeStream, + ReactVersion as version, +}; diff --git a/packages/react-dom/src/server/react-dom-server.browser.js b/packages/react-dom/src/server/react-dom-server.browser.js index c12bb28c19dc4..5ab1f0e14256e 100644 --- a/packages/react-dom/src/server/react-dom-server.browser.js +++ b/packages/react-dom/src/server/react-dom-server.browser.js @@ -8,4 +8,4 @@ */ export * from './ReactDOMFizzServerBrowser.js'; -export {prerender} from './ReactDOMFizzStaticBrowser.js'; +export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticBrowser.js'; diff --git a/packages/react-dom/src/server/react-dom-server.edge.js b/packages/react-dom/src/server/react-dom-server.edge.js index c3882bed01dc4..e70e8fd4cbefe 100644 --- a/packages/react-dom/src/server/react-dom-server.edge.js +++ b/packages/react-dom/src/server/react-dom-server.edge.js @@ -8,4 +8,4 @@ */ export * from './ReactDOMFizzServerEdge.js'; -export {prerender} from './ReactDOMFizzStaticEdge.js'; +export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticEdge.js'; diff --git a/packages/react-dom/src/server/react-dom-server.node.js b/packages/react-dom/src/server/react-dom-server.node.js index 9ca72308b08e5..17c2d755b4873 100644 --- a/packages/react-dom/src/server/react-dom-server.node.js +++ b/packages/react-dom/src/server/react-dom-server.node.js @@ -8,4 +8,7 @@ */ export * from './ReactDOMFizzServerNode.js'; -export {prerenderToNodeStream} from './ReactDOMFizzStaticNode.js'; +export { + prerenderToNodeStream, + resumeAndPrerenderToNodeStream, +} from './ReactDOMFizzStaticNode.js'; diff --git a/packages/react-dom/static.browser.js b/packages/react-dom/static.browser.js index f5148e087fa85..23e07d7dd29fd 100644 --- a/packages/react-dom/static.browser.js +++ b/packages/react-dom/static.browser.js @@ -7,4 +7,8 @@ * @flow */ -export {prerender, version} from './src/server/react-dom-server.browser'; +export { + prerender, + resumeAndPrerender, + version, +} from './src/server/react-dom-server.browser'; diff --git a/packages/react-dom/static.edge.js b/packages/react-dom/static.edge.js index 40bdffd4e4b52..e2cbc692869a0 100644 --- a/packages/react-dom/static.edge.js +++ b/packages/react-dom/static.edge.js @@ -7,4 +7,8 @@ * @flow */ -export {prerender, version} from './src/server/react-dom-server.edge'; +export { + prerender, + resumeAndPrerender, + version, +} from './src/server/react-dom-server.edge'; diff --git a/packages/react-dom/static.node.js b/packages/react-dom/static.node.js index 9036370546960..a25c88af4d9f4 100644 --- a/packages/react-dom/static.node.js +++ b/packages/react-dom/static.node.js @@ -9,5 +9,6 @@ export { prerenderToNodeStream, + resumeAndPrerenderToNodeStream, version, } from './src/server/react-dom-server.node'; diff --git a/packages/react-native-renderer/src/ReactNativeTypes.js b/packages/react-native-renderer/src/ReactNativeTypes.js index 210366842383e..000ea0d0f766d 100644 --- a/packages/react-native-renderer/src/ReactNativeTypes.js +++ b/packages/react-native-renderer/src/ReactNativeTypes.js @@ -233,8 +233,8 @@ export type ReactNativeType = { ): ?ElementRef, unmountComponentAtNode(containerTag: number): void, unmountComponentAtNodeAndRemoveContainer(containerTag: number): void, - unstable_batchedUpdates: (fn: (T) => void, bookkeeping: T) => void, - __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: SecretInternalsType, + +unstable_batchedUpdates: (fn: (T) => void, bookkeeping: T) => void, + +__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: SecretInternalsType, ... }; diff --git a/packages/react-reconciler/src/DebugTracing.js b/packages/react-reconciler/src/DebugTracing.js index 6a7870baa4eb8..16606748be696 100644 --- a/packages/react-reconciler/src/DebugTracing.js +++ b/packages/react-reconciler/src/DebugTracing.js @@ -66,7 +66,7 @@ export function logCommitStarted(lanes: Lanes): void { if (__DEV__) { if (enableDebugTracing) { group( - `%c⚛️%c commit%c (${formatLanes(lanes)})`, + `%c⚛%c commit%c (${formatLanes(lanes)})`, REACT_LOGO_STYLE, '', 'font-weight: normal;', @@ -103,7 +103,7 @@ export function logComponentSuspended( const id = getWakeableID(wakeable); const display = (wakeable: any).displayName || wakeable; log( - `%c⚛️%c ${componentName} suspended`, + `%c⚛%c ${componentName} suspended`, REACT_LOGO_STYLE, 'color: #80366d; font-weight: bold;', id, @@ -112,7 +112,7 @@ export function logComponentSuspended( wakeable.then( () => { log( - `%c⚛️%c ${componentName} resolved`, + `%c⚛%c ${componentName} resolved`, REACT_LOGO_STYLE, 'color: #80366d; font-weight: bold;', id, @@ -121,7 +121,7 @@ export function logComponentSuspended( }, () => { log( - `%c⚛️%c ${componentName} rejected`, + `%c⚛%c ${componentName} rejected`, REACT_LOGO_STYLE, 'color: #80366d; font-weight: bold;', id, @@ -137,7 +137,7 @@ export function logLayoutEffectsStarted(lanes: Lanes): void { if (__DEV__) { if (enableDebugTracing) { group( - `%c⚛️%c layout effects%c (${formatLanes(lanes)})`, + `%c⚛%c layout effects%c (${formatLanes(lanes)})`, REACT_LOGO_STYLE, '', 'font-weight: normal;', @@ -158,7 +158,7 @@ export function logPassiveEffectsStarted(lanes: Lanes): void { if (__DEV__) { if (enableDebugTracing) { group( - `%c⚛️%c passive effects%c (${formatLanes(lanes)})`, + `%c⚛%c passive effects%c (${formatLanes(lanes)})`, REACT_LOGO_STYLE, '', 'font-weight: normal;', @@ -179,7 +179,7 @@ export function logRenderStarted(lanes: Lanes): void { if (__DEV__) { if (enableDebugTracing) { group( - `%c⚛️%c render%c (${formatLanes(lanes)})`, + `%c⚛%c render%c (${formatLanes(lanes)})`, REACT_LOGO_STYLE, '', 'font-weight: normal;', @@ -203,7 +203,7 @@ export function logForceUpdateScheduled( if (__DEV__) { if (enableDebugTracing) { log( - `%c⚛️%c ${componentName} forced update %c(${formatLanes(lane)})`, + `%c⚛%c ${componentName} forced update %c(${formatLanes(lane)})`, REACT_LOGO_STYLE, 'color: #db2e1f; font-weight: bold;', '', @@ -220,7 +220,7 @@ export function logStateUpdateScheduled( if (__DEV__) { if (enableDebugTracing) { log( - `%c⚛️%c ${componentName} updated state %c(${formatLanes(lane)})`, + `%c⚛%c ${componentName} updated state %c(${formatLanes(lane)})`, REACT_LOGO_STYLE, 'color: #01a252; font-weight: bold;', '', diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 4147f0d04e96c..29a8931038c8d 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -187,18 +187,11 @@ function FiberNode( // Learn more about this here: // https://github.com/facebook/react/issues/14365 // https://bugs.chromium.org/p/v8/issues/detail?id=8538 - this.actualDuration = Number.NaN; - this.actualStartTime = Number.NaN; - this.selfBaseDuration = Number.NaN; - this.treeBaseDuration = Number.NaN; - - // It's okay to replace the initial doubles with smis after initialization. - // This won't trigger the performance cliff mentioned above, - // and it simplifies other profiler code (including DevTools). - this.actualDuration = 0; - this.actualStartTime = -1; - this.selfBaseDuration = 0; - this.treeBaseDuration = 0; + + this.actualDuration = -0; + this.actualStartTime = -1.1; + this.selfBaseDuration = -0; + this.treeBaseDuration = -0; } if (__DEV__) { @@ -286,10 +279,10 @@ function createFiberImplObject( }; if (enableProfilerTimer) { - fiber.actualDuration = 0; - fiber.actualStartTime = -1; - fiber.selfBaseDuration = 0; - fiber.treeBaseDuration = 0; + fiber.actualDuration = -0; + fiber.actualStartTime = -1.1; + fiber.selfBaseDuration = -0; + fiber.treeBaseDuration = -0; } if (__DEV__) { @@ -382,8 +375,8 @@ export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber { // This prevents time from endlessly accumulating in new commits. // This has the downside of resetting values for different priority renders, // But works for yielding (the common case) and should support resuming. - workInProgress.actualDuration = 0; - workInProgress.actualStartTime = -1; + workInProgress.actualDuration = -0; + workInProgress.actualStartTime = -1.1; } } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 34176bb83b5c9..398dd8372597a 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -1026,6 +1026,10 @@ function updateProfiler( workInProgress.flags |= Update; if (enableProfilerCommitHooks) { + // Schedule a passive effect for this Profiler to call onPostCommit hooks. + // This effect should be scheduled even if there is no onPostCommit callback for this Profiler, + // because the effect is also where times bubble to parent Profilers. + workInProgress.flags |= Passive; // Reset effect durations for the next eventual effect phase. // These are reset during render to allow the DevTools commit hook a chance to read them, const stateNode = workInProgress.stateNode; @@ -2430,10 +2434,10 @@ function mountSuspenseFallbackChildren( // final amounts. This seems counterintuitive, since we're intentionally // not measuring part of the render phase, but this makes it match what we // do in Concurrent Mode. - primaryChildFragment.actualDuration = 0; - primaryChildFragment.actualStartTime = -1; - primaryChildFragment.selfBaseDuration = 0; - primaryChildFragment.treeBaseDuration = 0; + primaryChildFragment.actualDuration = -0; + primaryChildFragment.actualStartTime = -1.1; + primaryChildFragment.selfBaseDuration = -0; + primaryChildFragment.treeBaseDuration = -0; } fallbackChildFragment = createFiberFromFragment( @@ -2560,8 +2564,8 @@ function updateSuspenseFallbackChildren( // final amounts. This seems counterintuitive, since we're intentionally // not measuring part of the render phase, but this makes it match what we // do in Concurrent Mode. - primaryChildFragment.actualDuration = 0; - primaryChildFragment.actualStartTime = -1; + primaryChildFragment.actualDuration = -0; + primaryChildFragment.actualStartTime = -1.1; primaryChildFragment.selfBaseDuration = currentPrimaryChildFragment.selfBaseDuration; primaryChildFragment.treeBaseDuration = @@ -3700,6 +3704,10 @@ function attemptEarlyBailoutIfNoScheduledUpdate( } if (enableProfilerCommitHooks) { + // Schedule a passive effect for this Profiler to call onPostCommit hooks. + // This effect should be scheduled even if there is no onPostCommit callback for this Profiler, + // because the effect is also where times bubble to parent Profilers. + workInProgress.flags |= Passive; // Reset effect durations for the next eventual effect phase. // These are reset during render to allow the DevTools commit hook a chance to read them, const stateNode = workInProgress.stateNode; diff --git a/packages/react-reconciler/src/ReactFiberCommitEffects.js b/packages/react-reconciler/src/ReactFiberCommitEffects.js index 1d9d5c65b8aa4..af5762df2110a 100644 --- a/packages/react-reconciler/src/ReactFiberCommitEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitEffects.js @@ -881,7 +881,7 @@ function commitProfiler( commitTime: number, effectDuration: number, ) { - const {onCommit, onRender} = finishedWork.memoizedProps; + const {id, onCommit, onRender} = finishedWork.memoizedProps; let phase = current === null ? 'mount' : 'update'; if (enableProfilerNestedUpdatePhase) { @@ -892,7 +892,7 @@ function commitProfiler( if (typeof onRender === 'function') { onRender( - finishedWork.memoizedProps.id, + id, phase, finishedWork.actualDuration, finishedWork.treeBaseDuration, @@ -938,3 +938,52 @@ export function commitProfilerUpdate( } } } + +function commitProfilerPostCommitImpl( + finishedWork: Fiber, + current: Fiber | null, + commitTime: number, + passiveEffectDuration: number, +): void { + const {id, onPostCommit} = finishedWork.memoizedProps; + + let phase = current === null ? 'mount' : 'update'; + if (enableProfilerNestedUpdatePhase) { + if (isCurrentUpdateNested()) { + phase = 'nested-update'; + } + } + + if (typeof onPostCommit === 'function') { + onPostCommit(id, phase, passiveEffectDuration, commitTime); + } +} + +export function commitProfilerPostCommit( + finishedWork: Fiber, + current: Fiber | null, + commitTime: number, + passiveEffectDuration: number, +) { + try { + if (__DEV__) { + runWithFiberInDEV( + finishedWork, + commitProfilerPostCommitImpl, + finishedWork, + current, + commitTime, + passiveEffectDuration, + ); + } else { + commitProfilerPostCommitImpl( + finishedWork, + current, + commitTime, + passiveEffectDuration, + ); + } + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } +} diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 6d600971d4461..bba93bbc0da2e 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -40,10 +40,10 @@ import type { import { alwaysThrottleRetries, enableCreateEventHandleAPI, + enableHiddenSubtreeInsertionEffectCleanup, enablePersistedModeClonedFlag, enableProfilerTimer, enableProfilerCommitHooks, - enableProfilerNestedUpdatePhase, enableSchedulingProfiler, enableSuspenseCallback, enableScopeAPI, @@ -53,6 +53,7 @@ import { enableUseEffectEventHook, enableLegacyHidden, disableLegacyMode, + enableComponentPerformanceTrack, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -99,11 +100,12 @@ import { Cloned, } from './ReactFiberFlags'; import { - isCurrentUpdateNested, getCommitTime, recordLayoutEffectDuration, startLayoutEffectTimer, + getCompleteTime, } from './ReactProfilerTimer'; +import {logComponentRender} from './ReactFiberPerformanceTrack'; import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode'; import {deferHiddenCallbacks} from './ReactFiberClassUpdateQueue'; import { @@ -136,7 +138,6 @@ import { captureCommitPhaseError, resolveRetryWakeable, markCommitTimeOfFallback, - enqueuePendingPassiveProfilerEffect, restorePendingUpdaters, addTransitionStartCallbackToPendingTransition, addTransitionProgressCallbackToPendingTransition, @@ -147,6 +148,7 @@ import { getExecutionContext, CommitContext, NoContext, + setIsRunningInsertionEffect, } from './ReactFiberWorkLoop'; import { NoFlags as NoHookEffect, @@ -191,6 +193,7 @@ import { safelyDetachRef, safelyCallDestroy, commitProfilerUpdate, + commitProfilerPostCommit, commitRootCallbacks, } from './ReactFiberCommitEffects'; import { @@ -392,62 +395,6 @@ function commitBeforeMutationEffectsDeletion(deletion: Fiber) { } } -export function commitPassiveEffectDurations( - finishedRoot: FiberRoot, - finishedWork: Fiber, -): void { - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - getExecutionContext() & CommitContext - ) { - // Only Profilers with work in their subtree will have an Update effect scheduled. - if ((finishedWork.flags & Update) !== NoFlags) { - switch (finishedWork.tag) { - case Profiler: { - const {passiveEffectDuration} = finishedWork.stateNode; - const {id, onPostCommit} = finishedWork.memoizedProps; - - // This value will still reflect the previous commit phase. - // It does not get reset until the start of the next commit phase. - const commitTime = getCommitTime(); - - let phase = finishedWork.alternate === null ? 'mount' : 'update'; - if (enableProfilerNestedUpdatePhase) { - if (isCurrentUpdateNested()) { - phase = 'nested-update'; - } - } - - if (typeof onPostCommit === 'function') { - onPostCommit(id, phase, passiveEffectDuration, commitTime); - } - - // Bubble times to the next nearest ancestor Profiler. - // After we process that Profiler, we'll bubble further up. - let parentFiber = finishedWork.return; - outer: while (parentFiber !== null) { - switch (parentFiber.tag) { - case HostRoot: - const root = parentFiber.stateNode; - root.passiveEffectDuration += passiveEffectDuration; - break outer; - case Profiler: - const parentStateNode = parentFiber.stateNode; - parentStateNode.passiveEffectDuration += passiveEffectDuration; - break outer; - } - parentFiber = parentFiber.return; - } - break; - } - default: - break; - } - } - } -} - function commitLayoutEffectOnFiber( finishedRoot: FiberRoot, current: Fiber | null, @@ -555,11 +502,6 @@ function commitLayoutEffectOnFiber( effectDuration, ); - // Schedule a passive effect for this Profiler to call onPostCommit hooks. - // This effect should be scheduled even if there is no onPostCommit callback for this Profiler, - // because the effect is also where times bubble to parent Profilers. - enqueuePendingPassiveProfilerEffect(finishedWork); - // Propagate layout effect durations to the next nearest Profiler ancestor. // Do not reset these values until the next render so DevTools has a chance to read them first. let parentFiber = finishedWork.return; @@ -1324,7 +1266,78 @@ function commitDeletionEffectsOnFiber( case ForwardRef: case MemoComponent: case SimpleMemoComponent: { - if (!offscreenSubtreeWasHidden) { + if (enableHiddenSubtreeInsertionEffectCleanup) { + // When deleting a fiber, we may need to destroy insertion or layout effects. + // Insertion effects are not destroyed on hidden, only when destroyed, so now + // we need to destroy them. Layout effects are destroyed when hidden, so + // we only need to destroy them if the tree is visible. + const updateQueue: FunctionComponentUpdateQueue | null = + (deletedFiber.updateQueue: any); + if (updateQueue !== null) { + const lastEffect = updateQueue.lastEffect; + if (lastEffect !== null) { + const firstEffect = lastEffect.next; + + let effect = firstEffect; + do { + const tag = effect.tag; + const inst = effect.inst; + const destroy = inst.destroy; + if (destroy !== undefined) { + if ((tag & HookInsertion) !== NoHookEffect) { + // TODO: add insertion effect marks and profiling. + if (__DEV__) { + setIsRunningInsertionEffect(true); + } + + inst.destroy = undefined; + safelyCallDestroy( + deletedFiber, + nearestMountedAncestor, + destroy, + ); + + if (__DEV__) { + setIsRunningInsertionEffect(false); + } + } else if ( + !offscreenSubtreeWasHidden && + (tag & HookLayout) !== NoHookEffect + ) { + // Offscreen fibers already unmounted their layout effects. + // We only need to destroy layout effects for visible trees. + if (enableSchedulingProfiler) { + markComponentLayoutEffectUnmountStarted(deletedFiber); + } + + if (shouldProfile(deletedFiber)) { + startLayoutEffectTimer(); + inst.destroy = undefined; + safelyCallDestroy( + deletedFiber, + nearestMountedAncestor, + destroy, + ); + recordLayoutEffectDuration(deletedFiber); + } else { + inst.destroy = undefined; + safelyCallDestroy( + deletedFiber, + nearestMountedAncestor, + destroy, + ); + } + + if (enableSchedulingProfiler) { + markComponentLayoutEffectUnmountStopped(); + } + } + } + effect = effect.next; + } while (effect !== firstEffect); + } + } + } else if (!offscreenSubtreeWasHidden) { const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any); if (updateQueue !== null) { @@ -2402,11 +2415,6 @@ export function reappearLayoutEffects( effectDuration, ); - // Schedule a passive effect for this Profiler to call onPostCommit hooks. - // This effect should be scheduled even if there is no onPostCommit callback for this Profiler, - // because the effect is also where times bubble to parent Profilers. - enqueuePendingPassiveProfilerEffect(finishedWork); - // Propagate layout effect durations to the next nearest Profiler ancestor. // Do not reset these values until the next render so DevTools has a chance to read them first. let parentFiber = finishedWork.return; @@ -2643,6 +2651,9 @@ export function commitPassiveMountEffects( finishedWork, committedLanes, committedTransitions, + enableProfilerTimer && enableComponentPerformanceTrack + ? getCompleteTime() + : 0, ); } @@ -2651,17 +2662,41 @@ function recursivelyTraversePassiveMountEffects( parentFiber: Fiber, committedLanes: Lanes, committedTransitions: Array | null, + endTime: number, // Profiling-only. The start time of the next Fiber or root completion. ) { - if (parentFiber.subtreeFlags & PassiveMask) { + if ( + parentFiber.subtreeFlags & PassiveMask || + // If this subtree rendered with profiling this commit, we need to visit it to log it. + (enableProfilerTimer && + enableComponentPerformanceTrack && + parentFiber.actualDuration !== 0 && + (parentFiber.alternate === null || + parentFiber.alternate.child !== parentFiber.child)) + ) { let child = parentFiber.child; while (child !== null) { - commitPassiveMountOnFiber( - root, - child, - committedLanes, - committedTransitions, - ); - child = child.sibling; + if (enableProfilerTimer && enableComponentPerformanceTrack) { + const nextSibling = child.sibling; + commitPassiveMountOnFiber( + root, + child, + committedLanes, + committedTransitions, + nextSibling !== null + ? ((nextSibling.actualStartTime: any): number) + : endTime, + ); + child = nextSibling; + } else { + commitPassiveMountOnFiber( + root, + child, + committedLanes, + committedTransitions, + 0, + ); + child = child.sibling; + } } } } @@ -2671,7 +2706,25 @@ function commitPassiveMountOnFiber( finishedWork: Fiber, committedLanes: Lanes, committedTransitions: Array | null, + endTime: number, // Profiling-only. The start time of the next Fiber or root completion. ): void { + // If this component rendered in Profiling mode (DEV or in Profiler component) then log its + // render time. We do this after the fact in the passive effect to avoid the overhead of this + // getting in the way of the render characteristics and avoid the overhead of unwinding + // uncommitted renders. + if ( + enableProfilerTimer && + enableComponentPerformanceTrack && + (finishedWork.mode & ProfileMode) !== NoMode && + ((finishedWork.actualStartTime: any): number) > 0 + ) { + logComponentRender( + finishedWork, + ((finishedWork.actualStartTime: any): number), + endTime, + ); + } + // When updating this function, also update reconnectPassiveEffects, which does // most of the same things when an offscreen tree goes from hidden -> visible, // or when toggling effects inside a hidden tree. @@ -2685,6 +2738,7 @@ function commitPassiveMountOnFiber( finishedWork, committedLanes, committedTransitions, + endTime, ); if (flags & Passive) { commitHookPassiveMountEffects( @@ -2700,6 +2754,7 @@ function commitPassiveMountOnFiber( finishedWork, committedLanes, committedTransitions, + endTime, ); if (flags & Passive) { if (enableCache) { @@ -2751,6 +2806,53 @@ function commitPassiveMountOnFiber( } break; } + case Profiler: { + recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions, + endTime, + ); + + // Only Profilers with work in their subtree will have a Passive effect scheduled. + if (flags & Passive) { + if ( + enableProfilerTimer && + enableProfilerCommitHooks && + getExecutionContext() & CommitContext + ) { + const {passiveEffectDuration} = finishedWork.stateNode; + + commitProfilerPostCommit( + finishedWork, + finishedWork.alternate, + // This value will still reflect the previous commit phase. + // It does not get reset until the start of the next commit phase. + getCommitTime(), + passiveEffectDuration, + ); + + // Bubble times to the next nearest ancestor Profiler. + // After we process that Profiler, we'll bubble further up. + let parentFiber = finishedWork.return; + outer: while (parentFiber !== null) { + switch (parentFiber.tag) { + case HostRoot: + const root = parentFiber.stateNode; + root.passiveEffectDuration += passiveEffectDuration; + break outer; + case Profiler: + const parentStateNode = parentFiber.stateNode; + parentStateNode.passiveEffectDuration += passiveEffectDuration; + break outer; + } + parentFiber = parentFiber.return; + } + } + } + break; + } case LegacyHiddenComponent: { if (enableLegacyHidden) { recursivelyTraversePassiveMountEffects( @@ -2758,6 +2860,7 @@ function commitPassiveMountOnFiber( finishedWork, committedLanes, committedTransitions, + endTime, ); if (flags & Passive) { @@ -2783,6 +2886,7 @@ function commitPassiveMountOnFiber( finishedWork, committedLanes, committedTransitions, + endTime, ); } else { if (disableLegacyMode || finishedWork.mode & ConcurrentMode) { @@ -2807,6 +2911,7 @@ function commitPassiveMountOnFiber( finishedWork, committedLanes, committedTransitions, + endTime, ); } } @@ -2819,6 +2924,7 @@ function commitPassiveMountOnFiber( finishedWork, committedLanes, committedTransitions, + endTime, ); } else { // The effects are currently disconnected. Reconnect them, while also @@ -2850,6 +2956,7 @@ function commitPassiveMountOnFiber( finishedWork, committedLanes, committedTransitions, + endTime, ); if (flags & Passive) { // TODO: Pass `current` as argument to this function @@ -2865,6 +2972,7 @@ function commitPassiveMountOnFiber( finishedWork, committedLanes, committedTransitions, + endTime, ); if (flags & Passive) { commitTracingMarkerPassiveMountEffect(finishedWork); @@ -2879,6 +2987,7 @@ function commitPassiveMountOnFiber( finishedWork, committedLanes, committedTransitions, + endTime, ); break; } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index c5d0c1575be04..e3fba900dd116 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -155,6 +155,7 @@ import { getRenderTargetTime, getWorkInProgressTransitions, shouldRemainOnPreviousScreen, + markSpawnedRetryLane, } from './ReactFiberWorkLoop'; import { OffscreenLane, @@ -600,25 +601,28 @@ function scheduleRetryEffect( // Schedule an effect to attach a retry listener to the promise. // TODO: Move to passive phase workInProgress.flags |= Update; - } else { - // This boundary suspended, but no wakeables were added to the retry - // queue. Check if the renderer suspended commit. If so, this means - // that once the fallback is committed, we can immediately retry - // rendering again, because rendering wasn't actually blocked. Only - // the commit phase. - // TODO: Consider a model where we always schedule an immediate retry, even - // for normal Suspense. That way the retry can partially render up to the - // first thing that suspends. - if (workInProgress.flags & ScheduleRetry) { - const retryLane = - // TODO: This check should probably be moved into claimNextRetryLane - // I also suspect that we need some further consolidation of offscreen - // and retry lanes. - workInProgress.tag !== OffscreenComponent - ? claimNextRetryLane() - : OffscreenLane; - workInProgress.lanes = mergeLanes(workInProgress.lanes, retryLane); - } + } + + // Check if we need to schedule an immediate retry. This should happen + // whenever we unwind a suspended tree without fully rendering its siblings; + // we need to begin the retry so we can start prerendering them. + // + // We also use this mechanism for Suspensey Resources (e.g. stylesheets), + // because those don't actually block the render phase, only the commit phase. + // So we can start rendering even before the resources are ready. + if (workInProgress.flags & ScheduleRetry) { + const retryLane = + // TODO: This check should probably be moved into claimNextRetryLane + // I also suspect that we need some further consolidation of offscreen + // and retry lanes. + workInProgress.tag !== OffscreenComponent + ? claimNextRetryLane() + : OffscreenLane; + workInProgress.lanes = mergeLanes(workInProgress.lanes, retryLane); + + // Track the lanes that have been scheduled for an immediate retry so that + // we can mark them as suspended upon committing the root. + markSpawnedRetryLane(retryLane); } } diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index ca02af3393102..59d2f0f025610 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1690,7 +1690,7 @@ function mountSyncExternalStore( } const rootRenderLanes = getWorkInProgressRootRenderLanes(); - if (!includesBlockingLane(root, rootRenderLanes)) { + if (!includesBlockingLane(rootRenderLanes)) { pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot); } } @@ -1802,7 +1802,7 @@ function updateSyncExternalStore( ); } - if (!isHydrating && !includesBlockingLane(root, renderLanes)) { + if (!isHydrating && !includesBlockingLane(renderLanes)) { pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot); } } diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index 2995548fbbee2..f72174e208555 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -26,9 +26,11 @@ import { syncLaneExpirationMs, transitionLaneExpirationMs, retryLaneExpirationMs, + disableLegacyMode, } from 'shared/ReactFeatureFlags'; import {isDevToolsPresent} from './ReactFiberDevToolsHook'; import {clz32} from './clz32'; +import {LegacyRoot} from './ReactRootTags'; // Lane values below should be kept in sync with getLabelForLane(), used by react-devtools-timeline. // If those values are changed that package should be rebuilt and redeployed. @@ -231,6 +233,29 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes { const pingedLanes = root.pingedLanes; const warmLanes = root.warmLanes; + // finishedLanes represents a completed tree that is ready to commit. + // + // It's not worth doing discarding the completed tree in favor of performing + // speculative work. So always check this before deciding to warm up + // the siblings. + // + // Note that this is not set in a "suspend indefinitely" scenario, like when + // suspending outside of a Suspense boundary, or in the shell during a + // transition — only in cases where we are very likely to commit the tree in + // a brief amount of time (i.e. below the "Just Noticeable Difference" + // threshold). + // + // TODO: finishedLanes is also set when a Suspensey resource, like CSS or + // images, suspends during the commit phase. (We could detect that here by + // checking for root.cancelPendingCommit.) These are also expected to resolve + // quickly, because of preloading, but theoretically they could block forever + // like in a normal "suspend indefinitely" scenario. In the future, we should + // consider only blocking for up to some time limit before discarding the + // commit in favor of prerendering. If we do discard a pending commit, then + // the commit phase callback should act as a ping to try the original + // render again. + const rootHasPendingCommit = root.finishedLanes !== NoLanes; + // Do not work on any idle work until all the non-idle work has finished, // even if the work is suspended. const nonIdlePendingLanes = pendingLanes & NonIdleLanes; @@ -246,9 +271,11 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes { nextLanes = getHighestPriorityLanes(nonIdlePingedLanes); } else { // Nothing has been pinged. Check for lanes that need to be prewarmed. - const lanesToPrewarm = nonIdlePendingLanes & ~warmLanes; - if (lanesToPrewarm !== NoLanes) { - nextLanes = getHighestPriorityLanes(lanesToPrewarm); + if (!rootHasPendingCommit) { + const lanesToPrewarm = nonIdlePendingLanes & ~warmLanes; + if (lanesToPrewarm !== NoLanes) { + nextLanes = getHighestPriorityLanes(lanesToPrewarm); + } } } } @@ -268,9 +295,11 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes { nextLanes = getHighestPriorityLanes(pingedLanes); } else { // Nothing has been pinged. Check for lanes that need to be prewarmed. - const lanesToPrewarm = pendingLanes & ~warmLanes; - if (lanesToPrewarm !== NoLanes) { - nextLanes = getHighestPriorityLanes(lanesToPrewarm); + if (!rootHasPendingCommit) { + const lanesToPrewarm = pendingLanes & ~warmLanes; + if (lanesToPrewarm !== NoLanes) { + nextLanes = getHighestPriorityLanes(lanesToPrewarm); + } } } } @@ -579,7 +608,7 @@ export function includesOnlyTransitions(lanes: Lanes): boolean { return (lanes & TransitionLanes) === lanes; } -export function includesBlockingLane(root: FiberRoot, lanes: Lanes): boolean { +export function includesBlockingLane(lanes: Lanes): boolean { const SyncDefaultLanes = InputContinuousHydrationLane | InputContinuousLane | @@ -753,10 +782,14 @@ export function markRootPinged(root: FiberRoot, pingedLanes: Lanes) { export function markRootFinished( root: FiberRoot, + finishedLanes: Lanes, remainingLanes: Lanes, spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, ) { - const noLongerPendingLanes = root.pendingLanes & ~remainingLanes; + const previouslyPendingLanes = root.pendingLanes; + const noLongerPendingLanes = previouslyPendingLanes & ~remainingLanes; root.pendingLanes = remainingLanes; @@ -812,6 +845,37 @@ export function markRootFinished( NoLanes, ); } + + // suspendedRetryLanes represents the retry lanes spawned by new Suspense + // boundaries during this render that were not later pinged. + // + // These lanes were marked as pending on their associated Suspense boundary + // fiber during the render phase so that we could start rendering them + // before new data streams in. As soon as the fallback commits, we can try + // to render them again. + // + // But since we know they're still suspended, we can skip straight to the + // "prerender" mode (i.e. don't skip over siblings after something + // suspended) instead of the regular mode (i.e. unwind and skip the siblings + // as soon as something suspends to unblock the rest of the update). + if ( + suspendedRetryLanes !== NoLanes && + // Note that we only do this if there were no updates since we started + // rendering. This mirrors the logic in markRootUpdated — whenever we + // receive an update, we reset all the suspended and pinged lanes. + updatedLanes === NoLanes && + !(disableLegacyMode && root.tag === LegacyRoot) + ) { + // We also need to avoid marking a retry lane as suspended if it was already + // pending before this render. We can't say these are now suspended if they + // weren't included in our attempt. + const freshlySpawnedRetryLanes = + suspendedRetryLanes & + // Remove any retry lane that was already pending before our just-finished + // attempt, and also wasn't included in that attempt. + ~(previouslyPendingLanes & ~finishedLanes); + root.suspendedLanes |= freshlySpawnedRetryLanes; + } } function markSpawnedDeferredLane( @@ -1079,3 +1143,35 @@ export function clearTransitionsForLanes(root: FiberRoot, lanes: Lane | Lanes) { lanes &= ~lane; } } + +// Used to name the Performance Track +export function getGroupNameOfHighestPriorityLane(lanes: Lanes): string { + if ( + lanes & + (SyncHydrationLane | + SyncLane | + InputContinuousHydrationLane | + InputContinuousLane | + DefaultHydrationLane | + DefaultLane) + ) { + return 'Blocking'; + } + if (lanes & (TransitionHydrationLane | TransitionLanes)) { + return 'Transition'; + } + if (lanes & RetryLanes) { + return 'Suspense'; + } + if ( + lanes & + (SelectiveHydrationLane | + IdleHydrationLane | + IdleLane | + OffscreenLane | + DeferredLane) + ) { + return 'Idle'; + } + return 'Other'; +} diff --git a/packages/react-reconciler/src/ReactFiberPerformanceTrack.js b/packages/react-reconciler/src/ReactFiberPerformanceTrack.js new file mode 100644 index 0000000000000..2058a04e47454 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberPerformanceTrack.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 {Fiber} from './ReactInternalTypes'; + +import getComponentNameFromFiber from './getComponentNameFromFiber'; + +import {getGroupNameOfHighestPriorityLane} from './ReactFiberLane'; + +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; + +const supportsUserTiming = + enableProfilerTimer && + typeof performance !== 'undefined' && + // $FlowFixMe[method-unbinding] + typeof performance.measure === 'function'; + +const TRACK_GROUP = 'Components ⚛'; + +// Reused to avoid thrashing the GC. +const reusableComponentDevToolDetails = { + dataType: 'track-entry', + color: 'primary', + track: 'Blocking', // Lane + trackGroup: TRACK_GROUP, +}; +const reusableComponentOptions = { + start: -0, + end: -0, + detail: { + devtools: reusableComponentDevToolDetails, + }, +}; + +export function setCurrentTrackFromLanes(lanes: number): void { + reusableComponentDevToolDetails.track = + getGroupNameOfHighestPriorityLane(lanes); +} + +export function logComponentRender( + fiber: Fiber, + startTime: number, + endTime: number, +): void { + const name = getComponentNameFromFiber(fiber); + if (name === null) { + // Skip + return; + } + if (supportsUserTiming) { + reusableComponentOptions.start = startTime; + reusableComponentOptions.end = endTime; + performance.measure(name, reusableComponentOptions); + } +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 69546293d3203..dba089e81f3e0 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -44,6 +44,7 @@ import { disableDefaultPropsExceptForClasses, disableStringRefs, enableSiblingPrerendering, + enableComponentPerformanceTrack, } from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import is from 'shared/objectIs'; @@ -128,6 +129,7 @@ import { DidDefer, ShouldSuspendCommit, MaySuspendCommit, + ScheduleRetry, } from './ReactFiberFlags'; import { NoLanes, @@ -188,7 +190,6 @@ import { commitBeforeMutationEffects, commitLayoutEffects, commitMutationEffects, - commitPassiveEffectDurations, commitPassiveMountEffects, commitPassiveUnmountEffects, disappearLayoutEffects, @@ -221,12 +222,15 @@ import { import { markNestedUpdateScheduled, + recordCompleteTime, recordCommitTime, resetNestedUpdateFlag, startProfilerTimer, - stopProfilerTimerIfRunningAndRecordDelta, + stopProfilerTimerIfRunningAndRecordDuration, + stopProfilerTimerIfRunningAndRecordIncompleteDuration, syncNestedUpdateFlag, } from './ReactProfilerTimer'; +import {setCurrentTrackFromLanes} from './ReactFiberPerformanceTrack'; // DEV stuff import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -365,8 +369,11 @@ let workInProgressRootInterleavedUpdatedLanes: Lanes = NoLanes; let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes; // Lanes that were pinged (in an interleaved event) during this render. let workInProgressRootPingedLanes: Lanes = NoLanes; -// If this lane scheduled deferred work, this is the lane of the deferred task. +// If this render scheduled deferred work, this is the lane of the deferred task. let workInProgressDeferredLane: Lane = NoLane; +// Represents the retry lanes that were spawned by this render and have not +// been pinged since, implying that they are still suspended. +let workInProgressSuspendedRetryLanes: Lanes = NoLanes; // Errors that are thrown during the render phase. let workInProgressRootConcurrentErrors: Array> | null = null; @@ -575,7 +582,6 @@ let legacyErrorBoundariesThatAlreadyFailed: Set | null = null; let rootDoesHavePassiveEffects: boolean = false; let rootWithPendingPassiveEffects: FiberRoot | null = null; let pendingPassiveEffectsLanes: Lanes = NoLanes; -let pendingPassiveProfilerEffects: Array = []; let pendingPassiveEffectsRemainingLanes: Lanes = NoLanes; let pendingPassiveTransitions: Array | null = null; @@ -907,7 +913,7 @@ export function performConcurrentWorkOnRoot( // bug we're still investigating. Once the bug in Scheduler is fixed, // we can remove this, since we track expiration ourselves. const shouldTimeSlice = - !includesBlockingLane(root, lanes) && + !includesBlockingLane(lanes) && !includesExpiredLane(root, lanes) && (disableSchedulerTimeoutInWorkLoop || !didTimeout); let exitStatus = shouldTimeSlice @@ -993,8 +999,6 @@ export function performConcurrentWorkOnRoot( // We now have a consistent tree. The next step is either to commit it, // or, if something suspended, wait to commit it after a timeout. - root.finishedWork = finishedWork; - root.finishedLanes = lanes; finishConcurrentRender(root, exitStatus, finishedWork, lanes); } break; @@ -1097,6 +1101,12 @@ function finishConcurrentRender( finishedWork: Fiber, lanes: Lanes, ) { + if (enableProfilerTimer && enableComponentPerformanceTrack) { + // Track when we finished the last unit of work, before we actually commit it. + // The commit can be suspended/blocked until we commit it. + recordCompleteTime(); + } + // TODO: The fact that most of these branches are identical suggests that some // of the exit statuses are not best modeled as exit statuses and should be // tracked orthogonally. @@ -1138,6 +1148,12 @@ function finishConcurrentRender( } } + // Only set these if we have a complete tree that is ready to be committed. + // We use these fields to determine later whether or not the work should be + // discarded for a fresh render attempt. + root.finishedWork = finishedWork; + root.finishedLanes = lanes; + if (shouldForceFlushFallbacksInDEV()) { // We're inside an `act` scope. Commit immediately. commitRoot( @@ -1146,6 +1162,8 @@ function finishConcurrentRender( workInProgressTransitions, workInProgressRootDidIncludeRecursiveRenderUpdate, workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, ); } else { if ( @@ -1188,6 +1206,8 @@ function finishConcurrentRender( workInProgressRootDidIncludeRecursiveRenderUpdate, lanes, workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, workInProgressRootDidSkipSuspendedSiblings, ), msUntilTimeout, @@ -1203,6 +1223,8 @@ function finishConcurrentRender( workInProgressRootDidIncludeRecursiveRenderUpdate, lanes, workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, workInProgressRootDidSkipSuspendedSiblings, ); } @@ -1216,6 +1238,8 @@ function commitRootWhenReady( didIncludeRenderPhaseUpdate: boolean, lanes: Lanes, spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, didSkipSuspendedSiblings: boolean, ) { // TODO: Combine retry throttling with Suspensey commits. Right now they run @@ -1254,6 +1278,9 @@ function commitRootWhenReady( recoverableErrors, transitions, didIncludeRenderPhaseUpdate, + spawnedLane, + updatedLanes, + suspendedRetryLanes, ), ); markRootSuspended(root, lanes, spawnedLane, didSkipSuspendedSiblings); @@ -1261,13 +1288,15 @@ function commitRootWhenReady( } } - // Otherwise, commit immediately. + // Otherwise, commit immediately.; commitRoot( root, recoverableErrors, transitions, didIncludeRenderPhaseUpdate, spawnedLane, + updatedLanes, + suspendedRetryLanes, ); } @@ -1277,7 +1306,13 @@ function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean { // loop instead of recursion so we can exit early. let node: Fiber = finishedWork; while (true) { - if (node.flags & StoreConsistency) { + const tag = node.tag; + if ( + (tag === FunctionComponent || + tag === ForwardRef || + tag === SimpleMemoComponent) && + node.flags & StoreConsistency + ) { const updateQueue: FunctionComponentUpdateQueue | null = (node.updateQueue: any); if (updateQueue !== null) { @@ -1453,6 +1488,10 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null { return null; } + if (enableProfilerTimer && enableComponentPerformanceTrack) { + recordCompleteTime(); + } + // We now have a consistent tree. Because this is a sync render, we // will commit it even if something suspended. const finishedWork: Fiber = (root.current.alternate: any); @@ -1464,6 +1503,8 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null { workInProgressTransitions, workInProgressRootDidIncludeRecursiveRenderUpdate, workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, ); // Before exiting, make sure there's a callback scheduled for the next @@ -1691,6 +1732,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRootRenderPhaseUpdatedLanes = NoLanes; workInProgressRootPingedLanes = NoLanes; workInProgressDeferredLane = NoLane; + workInProgressSuspendedRetryLanes = NoLanes; workInProgressRootConcurrentErrors = null; workInProgressRootRecoverableErrors = null; workInProgressRootDidIncludeRecursiveRenderUpdate = false; @@ -1814,7 +1856,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void { // Record the time spent rendering before an error was thrown. This // avoids inaccurate Profiler durations in the case of a // suspended render. - stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); + stopProfilerTimerIfRunningAndRecordDuration(erroredWork); } if (enableSchedulingProfiler) { @@ -1968,6 +2010,18 @@ export function renderDidSuspend(): void { export function renderDidSuspendDelayIfPossible(): void { workInProgressRootExitStatus = RootSuspendedWithDelay; + if ( + !workInProgressRootDidSkipSuspendedSiblings && + !includesBlockingLane(workInProgressRootRenderLanes) + ) { + // This render may not have originally been scheduled as a prerender, but + // something suspended inside the visible part of the tree, which means we + // won't be able to commit a fallback anyway. Let's proceed as if this were + // a prerender so that we can warm up the siblings without scheduling a + // separate pass. + workInProgressRootIsPrerendering = true; + } + // Check if there are updates that we skipped tree that might have unblocked // this render. if ( @@ -2092,9 +2146,10 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { } default: { // Unwind then continue with the normal work loop. + const reason = workInProgressSuspendedReason; workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; - throwAndUnwindWorkLoop(root, unitOfWork, thrownValue); + throwAndUnwindWorkLoop(root, unitOfWork, thrownValue, reason); break; } } @@ -2187,6 +2242,14 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { workInProgressTransitions = getTransitionsForLanes(root, lanes); resetRenderTimer(); prepareFreshStack(root, lanes); + } else { + // This is a continuation of an existing work-in-progress. + // + // If we were previously in prerendering mode, check if we received any new + // data during an interleaved event. + if (workInProgressRootIsPrerendering) { + workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes); + } } if (__DEV__) { @@ -2214,7 +2277,12 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { // Unwind then continue with the normal work loop. workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; - throwAndUnwindWorkLoop(root, unitOfWork, thrownValue); + throwAndUnwindWorkLoop( + root, + unitOfWork, + thrownValue, + SuspendedOnError, + ); break; } case SuspendedOnData: { @@ -2272,7 +2340,12 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { // Otherwise, unwind then continue with the normal work loop. workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; - throwAndUnwindWorkLoop(root, unitOfWork, thrownValue); + throwAndUnwindWorkLoop( + root, + unitOfWork, + thrownValue, + SuspendedAndReadyToContinue, + ); } break; } @@ -2335,7 +2408,12 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { // Otherwise, unwind then continue with the normal work loop. workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; - throwAndUnwindWorkLoop(root, unitOfWork, thrownValue); + throwAndUnwindWorkLoop( + root, + unitOfWork, + thrownValue, + SuspendedOnInstanceAndReadyToContinue, + ); break; } case SuspendedOnDeprecatedThrowPromise: { @@ -2345,7 +2423,12 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { // always unwind. workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; - throwAndUnwindWorkLoop(root, unitOfWork, thrownValue); + throwAndUnwindWorkLoop( + root, + unitOfWork, + thrownValue, + SuspendedOnDeprecatedThrowPromise, + ); break; } case SuspendedOnHydration: { @@ -2445,7 +2528,7 @@ function performUnitOfWork(unitOfWork: Fiber): void { } else { next = beginWork(current, unitOfWork, entangledRenderLanes); } - stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); + stopProfilerTimerIfRunningAndRecordDuration(unitOfWork); } else { if (__DEV__) { next = runWithFiberInDEV( @@ -2589,7 +2672,7 @@ function replayBeginWork(unitOfWork: Fiber): null | Fiber { } } if (isProfilingMode) { - stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); + stopProfilerTimerIfRunningAndRecordDuration(unitOfWork); } return next; @@ -2599,6 +2682,7 @@ function throwAndUnwindWorkLoop( root: FiberRoot, unitOfWork: Fiber, thrownValue: mixed, + suspendedReason: SuspendedReason, ) { // This is a fork of performUnitOfWork specifcally for unwinding a fiber // that threw an exception. @@ -2646,16 +2730,43 @@ function throwAndUnwindWorkLoop( // The current algorithm for both hydration and error handling assumes // that the tree is rendered sequentially. So we always skip the siblings. getIsHydrating() || - workInProgressSuspendedReason === SuspendedOnError + suspendedReason === SuspendedOnError ) { skipSiblings = true; // We intentionally don't set workInProgressRootDidSkipSuspendedSiblings, // because we don't want to trigger another prerender attempt. - } else if (!workInProgressRootIsPrerendering) { + } else if ( + // Check whether this is a prerender + !workInProgressRootIsPrerendering && + // Offscreen rendering is also a form of speculative rendering + !includesSomeLane(workInProgressRootRenderLanes, OffscreenLane) + ) { // This is not a prerender. Skip the siblings during this render. A // separate prerender will be scheduled for later. skipSiblings = true; workInProgressRootDidSkipSuspendedSiblings = true; + + // Because we're skipping the siblings, schedule an immediate retry of + // this boundary. + // + // The reason we do this is because a prerender is only scheduled when + // the root is blocked from committing, i.e. RootSuspendedWithDelay. + // When the root is not blocked, as in the case when we render a + // fallback, the original lane is considered to be finished, and + // therefore no longer in need of being prerendered. However, there's + // still a pending retry that will happen once the data streams in. + // We should start rendering that even before the data streams in so we + // can prerender the siblings. + if ( + suspendedReason === SuspendedOnData || + suspendedReason === SuspendedOnImmediate || + suspendedReason === SuspendedOnDeprecatedThrowPromise + ) { + const boundary = getSuspenseHandler(); + if (boundary !== null && boundary.tag === SuspenseComponent) { + boundary.flags |= ScheduleRetry; + } + } } else { // This is a prerender. Don't skip the siblings. skipSiblings = false; @@ -2676,6 +2787,16 @@ function throwAndUnwindWorkLoop( } } +export function markSpawnedRetryLane(lane: Lane): void { + // Keep track of the retry lanes that were spawned by a fallback during the + // current render and were not later pinged. This will represent the lanes + // that are known to still be suspended. + workInProgressSuspendedRetryLanes = mergeLanes( + workInProgressSuspendedRetryLanes, + lane, + ); +} + function panicOnRootError(root: FiberRoot, error: mixed) { // There's no ancestor that can handle this exception. This should never // happen because the root is supposed to capture all errors that weren't @@ -2716,35 +2837,22 @@ function completeUnitOfWork(unitOfWork: Fiber): void { const returnFiber = completedWork.return; let next; - if (!enableProfilerTimer || (completedWork.mode & ProfileMode) === NoMode) { - if (__DEV__) { - next = runWithFiberInDEV( - completedWork, - completeWork, - current, - completedWork, - entangledRenderLanes, - ); - } else { - next = completeWork(current, completedWork, entangledRenderLanes); - } + startProfilerTimer(completedWork); + if (__DEV__) { + next = runWithFiberInDEV( + completedWork, + completeWork, + current, + completedWork, + entangledRenderLanes, + ); } else { - startProfilerTimer(completedWork); - if (__DEV__) { - next = runWithFiberInDEV( - completedWork, - completeWork, - current, - completedWork, - entangledRenderLanes, - ); - } else { - next = completeWork(current, completedWork, entangledRenderLanes); - } + next = completeWork(current, completedWork, entangledRenderLanes); + } + if (enableProfilerTimer && (completedWork.mode & ProfileMode) !== NoMode) { // Update render duration assuming we didn't error. - stopProfilerTimerIfRunningAndRecordDelta(completedWork, false); + stopProfilerTimerIfRunningAndRecordIncompleteDuration(completedWork); } - if (next !== null) { // Completing this fiber spawned new work. Work on that next. workInProgress = next; @@ -2800,7 +2908,7 @@ function unwindUnitOfWork(unitOfWork: Fiber, skipSiblings: boolean): void { if (enableProfilerTimer && (incompleteWork.mode & ProfileMode) !== NoMode) { // Record the render duration for the fiber that errored. - stopProfilerTimerIfRunningAndRecordDelta(incompleteWork, false); + stopProfilerTimerIfRunningAndRecordIncompleteDuration(incompleteWork); // Include the time spent working on failed children before continuing. let actualDuration = incompleteWork.actualDuration; @@ -2854,6 +2962,8 @@ function commitRoot( transitions: Array | null, didIncludeRenderPhaseUpdate: boolean, spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, ) { // TODO: This no longer makes any sense. We already wrap the mutation and // layout phases. Should be able to remove. @@ -2869,6 +2979,8 @@ function commitRoot( didIncludeRenderPhaseUpdate, previousUpdateLanePriority, spawnedLane, + updatedLanes, + suspendedRetryLanes, ); } finally { ReactSharedInternals.T = prevTransition; @@ -2885,6 +2997,8 @@ function commitRootImpl( didIncludeRenderPhaseUpdate: boolean, renderPriorityLevel: EventPriority, spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, ) { do { // `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which @@ -2961,7 +3075,14 @@ function commitRootImpl( const concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes(); remainingLanes = mergeLanes(remainingLanes, concurrentlyUpdatedLanes); - markRootFinished(root, remainingLanes, spawnedLane); + markRootFinished( + root, + lanes, + remainingLanes, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + ); // Reset this before firing side effects so we can detect recursive updates. didIncludeCommitPhaseUpdate = false; @@ -2983,6 +3104,10 @@ function commitRootImpl( // TODO: Delete all other places that schedule the passive effect callback // They're redundant. if ( + // If this subtree rendered with profiling this commit, we need to visit it to log it. + (enableProfilerTimer && + enableComponentPerformanceTrack && + finishedWork.actualDuration !== 0) || (finishedWork.subtreeFlags & PassiveMask) !== NoFlags || (finishedWork.flags & PassiveMask) !== NoFlags ) { @@ -3352,19 +3477,6 @@ export function flushPassiveEffects(): boolean { return false; } -export function enqueuePendingPassiveProfilerEffect(fiber: Fiber): void { - if (enableProfilerTimer && enableProfilerCommitHooks) { - pendingPassiveProfilerEffects.push(fiber); - if (!rootDoesHavePassiveEffects) { - rootDoesHavePassiveEffects = true; - scheduleCallback(NormalSchedulerPriority, () => { - flushPassiveEffects(); - return null; - }); - } - } -} - function flushPassiveEffectsImpl() { if (rootWithPendingPassiveEffects === null) { return false; @@ -3386,6 +3498,12 @@ function flushPassiveEffectsImpl() { throw new Error('Cannot flush passive effects while already rendering.'); } + if (enableProfilerTimer && enableComponentPerformanceTrack) { + // We're about to log a lot of profiling for this commit. + // We set this once so we don't have to recompute it for every log. + setCurrentTrackFromLanes(lanes); + } + if (__DEV__) { isFlushingPassiveEffects = true; didScheduleUpdateDuringPassiveEffects = false; @@ -3405,16 +3523,6 @@ function flushPassiveEffectsImpl() { commitPassiveUnmountEffects(root.current); commitPassiveMountEffects(root, root.current, lanes, transitions); - // TODO: Move to commitPassiveMountEffects - if (enableProfilerTimer && enableProfilerCommitHooks) { - const profilerEffects = pendingPassiveProfilerEffects; - pendingPassiveProfilerEffects = []; - for (let i = 0; i < profilerEffects.length; i++) { - const fiber = ((profilerEffects[i]: any): Fiber); - commitPassiveEffectDurations(root, fiber); - } - } - if (__DEV__) { if (enableDebugTracing) { logPassiveEffectsStopped(); @@ -3667,6 +3775,17 @@ function pingSuspendedRoot( pingedLanes, ); } + + // If something pings the work-in-progress render, any work that suspended + // up to this point may now be unblocked; in other words, no + // longer suspended. + // + // Unlike the broader check above, we only need do this if the lanes match + // exactly. If the lanes don't exactly match, that implies the promise + // was created by an older render. + if (workInProgressSuspendedRetryLanes === workInProgressRootRenderLanes) { + workInProgressSuspendedRetryLanes = NoLanes; + } } ensureRootIsScheduled(root); diff --git a/packages/react-reconciler/src/ReactProfilerTimer.js b/packages/react-reconciler/src/ReactProfilerTimer.js index 283b0e1f6a920..bad2f60490d54 100644 --- a/packages/react-reconciler/src/ReactProfilerTimer.js +++ b/packages/react-reconciler/src/ReactProfilerTimer.js @@ -29,11 +29,13 @@ export type ProfilerTimer = { recordCommitTime(): void, startProfilerTimer(fiber: Fiber): void, stopProfilerTimerIfRunning(fiber: Fiber): void, - stopProfilerTimerIfRunningAndRecordDelta(fiber: Fiber): void, + stopProfilerTimerIfRunningAndRecordDuration(fiber: Fiber): void, + stopProfilerTimerIfRunningAndRecordIncompleteDuration(fiber: Fiber): void, syncNestedUpdateFlag(): void, ... }; +let completeTime: number = 0; let commitTime: number = 0; let layoutEffectStartTime: number = -1; let profilerStartTime: number = -1; @@ -82,6 +84,17 @@ function syncNestedUpdateFlag(): void { } } +function getCompleteTime(): number { + return completeTime; +} + +function recordCompleteTime(): void { + if (!enableProfilerTimer) { + return; + } + completeTime = now(); +} + function getCommitTime(): number { return commitTime; } @@ -101,7 +114,7 @@ function startProfilerTimer(fiber: Fiber): void { profilerStartTime = now(); if (((fiber.actualStartTime: any): number) < 0) { - fiber.actualStartTime = now(); + fiber.actualStartTime = profilerStartTime; } } @@ -112,9 +125,21 @@ function stopProfilerTimerIfRunning(fiber: Fiber): void { profilerStartTime = -1; } -function stopProfilerTimerIfRunningAndRecordDelta( +function stopProfilerTimerIfRunningAndRecordDuration(fiber: Fiber): void { + if (!enableProfilerTimer) { + return; + } + + if (profilerStartTime >= 0) { + const elapsedTime = now() - profilerStartTime; + fiber.actualDuration += elapsedTime; + fiber.selfBaseDuration = elapsedTime; + profilerStartTime = -1; + } +} + +function stopProfilerTimerIfRunningAndRecordIncompleteDuration( fiber: Fiber, - overrideBaseTime: boolean, ): void { if (!enableProfilerTimer) { return; @@ -123,9 +148,7 @@ function stopProfilerTimerIfRunningAndRecordDelta( if (profilerStartTime >= 0) { const elapsedTime = now() - profilerStartTime; fiber.actualDuration += elapsedTime; - if (overrideBaseTime) { - fiber.selfBaseDuration = elapsedTime; - } + // We don't update the selfBaseDuration here because we errored. profilerStartTime = -1; } } @@ -222,10 +245,12 @@ function transferActualDuration(fiber: Fiber): void { } export { + getCompleteTime, + recordCompleteTime, getCommitTime, + recordCommitTime, isCurrentUpdateNested, markNestedUpdateScheduled, - recordCommitTime, recordLayoutEffectDuration, recordPassiveEffectDuration, resetNestedUpdateFlag, @@ -233,7 +258,8 @@ export { startPassiveEffectTimer, startProfilerTimer, stopProfilerTimerIfRunning, - stopProfilerTimerIfRunningAndRecordDelta, + stopProfilerTimerIfRunningAndRecordDuration, + stopProfilerTimerIfRunningAndRecordIncompleteDuration, syncNestedUpdateFlag, transferActualDuration, }; diff --git a/packages/react-reconciler/src/__tests__/Activity-test.js b/packages/react-reconciler/src/__tests__/Activity-test.js index e174c7c06ca76..cc5c4b921ce98 100644 --- a/packages/react-reconciler/src/__tests__/Activity-test.js +++ b/packages/react-reconciler/src/__tests__/Activity-test.js @@ -7,6 +7,7 @@ let Activity; let useState; let useLayoutEffect; let useEffect; +let useInsertionEffect; let useMemo; let useRef; let startTransition; @@ -25,6 +26,7 @@ describe('Activity', () => { LegacyHidden = React.unstable_LegacyHidden; Activity = React.unstable_Activity; useState = React.useState; + useInsertionEffect = React.useInsertionEffect; useLayoutEffect = React.useLayoutEffect; useEffect = React.useEffect; useMemo = React.useMemo; @@ -43,6 +45,13 @@ describe('Activity', () => { } function LoggedText({text, children}) { + useInsertionEffect(() => { + Scheduler.log(`mount insertion ${text}`); + return () => { + Scheduler.log(`unmount insertion ${text}`); + }; + }); + useEffect(() => { Scheduler.log(`mount ${text}`); return () => { @@ -1436,6 +1445,63 @@ describe('Activity', () => { ); }); + // @gate enableActivity + it('insertion effects are not disconnected when the visibility changes', async () => { + function Child({step}) { + useInsertionEffect(() => { + Scheduler.log(`Commit mount [${step}]`); + return () => { + Scheduler.log(`Commit unmount [${step}]`); + }; + }, [step]); + return ; + } + + function App({show, step}) { + return ( + + {useMemo( + () => ( + + ), + [step], + )} + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog([1, 'Commit mount [1]']); + expect(root).toMatchRenderedOutput(); + + // Hide the tree. This will not unmount insertion effects. + await act(() => { + root.render(); + }); + assertLog([]); + expect(root).toMatchRenderedOutput(