From f60ed776d2399f406aa58b6a8e63ca86e2a9c142 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 6 Feb 2026 08:55:22 -0800 Subject: [PATCH 1/6] Claude's first pass at StableHandler --- .../src/Entrypoint/Pipeline.ts | 8 + .../src/HIR/BuildHIR.ts | 80 +++++++++- .../src/HIR/Environment.ts | 9 ++ .../src/HIR/HIR.ts | 17 +- .../src/HIR/ObjectShape.ts | 14 ++ .../src/Inference/InferEffectDependencies.ts | 5 +- .../src/Inference/InferReactivePlaces.ts | 10 ++ .../src/Optimization/LowerContextAccess.ts | 1 + .../src/Optimization/OutlineJsx.ts | 1 + .../ReactiveScopes/CodegenReactiveFunction.ts | 150 +++++++++++++++++- .../InferReactiveScopeVariables.ts | 1 + .../ReactiveScopes/MarkStableHandlerScopes.ts | 73 +++++++++ ...rgeReactiveScopesThatInvalidateTogether.ts | 2 + .../src/ReactiveScopes/index.ts | 1 + .../src/TypeInference/InferTypes.ts | 50 +++++- .../ValidateExhaustiveDependencies.ts | 11 +- .../Validation/ValidateNoRefAccessInRender.ts | 11 +- .../stable-handler-local-basic.expect.md | 62 ++++++++ .../stable-handler-local-basic.ts | 14 ++ .../stable-handler-local-captures.expect.md | 105 ++++++++++++ .../stable-handler-local-captures.ts | 26 +++ .../stable-handler-local-ref-access.expect.md | 82 ++++++++++ .../stable-handler-local-ref-access.ts | 26 +++ .../stable-handler-prop-basic.expect.md | 69 ++++++++ .../stable-handler-prop-basic.ts | 17 ++ .../stable-handler-prop-mixed.expect.md | 73 +++++++++ .../stable-handler-prop-mixed.ts | 21 +++ .../stable-handler-prop-ref-access.expect.md | 85 ++++++++++ .../stable-handler-prop-ref-access.ts | 26 +++ 29 files changed, 1034 insertions(+), 16 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkStableHandlerScopes.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.ts 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 30d665227159..1de68362bae8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -52,6 +52,7 @@ import { codegenFunction, extractScopeDeclarationsFromDestructuring, inferReactiveScopeVariables, + markStableHandlerScopes, memoizeFbtAndMacroOperandsInSameScope, mergeReactiveScopesThatInvalidateTogether, promoteUsedTemporaries, @@ -460,6 +461,13 @@ function runWithEnvironment( assertWellFormedBreakTargets(reactiveFunction); + markStableHandlerScopes(reactiveFunction); + log({ + kind: 'reactive', + name: 'MarkStableHandlerScopes', + value: reactiveFunction, + }); + pruneUnusedLabels(reactiveFunction); log({ kind: 'reactive', diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts index f43b3dd70157..2b4ea6bdc57d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts @@ -50,7 +50,7 @@ import { validateIdentifierName, } from './HIR'; import HIRBuilder, {Bindings, createTemporaryPlace} from './HIRBuilder'; -import {BuiltInArrayId} from './ObjectShape'; +import {BuiltInArrayId, BuiltInStableHandlerId} from './ObjectShape'; /* * ******************************************************************************************* @@ -184,6 +184,67 @@ export function lower( } }); + // Extract per-property type annotations for StableHandler props + let propsTypeAnnotations: Map | null = null; + if (env.config.enableStableHandlerAnnotation) { + const firstParam = func.get('params')[0]; + if (firstParam != null && firstParam.isObjectPattern()) { + const typeAnnotation = (firstParam.node as t.ObjectPattern).typeAnnotation; + if (typeAnnotation != null) { + const annoNode = + typeAnnotation.type === 'TSTypeAnnotation' + ? typeAnnotation.typeAnnotation + : typeAnnotation.type === 'TypeAnnotation' + ? typeAnnotation.typeAnnotation + : null; + if (annoNode != null) { + const members = + annoNode.type === 'TSTypeLiteral' + ? annoNode.members + : annoNode.type === 'ObjectTypeAnnotation' + ? annoNode.properties + : null; + if (members != null) { + for (const member of members) { + let propName: string | null = null; + let propType: t.FlowType | t.TSType | null = null; + if ( + member.type === 'TSPropertySignature' && + member.key.type === 'Identifier' && + member.typeAnnotation?.type === 'TSTypeAnnotation' + ) { + const tsType = member.typeAnnotation.typeAnnotation; + if ( + tsType.type === 'TSTypeReference' && + tsType.typeName.type === 'Identifier' && + tsType.typeName.name === 'StableHandler' + ) { + propName = member.key.name; + propType = tsType; + } + } else if ( + member.type === 'ObjectTypeProperty' && + member.key.type === 'Identifier' && + member.value.type === 'GenericTypeAnnotation' && + member.value.id.type === 'Identifier' && + member.value.id.name === 'StableHandler' + ) { + propName = member.key.name; + propType = member.value; + } + if (propName != null && propType != null) { + if (propsTypeAnnotations == null) { + propsTypeAnnotations = new Map(); + } + propsTypeAnnotations.set(propName, propType); + } + } + } + } + } + } + } + let directives: Array = []; const body = func.get('body'); if (body.isExpression()) { @@ -260,6 +321,7 @@ export function lower( effects: null, aliasingEffects: null, directives, + propsTypeAnnotations, }); } @@ -4349,6 +4411,14 @@ export function lowerType(node: t.FlowType | t.TSType): Type { if (id.type === 'Identifier' && id.name === 'Array') { return {kind: 'Object', shapeId: BuiltInArrayId}; } + if (id.type === 'Identifier' && id.name === 'StableHandler') { + return { + kind: 'Function', + shapeId: BuiltInStableHandlerId, + return: makeType(), + isConstructor: false, + }; + } return makeType(); } case 'TSTypeReference': { @@ -4356,6 +4426,14 @@ export function lowerType(node: t.FlowType | t.TSType): Type { if (typeName.type === 'Identifier' && typeName.name === 'Array') { return {kind: 'Object', shapeId: BuiltInArrayId}; } + if (typeName.type === 'Identifier' && typeName.name === 'StableHandler') { + return { + kind: 'Function', + shapeId: BuiltInStableHandlerId, + return: makeType(), + isConstructor: false, + }; + } return makeType(); } case 'ArrayTypeAnnotation': 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 7ceb5bf005ce..67bebecda9ec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -254,6 +254,15 @@ export const EnvironmentConfigSchema = z.object({ */ enableUseTypeAnnotations: z.boolean().default(false), + /** + * Enable support for the StableHandler type annotation. When enabled, + * props annotated as StableHandler are treated as stable (non-reactive) and + * safe for ref access. Local functions annotated as StableHandler are compiled + * using a two-slot pattern that produces a stable function identity. + * Requires enableUseTypeAnnotations to also be enabled. + */ + enableStableHandlerAnnotation: z.boolean().default(false), + /** * Allows specifying a function that can populate HIR with type information from * Flow diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts index dca41eac92fe..050bf54729d1 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -294,6 +294,7 @@ export type HIRFunction = { async: boolean; directives: Array; aliasingEffects: Array | null; + propsTypeAnnotations: Map | null; }; /* @@ -1620,6 +1621,13 @@ export type ReactiveScope = { merged: Set; loc: SourceLocation; + + /** + * When true, this scope contains a StableHandler function that should use + * the two-slot codegen pattern: one slot always holds the latest function, + * another slot holds a stable wrapper created once on first render. + */ + stableHandler: boolean; }; export type ReactiveScopeDependencies = Set; @@ -1899,6 +1907,12 @@ export function isEffectEventFunctionType(id: Identifier): boolean { ); } +export function isStableHandlerType(id: Identifier): boolean { + return ( + id.type.kind === 'Function' && id.type.shapeId === 'BuiltInStableHandler' + ); +} + export function isStableType(id: Identifier): boolean { return ( isSetStateType(id) || @@ -1906,7 +1920,8 @@ export function isStableType(id: Identifier): boolean { isDispatcherType(id) || isUseRefType(id) || isStartTransitionType(id) || - isSetOptimisticType(id) + isSetOptimisticType(id) || + isStableHandlerType(id) ); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts index d104d799d726..34bf2a0ad13d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -389,6 +389,7 @@ export const BuiltInUseEffectEventId = 'BuiltInUseEffectEvent'; export const BuiltInEffectEventId = 'BuiltInEffectEventFunction'; export const BuiltInAutodepsId = 'BuiltInAutoDepsId'; export const BuiltInEventHandlerId = 'BuiltInEventHandlerId'; +export const BuiltInStableHandlerId = 'BuiltInStableHandler'; // See getReanimatedModuleType() in Globals.ts — this is part of supporting Reanimated's ref-like types export const ReanimatedSharedValueId = 'ReanimatedSharedValueId'; @@ -1262,6 +1263,19 @@ addFunction( BuiltInEventHandlerId, ); +addFunction( + BUILTIN_SHAPES, + [], + { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: {kind: 'Poly'}, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Mutable, + }, + BuiltInStableHandlerId, +); + /** * MixedReadOnly = * | primitive diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts index 2997a449dead..8b47656a2907 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts @@ -32,6 +32,7 @@ import { BasicBlock, BlockId, isEffectEventFunctionType, + isStableHandlerType, } from '../HIR'; import {collectHoistablePropertyLoadsInInnerFn} from '../HIR/CollectHoistablePropertyLoads'; import {collectOptionalChainSidemap} from '../HIR/CollectOptionalChainDependencies'; @@ -226,7 +227,8 @@ export function inferEffectDependencies(fn: HIRFunction): void { isSetStateType(maybeDep.identifier)) && !reactiveIds.has(maybeDep.identifier.id)) || isFireFunctionType(maybeDep.identifier) || - isEffectEventFunctionType(maybeDep.identifier) + isEffectEventFunctionType(maybeDep.identifier) || + isStableHandlerType(maybeDep.identifier) ) { // exclude non-reactive hook results, which will never be in a memo block continue; @@ -614,6 +616,7 @@ function inferDependencies( earlyReturnValue: null, merged: new Set(), loc: GeneratedSource, + stableHandler: false, }; context.enterScope(placeholderScope); inferDependenciesInFn(fn, context, temporaries); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts index 6b77c89f3b72..12ab7ec70f93 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts @@ -16,6 +16,7 @@ import { Place, evaluatesToStableTypeOrContainer, getHookKind, + isStableHandlerType, isStableType, isStableTypeContainer, isUseOperator, @@ -96,6 +97,15 @@ class StableSidemap { }); } } + } else { + // StableHandler-typed identifiers are stable regardless of source + for (const lvalue of eachInstructionLValue(instr)) { + if (isStableHandlerType(lvalue.identifier)) { + this.map.set(lvalue.identifier.id, { + isStable: true, + }); + } + } } break; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts b/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts index 50f00427205b..cb460b37974b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts @@ -264,6 +264,7 @@ function emitSelectorFn(env: Environment, keys: Array): Instruction { async: false, directives: [], aliasingEffects: [], + propsTypeAnnotations: null, }; reversePostorderBlocks(fn.body); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts b/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts index a38256896b1a..f4d93f830562 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts @@ -379,6 +379,7 @@ function emitOutlinedFn( async: false, directives: [], aliasingEffects: [], + propsTypeAnnotations: null, }; return fn; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts index c60e8fb95967..d1dabcdc76a6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -536,7 +536,21 @@ function codegenBlockNoReset( } case 'scope': { const temp = new Map(cx.temp); - codegenReactiveScope(cx, statements, item.scope, item.instructions); + if (item.scope.stableHandler) { + codegenStableHandlerScope( + cx, + statements, + item.scope, + item.instructions, + ); + } else { + codegenReactiveScope( + cx, + statements, + item.scope, + item.instructions, + ); + } cx.temp = temp; break; } @@ -934,6 +948,140 @@ function codegenReactiveScope( } } +/** + * Generates the two-slot codegen pattern for StableHandler scopes. + * + * The scope contains a FunctionExpression that produces a value (e.g. t0). + * We always recompute the function (to capture latest closure values), + * store it in cache slot 0, and maintain a stable wrapper in cache slot 1 + * that delegates to slot 0. + * + * Output pattern: + * let t0; // scope declaration + * t0 = () => { ... }; // always recomputed + * $[0] = t0; // store latest + * if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + * t0 = (...args) => $[0](...args); // create stable wrapper once + * $[1] = t0; + * } else { + * t0 = $[1]; // load cached stable wrapper + * } + */ +function codegenStableHandlerScope( + cx: Context, + statements: Array, + scope: ReactiveScope, + block: ReactiveBlock, +): void { + // Allocate two cache slots + const latestIndex = cx.nextCacheIndex; + const wrapperIndex = cx.nextCacheIndex; + + // Declare scope outputs before emitting the computation block. + // This mirrors the normal scope codegen pattern. + const declNames: Array = []; + for (const [, {identifier}] of [...scope.declarations].sort( + ([, a], [, b]) => compareScopeDeclaration(a, b), + )) { + CompilerError.invariant(identifier.name != null, { + reason: `Expected scope declaration identifier to be named`, + description: `Declaration \`${printIdentifier( + identifier, + )}\` is unnamed in scope @${scope.id}`, + loc: GeneratedSource, + }); + const name = convertIdentifier(identifier); + if (!cx.hasDeclared(identifier)) { + statements.push( + t.variableDeclaration('let', [ + t.variableDeclarator(t.cloneNode(name), null), + ]), + ); + } + cx.declare(identifier); + declNames.push(name); + } + + // Generate and emit the scope body unconditionally. + // This produces the function expression and assigns it to the declared variable. + const computationBlock = codegenBlock(cx, block); + statements.push(...computationBlock.body); + + // For each declaration (typically just one function expression): + for (const name of declNames) { + // $[latestIndex] = ; (always update with latest closure) + statements.push( + t.expressionStatement( + t.assignmentExpression( + '=', + t.memberExpression( + t.identifier(cx.synthesizeName('$')), + t.numericLiteral(latestIndex), + true, + ), + t.cloneNode(name), + ), + ), + ); + + // Sentinel-guarded wrapper: created once, loaded from cache thereafter + const wrapperSlot = t.memberExpression( + t.identifier(cx.synthesizeName('$')), + t.numericLiteral(wrapperIndex), + true, + ); + + // The stable wrapper: (...args) => $[latestIndex](...args) + const stableWrapper = t.arrowFunctionExpression( + [t.restElement(t.identifier('args'))], + t.callExpression( + t.memberExpression( + t.identifier(cx.synthesizeName('$')), + t.numericLiteral(latestIndex), + true, + ), + [t.spreadElement(t.identifier('args'))], + ), + ); + + const sentinelCheck = t.binaryExpression( + '===', + t.cloneNode(wrapperSlot), + t.callExpression( + t.memberExpression(t.identifier('Symbol'), t.identifier('for')), + [t.stringLiteral(MEMO_CACHE_SENTINEL)], + ), + ); + + statements.push( + t.ifStatement( + sentinelCheck, + t.blockStatement([ + t.expressionStatement( + t.assignmentExpression('=', t.cloneNode(name), stableWrapper), + ), + t.expressionStatement( + t.assignmentExpression( + '=', + t.cloneNode(wrapperSlot), + t.cloneNode(name), + ), + ), + ]), + t.blockStatement([ + t.expressionStatement( + t.assignmentExpression( + '=', + t.cloneNode(name), + t.cloneNode(wrapperSlot), + ), + ), + ]), + ), + ); + } +} + function codegenTerminal( cx: Context, terminal: ReactiveTerminal, 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 efa8975a78af..3db94a5e0244 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts @@ -113,6 +113,7 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void { earlyReturnValue: null, merged: new Set(), loc: identifier.loc, + stableHandler: false, }; scopes.set(groupIdentifier, scope); } else { diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkStableHandlerScopes.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkStableHandlerScopes.ts new file mode 100644 index 000000000000..443d5ad765d6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkStableHandlerScopes.ts @@ -0,0 +1,73 @@ +/** + * 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 { + IdentifierId, + ReactiveFunction, + ReactiveInstruction, + ReactiveScopeBlock, + isStableHandlerType, +} from '../HIR'; +import {ReactiveFunctionVisitor, visitReactiveFunction} from './visitors'; + +/** + * Marks reactive scopes that contain a StableHandler-typed local function + * assignment. These scopes will use a special two-slot codegen pattern: + * one slot always holds the latest function (updated every render), + * another slot holds a stable wrapper (created once on first render). + * + * This pass runs before scope merging. It finds StoreLocal instructions + * targeting StableHandler-typed variables, then marks the scope that + * declares the source function expression. + */ +export function markStableHandlerScopes(fn: ReactiveFunction): void { + if (!fn.env.config.enableStableHandlerAnnotation) { + return; + } + // First pass: collect identifier IDs of function expression values that are + // stored into StableHandler-typed local variables. + const stableHandlerSourceIds = new Set(); + visitReactiveFunction(fn, new CollectVisitor(), stableHandlerSourceIds); + if (stableHandlerSourceIds.size === 0) { + return; + } + // Second pass: mark scopes whose declarations include a StableHandler source. + visitReactiveFunction(fn, new MarkVisitor(), stableHandlerSourceIds); +} + +class CollectVisitor extends ReactiveFunctionVisitor> { + override visitInstruction( + instruction: ReactiveInstruction, + state: Set, + ): void { + this.traverseInstruction(instruction, state); + const {value} = instruction; + if ( + value.kind === 'StoreLocal' && + isStableHandlerType(value.lvalue.place.identifier) + ) { + // Track the source value's identifier id — this is the function + // expression produced by a scope that we need to mark. + state.add(value.value.identifier.id); + } + } +} + +class MarkVisitor extends ReactiveFunctionVisitor> { + override visitScope( + scopeBlock: ReactiveScopeBlock, + state: Set, + ): void { + this.traverseScope(scopeBlock, state); + for (const [id] of scopeBlock.scope.declarations) { + if (state.has(id)) { + scopeBlock.scope.stableHandler = true; + return; + } + } + } +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts index bdf2f29284aa..e7f5df28a2c5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts @@ -273,6 +273,8 @@ class Transform extends ReactiveFunctionTransform, instr: Instruction, + propsTypeAnnotations: Map | null, ): Generator { const {lvalue, value} = instr; const left = lvalue.identifier.type; @@ -224,14 +231,26 @@ function* generateInstructionTypes( case 'StoreLocal': { if (env.config.enableUseTypeAnnotations) { - yield equation( - value.lvalue.place.identifier.type, - value.value.identifier.type, - ); const valueType = value.type === null ? makeType() : lowerType(value.type); - yield equation(valueType, value.lvalue.place.identifier.type); - yield equation(left, valueType); + // When the annotation is a StableHandler, bind the lvalue to the + // annotation type first so it takes priority over the inferred + // function expression type. + if ( + env.config.enableStableHandlerAnnotation && + valueType.kind === 'Function' && + valueType.shapeId === BuiltInStableHandlerId + ) { + yield equation(value.lvalue.place.identifier.type, valueType); + yield equation(left, valueType); + } else { + yield equation( + value.lvalue.place.identifier.type, + value.value.identifier.type, + ); + yield equation(valueType, value.lvalue.place.identifier.type); + yield equation(left, valueType); + } } else { yield equation(left, value.value.identifier.type); yield equation( @@ -414,6 +433,23 @@ function* generateInstructionTypes( value: makePropertyLiteral(property.key.name), }, }); + // If this property is annotated as StableHandler, emit an + // additional type equation so the identifier gets the + // BuiltInStableHandler function type. + if ( + env.config.enableStableHandlerAnnotation && + propsTypeAnnotations != null + ) { + const annotation = propsTypeAnnotations.get( + property.key.name, + ); + if (annotation != null) { + yield equation( + property.place.identifier.type, + lowerType(annotation), + ); + } + } } } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateExhaustiveDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateExhaustiveDependencies.ts index b8647ec7c9bd..03cd90612cc5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateExhaustiveDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateExhaustiveDependencies.ts @@ -26,6 +26,7 @@ import { InstructionKind, isEffectEventFunctionType, isPrimitiveType, + isStableHandlerType, isStableType, isSubPath, isSubPathIgnoringOptionals, @@ -325,9 +326,12 @@ function validateDependencies( loc: inferredDependency.loc, }); /** - * Skip effect event functions as they are not valid dependencies + * Skip effect event functions and stable handlers as they are not valid dependencies */ - if (isEffectEventFunctionType(inferredDependency.identifier)) { + if ( + isEffectEventFunctionType(inferredDependency.identifier) || + isStableHandlerType(inferredDependency.identifier) + ) { continue; } let hasMatchingManualDependency = false; @@ -400,7 +404,8 @@ function validateDependencies( dep => dep.kind === 'Local' && !isOptionalDependency(dep, reactive) && - !isEffectEventFunctionType(dep.identifier), + !isEffectEventFunctionType(dep.identifier) && + !isStableHandlerType(dep.identifier), ) .map(printInferredDependency) .join(', ')}]`, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts index dd7b04a11d69..7233789ec8cb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts @@ -22,7 +22,10 @@ import { isRefValueType, isUseRefType, } from '../HIR'; -import {BuiltInEventHandlerId} from '../HIR/ObjectShape'; +import { + BuiltInEventHandlerId, + BuiltInStableHandlerId, +} from '../HIR/ObjectShape'; import { eachInstructionOperand, eachInstructionValueOperand, @@ -180,7 +183,11 @@ function refTypeOfType(place: Place): RefAccessType { function isEventHandlerType(identifier: Identifier): boolean { const type = identifier.type; - return type.kind === 'Function' && type.shapeId === BuiltInEventHandlerId; + return ( + type.kind === 'Function' && + (type.shapeId === BuiltInEventHandlerId || + type.shapeId === BuiltInStableHandlerId) + ); } function tyEqual(a: RefAccessType, b: RefAccessType): boolean { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.expect.md new file mode 100644 index 000000000000..d1bc2950c991 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.expect.md @@ -0,0 +1,62 @@ + +## Input + +```javascript +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +type StableHandler = T; + +function Component({value}: {value: string}) { + const handler: StableHandler<() => void> = () => { + console.log(value); + }; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'hello'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations +type StableHandler = T; + +function Component(t0) { + const $ = _c(4); + const { value } = t0; + let t1; + t1 = () => { + console.log(value); + }; + $[0] = t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (...args) => $[0](...args); + $[1] = t1; + } else { + t1 = $[1]; + } + const handler = t1; + let t2; + if ($[2] !== handler) { + t2 = ; + $[2] = handler; + $[3] = t2; + } else { + t2 = $[3]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: "hello" }], +}; + +``` + +### Eval output +(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.ts new file mode 100644 index 000000000000..7dfca4da10f2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.ts @@ -0,0 +1,14 @@ +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +type StableHandler = T; + +function Component({value}: {value: string}) { + const handler: StableHandler<() => void> = () => { + console.log(value); + }; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'hello'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.expect.md new file mode 100644 index 000000000000..e4998fe705aa --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.expect.md @@ -0,0 +1,105 @@ + +## Input + +```javascript +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +import {useState} from 'react'; + +type StableHandler = T; + +function Component() { + const [count, setCount] = useState(0); + const [name, setName] = useState('world'); + + const handler: StableHandler<() => void> = () => { + console.log(count, name); + }; + + return ( +
+ + + +
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations +import { useState } from "react"; + +type StableHandler = T; + +function Component() { + const $ = _c(8); + const [count, setCount] = useState(0); + const [name, setName] = useState("world"); + let t0; + t0 = () => { + console.log(count, name); + }; + $[0] = t0; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t0 = (...args) => $[0](...args); + $[1] = t0; + } else { + t0 = $[1]; + } + const handler = t0; + let t1; + if ($[2] !== handler) { + t1 = ; + $[2] = handler; + $[3] = t1; + } else { + t1 = $[3]; + } + let t2; + let t3; + if ($[4] === Symbol.for("react.memo_cache_sentinel")) { + t2 = ; + t3 = ; + $[4] = t2; + $[5] = t3; + } else { + t2 = $[4]; + t3 = $[5]; + } + let t4; + if ($[6] !== t1) { + t4 = ( +
+ {t1} + {t2} + {t3} +
+ ); + $[6] = t1; + $[7] = t4; + } else { + t4 = $[7]; + } + return t4; +} +function _temp(c) { + return c + 1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.ts new file mode 100644 index 000000000000..27ebeccf3aae --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.ts @@ -0,0 +1,26 @@ +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +import {useState} from 'react'; + +type StableHandler = T; + +function Component() { + const [count, setCount] = useState(0); + const [name, setName] = useState('world'); + + const handler: StableHandler<() => void> = () => { + console.log(count, name); + }; + + return ( +
+ + + +
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.expect.md new file mode 100644 index 000000000000..46a988ce7f07 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.expect.md @@ -0,0 +1,82 @@ + +## Input + +```javascript +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +import {useRef} from 'react'; + +type StableHandler = T; + +function Component() { + const ref = useRef(null); + + const handler: StableHandler<() => void> = () => { + if (ref.current !== null) { + console.log(ref.current.value); + } + }; + + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations +import { useRef } from "react"; + +type StableHandler = T; + +function Component() { + const $ = _c(3); + const ref = useRef(null); + let t0; + t0 = () => { + if (ref.current !== null) { + console.log(ref.current.value); + } + }; + $[0] = t0; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t0 = (...args) => $[0](...args); + $[1] = t0; + } else { + t0 = $[1]; + } + const handler = t0; + let t1; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t1 = ( + <> + + + + ); + $[2] = t1; + } else { + t1 = $[2]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +### Eval output +(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.ts new file mode 100644 index 000000000000..56ca7a9045ed --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.ts @@ -0,0 +1,26 @@ +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +import {useRef} from 'react'; + +type StableHandler = T; + +function Component() { + const ref = useRef(null); + + const handler: StableHandler<() => void> = () => { + if (ref.current !== null) { + console.log(ref.current.value); + } + }; + + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md new file mode 100644 index 000000000000..681038d3c095 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md @@ -0,0 +1,69 @@ + +## Input + +```javascript +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +type StableHandler = T; + +function Component({ + onSubmit, + value, +}: { + onSubmit: StableHandler<(data: string) => void>; + value: string; +}) { + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{onSubmit: (data: string) => console.log(data), value: 'hello'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations +type StableHandler = T; + +function Component(t0) { + const $ = _c(5); + const { onSubmit, value } = t0; + let t1; + if ($[0] !== value) { + t1 = () => onSubmit(value); + $[0] = value; + $[1] = t1; + } else { + t1 = $[1]; + } + let t2; + if ($[2] !== t1 || $[3] !== value) { + t2 = ; + $[2] = t1; + $[3] = value; + $[4] = t2; + } else { + t2 = $[4]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [ + { + onSubmit: (data) => { + return console.log(data); + }, + value: "hello", + }, + ], +}; + +``` + +### Eval output +(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.ts new file mode 100644 index 000000000000..a8360c7a1f77 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.ts @@ -0,0 +1,17 @@ +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +type StableHandler = T; + +function Component({ + onSubmit, + value, +}: { + onSubmit: StableHandler<(data: string) => void>; + value: string; +}) { + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{onSubmit: (data: string) => console.log(data), value: 'hello'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md new file mode 100644 index 000000000000..8d12cebd4c55 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md @@ -0,0 +1,73 @@ + +## Input + +```javascript +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +type StableHandler = T; + +function Component({ + onSubmit, + label, +}: { + onSubmit: StableHandler<(data: string) => void>; + label: string; +}) { + return ( + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{onSubmit: (data: string) => console.log(data), label: 'click me'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations +type StableHandler = T; + +function Component(t0) { + const $ = _c(5); + const { onSubmit, label } = t0; + let t1; + if ($[0] !== label) { + t1 = () => onSubmit(label); + $[0] = label; + $[1] = t1; + } else { + t1 = $[1]; + } + let t2; + if ($[2] !== label || $[3] !== t1) { + t2 = ; + $[2] = label; + $[3] = t1; + $[4] = t2; + } else { + t2 = $[4]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [ + { + onSubmit: (data) => { + return console.log(data); + }, + label: "click me", + }, + ], +}; + +``` + +### Eval output +(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.ts new file mode 100644 index 000000000000..9885a9765a9b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.ts @@ -0,0 +1,21 @@ +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +type StableHandler = T; + +function Component({ + onSubmit, + label, +}: { + onSubmit: StableHandler<(data: string) => void>; + label: string; +}) { + return ( + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{onSubmit: (data: string) => console.log(data), label: 'click me'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.expect.md new file mode 100644 index 000000000000..e99c9df69b27 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.expect.md @@ -0,0 +1,85 @@ + +## Input + +```javascript +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +import {useRef} from 'react'; + +type StableHandler = T; + +function Component({ + onSubmit, +}: { + onSubmit: StableHandler<(data: string) => void>; +}) { + const ref = useRef(null); + const handler = () => { + onSubmit(ref.current!.value); + }; + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{onSubmit: (data: string) => console.log(data)}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations +import { useRef } from "react"; + +type StableHandler = T; + +function Component(t0) { + const $ = _c(2); + const { onSubmit } = t0; + const ref = useRef(null); + let t1; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t1 = () => { + onSubmit(ref.current.value); + }; + $[0] = t1; + } else { + t1 = $[0]; + } + const handler = t1; + let t2; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t2 = ( + <> + + + + ); + $[1] = t2; + } else { + t2 = $[1]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [ + { + onSubmit: (data) => { + return console.log(data); + }, + }, + ], +}; + +``` + +### Eval output +(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.ts new file mode 100644 index 000000000000..f5e08b7ddebb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.ts @@ -0,0 +1,26 @@ +// @enableStableHandlerAnnotation @enableUseTypeAnnotations +import {useRef} from 'react'; + +type StableHandler = T; + +function Component({ + onSubmit, +}: { + onSubmit: StableHandler<(data: string) => void>; +}) { + const ref = useRef(null); + const handler = () => { + onSubmit(ref.current!.value); + }; + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{onSubmit: (data: string) => console.log(data)}], +}; From 9d5180765384c6937b668a21a92cb935452c1925 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 6 Feb 2026 09:48:50 -0800 Subject: [PATCH 2/6] Another pass, fixing a few issues Prompt: Looking at @compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md -- I see two potential problems. It looks to me like the onClick that is being passed to ); - $[2] = t1; + $[1] = t1; } else { - t1 = $[2]; + t1 = $[1]; } return t1; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md index 681038d3c095..9cfedcca3462 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md @@ -32,9 +32,10 @@ function Component(t0) { const $ = _c(5); const { onSubmit, value } = t0; let t1; - if ($[0] !== value) { - t1 = () => onSubmit(value); - $[0] = value; + t1 = () => onSubmit(value); + $[0] = t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (...args) => $[0](...args); $[1] = t1; } else { t1 = $[1]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md index 8d12cebd4c55..b32631dc6e1b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md @@ -36,9 +36,10 @@ function Component(t0) { const $ = _c(5); const { onSubmit, label } = t0; let t1; - if ($[0] !== label) { - t1 = () => onSubmit(label); - $[0] = label; + t1 = () => onSubmit(label); + $[0] = t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (...args) => $[0](...args); $[1] = t1; } else { t1 = $[1]; From 87e0819bbdb2eaeb10f0794da01cad702a2545d7 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 6 Feb 2026 14:47:10 -0800 Subject: [PATCH 3/6] more progress. changing name to NonReactive --- .../src/Entrypoint/Pipeline.ts | 6 +- .../src/HIR/BuildHIR.ts | 18 +-- .../src/HIR/Environment.ts | 8 +- .../src/HIR/HIR.ts | 10 +- .../src/HIR/ObjectShape.ts | 4 +- .../src/Inference/InferEffectDependencies.ts | 6 +- .../src/Inference/InferReactivePlaces.ts | 6 +- .../ReactiveScopes/CodegenReactiveFunction.ts | 8 +- .../InferReactiveScopeVariables.ts | 2 +- .../ReactiveScopes/MarkNonReactiveScopes.ts | 114 ++++++++++++++++++ .../ReactiveScopes/MarkStableHandlerScopes.ts | 91 -------------- ...rgeReactiveScopesThatInvalidateTogether.ts | 4 +- .../src/ReactiveScopes/index.ts | 2 +- .../src/TypeInference/InferTypes.ts | 37 ++++-- .../ValidateExhaustiveDependencies.ts | 6 +- .../Validation/ValidateNoRefAccessInRender.ts | 4 +- .../non-reactive-local-basic.expect.md} | 10 +- .../non-reactive-local-basic.ts} | 6 +- .../non-reactive-local-captures.expect.md} | 10 +- .../non-reactive-local-captures.ts} | 6 +- .../non-reactive-local-ref-access.expect.md} | 36 +++--- .../non-reactive-local-ref-access.ts} | 6 +- .../non-reactive-prop-basic.expect.md} | 17 ++- .../non-reactive-prop-basic.ts} | 6 +- .../non-reactive-prop-mixed.expect.md} | 17 ++- .../non-reactive-prop-mixed.ts} | 6 +- .../non-reactive-prop-ref-access.expect.md} | 10 +- .../non-reactive-prop-ref-access.ts} | 6 +- .../non-reactive-wrapper-basic.expect.md | 66 ++++++++++ .../non-reactive-wrapper-basic.ts | 16 +++ .../react-compiler-runtime/src/index.ts | 37 ++++++ 31 files changed, 370 insertions(+), 211 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkNonReactiveScopes.ts delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkStableHandlerScopes.ts rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-local-basic.expect.md => non-reactive/non-reactive-local-basic.expect.md} (76%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-local-basic.ts => non-reactive/non-reactive-local-basic.ts} (60%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-local-captures.expect.md => non-reactive/non-reactive-local-captures.expect.md} (86%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-local-captures.ts => non-reactive/non-reactive-local-captures.ts} (77%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-local-ref-access.expect.md => non-reactive/non-reactive-local-ref-access.expect.md} (62%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-local-ref-access.ts => non-reactive/non-reactive-local-ref-access.ts} (72%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-prop-basic.expect.md => non-reactive/non-reactive-prop-basic.expect.md} (68%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-prop-basic.ts => non-reactive/non-reactive-prop-basic.ts} (66%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-prop-mixed.expect.md => non-reactive/non-reactive-prop-mixed.expect.md} (69%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-prop-mixed.ts => non-reactive/non-reactive-prop-mixed.ts} (68%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-prop-ref-access.expect.md => non-reactive/non-reactive-prop-ref-access.expect.md} (82%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{stable-handler/stable-handler-prop-ref-access.ts => non-reactive/non-reactive-prop-ref-access.ts} (74%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-wrapper-basic.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-wrapper-basic.ts 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 1de68362bae8..308d49fc0d06 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -52,7 +52,7 @@ import { codegenFunction, extractScopeDeclarationsFromDestructuring, inferReactiveScopeVariables, - markStableHandlerScopes, + markNonReactiveScopes, memoizeFbtAndMacroOperandsInSameScope, mergeReactiveScopesThatInvalidateTogether, promoteUsedTemporaries, @@ -461,10 +461,10 @@ function runWithEnvironment( assertWellFormedBreakTargets(reactiveFunction); - markStableHandlerScopes(reactiveFunction); + markNonReactiveScopes(reactiveFunction); log({ kind: 'reactive', - name: 'MarkStableHandlerScopes', + name: 'MarkNonReactiveScopes', value: reactiveFunction, }); diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts index 2b4ea6bdc57d..5b7767462f56 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts @@ -50,7 +50,7 @@ import { validateIdentifierName, } from './HIR'; import HIRBuilder, {Bindings, createTemporaryPlace} from './HIRBuilder'; -import {BuiltInArrayId, BuiltInStableHandlerId} from './ObjectShape'; +import {BuiltInArrayId, BuiltInNonReactiveId} from './ObjectShape'; /* * ******************************************************************************************* @@ -184,9 +184,9 @@ export function lower( } }); - // Extract per-property type annotations for StableHandler props + // Extract per-property type annotations for NonReactive props let propsTypeAnnotations: Map | null = null; - if (env.config.enableStableHandlerAnnotation) { + if (env.config.enableNonReactiveAnnotation) { const firstParam = func.get('params')[0]; if (firstParam != null && firstParam.isObjectPattern()) { const typeAnnotation = (firstParam.node as t.ObjectPattern).typeAnnotation; @@ -217,7 +217,7 @@ export function lower( if ( tsType.type === 'TSTypeReference' && tsType.typeName.type === 'Identifier' && - tsType.typeName.name === 'StableHandler' + tsType.typeName.name === 'NonReactive' ) { propName = member.key.name; propType = tsType; @@ -227,7 +227,7 @@ export function lower( member.key.type === 'Identifier' && member.value.type === 'GenericTypeAnnotation' && member.value.id.type === 'Identifier' && - member.value.id.name === 'StableHandler' + member.value.id.name === 'NonReactive' ) { propName = member.key.name; propType = member.value; @@ -4411,10 +4411,10 @@ export function lowerType(node: t.FlowType | t.TSType): Type { if (id.type === 'Identifier' && id.name === 'Array') { return {kind: 'Object', shapeId: BuiltInArrayId}; } - if (id.type === 'Identifier' && id.name === 'StableHandler') { + if (id.type === 'Identifier' && id.name === 'NonReactive') { return { kind: 'Function', - shapeId: BuiltInStableHandlerId, + shapeId: BuiltInNonReactiveId, return: makeType(), isConstructor: false, }; @@ -4426,10 +4426,10 @@ export function lowerType(node: t.FlowType | t.TSType): Type { if (typeName.type === 'Identifier' && typeName.name === 'Array') { return {kind: 'Object', shapeId: BuiltInArrayId}; } - if (typeName.type === 'Identifier' && typeName.name === 'StableHandler') { + if (typeName.type === 'Identifier' && typeName.name === 'NonReactive') { return { kind: 'Function', - shapeId: BuiltInStableHandlerId, + shapeId: BuiltInNonReactiveId, return: makeType(), isConstructor: false, }; 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 67bebecda9ec..dbc1a1ce14d0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -255,13 +255,13 @@ export const EnvironmentConfigSchema = z.object({ enableUseTypeAnnotations: z.boolean().default(false), /** - * Enable support for the StableHandler type annotation. When enabled, - * props annotated as StableHandler are treated as stable (non-reactive) and - * safe for ref access. Local functions annotated as StableHandler are compiled + * Enable support for the NonReactive type annotation. When enabled, + * props annotated as NonReactive are treated as stable (non-reactive) and + * safe for ref access. Local functions annotated as NonReactive are compiled * using a two-slot pattern that produces a stable function identity. * Requires enableUseTypeAnnotations to also be enabled. */ - enableStableHandlerAnnotation: z.boolean().default(false), + enableNonReactiveAnnotation: z.boolean().default(false), /** * Allows specifying a function that can populate HIR with type information from diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts index 050bf54729d1..b5abf3bab927 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -1623,11 +1623,11 @@ export type ReactiveScope = { loc: SourceLocation; /** - * When true, this scope contains a StableHandler function that should use + * When true, this scope contains a NonReactive function that should use * the two-slot codegen pattern: one slot always holds the latest function, * another slot holds a stable wrapper created once on first render. */ - stableHandler: boolean; + nonReactive: boolean; }; export type ReactiveScopeDependencies = Set; @@ -1907,9 +1907,9 @@ export function isEffectEventFunctionType(id: Identifier): boolean { ); } -export function isStableHandlerType(id: Identifier): boolean { +export function isNonReactiveType(id: Identifier): boolean { return ( - id.type.kind === 'Function' && id.type.shapeId === 'BuiltInStableHandler' + id.type.kind === 'Function' && id.type.shapeId === 'BuiltInNonReactive' ); } @@ -1921,7 +1921,7 @@ export function isStableType(id: Identifier): boolean { isUseRefType(id) || isStartTransitionType(id) || isSetOptimisticType(id) || - isStableHandlerType(id) + isNonReactiveType(id) ); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts index 34bf2a0ad13d..f28445e6d429 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -389,7 +389,7 @@ export const BuiltInUseEffectEventId = 'BuiltInUseEffectEvent'; export const BuiltInEffectEventId = 'BuiltInEffectEventFunction'; export const BuiltInAutodepsId = 'BuiltInAutoDepsId'; export const BuiltInEventHandlerId = 'BuiltInEventHandlerId'; -export const BuiltInStableHandlerId = 'BuiltInStableHandler'; +export const BuiltInNonReactiveId = 'BuiltInNonReactive'; // See getReanimatedModuleType() in Globals.ts — this is part of supporting Reanimated's ref-like types export const ReanimatedSharedValueId = 'ReanimatedSharedValueId'; @@ -1273,7 +1273,7 @@ addFunction( calleeEffect: Effect.ConditionallyMutate, returnValueKind: ValueKind.Mutable, }, - BuiltInStableHandlerId, + BuiltInNonReactiveId, ); /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts index 8b47656a2907..81869c6e5c17 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts @@ -32,7 +32,7 @@ import { BasicBlock, BlockId, isEffectEventFunctionType, - isStableHandlerType, + isNonReactiveType, } from '../HIR'; import {collectHoistablePropertyLoadsInInnerFn} from '../HIR/CollectHoistablePropertyLoads'; import {collectOptionalChainSidemap} from '../HIR/CollectOptionalChainDependencies'; @@ -228,7 +228,7 @@ export function inferEffectDependencies(fn: HIRFunction): void { !reactiveIds.has(maybeDep.identifier.id)) || isFireFunctionType(maybeDep.identifier) || isEffectEventFunctionType(maybeDep.identifier) || - isStableHandlerType(maybeDep.identifier) + isNonReactiveType(maybeDep.identifier) ) { // exclude non-reactive hook results, which will never be in a memo block continue; @@ -616,7 +616,7 @@ function inferDependencies( earlyReturnValue: null, merged: new Set(), loc: GeneratedSource, - stableHandler: false, + nonReactive: false, }; context.enterScope(placeholderScope); inferDependenciesInFn(fn, context, temporaries); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts index 12ab7ec70f93..e29834e5588a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts @@ -16,7 +16,7 @@ import { Place, evaluatesToStableTypeOrContainer, getHookKind, - isStableHandlerType, + isNonReactiveType, isStableType, isStableTypeContainer, isUseOperator, @@ -98,9 +98,9 @@ class StableSidemap { } } } else { - // StableHandler-typed identifiers are stable regardless of source + // NonReactive-typed identifiers are stable regardless of source for (const lvalue of eachInstructionLValue(instr)) { - if (isStableHandlerType(lvalue.identifier)) { + if (isNonReactiveType(lvalue.identifier)) { this.map.set(lvalue.identifier.id, { isStable: true, }); diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts index 750e86bd6b08..a899411a1ddd 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -536,8 +536,8 @@ function codegenBlockNoReset( } case 'scope': { const temp = new Map(cx.temp); - if (item.scope.stableHandler && item.scope.dependencies.size > 0) { - codegenStableHandlerScope( + if (item.scope.nonReactive) { + codegenNonReactiveScope( cx, statements, item.scope, @@ -949,7 +949,7 @@ function codegenReactiveScope( } /** - * Generates the two-slot codegen pattern for StableHandler scopes. + * Generates the two-slot codegen pattern for NonReactive scopes. * * The scope contains a FunctionExpression that produces a value (e.g. t0). * We always recompute the function (to capture latest closure values), @@ -967,7 +967,7 @@ function codegenReactiveScope( * t0 = $[1]; // load cached stable wrapper * } */ -function codegenStableHandlerScope( +function codegenNonReactiveScope( cx: Context, statements: Array, scope: ReactiveScope, 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 3db94a5e0244..9bcd0e691ef0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts @@ -113,7 +113,7 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void { earlyReturnValue: null, merged: new Set(), loc: identifier.loc, - stableHandler: false, + nonReactive: false, }; scopes.set(groupIdentifier, scope); } else { diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkNonReactiveScopes.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkNonReactiveScopes.ts new file mode 100644 index 000000000000..af35b0e8eb75 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkNonReactiveScopes.ts @@ -0,0 +1,114 @@ +/** + * 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 { + IdentifierId, + ReactiveFunction, + ReactiveInstruction, + ReactiveScopeBlock, + isNonReactiveType, +} from '../HIR'; +import {ReactiveFunctionVisitor, visitReactiveFunction} from './visitors'; + +/** + * Marks reactive scopes that contain a NonReactive-typed local function + * assignment. These scopes will use a special two-slot codegen pattern: + * one slot always holds the latest function (updated every render), + * another slot holds a stable wrapper (created once on first render). + * + * This pass detects StoreLocal instructions targeting NonReactive-typed + * variables (explicit annotation: `const handler: NonReactive<...> = () => ...` + * or `nonReactive(() => ...)` wrapper). + * + * For each detected function, the pass marks the scope that declares it + * so codegen uses the stable two-slot pattern. + */ +export function markNonReactiveScopes(fn: ReactiveFunction): void { + if (!fn.env.config.enableNonReactiveAnnotation) { + return; + } + // First pass: collect identifier IDs of function expression values that are + // stored into NonReactive-typed local variables. + const collectState: CollectState = { + nonReactiveSourceIds: new Set(), + callArgMap: new Map(), + }; + visitReactiveFunction(fn, new CollectVisitor(), collectState); + if (collectState.nonReactiveSourceIds.size === 0) { + return; + } + // Second pass: mark scopes whose declarations include a NonReactive source. + visitReactiveFunction( + fn, + new MarkVisitor(), + collectState.nonReactiveSourceIds, + ); +} + +/** + * Maps NonReactive-typed CallExpression results to their first argument. + * For `nonReactive(() => ...)`, the Call result is NonReactive-typed, + * and the first argument is the actual function expression. + */ +type CollectState = { + nonReactiveSourceIds: Set; + callArgMap: Map; +}; + +class CollectVisitor extends ReactiveFunctionVisitor { + override visitInstruction( + instruction: ReactiveInstruction, + state: CollectState, + ): void { + this.traverseInstruction(instruction, state); + const {value} = instruction; + // Track NonReactive-typed CallExpression results and their arguments. + // For nonReactive(() => ...), the call result is NonReactive-typed + // and the first argument is the function expression we want to wrap. + if ( + value.kind === 'CallExpression' && + instruction.lvalue !== null && + isNonReactiveType(instruction.lvalue.identifier) && + value.args.length > 0 && + value.args[0].kind === 'Identifier' + ) { + state.callArgMap.set( + instruction.lvalue.identifier.id, + value.args[0].identifier.id, + ); + } + if ( + value.kind === 'StoreLocal' && + isNonReactiveType(value.lvalue.place.identifier) + ) { + const valueId = value.value.identifier.id; + // If the source value is a nonReactive() call result, trace through + // to the actual function expression argument. + const argId = state.callArgMap.get(valueId); + if (argId !== undefined) { + state.nonReactiveSourceIds.add(argId); + } else { + state.nonReactiveSourceIds.add(valueId); + } + } + } +} + +class MarkVisitor extends ReactiveFunctionVisitor> { + override visitScope( + scopeBlock: ReactiveScopeBlock, + state: Set, + ): void { + this.traverseScope(scopeBlock, state); + for (const [id] of scopeBlock.scope.declarations) { + if (state.has(id)) { + scopeBlock.scope.nonReactive = true; + return; + } + } + } +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkStableHandlerScopes.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkStableHandlerScopes.ts deleted file mode 100644 index c00a53e3fd3b..000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkStableHandlerScopes.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { - IdentifierId, - ReactiveFunction, - ReactiveInstruction, - ReactiveScopeBlock, - isStableHandlerType, -} from '../HIR'; -import {ReactiveFunctionVisitor, visitReactiveFunction} from './visitors'; - -/** - * Marks reactive scopes that contain a StableHandler-typed local function - * assignment. These scopes will use a special two-slot codegen pattern: - * one slot always holds the latest function (updated every render), - * another slot holds a stable wrapper (created once on first render). - * - * This pass detects two patterns: - * 1. StoreLocal instructions targeting StableHandler-typed variables - * (explicit annotation: `const handler: StableHandler<...> = () => ...`) - * 2. Function expressions used as JSX event handler attributes - * (auto-detection: `onClick={() => ...}`, `onSubmit={handler}`, etc.) - * - * For each detected function, the pass marks the scope that declares it - * so codegen uses the stable two-slot pattern. - */ -export function markStableHandlerScopes(fn: ReactiveFunction): void { - if (!fn.env.config.enableStableHandlerAnnotation) { - return; - } - // First pass: collect identifier IDs of function expression values that are - // stored into StableHandler-typed local variables. - const stableHandlerSourceIds = new Set(); - visitReactiveFunction(fn, new CollectVisitor(), stableHandlerSourceIds); - if (stableHandlerSourceIds.size === 0) { - return; - } - // Second pass: mark scopes whose declarations include a StableHandler source. - visitReactiveFunction(fn, new MarkVisitor(), stableHandlerSourceIds); -} - -class CollectVisitor extends ReactiveFunctionVisitor> { - override visitInstruction( - instruction: ReactiveInstruction, - state: Set, - ): void { - this.traverseInstruction(instruction, state); - const {value} = instruction; - if ( - value.kind === 'StoreLocal' && - isStableHandlerType(value.lvalue.place.identifier) - ) { - // Track the source value's identifier id — this is the function - // expression produced by a scope that we need to mark. - state.add(value.value.identifier.id); - } - // Detect function expressions used as JSX event handler attributes. - // Inline functions passed to event handler props (onClick, onSubmit, etc.) - // are automatically compiled with the stable two-slot pattern. - if (value.kind === 'JsxExpression') { - for (const prop of value.props) { - if ( - prop.kind === 'JsxAttribute' && - /^on[A-Z]/.test(prop.name) - ) { - state.add(prop.place.identifier.id); - } - } - } - } -} - -class MarkVisitor extends ReactiveFunctionVisitor> { - override visitScope( - scopeBlock: ReactiveScopeBlock, - state: Set, - ): void { - this.traverseScope(scopeBlock, state); - for (const [id] of scopeBlock.scope.declarations) { - if (state.has(id)) { - scopeBlock.scope.stableHandler = true; - return; - } - } - } -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts index b0e341177c50..9a944f09691d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts @@ -273,8 +273,8 @@ class Transform extends ReactiveFunctionTransform 0) && - !(instr.scope.stableHandler && instr.scope.dependencies.size > 0) && + !current.block.scope.nonReactive && + !instr.scope.nonReactive && canMergeScopes(current.block, instr, this.temporaries) && areLValuesLastUsedByScope( instr.scope, diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/index.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/index.ts index 33b38d72d3d2..400f24f9c7f2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/index.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/index.ts @@ -12,7 +12,7 @@ export {buildReactiveFunction} from './BuildReactiveFunction'; export {codegenFunction, type CodegenFunction} from './CodegenReactiveFunction'; export {extractScopeDeclarationsFromDestructuring} from './ExtractScopeDeclarationsFromDestructuring'; export {inferReactiveScopeVariables} from './InferReactiveScopeVariables'; -export {markStableHandlerScopes} from './MarkStableHandlerScopes'; +export {markNonReactiveScopes} from './MarkNonReactiveScopes'; export {memoizeFbtAndMacroOperandsInSameScope} from './MemoizeFbtAndMacroOperandsInSameScope'; export {mergeReactiveScopesThatInvalidateTogether} from './MergeReactiveScopesThatInvalidateTogether'; export { diff --git a/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts index 794ff00af154..2c8045e19773 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts @@ -28,7 +28,7 @@ import { BuiltInArrayId, BuiltInEventHandlerId, BuiltInFunctionId, - BuiltInStableHandlerId, + BuiltInNonReactiveId, BuiltInJsxId, BuiltInMixedReadonlyId, BuiltInObjectId, @@ -233,13 +233,13 @@ function* generateInstructionTypes( if (env.config.enableUseTypeAnnotations) { const valueType = value.type === null ? makeType() : lowerType(value.type); - // When the annotation is a StableHandler, bind the lvalue to the + // When the annotation is a NonReactive, bind the lvalue to the // annotation type first so it takes priority over the inferred // function expression type. if ( - env.config.enableStableHandlerAnnotation && + env.config.enableNonReactiveAnnotation && valueType.kind === 'Function' && - valueType.shapeId === BuiltInStableHandlerId + valueType.shapeId === BuiltInNonReactiveId ) { yield equation(value.lvalue.place.identifier.type, valueType); yield equation(left, valueType); @@ -284,6 +284,9 @@ function* generateInstructionTypes( } case 'LoadGlobal': { + // Record the global binding name so it can be looked up by callee id + // in CallExpression handling (e.g. for nonReactive() detection). + names.set(lvalue.identifier.id, value.binding.name); const globalType = env.getGlobalDeclaration(value.binding, value.loc); if (globalType) { yield equation(left, globalType); @@ -298,10 +301,10 @@ function* generateInstructionTypes( * We should change Hook to a subtype of Function or change unifier logic. * (see https://github.com/facebook/react-forget/pull/1427) */ + const calleeName = getName(names, value.callee.identifier.id); let shapeId: string | null = null; if (env.config.enableTreatSetIdentifiersAsStateSetters) { - const name = getName(names, value.callee.identifier.id); - if (name.startsWith('set')) { + if (calleeName.startsWith('set')) { shapeId = BuiltInSetStateId; } } @@ -311,7 +314,21 @@ function* generateInstructionTypes( return: returnType, isConstructor: false, }); - yield equation(left, returnType); + // nonReactive() is a compiler intrinsic that marks its return value + // as NonReactive-typed, producing the two-slot stable wrapper pattern. + if ( + env.config.enableNonReactiveAnnotation && + calleeName === 'nonReactive' + ) { + yield equation(left, { + kind: 'Function', + shapeId: BuiltInNonReactiveId, + return: makeType(), + isConstructor: false, + }); + } else { + yield equation(left, returnType); + } break; } @@ -433,11 +450,11 @@ function* generateInstructionTypes( value: makePropertyLiteral(property.key.name), }, }); - // If this property is annotated as StableHandler, emit an + // If this property is annotated as NonReactive, emit an // additional type equation so the identifier gets the - // BuiltInStableHandler function type. + // BuiltInNonReactive function type. if ( - env.config.enableStableHandlerAnnotation && + env.config.enableNonReactiveAnnotation && propsTypeAnnotations != null ) { const annotation = propsTypeAnnotations.get( diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateExhaustiveDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateExhaustiveDependencies.ts index 03cd90612cc5..eb2c219d9633 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateExhaustiveDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateExhaustiveDependencies.ts @@ -26,7 +26,7 @@ import { InstructionKind, isEffectEventFunctionType, isPrimitiveType, - isStableHandlerType, + isNonReactiveType, isStableType, isSubPath, isSubPathIgnoringOptionals, @@ -330,7 +330,7 @@ function validateDependencies( */ if ( isEffectEventFunctionType(inferredDependency.identifier) || - isStableHandlerType(inferredDependency.identifier) + isNonReactiveType(inferredDependency.identifier) ) { continue; } @@ -405,7 +405,7 @@ function validateDependencies( dep.kind === 'Local' && !isOptionalDependency(dep, reactive) && !isEffectEventFunctionType(dep.identifier) && - !isStableHandlerType(dep.identifier), + !isNonReactiveType(dep.identifier), ) .map(printInferredDependency) .join(', ')}]`, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts index 7233789ec8cb..4ff70ab5c52f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts @@ -24,7 +24,7 @@ import { } from '../HIR'; import { BuiltInEventHandlerId, - BuiltInStableHandlerId, + BuiltInNonReactiveId, } from '../HIR/ObjectShape'; import { eachInstructionOperand, @@ -186,7 +186,7 @@ function isEventHandlerType(identifier: Identifier): boolean { return ( type.kind === 'Function' && (type.shapeId === BuiltInEventHandlerId || - type.shapeId === BuiltInStableHandlerId) + type.shapeId === BuiltInNonReactiveId) ); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-basic.expect.md similarity index 76% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-basic.expect.md index d1bc2950c991..67187e85a017 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-basic.expect.md @@ -2,11 +2,11 @@ ## Input ```javascript -// @enableStableHandlerAnnotation @enableUseTypeAnnotations -type StableHandler = T; +// @enableNonReactiveAnnotation @enableUseTypeAnnotations +type NonReactive = T; function Component({value}: {value: string}) { - const handler: StableHandler<() => void> = () => { + const handler: NonReactive<() => void> = () => { console.log(value); }; return ; @@ -22,8 +22,8 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations -type StableHandler = T; +import { c as _c } from "react/compiler-runtime"; // @enableNonReactiveAnnotation @enableUseTypeAnnotations +type NonReactive = T; function Component(t0) { const $ = _c(4); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-basic.ts similarity index 60% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-basic.ts index 7dfca4da10f2..c72531248183 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-basic.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-basic.ts @@ -1,8 +1,8 @@ -// @enableStableHandlerAnnotation @enableUseTypeAnnotations -type StableHandler = T; +// @enableNonReactiveAnnotation @enableUseTypeAnnotations +type NonReactive = T; function Component({value}: {value: string}) { - const handler: StableHandler<() => void> = () => { + const handler: NonReactive<() => void> = () => { console.log(value); }; return ; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-captures.expect.md similarity index 86% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-captures.expect.md index e4998fe705aa..7b8f285d1831 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-captures.expect.md @@ -2,16 +2,16 @@ ## Input ```javascript -// @enableStableHandlerAnnotation @enableUseTypeAnnotations +// @enableNonReactiveAnnotation @enableUseTypeAnnotations import {useState} from 'react'; -type StableHandler = T; +type NonReactive = T; function Component() { const [count, setCount] = useState(0); const [name, setName] = useState('world'); - const handler: StableHandler<() => void> = () => { + const handler: NonReactive<() => void> = () => { console.log(count, name); }; @@ -34,10 +34,10 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations +import { c as _c } from "react/compiler-runtime"; // @enableNonReactiveAnnotation @enableUseTypeAnnotations import { useState } from "react"; -type StableHandler = T; +type NonReactive = T; function Component() { const $ = _c(8); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-captures.ts similarity index 77% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-captures.ts index 27ebeccf3aae..65db6d047abc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-captures.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-captures.ts @@ -1,13 +1,13 @@ -// @enableStableHandlerAnnotation @enableUseTypeAnnotations +// @enableNonReactiveAnnotation @enableUseTypeAnnotations import {useState} from 'react'; -type StableHandler = T; +type NonReactive = T; function Component() { const [count, setCount] = useState(0); const [name, setName] = useState('world'); - const handler: StableHandler<() => void> = () => { + const handler: NonReactive<() => void> = () => { console.log(count, name); }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-ref-access.expect.md similarity index 62% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-ref-access.expect.md index 9d12ee279eab..96c163176dd4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-ref-access.expect.md @@ -2,15 +2,15 @@ ## Input ```javascript -// @enableStableHandlerAnnotation @enableUseTypeAnnotations +// @enableNonReactiveAnnotation @enableUseTypeAnnotations import {useRef} from 'react'; -type StableHandler = T; +type NonReactive = T; function Component() { const ref = useRef(null); - const handler: StableHandler<() => void> = () => { + const handler: NonReactive<() => void> = () => { if (ref.current !== null) { console.log(ref.current.value); } @@ -34,37 +34,39 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations +import { c as _c } from "react/compiler-runtime"; // @enableNonReactiveAnnotation @enableUseTypeAnnotations import { useRef } from "react"; -type StableHandler = T; +type NonReactive = T; function Component() { - const $ = _c(2); + const $ = _c(3); const ref = useRef(null); let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = () => { - if (ref.current !== null) { - console.log(ref.current.value); - } - }; - $[0] = t0; + t0 = () => { + if (ref.current !== null) { + console.log(ref.current.value); + } + }; + $[0] = t0; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t0 = (...args) => $[0](...args); + $[1] = t0; } else { - t0 = $[0]; + t0 = $[1]; } const handler = t0; let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { t1 = ( <> ); - $[1] = t1; + $[2] = t1; } else { - t1 = $[1]; + t1 = $[2]; } return t1; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-ref-access.ts similarity index 72% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-ref-access.ts index 56ca7a9045ed..0675099785b3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-local-ref-access.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-local-ref-access.ts @@ -1,12 +1,12 @@ -// @enableStableHandlerAnnotation @enableUseTypeAnnotations +// @enableNonReactiveAnnotation @enableUseTypeAnnotations import {useRef} from 'react'; -type StableHandler = T; +type NonReactive = T; function Component() { const ref = useRef(null); - const handler: StableHandler<() => void> = () => { + const handler: NonReactive<() => void> = () => { if (ref.current !== null) { console.log(ref.current.value); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.expect.md similarity index 68% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.expect.md index 9cfedcca3462..c2fee9fc8beb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.expect.md @@ -2,14 +2,14 @@ ## Input ```javascript -// @enableStableHandlerAnnotation @enableUseTypeAnnotations -type StableHandler = T; +// @enableNonReactiveAnnotation @enableUseTypeAnnotations +type NonReactive = T; function Component({ onSubmit, value, }: { - onSubmit: StableHandler<(data: string) => void>; + onSubmit: NonReactive<(data: string) => void>; value: string; }) { return ; @@ -25,17 +25,16 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations -type StableHandler = T; +import { c as _c } from "react/compiler-runtime"; // @enableNonReactiveAnnotation @enableUseTypeAnnotations +type NonReactive = T; function Component(t0) { const $ = _c(5); const { onSubmit, value } = t0; let t1; - t1 = () => onSubmit(value); - $[0] = t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = (...args) => $[0](...args); + if ($[0] !== value) { + t1 = () => onSubmit(value); + $[0] = value; $[1] = t1; } else { t1 = $[1]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.ts similarity index 66% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.ts index a8360c7a1f77..300797594571 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-basic.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.ts @@ -1,11 +1,11 @@ -// @enableStableHandlerAnnotation @enableUseTypeAnnotations -type StableHandler = T; +// @enableNonReactiveAnnotation @enableUseTypeAnnotations +type NonReactive = T; function Component({ onSubmit, value, }: { - onSubmit: StableHandler<(data: string) => void>; + onSubmit: NonReactive<(data: string) => void>; value: string; }) { return ; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md similarity index 69% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md index b32631dc6e1b..e06c69607ea8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md @@ -2,14 +2,14 @@ ## Input ```javascript -// @enableStableHandlerAnnotation @enableUseTypeAnnotations -type StableHandler = T; +// @enableNonReactiveAnnotation @enableUseTypeAnnotations +type NonReactive = T; function Component({ onSubmit, label, }: { - onSubmit: StableHandler<(data: string) => void>; + onSubmit: NonReactive<(data: string) => void>; label: string; }) { return ( @@ -29,17 +29,16 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations -type StableHandler = T; +import { c as _c } from "react/compiler-runtime"; // @enableNonReactiveAnnotation @enableUseTypeAnnotations +type NonReactive = T; function Component(t0) { const $ = _c(5); const { onSubmit, label } = t0; let t1; - t1 = () => onSubmit(label); - $[0] = t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = (...args) => $[0](...args); + if ($[0] !== label) { + t1 = () => onSubmit(label); + $[0] = label; $[1] = t1; } else { t1 = $[1]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.ts similarity index 68% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.ts index 9885a9765a9b..9d11181c5589 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-mixed.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.ts @@ -1,11 +1,11 @@ -// @enableStableHandlerAnnotation @enableUseTypeAnnotations -type StableHandler = T; +// @enableNonReactiveAnnotation @enableUseTypeAnnotations +type NonReactive = T; function Component({ onSubmit, label, }: { - onSubmit: StableHandler<(data: string) => void>; + onSubmit: NonReactive<(data: string) => void>; label: string; }) { return ( diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.expect.md similarity index 82% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.expect.md index e99c9df69b27..b75538b3177b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.expect.md @@ -2,15 +2,15 @@ ## Input ```javascript -// @enableStableHandlerAnnotation @enableUseTypeAnnotations +// @enableNonReactiveAnnotation @enableUseTypeAnnotations import {useRef} from 'react'; -type StableHandler = T; +type NonReactive = T; function Component({ onSubmit, }: { - onSubmit: StableHandler<(data: string) => void>; + onSubmit: NonReactive<(data: string) => void>; }) { const ref = useRef(null); const handler = () => { @@ -34,10 +34,10 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @enableStableHandlerAnnotation @enableUseTypeAnnotations +import { c as _c } from "react/compiler-runtime"; // @enableNonReactiveAnnotation @enableUseTypeAnnotations import { useRef } from "react"; -type StableHandler = T; +type NonReactive = T; function Component(t0) { const $ = _c(2); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.ts similarity index 74% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.ts index f5e08b7ddebb..c8e30a71a35e 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/stable-handler/stable-handler-prop-ref-access.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.ts @@ -1,12 +1,12 @@ -// @enableStableHandlerAnnotation @enableUseTypeAnnotations +// @enableNonReactiveAnnotation @enableUseTypeAnnotations import {useRef} from 'react'; -type StableHandler = T; +type NonReactive = T; function Component({ onSubmit, }: { - onSubmit: StableHandler<(data: string) => void>; + onSubmit: NonReactive<(data: string) => void>; }) { const ref = useRef(null); const handler = () => { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-wrapper-basic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-wrapper-basic.expect.md new file mode 100644 index 000000000000..a16868f766ef --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-wrapper-basic.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @enableNonReactiveAnnotation @enableUseTypeAnnotations +function nonReactive(value: T): T { + return value; +} + +function Component({value}: {value: string}) { + const handler = nonReactive(() => { + console.log(value); + }); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'hello'}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNonReactiveAnnotation @enableUseTypeAnnotations +function nonReactive(value) { + return value; +} + +function Component(t0) { + const $ = _c(4); + const { value } = t0; + let t1; + t1 = () => { + console.log(value); + }; + $[0] = t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (...args) => $[0](...args); + $[1] = t1; + } else { + t1 = $[1]; + } + let t2; + if ($[2] !== t1) { + const handler = nonReactive(t1); + t2 = ; + $[2] = t1; + $[3] = t2; + } else { + t2 = $[3]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: "hello" }], +}; + +``` + +### Eval output +(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-wrapper-basic.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-wrapper-basic.ts new file mode 100644 index 000000000000..02c86689ae82 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-wrapper-basic.ts @@ -0,0 +1,16 @@ +// @enableNonReactiveAnnotation @enableUseTypeAnnotations +function nonReactive(value: T): T { + return value; +} + +function Component({value}: {value: string}) { + const handler = nonReactive(() => { + console.log(value); + }); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 'hello'}], +}; diff --git a/compiler/packages/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index bdaface961ed..3333437158c0 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -18,6 +18,43 @@ type MemoCache = Array; const $empty = Symbol.for('react.memo_cache_sentinel'); +/** + * Compile-time annotation that tells the React Compiler to treat a value as + * non-reactive. The value is excluded from dependency tracking and, for local + * functions, the compiler generates a stable wrapper whose identity never + * changes across renders while always delegating to the latest closure. + * + * Usage on props — signals the compiler that the value is non-reactive: + * ```ts + * function Child({onSubmit}: {onSubmit: NonReactive<(data: string) => void>}) { ... } + * ``` + * + * Usage on local functions — compiler generates a stable wrapper pattern: + * ```ts + * const handler: NonReactive<() => void> = () => { console.log(value); }; + * ``` + * + * Requires the `enableNonReactiveAnnotation` compiler flag. + */ +export type NonReactive = T; + +/** + * Compiler intrinsic that marks a value as non-reactive at the call site. + * This is an alternative to the `NonReactive` type annotation. + * + * ```ts + * return onSubmit(value))} />; + * ``` + * + * At runtime this is an identity function. The compiler recognizes calls to + * `nonReactive()` and generates a stable wrapper pattern for the result. + * + * Requires the `enableNonReactiveAnnotation` compiler flag. + */ +export function nonReactive(value: T): T { + return value; +} + // Re-export React.c if present, otherwise fallback to the userspace polyfill for versions of React // < 19. export const c = From 121a3b53e1d844c39dabe4d7f936a64f8823e18a Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 6 Feb 2026 16:58:08 -0800 Subject: [PATCH 4/6] use two-slot pattern for NonReactive props --- .../ReactiveScopes/MarkNonReactiveScopes.ts | 14 ++++++++++++ .../non-reactive-prop-basic.expect.md | 7 +++--- .../non-reactive-prop-mixed.expect.md | 7 +++--- .../non-reactive-prop-ref-access.expect.md | 22 ++++++++++--------- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkNonReactiveScopes.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkNonReactiveScopes.ts index af35b0e8eb75..e05593e2d983 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkNonReactiveScopes.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MarkNonReactiveScopes.ts @@ -95,6 +95,20 @@ class CollectVisitor extends ReactiveFunctionVisitor { state.nonReactiveSourceIds.add(valueId); } } + // Detect function expressions that capture a NonReactive-typed value. + // These should get the two-slot pattern so their identity is stable + // while always delegating to the latest closure. + if ( + value.kind === 'FunctionExpression' && + instruction.lvalue !== null + ) { + for (const place of value.loweredFunc.func.context) { + if (isNonReactiveType(place.identifier)) { + state.nonReactiveSourceIds.add(instruction.lvalue.identifier.id); + break; + } + } + } } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.expect.md index c2fee9fc8beb..c13d77a93c9f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-basic.expect.md @@ -32,9 +32,10 @@ function Component(t0) { const $ = _c(5); const { onSubmit, value } = t0; let t1; - if ($[0] !== value) { - t1 = () => onSubmit(value); - $[0] = value; + t1 = () => onSubmit(value); + $[0] = t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (...args) => $[0](...args); $[1] = t1; } else { t1 = $[1]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md index e06c69607ea8..c34f93ccce75 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md @@ -36,9 +36,10 @@ function Component(t0) { const $ = _c(5); const { onSubmit, label } = t0; let t1; - if ($[0] !== label) { - t1 = () => onSubmit(label); - $[0] = label; + t1 = () => onSubmit(label); + $[0] = t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (...args) => $[0](...args); $[1] = t1; } else { t1 = $[1]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.expect.md index b75538b3177b..ccd633457b7a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-ref-access.expect.md @@ -40,30 +40,32 @@ import { useRef } from "react"; type NonReactive = T; function Component(t0) { - const $ = _c(2); + const $ = _c(3); const { onSubmit } = t0; const ref = useRef(null); let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - onSubmit(ref.current.value); - }; - $[0] = t1; + t1 = () => { + onSubmit(ref.current.value); + }; + $[0] = t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (...args) => $[0](...args); + $[1] = t1; } else { - t1 = $[0]; + t1 = $[1]; } const handler = t1; let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { t2 = ( <> ); - $[1] = t2; + $[2] = t2; } else { - t2 = $[1]; + t2 = $[2]; } return t2; } From e093f820dd642de88fc8b97db5be62c9e5f99f24 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 6 Feb 2026 17:06:08 -0800 Subject: [PATCH 5/6] remove redundant test --- .../non-reactive-prop-mixed.expect.md | 74 ------------------- .../non-reactive/non-reactive-prop-mixed.ts | 21 ------ 2 files changed, 95 deletions(-) delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md deleted file mode 100644 index c34f93ccce75..000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.expect.md +++ /dev/null @@ -1,74 +0,0 @@ - -## Input - -```javascript -// @enableNonReactiveAnnotation @enableUseTypeAnnotations -type NonReactive = T; - -function Component({ - onSubmit, - label, -}: { - onSubmit: NonReactive<(data: string) => void>; - label: string; -}) { - return ( - - ); -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{onSubmit: (data: string) => console.log(data), label: 'click me'}], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; // @enableNonReactiveAnnotation @enableUseTypeAnnotations -type NonReactive = T; - -function Component(t0) { - const $ = _c(5); - const { onSubmit, label } = t0; - let t1; - t1 = () => onSubmit(label); - $[0] = t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = (...args) => $[0](...args); - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== label || $[3] !== t1) { - t2 = ; - $[2] = label; - $[3] = t1; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [ - { - onSubmit: (data) => { - return console.log(data); - }, - label: "click me", - }, - ], -}; - -``` - -### Eval output -(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.ts deleted file mode 100644 index 9d11181c5589..000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/non-reactive/non-reactive-prop-mixed.ts +++ /dev/null @@ -1,21 +0,0 @@ -// @enableNonReactiveAnnotation @enableUseTypeAnnotations -type NonReactive = T; - -function Component({ - onSubmit, - label, -}: { - onSubmit: NonReactive<(data: string) => void>; - label: string; -}) { - return ( - - ); -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{onSubmit: (data: string) => console.log(data), label: 'click me'}], -}; From 27c34b5de2613f484fcec2aaaf096c87981df249 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Sat, 7 Feb 2026 08:41:44 -0800 Subject: [PATCH 6/6] emit types --- compiler/packages/react-compiler-runtime/tsup.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/packages/react-compiler-runtime/tsup.config.ts b/compiler/packages/react-compiler-runtime/tsup.config.ts index 30a7f9da96f0..87da6eda1ee1 100644 --- a/compiler/packages/react-compiler-runtime/tsup.config.ts +++ b/compiler/packages/react-compiler-runtime/tsup.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ external: ['react'], splitting: false, sourcemap: true, - dts: false, + dts: true, bundle: true, format: 'cjs', platform: 'node',