From 0c10c8f7e411a46f7dd1b947f9401f62c49a4549 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Fri, 23 Jan 2026 11:07:42 -0800 Subject: [PATCH 1/6] [compiler] Improve snap workflow for debugging errors Much nicer workflow for working through errors in the compiler: * Run `yarn snap -w`, oops there are are errors * Hit 'p' to select a fixture => the suggestions populate with recent failures, sorted alphabetically. No need to copy/paste the name of the fixture you want to focus on! * tab/shift-tab to pick one, hit enter to select that one * ...Focus on fixing that test... * 'p' to re-enter the picker. Snap tracks the last state of each fixture and continues to show all tests that failed on their last run, so you can easily move on to the next one. The currently selected test is highlighted, making it easy to move to the next one. * 'a' at any time to run all tests * 'd' at any time to toggle debug output on/off (while focusing on a single test) --- compiler/packages/snap/src/runner-watch.ts | 192 +++++++++++++++++++-- compiler/packages/snap/src/runner.ts | 8 + 2 files changed, 186 insertions(+), 14 deletions(-) diff --git a/compiler/packages/snap/src/runner-watch.ts b/compiler/packages/snap/src/runner-watch.ts index 06ae9984668..c29a2985153 100644 --- a/compiler/packages/snap/src/runner-watch.ts +++ b/compiler/packages/snap/src/runner-watch.ts @@ -9,7 +9,7 @@ import watcher from '@parcel/watcher'; import path from 'path'; import ts from 'typescript'; import {FIXTURES_PATH, PROJECT_ROOT} from './constants'; -import {TestFilter} from './fixture-utils'; +import {TestFilter, getFixtures} from './fixture-utils'; import {execSync} from 'child_process'; export function watchSrc( @@ -121,6 +121,12 @@ export type RunnerState = { // Input mode for interactive pattern entry inputMode: 'none' | 'pattern'; inputBuffer: string; + // Autocomplete state + allFixtureNames: Array; + matchingFixtures: Array; + selectedIndex: number; + // Track last run status of each fixture (for autocomplete suggestions) + fixtureLastRunStatus: Map; }; function subscribeFixtures( @@ -179,46 +185,187 @@ function subscribeTsc( ); } +/** + * Levenshtein edit distance between two strings + */ +function editDistance(a: string, b: string): number { + const m = a.length; + const n = b.length; + + // Create a 2D array for memoization + const dp: number[][] = Array.from({length: m + 1}, () => + Array(n + 1).fill(0), + ); + + // Base cases + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + + // Fill in the rest + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) { + dp[i][j] = dp[i - 1][j - 1]; + } else { + dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + } + + return dp[m][n]; +} + +function filterFixtures( + allNames: Array, + pattern: string, +): Array { + if (pattern === '') { + return allNames; + } + const lowerPattern = pattern.toLowerCase(); + const matches = allNames.filter(name => + name.toLowerCase().includes(lowerPattern), + ); + // Sort by edit distance (lower = better match) + matches.sort((a, b) => { + const distA = editDistance(lowerPattern, a.toLowerCase()); + const distB = editDistance(lowerPattern, b.toLowerCase()); + return distA - distB; + }); + return matches; +} + +const MAX_DISPLAY = 15; + +function renderAutocomplete(state: RunnerState): void { + // Clear terminal + console.log('\u001Bc'); + + // Show current input + console.log(`Pattern: ${state.inputBuffer}`); + console.log(''); + + // Get current filter pattern if active + const currentFilterPattern = + state.mode.filter && state.filter ? state.filter.paths[0] : null; + + // Show matching fixtures (limit to MAX_DISPLAY) + const toShow = state.matchingFixtures.slice(0, MAX_DISPLAY); + + toShow.forEach((name, i) => { + const isSelected = i === state.selectedIndex; + const matchesCurrentFilter = + currentFilterPattern != null && + name.toLowerCase().includes(currentFilterPattern.toLowerCase()); + + let prefix: string; + if (isSelected) { + prefix = '> '; + } else if (matchesCurrentFilter) { + prefix = '* '; + } else { + prefix = ' '; + } + console.log(`${prefix}${name}`); + }); + + if (state.matchingFixtures.length > MAX_DISPLAY) { + console.log( + ` ... and ${state.matchingFixtures.length - MAX_DISPLAY} more`, + ); + } + + console.log(''); + console.log('↑/↓/Tab navigate | Enter select | Esc cancel'); +} + function subscribeKeyEvents( state: RunnerState, onChange: (state: RunnerState) => void, ) { process.stdin.on('keypress', async (str, key) => { - // Handle input mode (pattern entry) + // Handle input mode (pattern entry with autocomplete) if (state.inputMode !== 'none') { if (key.name === 'return') { - // Enter pressed - process input - const pattern = state.inputBuffer.trim(); + // Enter pressed - use selected fixture or typed text + let pattern: string; + if ( + state.selectedIndex >= 0 && + state.selectedIndex < state.matchingFixtures.length + ) { + pattern = state.matchingFixtures[state.selectedIndex]; + } else { + pattern = state.inputBuffer.trim(); + } + state.inputMode = 'none'; state.inputBuffer = ''; - process.stdout.write('\n'); + state.allFixtureNames = []; + state.matchingFixtures = []; + state.selectedIndex = -1; if (pattern !== '') { - // Set the pattern as filter state.filter = {paths: [pattern]}; state.mode.filter = true; state.mode.action = RunnerAction.Test; onChange(state); } - // If empty, just exit input mode without changes return; } else if (key.name === 'escape') { // Cancel input mode state.inputMode = 'none'; state.inputBuffer = ''; - process.stdout.write(' (cancelled)\n'); + state.allFixtureNames = []; + state.matchingFixtures = []; + state.selectedIndex = -1; + // Redraw normal UI + onChange(state); + return; + } else if (key.name === 'up' || (key.name === 'tab' && key.shift)) { + // Navigate up in autocomplete list + if (state.matchingFixtures.length > 0) { + if (state.selectedIndex <= 0) { + state.selectedIndex = + Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1; + } else { + state.selectedIndex--; + } + renderAutocomplete(state); + } + return; + } else if (key.name === 'down' || (key.name === 'tab' && !key.shift)) { + // Navigate down in autocomplete list + if (state.matchingFixtures.length > 0) { + const maxIndex = + Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1; + if (state.selectedIndex >= maxIndex) { + state.selectedIndex = 0; + } else { + state.selectedIndex++; + } + renderAutocomplete(state); + } return; } else if (key.name === 'backspace') { if (state.inputBuffer.length > 0) { state.inputBuffer = state.inputBuffer.slice(0, -1); - // Erase character: backspace, space, backspace - process.stdout.write('\b \b'); + state.matchingFixtures = filterFixtures( + state.allFixtureNames, + state.inputBuffer, + ); + state.selectedIndex = -1; + renderAutocomplete(state); } return; } else if (str && !key.ctrl && !key.meta) { - // Regular character - accumulate and echo + // Regular character - accumulate, filter, and render state.inputBuffer += str; - process.stdout.write(str); + state.matchingFixtures = filterFixtures( + state.allFixtureNames, + state.inputBuffer, + ); + state.selectedIndex = -1; + renderAutocomplete(state); return; } return; // Ignore other keys in input mode @@ -240,10 +387,23 @@ function subscribeKeyEvents( state.debug = !state.debug; state.mode.action = RunnerAction.Test; } else if (key.name === 'p') { - // p => enter pattern input mode + // p => enter pattern input mode with autocomplete state.inputMode = 'pattern'; state.inputBuffer = ''; - process.stdout.write('Pattern: '); + + // Load all fixtures for autocomplete + const fixtures = await getFixtures(null); + state.allFixtureNames = Array.from(fixtures.keys()).sort(); + // Show failed fixtures first when no pattern entered + const failedFixtures = Array.from(state.fixtureLastRunStatus.entries()) + .filter(([_, status]) => status === 'fail') + .map(([name]) => name) + .sort(); + state.matchingFixtures = + failedFixtures.length > 0 ? failedFixtures : state.allFixtureNames; + state.selectedIndex = -1; + + renderAutocomplete(state); return; // Don't trigger onChange yet } else { // any other key re-runs tests @@ -279,6 +439,10 @@ export async function makeWatchRunner( debug: debugMode, inputMode: 'none', inputBuffer: '', + allFixtureNames: [], + matchingFixtures: [], + selectedIndex: -1, + fixtureLastRunStatus: new Map(), }; subscribeTsc(state, onChange); diff --git a/compiler/packages/snap/src/runner.ts b/compiler/packages/snap/src/runner.ts index 04724532b64..21320048eb2 100644 --- a/compiler/packages/snap/src/runner.ts +++ b/compiler/packages/snap/src/runner.ts @@ -142,6 +142,14 @@ async function onChange( true, // requireSingleFixture in watch mode ); const end = performance.now(); + + // Track fixture status for autocomplete suggestions + for (const [basename, result] of results) { + const failed = + result.actual !== result.expected || result.unexpectedError != null; + state.fixtureLastRunStatus.set(basename, failed ? 'fail' : 'pass'); + } + if (mode.action === RunnerAction.Update) { update(results); state.lastUpdate = end; From ff39247ee019ae5f2380228c507cc39a22e60f94 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Fri, 23 Jan 2026 11:07:42 -0800 Subject: [PATCH 2/6] [compiler] Summaries of the compiler passes to assist agents in development Autogenerated summaries of each of the compiler passes which allow agents to get the key ideas of a compiler pass, including key input/output invariants, without having to reprocess the file each time. In the subsequent diff this seemed to help. --- compiler/CLAUDE.md | 2 + .../docs/passes/01-lower.md | 157 ++++++++ .../docs/passes/02-enterSSA.md | 182 +++++++++ .../docs/passes/03-eliminateRedundantPhi.md | 90 +++++ .../docs/passes/04-constantPropagation.md | 110 +++++ .../docs/passes/05-deadCodeElimination.md | 109 +++++ .../docs/passes/06-inferTypes.md | 127 ++++++ .../docs/passes/07-analyseFunctions.md | 84 ++++ .../passes/08-inferMutationAliasingEffects.md | 144 +++++++ .../passes/09-inferMutationAliasingRanges.md | 149 +++++++ .../docs/passes/10-inferReactivePlaces.md | 169 ++++++++ .../passes/11-inferReactiveScopeVariables.md | 176 ++++++++ ...riteInstructionKindsBasedOnReassignment.md | 151 +++++++ .../docs/passes/13-alignMethodCallScopes.md | 131 ++++++ .../docs/passes/14-alignObjectMethodScopes.md | 128 ++++++ .../15-alignReactiveScopesToBlockScopesHIR.md | 177 +++++++++ .../16-mergeOverlappingReactiveScopesHIR.md | 134 +++++++ .../17-buildReactiveScopeTerminalsHIR.md | 161 ++++++++ .../docs/passes/18-flattenReactiveLoopsHIR.md | 158 ++++++++ .../19-flattenScopesWithHooksOrUseHIR.md | 143 +++++++ .../20-propagateScopeDependenciesHIR.md | 158 ++++++++ .../docs/passes/21-buildReactiveFunction.md | 180 +++++++++ .../docs/passes/22-pruneUnusedLabels.md | 145 +++++++ .../docs/passes/23-pruneNonEscapingScopes.md | 130 ++++++ .../passes/24-pruneNonReactiveDependencies.md | 138 +++++++ .../docs/passes/25-pruneUnusedScopes.md | 111 ++++++ ...rgeReactiveScopesThatInvalidateTogether.md | 213 ++++++++++ .../27-pruneAlwaysInvalidatingScopes.md | 143 +++++++ .../docs/passes/28-propagateEarlyReturns.md | 183 +++++++++ .../docs/passes/29-promoteUsedTemporaries.md | 203 ++++++++++ .../docs/passes/30-renameVariables.md | 200 ++++++++++ .../docs/passes/31-codegenReactiveFunction.md | 289 ++++++++++++++ .../docs/passes/32-transformFire.md | 203 ++++++++++ .../docs/passes/33-lowerContextAccess.md | 174 ++++++++ .../passes/34-optimizePropsMethodCalls.md | 132 ++++++ .../docs/passes/35-optimizeForSSR.md | 187 +++++++++ .../docs/passes/36-outlineJSX.md | 224 +++++++++++ .../docs/passes/37-outlineFunctions.md | 169 ++++++++ ...8-memoizeFbtAndMacroOperandsInSameScope.md | 231 +++++++++++ .../39-validateContextVariableLValues.md | 192 +++++++++ .../docs/passes/40-validateUseMemo.md | 299 ++++++++++++++ .../docs/passes/41-validateHooksUsage.md | 330 +++++++++++++++ .../passes/42-validateNoCapitalizedCalls.md | 233 +++++++++++ ...-validateLocalsNotReassignedAfterRender.md | 321 +++++++++++++++ .../passes/44-validateNoSetStateInRender.md | 133 +++++++ ...-validateNoDerivedComputationsInEffects.md | 141 +++++++ .../passes/46-validateNoSetStateInEffects.md | 150 +++++++ .../passes/47-validateNoJSXInTryStatement.md | 167 ++++++++ .../48-validateNoImpureValuesInRender.md | 180 +++++++++ .../passes/49-validateNoRefAccessInRender.md | 268 +++++++++++++ ...validateNoFreezingKnownMutableFunctions.md | 285 +++++++++++++ .../51-validateExhaustiveDependencies.md | 376 ++++++++++++++++++ .../52-validateMemoizedEffectDependencies.md | 93 +++++ .../53-validatePreservedManualMemoization.md | 152 +++++++ .../passes/54-validateStaticComponents.md | 153 +++++++ .../docs/passes/55-validateSourceLocations.md | 199 +++++++++ .../docs/passes/README.md | 305 ++++++++++++++ 57 files changed, 10072 insertions(+) create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/01-lower.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/02-enterSSA.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/03-eliminateRedundantPhi.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/04-constantPropagation.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/05-deadCodeElimination.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/06-inferTypes.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/07-analyseFunctions.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/08-inferMutationAliasingEffects.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/09-inferMutationAliasingRanges.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/10-inferReactivePlaces.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/11-inferReactiveScopeVariables.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/12-rewriteInstructionKindsBasedOnReassignment.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/13-alignMethodCallScopes.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/14-alignObjectMethodScopes.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/15-alignReactiveScopesToBlockScopesHIR.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/16-mergeOverlappingReactiveScopesHIR.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/17-buildReactiveScopeTerminalsHIR.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/18-flattenReactiveLoopsHIR.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/19-flattenScopesWithHooksOrUseHIR.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/20-propagateScopeDependenciesHIR.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/21-buildReactiveFunction.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/22-pruneUnusedLabels.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/23-pruneNonEscapingScopes.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/24-pruneNonReactiveDependencies.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/25-pruneUnusedScopes.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/26-mergeReactiveScopesThatInvalidateTogether.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/27-pruneAlwaysInvalidatingScopes.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/28-propagateEarlyReturns.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/29-promoteUsedTemporaries.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/30-renameVariables.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/31-codegenReactiveFunction.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/32-transformFire.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/33-lowerContextAccess.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/34-optimizePropsMethodCalls.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/35-optimizeForSSR.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/36-outlineJSX.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/37-outlineFunctions.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/38-memoizeFbtAndMacroOperandsInSameScope.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/39-validateContextVariableLValues.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/40-validateUseMemo.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/41-validateHooksUsage.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/42-validateNoCapitalizedCalls.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/43-validateLocalsNotReassignedAfterRender.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/44-validateNoSetStateInRender.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/45-validateNoDerivedComputationsInEffects.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/46-validateNoSetStateInEffects.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/47-validateNoJSXInTryStatement.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/48-validateNoImpureValuesInRender.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/49-validateNoRefAccessInRender.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/50-validateNoFreezingKnownMutableFunctions.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/51-validateExhaustiveDependencies.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/52-validateMemoizedEffectDependencies.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/53-validatePreservedManualMemoization.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/54-validateStaticComponents.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/55-validateSourceLocations.md create mode 100644 compiler/packages/babel-plugin-react-compiler/docs/passes/README.md diff --git a/compiler/CLAUDE.md b/compiler/CLAUDE.md index c8e909bf214..8de9c88fcf7 100644 --- a/compiler/CLAUDE.md +++ b/compiler/CLAUDE.md @@ -4,6 +4,8 @@ This document contains knowledge about the React Compiler gathered during develo ## Project Structure +When modifying the compiler, you MUST read the documentation about that pass in `compiler/packages/babel-plugin-react-compiler/docs/passes/` to learn more about the role of that pass within the compiler. + - `packages/babel-plugin-react-compiler/` - Main compiler package - `src/HIR/` - High-level Intermediate Representation types and utilities - `src/Inference/` - Effect inference passes (aliasing, mutation, etc.) diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/01-lower.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/01-lower.md new file mode 100644 index 00000000000..b5b3d8f46e6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/01-lower.md @@ -0,0 +1,157 @@ +# lower (BuildHIR) + +## File +`src/HIR/BuildHIR.ts` + +## Purpose +Converts a Babel AST function node into a High-level Intermediate Representation (HIR), which represents code as a control-flow graph (CFG) with basic blocks, instructions, and terminals. This is the first major transformation pass in the React Compiler pipeline, enabling precise expression-level memoization analysis. + +## Input Invariants +- Input must be a valid Babel `NodePath` (FunctionDeclaration, FunctionExpression, or ArrowFunctionExpression) +- The function must be a component or hook (determined by the environment) +- Babel scope analysis must be available for binding resolution +- An `Environment` instance must be provided with compiler configuration +- Optional `bindings` map for nested function lowering (recursive calls) +- Optional `capturedRefs` map for context variables captured from outer scope + +## Output Guarantees +- Returns `Result` - either a successfully lowered function or compilation errors +- The HIR function contains: + - A complete CFG with basic blocks (`body.blocks: Map`) + - Each block has an array of instructions and exactly one terminal + - All control flow is explicit (if/else, loops, switch, logical operators, ternary) + - Parameters are converted to `Place` or `SpreadPattern` + - Context captures are tracked in `context` array + - Function metadata (id, async, generator, directives) +- All identifiers get unique `IdentifierId` values +- Instructions have placeholder instruction IDs (set to 0, assigned later) +- Effects are null (populated by later inference passes) + +## Algorithm +The lowering algorithm uses a recursive descent pattern with a `HIRBuilder` helper class: + +1. **Initialization**: Create an `HIRBuilder` with environment and optional bindings. Process captured context variables. + +2. **Parameter Processing**: For each function parameter: + - Simple identifiers: resolve binding and create Place + - Patterns (object/array): create temporary Place, then emit destructuring assignments + - Rest elements: wrap in SpreadPattern + - Unsupported: emit Todo error + +3. **Body Processing**: + - Arrow function expressions: lower body expression to temporary, emit implicit return + - Block statements: recursively lower each statement + +4. **Statement Lowering** (`lowerStatement`): Handle each statement type: + - **Control flow**: Create separate basic blocks for branches, loops connect back to conditional blocks + - **Variable declarations**: Create `DeclareLocal`/`DeclareContext` or `StoreLocal`/`StoreContext` instructions + - **Expressions**: Lower to temporary and discard result + - **Hoisting**: Detect forward references and emit `DeclareContext` for hoisted identifiers + +5. **Expression Lowering** (`lowerExpression`): Convert expressions to `InstructionValue`: + - **Identifiers**: Create `LoadLocal`, `LoadContext`, or `LoadGlobal` based on binding + - **Literals**: Create `Primitive` values + - **Operators**: Create `BinaryExpression`, `UnaryExpression` etc. + - **Calls**: Distinguish `CallExpression` vs `MethodCall` (member expression callee) + - **Control flow expressions**: Create separate value blocks for branches (ternary, logical, optional chaining) + - **JSX**: Lower to `JsxExpression` with lowered tag, props, and children + +6. **Block Management**: The builder maintains: + - A current work-in-progress block accumulating instructions + - Completed blocks map + - Scope stack for break/continue resolution + - Exception handler stack for try/catch + +7. **Termination**: Add implicit void return at end if no explicit return + +## Key Data Structures + +### HIRBuilder (from HIRBuilder.ts) +- `#current: WipBlock` - Work-in-progress block being populated +- `#completed: Map` - Finished blocks +- `#scopes: Array` - Stack for break/continue target resolution (LoopScope, LabelScope, SwitchScope) +- `#exceptionHandlerStack: Array` - Stack of catch handlers for try/catch +- `#bindings: Bindings` - Map of variable names to their identifiers +- `#context: Map` - Captured context variables +- Methods: `push()`, `reserve()`, `enter()`, `terminate()`, `terminateWithContinuation()` + +### Core HIR Types +- **BasicBlock**: Contains `instructions: Array`, `terminal: Terminal`, `preds: Set`, `phis: Set`, `kind: BlockKind` +- **Instruction**: Contains `id`, `lvalue` (Place), `value` (InstructionValue), `effects` (null initially), `loc` +- **Terminal**: Block terminator - `if`, `branch`, `goto`, `return`, `throw`, `for`, `while`, `switch`, `ternary`, `logical`, etc. +- **Place**: Reference to a value - `{kind: 'Identifier', identifier, effect, reactive, loc}` +- **InstructionValue**: The operation - `LoadLocal`, `StoreLocal`, `CallExpression`, `BinaryExpression`, `FunctionExpression`, etc. + +### Block Kinds +- `block` - Regular sequential block +- `loop` - Loop header/test block +- `value` - Block that produces a value (ternary/logical branches) +- `sequence` - Sequence expression block +- `catch` - Exception handler block + +## Edge Cases + +1. **Hoisting**: Forward references to `let`/`const`/`function` declarations emit `DeclareContext` before the reference, enabling correct temporal dead zone handling + +2. **Context Variables**: Variables captured by nested functions use `LoadContext`/`StoreContext` instead of `LoadLocal`/`StoreLocal` + +3. **For-of/For-in Loops**: Synthesize iterator instructions (`GetIterator`, `IteratorNext`, `NextPropertyOf`) + +4. **Optional Chaining**: Creates nested `OptionalTerminal` structures with short-circuit branches + +5. **Logical Expressions**: Create branching structures where left side stores to temporary, right side only evaluated if needed + +6. **Try/Catch**: Adds `MaybeThrowTerminal` after each instruction in try block, modeling potential control flow to handler + +7. **JSX in fbt**: Tracks `fbtDepth` counter to handle whitespace differently in fbt/fbs tags + +8. **Unsupported Syntax**: `var` declarations, `with` statements, inline `class` declarations, `eval` - emit appropriate errors + +## TODOs +- `returnTypeAnnotation: null, // TODO: extract the actual return type node if present` +- `TODO(gsn): In the future, we could only pass in the context identifiers that are actually used by this function and its nested functions` +- Multiple `// TODO remove type cast` in destructuring pattern handling +- `// TODO: should JSX namespaced names be handled here as well?` + +## Example +Input JavaScript: +```javascript +export default function foo(x, y) { + if (x) { + return foo(false, y); + } + return [y * 10]; +} +``` + +Output HIR (simplified): +``` +foo( x$0, y$1): $12 +bb0 (block): + [1] $6 = LoadLocal x$0 + [2] If ( $6) then:bb2 else:bb1 fallthrough=bb1 + +bb2 (block): + predecessor blocks: bb0 + [3] $2 = LoadGlobal(module) foo + [4] $3 = false + [5] $4 = LoadLocal y$1 + [6] $5 = Call $2( $3, $4) + [7] Return Explicit $5 + +bb1 (block): + predecessor blocks: bb0 + [8] $7 = LoadLocal y$1 + [9] $8 = 10 + [10] $9 = Binary $7 * $8 + [11] $10 = Array [ $9] + [12] Return Explicit $10 +``` + +Key observations: +- The function has 3 basic blocks: entry (bb0), consequent (bb2), alternate/fallthrough (bb1) +- The if statement creates an `IfTerminal` at the end of bb0 +- Each branch ends with its own `ReturnTerminal` +- All values are stored in temporaries (`$N`) or named identifiers (`x$0`, `y$1`) +- Instructions have sequential IDs within blocks +- Types and effects are `` at this stage (populated by later passes) diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/02-enterSSA.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/02-enterSSA.md new file mode 100644 index 00000000000..d5e03b60d76 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/02-enterSSA.md @@ -0,0 +1,182 @@ +# enterSSA + +## File +`src/SSA/EnterSSA.ts` + +## Purpose +Converts the HIR from a non-SSA form (where variables can be reassigned) into Static Single Assignment (SSA) form, where each variable is defined exactly once and phi nodes are inserted at control flow join points to merge values from different paths. + +## Input Invariants +- The HIR must have blocks in reverse postorder (predecessors visited before successors, except for back-edges) +- Block predecessor information (`block.preds`) must be populated correctly +- The function's `context` array must be empty for the root function (outer function declarations) +- Identifiers may be reused across multiple definitions/assignments (non-SSA form) + +## Output Guarantees +- Each identifier has a unique `IdentifierId` - no identifier is defined more than once +- All operand references use the SSA-renamed identifiers +- Phi nodes are inserted at join points where values from different control flow paths converge +- Function parameters are SSA-renamed +- Nested functions (FunctionExpression, ObjectMethod) are recursively converted to SSA form +- Context variables (captured from outer scopes) are handled specially and not redefined + +## Algorithm +The pass uses the Braun et al. algorithm ("Simple and Efficient Construction of Static Single Assignment Form") with adaptations for handling loops and nested functions. + +### Key Steps: +1. **Block Traversal**: Iterate through blocks in order (assumed reverse postorder from previous passes) +2. **Definition Tracking**: Maintain a per-block `defs` map from original identifiers to their SSA-renamed versions +3. **Renaming**: + - When a value is **defined** (lvalue), create a new SSA identifier with fresh `IdentifierId` + - When a value is **used** (operand), look up the current SSA identifier via `getIdAt` +4. **Phi Node Insertion**: When looking up an identifier at a block with multiple predecessors: + - If all predecessors have been visited, create a phi node collecting values from each predecessor + - If some predecessors are unvisited (back-edge/loop), create an "incomplete phi" that will be fixed later +5. **Incomplete Phi Resolution**: When all predecessors of a block are finally visited, fix any incomplete phi nodes by populating their operands +6. **Nested Function Handling**: Recursively apply SSA transformation to nested functions, temporarily adding a fake predecessor edge to enable identifier lookup from the enclosing scope + +### Phi Node Placement Logic (`getIdAt`): +- If the identifier is defined locally in the current block, return it +- If at entry block with no predecessors and not found, mark as unknown (global) +- If some predecessors are unvisited (loop), create incomplete phi +- If exactly one predecessor, recursively look up in that predecessor +- If multiple predecessors, create phi node with operands from all predecessors + +## Key Data Structures +- **SSABuilder**: Main class managing the transformation + - `#states: Map` - Per-block state (defs map and incomplete phis) + - `unsealedPreds: Map` - Count of unvisited predecessors per block + - `#unknown: Set` - Identifiers assumed to be globals + - `#context: Set` - Context variables that should not be redefined +- **State**: Per-block state containing: + - `defs: Map` - Maps original identifiers to SSA-renamed versions + - `incompletePhis: Array` - Phi nodes waiting for predecessor values +- **IncompletePhi**: Tracks a phi node created before all predecessors were visited + - `oldPlace: Place` - Original place being phi'd + - `newPlace: Place` - SSA-renamed phi result place +- **Phi**: The actual phi node in the HIR + - `place: Place` - The result of the phi + - `operands: Map` - Maps predecessor block to the place providing the value + +## Edge Cases +- **Loops (back-edges)**: When a variable is used in a loop header before the loop body assigns it, an incomplete phi is created and later fixed when the loop body block is visited +- **Globals**: If an identifier is used but never defined (reaching the entry block without a definition), it's assumed to be a global and not renamed +- **Context variables**: Variables captured from an outer function scope are tracked specially and not redefined when reassigned +- **Nested functions**: Function expressions and object methods are processed recursively with a temporary predecessor edge linking them to the enclosing block + +## TODOs +- `[hoisting] EnterSSA: Expected identifier to be defined before being used` - Handles cases where hoisting causes an identifier to be used before definition (throws a Todo error for graceful bailout) + +## Example + +### Input (simple reassignment with control flow): +```javascript +function foo() { + let y = 2; + if (y > 1) { + y = 1; + } else { + y = 2; + } + let x = y; +} +``` + +### Before SSA (HIR): +``` +bb0 (block): + [1] $0 = 2 + [2] $2 = StoreLocal Let y$1 = $0 + [3] $7 = LoadLocal y$1 + [4] $8 = 1 + [5] $9 = Binary $7 > $8 + [6] If ($9) then:bb2 else:bb3 fallthrough=bb1 + +bb2 (block): + predecessor blocks: bb0 + [7] $3 = 1 + [8] $4 = StoreLocal Reassign y$1 = $3 // Same y$1 reassigned + [9] Goto bb1 + +bb3 (block): + predecessor blocks: bb0 + [10] $5 = 2 + [11] $6 = StoreLocal Reassign y$1 = $5 // Same y$1 reassigned + [12] Goto bb1 + +bb1 (block): + predecessor blocks: bb2 bb3 + [13] $10 = LoadLocal y$1 // Which y$1? + [14] $12 = StoreLocal Let x$11 = $10 +``` + +### After SSA: +``` +bb0 (block): + [1] $15 = 2 + [2] $17 = StoreLocal Let y$16 = $15 // y$16: initial definition + [3] $18 = LoadLocal y$16 + [4] $19 = 1 + [5] $20 = Binary $18 > $19 + [6] If ($20) then:bb2 else:bb3 fallthrough=bb1 + +bb2 (block): + predecessor blocks: bb0 + [7] $21 = 1 + [8] $23 = StoreLocal Reassign y$22 = $21 // y$22: new SSA name + [9] Goto bb1 + +bb3 (block): + predecessor blocks: bb0 + [10] $24 = 2 + [11] $26 = StoreLocal Reassign y$25 = $24 // y$25: new SSA name + [12] Goto bb1 + +bb1 (block): + predecessor blocks: bb2 bb3 + y$27: phi(bb2: y$22, bb3: y$25) // PHI NODE: merges y$22 and y$25 + [13] $28 = LoadLocal y$27 // Uses phi result + [14] $30 = StoreLocal Let x$29 = $28 +``` + +### Loop Example (while loop with back-edge): +```javascript +function foo() { + let x = 1; + while (x < 10) { + x = x + 1; + } + return x; +} +``` + +### After SSA: +``` +bb0 (block): + [1] $13 = 1 + [2] $15 = StoreLocal Let x$14 = $13 // x$14: initial definition + [3] While test=bb1 loop=bb3 fallthrough=bb2 + +bb1 (loop): + predecessor blocks: bb0 bb3 + x$16: phi(bb0: x$14, bb3: x$23) // PHI merges initial and loop-updated values + [4] $17 = LoadLocal x$16 + [5] $18 = 10 + [6] $19 = Binary $17 < $18 + [7] Branch ($19) then:bb3 else:bb2 + +bb3 (block): + predecessor blocks: bb1 + [8] $20 = LoadLocal x$16 // Uses phi result + [9] $21 = 1 + [10] $22 = Binary $20 + $21 + [11] $24 = StoreLocal Reassign x$23 = $22 // x$23: new SSA name in loop body + [12] Goto(Continue) bb1 + +bb2 (block): + predecessor blocks: bb1 + [13] $25 = LoadLocal x$16 // Uses phi result + [14] Return Explicit $25 +``` + +The phi node at `bb1` (the loop header) is initially created as an "incomplete phi" when first visited because `bb3` (the loop body) hasn't been visited yet. Once `bb3` is processed and its terminal is handled, the incomplete phi is fixed by calling `fixIncompletePhis` to populate the operand from `bb3`. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/03-eliminateRedundantPhi.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/03-eliminateRedundantPhi.md new file mode 100644 index 00000000000..fe774253ade --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/03-eliminateRedundantPhi.md @@ -0,0 +1,90 @@ +# eliminateRedundantPhi + +## File +`src/SSA/EliminateRedundantPhi.ts` + +## Purpose +Eliminates phi nodes whose operands are trivially the same, replacing all usages of the phi's output identifier with the single source identifier. This simplifies the HIR by removing unnecessary join points that do not actually merge distinct values. + +## Input Invariants +- The function must be in SSA form (i.e., `enterSSA` has already run) +- Blocks are in reverse postorder (guaranteed by the HIR structure) +- Phi nodes exist at the start of blocks where control flow merges + +## Output Guarantees +- All redundant phi nodes are removed from the HIR +- All references to eliminated phi identifiers are rewritten to the source identifier +- Non-redundant phi nodes (those merging two or more distinct values) are preserved +- Nested function expressions (FunctionExpression, ObjectMethod) also have their redundant phis eliminated and contexts rewritten + +## Algorithm +A phi node is considered redundant when: +1. **All operands are the same identifier**: e.g., `x2 = phi(x1, x1, x1)` - the phi is replaced with `x1` +2. **All operands are either the same identifier OR the phi's output**: e.g., `x2 = phi(x1, x2, x1, x2)` - this handles loop back-edges where the phi references itself + +The algorithm works as follows: +1. Visit blocks in reverse postorder, building a rewrite table (`Map`) +2. For each phi node in a block: + - First rewrite operands using any existing rewrites (to handle cascading eliminations) + - Check if all operands (excluding self-references) point to the same identifier + - If so, add a mapping from the phi's output to that identifier and delete the phi +3. After processing phis, rewrite all instruction lvalues, operands, and terminal operands +4. For nested functions, recursively call `eliminateRedundantPhi` with shared rewrites +5. If the CFG has back-edges (loops) and new rewrites were added, repeat the entire process + +The loop termination condition `rewrites.size > size && hasBackEdge` ensures: +- Without loops: completes in a single pass (reverse postorder guarantees forward propagation) +- With loops: repeats until no new rewrites are found (fixpoint) + +## Key Data Structures +- **`Phi`** (from `src/HIR/HIR.ts`): Represents a phi node with: + - `place: Place` - the output identifier + - `operands: Map` - maps predecessor block IDs to source places +- **`rewrites: Map`**: Maps eliminated phi outputs to their replacement identifier +- **`visited: Set`**: Tracks visited blocks to detect back-edges (loops) + +## Edge Cases +- **Loop back-edges**: When a block has a predecessor that hasn't been visited yet (in reverse postorder), that predecessor is a back-edge. The algorithm handles self-referential phis like `x2 = phi(x1, x2)` by ignoring operands equal to the phi's output. +- **Cascading eliminations**: When one phi's output is used in another phi's operands, the algorithm rewrites operands before checking redundancy, enabling transitive elimination in a single pass (for non-loop cases). +- **Nested functions**: FunctionExpression and ObjectMethod values contain nested HIR that may have their own phis. The algorithm recursively processes these with a shared rewrite table, ensuring context captures are also rewritten. +- **Empty phi check**: The algorithm includes an invariant check that phi operands are never empty (which would be invalid HIR). + +## TODOs +(None found in the source code) + +## Example + +Consider this fixture from `rewrite-phis-in-lambda-capture-context.js`: + +```javascript +function Component() { + const x = 4; + const get4 = () => { + while (bar()) { + if (baz) { bar(); } + } + return () => x; + }; + return get4; +} +``` + +**After SSA pass**, the inner function has redundant phis due to the loop: + +``` +bb2 (loop): + predecessor blocks: bb1 bb5 + x$29: phi(bb1: x$21, bb5: x$30) // Loop header phi + ... +bb5 (block): + predecessor blocks: bb6 bb4 + x$30: phi(bb6: x$29, bb4: x$29) // Redundant: both operands are x$29 + ... +``` + +**After EliminateRedundantPhi**: +- `x$30 = phi(x$29, x$29)` is eliminated because both operands are `x$29` +- `x$29 = phi(x$21, x$30)` becomes `x$29 = phi(x$21, x$29)` after rewriting, which is also redundant (one operand is the phi itself, the other is `x$21`) +- Both phis are eliminated, and all uses of `x$29` and `x$30` are rewritten to `x$21` + +The result: the context capture `@context[x$29]` becomes `@context[x$21]`, correctly propagating that `x` is never modified inside the loop. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/04-constantPropagation.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/04-constantPropagation.md new file mode 100644 index 00000000000..36e57abe03a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/04-constantPropagation.md @@ -0,0 +1,110 @@ +# constantPropagation + +## File +`src/Optimization/ConstantPropagation.ts` + +## Purpose +Applies Sparse Conditional Constant Propagation (SCCP) to fold compile-time evaluable expressions to constant values, propagate those constants through the program, and eliminate unreachable branches when conditionals have known constant values. + +## Input Invariants +- HIR must be in SSA form (runs after `enterSSA`) +- Redundant phi nodes should be eliminated (runs after `eliminateRedundantPhi`) +- Consistent identifiers must be ensured (`assertConsistentIdentifiers`) +- Terminal successors must exist (`assertTerminalSuccessorsExist`) + +## Output Guarantees +- Instructions with compile-time evaluable operands are replaced with `Primitive` constants +- `ComputedLoad`/`ComputedStore` with constant string/number properties are converted to `PropertyLoad`/`PropertyStore` +- `LoadLocal` and `StoreLocal` propagate known constant values +- `IfTerminal` with constant boolean test values are replaced with `goto` terminals +- Unreachable blocks are removed and the CFG is minimized +- Phi nodes with unreachable predecessor operands are pruned +- Nested functions (`FunctionExpression`, `ObjectMethod`) are recursively processed + +## Algorithm +The pass uses Sparse Conditional Constant Propagation (SCCP) with fixpoint iteration: + +1. **Data Structure**: A `Constants` map (`Map`) tracks known constant values (either `Primitive` or `LoadGlobal`) + +2. **Single Pass per Iteration**: Visits all blocks in order: + - Evaluates phi nodes - if all operands have the same constant value, the phi result is constant + - Evaluates instructions - replaces evaluable expressions with constants + - Evaluates terminals - if an `IfTerminal` test is a constant, replaces it with a `goto` + +3. **Fixpoint Loop**: If any terminals changed (branch elimination): + - Recomputes block ordering (`reversePostorderBlocks`) + - Removes unreachable code (`removeUnreachableForUpdates`, `removeDeadDoWhileStatements`, `removeUnnecessaryTryCatch`) + - Renumbers instructions (`markInstructionIds`) + - Updates predecessors (`markPredecessors`) + - Prunes phi operands from unreachable predecessors + - Eliminates newly-redundant phis (`eliminateRedundantPhi`) + - Merges consecutive blocks (`mergeConsecutiveBlocks`) + - Repeats until no more changes + +4. **Instruction Evaluation**: Handles various instruction types: + - **Primitives/LoadGlobal**: Directly constant + - **BinaryExpression**: Folds arithmetic (`+`, `-`, `*`, `/`, `%`, `**`), bitwise (`|`, `&`, `^`, `<<`, `>>`, `>>>`), and comparison (`<`, `<=`, `>`, `>=`, `==`, `===`, `!=`, `!==`) operators + - **UnaryExpression**: Folds `!` (boolean negation) and `-` (numeric negation) + - **PostfixUpdate/PrefixUpdate**: Folds `++`/`--` on constant numbers + - **PropertyLoad**: Folds `.length` on constant strings + - **TemplateLiteral**: Folds template strings with constant interpolations + - **ComputedLoad/ComputedStore**: Converts to property access when property is constant string/number + +## Key Data Structures +- `Constant = Primitive | LoadGlobal` - The lattice values (no top/bottom, absence means unknown) +- `Constants = Map` - Maps identifier IDs to their known constant values +- Uses HIR types: `Instruction`, `Phi`, `Place`, `Primitive`, `LoadGlobal`, `InstructionValue` + +## Edge Cases +- **Last instruction of sequence blocks**: Skipped to preserve evaluation order +- **Phi nodes with back-edges**: Single-pass analysis means loop back-edges won't have constant values propagated +- **Template literals with Symbol**: Not folded (would throw at runtime) +- **Template literals with objects/arrays**: Not folded (custom toString behavior) +- **Division results**: Computed at compile time (may produce `NaN`, `Infinity`, etc.) +- **LoadGlobal in phis**: Only propagated if all operands reference the same global name +- **Nested functions**: Constants from outer scope are propagated into nested function expressions + +## TODOs +- `// TODO: handle more cases` - The default case in `evaluateInstruction` has room for additional instruction types + +## Example + +**Input:** +```javascript +function Component() { + let a = 1; + + let b; + if (a === 1) { + b = true; + } else { + b = false; + } + + let c; + if (b) { + c = 'hello'; + } else { + c = null; + } + + return c; +} +``` + +**After ConstantPropagation:** +- `a === 1` evaluates to `true` +- The `if (a === 1)` branch is eliminated, only consequent remains +- `b` is known to be `true` +- `if (b)` branch is eliminated, only consequent remains +- `c` is known to be `'hello'` +- All intermediate blocks are merged + +**Output:** +```javascript +function Component() { + return "hello"; +} +``` + +The pass performs iterative simplification: first iteration determines `a === 1` is `true` and eliminates that branch. The CFG is updated, phi for `b` is pruned to single operand making `b = true`. Second iteration uses `b = true` to eliminate the next branch. This continues until no more branches can be eliminated. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/05-deadCodeElimination.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/05-deadCodeElimination.md new file mode 100644 index 00000000000..d1bbb5b742f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/05-deadCodeElimination.md @@ -0,0 +1,109 @@ +# deadCodeElimination + +## File +`src/Optimization/DeadCodeElimination.ts` + +## Purpose +Eliminates instructions whose values are unused, reducing generated code size. The pass performs mark-and-sweep analysis to identify and remove dead code while preserving side effects and program semantics. + +## Input Invariants +- Must run after `InferMutationAliasingEffects` because "dead" code may still affect effect inference +- HIR is in SSA form with phi nodes +- Unreachable blocks are already pruned during HIR construction + +## Output Guarantees +- All instructions with unused lvalues (that are safe to prune) are removed +- Unused phi nodes are deleted +- Unused context variables are removed from `fn.context` +- Destructuring patterns are rewritten to remove unused bindings +- `StoreLocal` instructions with unused initializers are converted to `DeclareLocal` + +## Algorithm +Two-phase mark-and-sweep with fixed-point iteration for loops: + +**Phase 1: Mark (findReferencedIdentifiers)** +1. Detect if function has back-edges (loops) +2. Iterate blocks in reverse postorder (successors before predecessors) to visit usages before declarations +3. For each block: + - Mark all terminal operands as referenced + - Process instructions in reverse order: + - If lvalue is used OR instruction is not pruneable, mark the lvalue and all operands as referenced + - Special case for `StoreLocal`: only mark initializer if the SSA lvalue is actually read + - Mark phi operands if the phi result is used +4. If loops exist and new identifiers were marked, repeat until fixed point + +**Phase 2: Sweep** +1. Remove unused phi nodes from each block +2. Remove instructions with unused lvalues using `retainWhere` +3. Rewrite retained instructions: + - **Array destructuring**: Replace unused elements with holes, truncate trailing holes + - **Object destructuring**: Remove unused properties (only if rest element is unused or absent) + - **StoreLocal**: Convert to `DeclareLocal` if initializer value is never read +4. Remove unused context variables + +## Key Data Structures +- **State class**: Tracks referenced identifiers + - `identifiers: Set` - SSA-specific usages + - `named: Set` - Named variable usages (any version) + - `isIdOrNameUsed()` - Checks if identifier or any version of named variable is used + - `isIdUsed()` - Checks if specific SSA id is used +- **hasBackEdge/findBlocksWithBackEdges**: Detect loops requiring fixed-point iteration + +## Edge Cases +- **Preserved even if unused:** + - `debugger` statements (to not break debugging workflows) + - Call expressions and method calls (may have side effects) + - Await expressions + - Store operations (ComputedStore, PropertyStore, StoreGlobal) + - Delete operations (ComputedDelete, PropertyDelete) + - Iterator operations (GetIterator, IteratorNext, NextPropertyOf) + - Context operations (LoadContext, DeclareContext, StoreContext) + - Memoization markers (StartMemoize, FinishMemoize) + +- **SSR mode special case:** + - In SSR mode, unused `useState`, `useReducer`, and `useRef` hooks can be removed + +- **Object destructuring with rest:** + - Cannot remove unused properties if rest element is used (would change rest's value) + +- **Block value instructions:** + - Last instruction of value blocks (not 'block' kind) is never pruned as it's the block's value + +## TODOs +- "TODO: we could be more precise and make this conditional on whether any arguments are actually modified" (for mutating instructions) + +## Example + +**Input:** +```javascript +function Component(props) { + const _ = 42; + return props.value; +} +``` + +**After DeadCodeElimination:** +The `const _ = 42` assignment is removed since `_` is never used: +```javascript +function Component(props) { + return props.value; +} +``` + +**Array destructuring example:** + +Input: +```javascript +function foo(props) { + const [x, unused, y] = props.a; + return x + y; +} +``` + +Output (middle element becomes a hole): +```javascript +function foo(props) { + const [x, , y] = props.a; + return x + y; +} +``` diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/06-inferTypes.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/06-inferTypes.md new file mode 100644 index 00000000000..eca37e88431 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/06-inferTypes.md @@ -0,0 +1,127 @@ +# inferTypes + +## File +`src/TypeInference/InferTypes.ts` + +## Purpose +Infers types for all identifiers in the HIR by generating type equations and solving them using unification. This pass annotates identifiers with concrete types (Primitive, Object, Function) based on the operations performed on them and the types of globals/hooks they interact with. + +## Input Invariants +- The HIR must be in SSA form (the pass runs after `enterSSA` and `eliminateRedundantPhi`) +- Constant propagation has already run +- Global declarations and hook shapes are available via the Environment + +## Output Guarantees +- All identifier types are resolved from type variables (`Type`) to concrete types where possible +- Phi nodes have their operand types unified to produce a single result type +- Function return types are inferred from the unified types of all return statements +- Property accesses on known objects/hooks resolve to the declared property types +- Component props parameters are typed as `TObject` +- Component ref parameters are typed as `TObject` + +## Algorithm +The pass uses a classic constraint-based type inference approach with three phases: + +1. **Constraint Generation (`generate`)**: Traverses all instructions and generates type equations: + - Primitives, literals, unary/binary operations -> `Primitive` type + - Hook/function calls -> Function type with fresh return type variable + - Property loads -> `Property` type that defers to object shape lookup + - Destructuring -> Property types for each extracted element + - Phi nodes -> `Phi` type with all operand types as candidates + - JSX -> `Object` + - Arrays -> `Object` + - Objects -> `Object` + +2. **Unification (`Unifier.unify`)**: Solves constraints by unifying type equations: + - Type variables are bound to concrete types via substitution + - Property types are resolved by looking up the object's shape + - Phi types are resolved by finding a common type among operands (or falling back to `Phi` if incompatible) + - Function types are unified by unifying their return types + - Occurs check prevents infinite types (cycles in type references) + +3. **Application (`apply`)**: Applies the computed substitutions to all identifiers in the HIR, replacing type variables with their resolved types. + +## Key Data Structures +- **TypeVar** (`kind: 'Type'`): A type variable with a unique TypeId, used for unknowns +- **Unifier**: Maintains a substitution map from TypeId to Type, with methods for unification and cycle detection +- **TypeEquation**: A pair of types that should be equal, used as constraints +- **PhiType** (`kind: 'Phi'`): Represents the join of multiple types from control flow merge points +- **PropType** (`kind: 'Property'`): Deferred property lookup that resolves based on object shape +- **FunctionType** (`kind: 'Function'`): Callable type with optional shapeId and return type +- **ObjectType** (`kind: 'Object'`): Object with optional shapeId for shape lookup + +## Edge Cases + +### Phi Type Resolution +When phi operands have incompatible types, the pass attempts to find a union: +- `Union(Primitive | MixedReadonly) = MixedReadonly` +- `Union(Array | MixedReadonly) = Array` +- If no union is possible, the type remains as `Phi` + +### Ref-like Name Inference +When `enableTreatRefLikeIdentifiersAsRefs` is enabled, property access on variables matching the pattern `/^(?:[a-zA-Z$_][a-zA-Z$_0-9]*)Ref$|^ref$/` with property name `current` infers: +- Object type as `TObject` +- Property type as `TObject` + +### Cycle Detection +The `occursCheck` method prevents infinite types by detecting when a type variable appears in its own substitution. When a cycle is detected, `tryResolveType` removes the cyclic reference from Phi operands. + +### Context Variables +- `DeclareContext` and `LoadContext` generate no type equations (intentionally untyped) +- `StoreContext` with `Const` kind does propagate the rvalue type to enable ref inference through context variables + +### Event Handler Inference +When `enableInferEventHandlers` is enabled, JSX props starting with "on" (e.g., `onClick`) on built-in DOM elements (excluding web components with hyphens) are inferred as `Function`. + +## TODOs +1. **Hook vs Function type ambiguity**: + > "TODO: callee could be a hook or a function, so this type equation isn't correct. We should change Hook to a subtype of Function or change unifier logic." + +2. **PropertyStore rvalue inference**: + > "TODO: consider using the rvalue type here" - Currently uses a dummy type for PropertyStore to avoid inferring rvalue types from lvalue assignments. + +## Example + +**Input (infer-phi-primitive.js):** +```javascript +function foo(a, b) { + let x; + if (a) { + x = 1; + } else { + x = 2; + } + let y = x; + return y; +} +``` + +**Before InferTypes (SSA form):** +``` + x$26: phi(bb2: x$21, bb3: x$24) +[10] $27 = LoadLocal x$26 +[11] $29 = StoreLocal Let y$28 = $27 +``` + +**After InferTypes:** +``` + x$26:TPrimitive: phi(bb2: x$21:TPrimitive, bb3: x$24:TPrimitive) +[10] $27:TPrimitive = LoadLocal x$26:TPrimitive +[11] $29:TPrimitive = StoreLocal Let y$28:TPrimitive = $27:TPrimitive +``` + +The pass infers that: +- Literals `1` and `2` are `TPrimitive` +- The phi of two primitives is `TPrimitive` +- Variables `x` and `y` are `TPrimitive` +- The function return type is `TPrimitive` + +**Hook type inference example (useState):** +```javascript +const [x, setX] = useState(initialValue); +``` + +After InferTypes: +- `useState` -> `TFunction:TObject` +- Return value `$27` -> `TObject` +- Destructured `setX` -> `TFunction:TPrimitive` diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/07-analyseFunctions.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/07-analyseFunctions.md new file mode 100644 index 00000000000..b86881163bb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/07-analyseFunctions.md @@ -0,0 +1,84 @@ +# analyseFunctions + +## File +`src/Inference/AnalyseFunctions.ts` + +## Purpose +Recursively analyzes all nested function expressions and object methods in a function to infer their aliasing effect signatures, which describe how the function affects its captured variables when invoked. + +## Input Invariants +- The HIR has been through SSA conversion and type inference +- FunctionExpression and ObjectMethod instructions have an empty `aliasingEffects` array (`@aliasingEffects=[]`) +- Context variables (captured variables from outer scope) exist on `fn.context` but do not have their effect populated + +## Output Guarantees +- Every FunctionExpression and ObjectMethod has its `aliasingEffects` array populated with the effects the function performs when called (mutations, captures, aliasing to return value, etc.) +- Each context variable's `effect` property is set to either `Effect.Capture` (if the variable is captured or mutated by the inner function) or `Effect.Read` (if only read) +- Context variable mutable ranges are reset to `{start: 0, end: 0}` and scopes are set to `null` to prepare for the outer function's subsequent `inferMutationAliasingRanges` pass + +## Algorithm +1. **Recursive traversal**: Iterates through all blocks and instructions looking for `FunctionExpression` or `ObjectMethod` instructions +2. **Depth-first processing**: For each function expression found, calls `lowerWithMutationAliasing()` which: + - Recursively calls `analyseFunctions()` on the inner function (handles nested functions) + - Runs `inferMutationAliasingEffects()` on the inner function to determine effects + - Runs `deadCodeElimination()` to clean up + - Runs `inferMutationAliasingRanges()` to compute mutable ranges and extract externally-visible effects + - Runs `rewriteInstructionKindsBasedOnReassignment()` and `inferReactiveScopeVariables()` + - Stores the computed effects in `fn.aliasingEffects` +3. **Context variable effect classification**: Scans the computed effects to determine which context variables are captured/mutated vs only read: + - Effects like `Capture`, `Alias`, `Assign`, `MaybeAlias`, `CreateFrom` mark the source as captured + - Mutation effects (`Mutate`, `MutateTransitive`, etc.) mark the target as captured + - Sets `operand.effect = Effect.Capture` or `Effect.Read` accordingly +4. **Range reset**: Resets mutable ranges and scopes on context variables to prepare for outer function analysis + +## Key Data Structures +- **HIRFunction.aliasingEffects**: Array of `AliasingEffect` storing the externally-visible behavior of a function when called +- **Place.effect**: Effect enum value (`Capture` or `Read`) describing how a context variable is used +- **AliasingEffect**: Union type describing data flow (Capture, Alias, Assign, etc.) and mutations (Mutate, MutateTransitive, etc.) +- **FunctionExpression/ObjectMethod.loweredFunc.func**: The inner HIRFunction to analyze + +## Edge Cases +- **Nested functions**: Handled via recursive call to `analyseFunctions()` before processing the current function - innermost functions are analyzed first +- **ObjectMethod**: Treated identically to FunctionExpression +- **Apply effects invariant**: The pass asserts that no `Apply` effects remain in the function's signature - these should have been resolved to more precise effects by `inferMutationAliasingRanges()` +- **Conditional mutations**: Effects like `MutateTransitiveConditionally` are tracked - a function that conditionally mutates a captured variable will have that effect in its signature +- **Immutable captures**: `ImmutableCapture`, `Freeze`, `Create`, `Impure`, `Render` effects do not contribute to marking context variables as `Capture` + +## TODOs +- No TODO comments in the pass itself + +## Example +Consider a function that captures and conditionally mutates a variable: + +```javascript +function useHook(a, b) { + let z = {a}; + let y = b; + let x = function () { + if (y) { + maybeMutate(z); // Unknown function, may mutate z + } + }; + return x; +} +``` + +**Before AnalyseFunctions:** +``` +Function @context[y$28, z$25] @aliasingEffects=[] +``` + +**After AnalyseFunctions:** +``` +Function @context[read y$28, capture z$25] @aliasingEffects=[ + MutateTransitiveConditionally z$25, + Create $14 = primitive +] +``` + +The pass infers: +- `y` is only read (used in the condition) +- `z` is captured into the function and conditionally mutated transitively (because `maybeMutate()` is unknown) +- The inner function's signature includes `MutateTransitiveConditionally z$25` to indicate this potential mutation + +This signature is then used by `InferMutationAliasingEffects` on the outer function to understand that creating this function captures `z`, and calling the function may mutate `z`. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/08-inferMutationAliasingEffects.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/08-inferMutationAliasingEffects.md new file mode 100644 index 00000000000..a8b488008e0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/08-inferMutationAliasingEffects.md @@ -0,0 +1,144 @@ +# inferMutationAliasingEffects + +## File +`src/Inference/InferMutationAliasingEffects.ts` + +## Purpose +Infers the mutation and aliasing effects for all instructions and terminals in the HIR, making the effects of built-in instructions/functions as well as user-defined functions explicit. These effects form the basis for subsequent analysis to determine the mutable range of each value in the program and for validation against invalid code patterns like mutating frozen values. + +## Input Invariants +- HIR must be in SSA form (run after SSA pass) +- Types must be inferred (run after InferTypes pass) +- Functions must be analyzed (run after AnalyseFunctions pass) - this provides `aliasingEffects` on FunctionExpressions +- Each instruction must have an lvalue (destination place) + +## Output Guarantees +- Every instruction has an `effects` array (or null if no effects) containing `AliasingEffect` objects +- Terminals that affect data flow (return, try/catch) have their `effects` populated +- Each instruction's lvalue is guaranteed to be defined in the inference state after visiting +- Effects describe: creation of values, data flow (Assign, Alias, Capture), mutations (Mutate, MutateTransitive), freezing, and errors (MutateFrozen, MutateGlobal, Impure) + +## Algorithm +The pass uses abstract interpretation with the following key phases: + +1. **Initialization**: + - Create initial `InferenceState` mapping identifiers to abstract values + - Initialize context variables as `ValueKind.Context` + - Initialize parameters as `ValueKind.Frozen` (for top-level components/hooks) or `ValueKind.Mutable` (for function expressions) + +2. **Two-Phase Effect Processing**: + - **Phase 1 - Signature Computation**: For each instruction, compute a "candidate signature" based purely on instruction semantics and types (cached per instruction via `computeSignatureForInstruction`) + - **Phase 2 - Effect Application**: Apply the signature to the current abstract state via `applySignature`, which refines effects based on the actual runtime kinds of values + +3. **Fixed-Point Iteration**: + - Process blocks in a worklist, queuing successors after each block + - Merge states at control flow join points using lattice operations + - Iterate until no changes occur (max 100 iterations as safety limit) + - Phi nodes are handled by unioning the abstract values from all predecessors + +4. **Effect Refinement** (in `applyEffect`): + - `MutateConditionally` effects are dropped if value is not mutable + - `Capture` effects are downgraded to `ImmutableCapture` if source is frozen + - `Mutate` on frozen values becomes `MutateFrozen` error + - `Assign` from primitives/globals creates new values rather than aliasing + +## Key Data Structures + +### InferenceState +Maintains two maps: +- `#values: Map` - Maps allocation sites to their abstract kind +- `#variables: Map>` - Maps identifiers to the set of values they may point to (set to handle phi joins) + +### AbstractValue +```typescript +type AbstractValue = { + kind: ValueKind; + reason: ReadonlySet; +}; +``` + +### ValueKind (lattice) +``` +MaybeFrozen <- top (unknown if frozen or mutable) + | + Frozen <- immutable, cannot be mutated + Mutable <- can be mutated locally + Context <- mutable box (context variables) + | + Global <- global value + Primitive <- copy-on-write semantics +``` + +The `mergeValueKinds` function implements the lattice join: +- `Frozen | Mutable -> MaybeFrozen` +- `Context | Mutable -> Context` +- `Context | Frozen -> MaybeFrozen` + +### AliasingEffect Types +Key effect kinds handled: +- **Create**: Creates a new value at a place +- **Assign**: Direct assignment (pointer copy) +- **Alias**: Mutation of destination implies mutation of source +- **Capture**: Information flow (MutateTransitive propagates through) +- **MaybeAlias**: Possible aliasing for unknown function returns +- **Mutate/MutateTransitive**: Direct/transitive mutation +- **MutateConditionally/MutateTransitiveConditionally**: Conditional versions +- **Freeze**: Marks value as immutable +- **Apply**: Function call with complex data flow + +## Edge Cases + +1. **Spread Destructuring from Props**: The `findNonMutatedDestructureSpreads` pre-pass identifies spread patterns from frozen values that are never mutated, allowing them to be treated as frozen. + +2. **Hoisted Context Declarations**: Special handling for variables declared with hoisting (`HoistedConst`, `HoistedFunction`, `HoistedLet`) to detect access before declaration. + +3. **Try-Catch Aliasing**: When a `maybe-throw` terminal is reached, call return values are aliased into the catch binding since exceptions can throw return values. + +4. **Function Expressions**: Functions are considered mutable only if they have mutable captures or tracked side effects (MutateFrozen, MutateGlobal, Impure). + +5. **Iterator Mutation**: Non-builtin iterators may alias their collection and mutation of the iterator is conditional. + +6. **Array.push and Similar**: Uses legacy signature system with `Store` effect on receiver and `Capture` of arguments. + +## TODOs +- `// TODO: using InstructionValue as a bit of a hack, but it's pragmatic` - context variable initialization +- `// TODO: call applyEffect() instead` - try-catch aliasing +- `// TODO: make sure we're also validating against global mutations somewhere` - global mutation validation for effects/event handlers +- `// TODO; include "render" here?` - whether to track Render effects in function hasTrackedSideEffects +- `// TODO: consider using persistent data structures to make clone cheaper` - performance optimization for state cloning +- `// TODO check this` and `// TODO: what kind here???` - DeclareLocal value kinds + +## Example + +For the code: +```javascript +const arr = []; +arr.push({}); +arr.push(x, y); +``` + +After `InferMutationAliasingEffects`, the effects are: + +``` +[10] $39 = Array [] + Create $39 = mutable // Array literal creates mutable value + +[11] $41 = StoreLocal arr$40 = $39 + Assign arr$40 = $39 // arr points to the array value + Assign $41 = $39 + +[15] $45 = MethodCall $42.push($44) + Apply $45 = $42.$43($44) // Records the call + Mutate $42 // push mutates the array + Capture $42 <- $44 // {} is captured into array + Create $45 = primitive // push returns number (length) + +[20] $50 = MethodCall $46.push($48, $49) + Apply $50 = $46.$47($48, $49) + Mutate $46 // push mutates the array + Capture $46 <- $48 // x captured into array + Capture $46 <- $49 // y captured into array + Create $50 = primitive +``` + +The key insight is that `Mutate` effects extend the mutable range of the array, and `Capture` effects record data flow so that if the array is later frozen (e.g., returned from a component), the captured values are also considered frozen for validation purposes. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/09-inferMutationAliasingRanges.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/09-inferMutationAliasingRanges.md new file mode 100644 index 00000000000..769f386abe8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/09-inferMutationAliasingRanges.md @@ -0,0 +1,149 @@ +# inferMutationAliasingRanges + +## File +`src/Inference/InferMutationAliasingRanges.ts` + +## Purpose +This pass builds an abstract model of the heap and interprets the effects of the given function to determine: (1) the mutable ranges of all identifiers, (2) the externally-visible effects of the function (mutations of params/context-vars, aliasing relationships), and (3) the legacy `Effect` annotation for each Place. + +## Input Invariants +- InferMutationAliasingEffects must have already run, populating `instr.effects` on each instruction with aliasing/mutation effects +- SSA form must be established (identifiers are in SSA) +- Type inference has been run (InferTypes) +- Functions have been analyzed (AnalyseFunctions) +- Dead code elimination has been performed + +## Output Guarantees +- Every identifier has a populated `mutableRange` (start:end instruction IDs) +- Every Place has a legacy `Effect` annotation (Read, Capture, Store, Freeze, etc.) +- The function's `aliasingEffects` array is populated with externally-visible effects (mutations of params/context-vars, aliasing between params/context-vars/return) +- Validation errors are collected for invalid effects like `MutateFrozen` or `MutateGlobal` + +## Algorithm +The pass operates in three main phases: + +**Part 1: Build Data Flow Graph and Infer Mutable Ranges** +1. Creates an `AliasingState` which maintains a `Node` for each identifier +2. Iterates through all blocks and instructions, processing effects in program order +3. For each effect: + - `Create`/`CreateFunction`: Creates a new node in the graph + - `CreateFrom`/`Assign`/`Alias`: Adds alias edges between nodes (with ordering index) + - `MaybeAlias`: Adds conditional alias edges + - `Capture`: Adds capture edges (for transitive mutations) + - `Mutate*`: Queues mutations for later processing + - `Render`: Queues render effects for later processing +4. Phi node operands are connected once their predecessor blocks have been visited +5. After the graph is built, mutations are processed: + - Mutations propagate both forward (via edges) and backward (via aliases/captures) + - Each mutation extends the `mutableRange.end` of affected identifiers + - Transitive mutations also traverse capture edges backward + - `MaybeAlias` edges downgrade mutations to `Conditional` +6. Render effects are processed to mark values as rendered + +**Part 2: Populate Legacy Per-Place Effects** +- Sets legacy effects on lvalues and operands based on instruction effects and mutable ranges +- Fixes up mutable range start values for identifiers that are mutated after creation + +**Part 3: Infer Externally-Visible Function Effects** +- Creates a `Create` effect for the return value +- Simulates transitive mutations of each param/context-var/return to detect capture relationships +- Produces `Alias`/`Capture` effects showing data flow between params/context-vars/return + +## Key Data Structures + +### `AliasingState` +The main state class maintaining the data flow graph: +- `nodes: Map` - Maps identifiers to their graph nodes + +### `Node` +Represents an identifier in the data flow graph: +```typescript +type Node = { + id: Identifier; + createdFrom: Map; // CreateFrom edges (source -> index) + captures: Map; // Capture edges (source -> index) + aliases: Map; // Alias/Assign edges (source -> index) + maybeAliases: Map; // MaybeAlias edges (source -> index) + edges: Array<{index, node, kind}>; // Forward edges to other nodes + transitive: {kind: MutationKind; loc} | null; // Transitive mutation info + local: {kind: MutationKind; loc} | null; // Local mutation info + lastMutated: number; // Index of last mutation affecting this node + mutationReason: MutationReason | null; // Reason for mutation + value: {kind: 'Object'} | {kind: 'Phi'} | {kind: 'Function'; function: HIRFunction}; + render: Place | null; // Render context if used in JSX +}; +``` + +### `MutationKind` +Enum describing mutation certainty: +```typescript +enum MutationKind { + None = 0, + Conditional = 1, // May mutate (e.g., via MaybeAlias or MutateConditionally) + Definite = 2, // Definitely mutates +} +``` + +## Edge Cases + +### Phi Nodes +- Phi nodes are created as special `{kind: 'Phi'}` nodes +- Phi operands from predecessor blocks are processed with pending edges until the predecessor is visited +- When traversing "forwards" through edges and encountering a phi, backward traversal is stopped (prevents mutation from one phi input affecting other inputs) + +### Transitive vs Local Mutations +- Local mutations (`Mutate`) only affect alias/assign edges backward +- Transitive mutations (`MutateTransitive`) also affect capture edges backward +- Both affect all forward edges + +### MaybeAlias +- Mutations through MaybeAlias edges are downgraded to `Conditional` +- This prevents false positive errors when we cannot be certain about aliasing + +### Function Values +- Functions are tracked specially as `{kind: 'Function'}` nodes +- When a function is mutated (transitively), errors from the function body are propagated +- This handles cases where mutating a captured value in a function affects render safety + +### Render Effect Propagation +- Render effects traverse backward through alias/capture/createFrom edges +- Functions that have not been mutated are skipped during render traversal (except for JSX-returning functions) +- Ref types (`isUseRefType`) stop render traversal + +## TODOs +1. Assign effects should have an invariant that the node is not initialized yet. Currently `InferFunctionExpressionAliasingEffectSignatures` infers Assign effects that should be Alias, causing reinitialization. + +2. Phi place effects are not properly set today. + +3. Phi mutable range start calculation is imprecise - currently just sets it to the instruction before the block rather than computing the exact start. + +## Example + +Consider the following code: +```javascript +function foo() { + let a = {}; // Create a (instruction 1) + let b = {}; // Create b (instruction 3) + a = b; // Assign a <- b (instruction 8) + mutate(a, b); // MutateTransitiveConditionally a, b (instruction 16) + return a; +} +``` + +The pass builds a graph: +1. Creates node for `{}` at instruction 1 (initially assigned to `a`) +2. Creates node for `{}` at instruction 3 (initially assigned to `b`) +3. At instruction 8, creates alias edge: `b -> a` with index 8 +4. At instruction 16, mutations are queued for `a` and `b` + +When processing the mutation of `a` at instruction 16: +- Extends `a`'s mutableRange.end to 17 +- Traverses backward through alias edge to `b`, extends `b`'s mutableRange.end to 17 +- Since `a = b`, both objects must be considered mutable until instruction 17 + +The output shows identifiers with range annotations like `$25[3:17]` meaning: +- `$25` is the identifier +- `3` is the instruction where it was created +- `17` is the instruction after which it is no longer mutated + +For aliased values, the ranges are unified - all values that could be affected by a mutation have their ranges extended to include that mutation point. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/10-inferReactivePlaces.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/10-inferReactivePlaces.md new file mode 100644 index 00000000000..c0b8b974e21 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/10-inferReactivePlaces.md @@ -0,0 +1,169 @@ +# inferReactivePlaces + +## File +`src/Inference/InferReactivePlaces.ts` + +## Purpose +Determines which `Place`s (identifiers and temporaries) in the HIR are **reactive** - meaning they may *semantically* change over the course of the component or hook's lifetime. This information is critical for memoization: reactive places form the dependencies that, when changed, should invalidate cached values. + +A place is reactive if it derives from any source of reactivity: +1. **Props** - Component parameters may change between renders +2. **Hooks** - Hooks can access state or context which can change +3. **`use` operator** - Can access context which may change +4. **Mutation with reactive operands** - Values mutated in instructions that have reactive operands become reactive themselves +5. **Conditional assignment based on reactive control flow** - Values assigned in branches controlled by reactive conditions become reactive + +## Input Invariants +- HIR is in SSA form with phi nodes at join points +- `inferMutationAliasingEffects` and `inferMutationAliasingRanges` have run, establishing: + - Effect annotations on operands (Effect.Capture, Effect.Store, Effect.Mutate, etc.) + - Mutable ranges on identifiers + - Aliasing relationships captured by `findDisjointMutableValues` +- All operands have known effects (asserts on `Effect.Unknown`) + +## Output Guarantees +- Every reactive Place has `place.reactive = true` +- Reactivity is transitively complete (derived from reactive → reactive) +- All identifiers in a mutable alias group share reactivity +- Reactivity is propagated to operands used within nested function expressions + +## Algorithm +The algorithm uses **fixpoint iteration** to propagate reactivity forward through the control-flow graph: + +### Initialization +1. Create a `ReactivityMap` backed by disjoint sets of mutably-aliased identifiers +2. Mark all function parameters as reactive (props are reactive by definition) +3. Create a `ControlDominators` helper to identify blocks controlled by reactive conditions + +### Fixpoint Loop +Iterate until no changes occur: + +For each block: +1. **Phi Nodes**: Mark phi nodes reactive if: + - Any operand is reactive, OR + - Any predecessor block is controlled by a reactive condition (control-flow dependency) + +2. **Instructions**: For each instruction: + - Track stable identifier sources (for hooks like `useRef`, `useState` dispatch) + - Check if any operand is reactive + - Hook calls and `use` operator are sources of reactivity + - If instruction has reactive input: + - Mark lvalues reactive (unless they are known-stable like `setState` functions) + - If instruction has reactive input OR is in reactive-controlled block: + - Mark mutable operands (Capture, Store, Mutate effects) as reactive + +3. **Terminals**: Check terminal operands for reactivity + +### Post-processing +Propagate reactivity to inner functions (nested `FunctionExpression` and `ObjectMethod`). + +## Key Data Structures + +### ReactivityMap +```typescript +class ReactivityMap { + hasChanges: boolean = false; // Tracks if fixpoint changed + reactive: Set = new Set(); // Set of reactive identifiers + aliasedIdentifiers: DisjointSet; // Mutable alias groups +} +``` +- Uses disjoint sets so that when one identifier in an alias group becomes reactive, they all are effectively reactive +- `isReactive(place)` checks and marks `place.reactive = true` as a side effect +- `snapshot()` resets change tracking and returns whether changes occurred + +### StableSidemap +```typescript +class StableSidemap { + map: Map = new Map(); +} +``` +Tracks sources of stability (e.g., `useState()[1]` dispatch function). Forward data-flow analysis that: +- Records hook calls that return stable types +- Propagates stability through PropertyLoad and Destructure from stable containers +- Propagates through LoadLocal and StoreLocal + +### ControlDominators +Uses post-dominator frontier analysis to determine which blocks are controlled by reactive branch conditions. + +## Edge Cases + +### Backward Reactivity Propagation via Mutable Aliasing +```javascript +const x = []; +const z = [x]; +x.push(props.input); +return
{z}
; +``` +Here `z` aliases `x` which is later mutated with reactive data. The disjoint set ensures `z` becomes reactive even though the mutation happens after its creation. + +### Stable Types Are Not Reactive +```javascript +const [state, setState] = useState(); +// setState is stable - not marked reactive despite coming from reactive hook +``` +The `StableSidemap` tracks these and skips marking them reactive. + +### Ternary with Stable Values Still Reactive +```javascript +props.cond ? setState1 : setState2 +``` +Even though both branches are stable types, the result depends on reactive control flow, so it cannot be marked non-reactive just based on type. + +### Phi Nodes with Reactive Predecessors +When a phi's predecessor block is controlled by a reactive condition, the phi becomes reactive even if its operands are all non-reactive constants. + +## TODOs +No explicit TODO comments are present in the source file. However, comments note: + +- **ComputedLoads not handled for stability**: Only PropertyLoad propagates stability from containers, not ComputedLoad. The comment notes this is safe because stable containers have differently-typed elements, but ComputedLoad handling could be added. + +## Example + +### Fixture: `reactive-dependency-fixpoint.js` + +**Input:** +```javascript +function Component(props) { + let x = 0; + let y = 0; + while (x === 0) { + x = y; + y = props.value; + } + return [x]; +} +``` + +**Before InferReactivePlaces:** +``` +bb1 (loop): + store x$26:TPhi:TPhi: phi(bb0: read x$21:TPrimitive, bb3: read x$32:TPhi) + store y$30:TPhi:TPhi: phi(bb0: read y$24:TPrimitive, bb3: read y$37) + ... +bb3 (block): + [12] mutate? $35 = LoadLocal read props$19 + [13] mutate? $36 = PropertyLoad read $35.value + [14] mutate? $38 = StoreLocal Reassign mutate? y$37 = read $36 +``` + +**After InferReactivePlaces:** +``` +bb1 (loop): + store x$26:TPhi{reactive}:TPhi: phi(bb0: read x$21:TPrimitive, bb3: read x$32:TPhi{reactive}) + store y$30:TPhi{reactive}:TPhi: phi(bb0: read y$24:TPrimitive, bb3: read y$37{reactive}) + [6] mutate? $27:TPhi{reactive} = LoadLocal read x$26:TPhi{reactive} + ... +bb3 (block): + [12] mutate? $35{reactive} = LoadLocal read props$19{reactive} + [13] mutate? $36{reactive} = PropertyLoad read $35{reactive}.value + [14] mutate? $38{reactive} = StoreLocal Reassign mutate? y$37{reactive} = read $36{reactive} +``` + +**Key observations:** +- `props$19` is marked `{reactive}` as a function parameter +- The reactivity propagates through the loop: + - First iteration: `y$37` becomes reactive from `props.value` + - Second iteration: `x$32` becomes reactive from `y$30` (which is reactive via the phi from `y$37`) + - The phi nodes `x$26` and `y$30` become reactive because their bb3 operands are reactive +- The fixpoint algorithm handles this backward propagation through the loop correctly +- The final output `$40` is reactive, so the array `[x]` will be memoized with `x` as a dependency diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/11-inferReactiveScopeVariables.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/11-inferReactiveScopeVariables.md new file mode 100644 index 00000000000..e8a480b115f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/11-inferReactiveScopeVariables.md @@ -0,0 +1,176 @@ +# inferReactiveScopeVariables + +## File +`src/ReactiveScopes/InferReactiveScopeVariables.ts` + +## Purpose +This is the **1st of 4 passes** that determine how to break a React function into discrete reactive scopes (independently memoizable units of code). Its specific responsibilities are: + +1. **Identify operands that mutate together** - Variables that are mutated in the same instruction must be placed in the same reactive scope +2. **Assign a unique ReactiveScope to each group** - Each disjoint set of co-mutating identifiers gets assigned a unique ScopeId +3. **Compute the mutable range** - The scope's range is computed as the union of all member identifiers' mutable ranges + +The pass does NOT determine which instructions compute each scope, only which variables belong together. + +## Input Invariants +- `InferMutationAliasingEffects` has run - Effects describe mutations, captures, and aliasing +- `InferMutationAliasingRanges` has run - Each identifier has a valid `mutableRange` property +- `InferReactivePlaces` has run - Places are marked as reactive or not +- `RewriteInstructionKindsBasedOnReassignment` has run - Let/Const properly determined +- All instructions have been numbered with valid `InstructionId` values +- Phi nodes are properly constructed at block join points + +## Output Guarantees +- Each identifier that is part of a mutable group has its `identifier.scope` property set to a `ReactiveScope` object +- All identifiers in the same scope share the same `ReactiveScope` reference +- The scope's `range` is the union (min start, max end) of all member mutable ranges +- The scope's `range` is validated to be within [1, maxInstruction+1] +- Identifiers that only have single-instruction lifetimes (read once) may not be assigned to a scope unless they allocate + +## Algorithm + +### Phase 1: Find Disjoint Mutable Values (`findDisjointMutableValues`) + +Uses a Union-Find (Disjoint Set) data structure to group identifiers that mutate together: + +1. **Handle Phi Nodes**: For each phi in each block: + - If the phi's result is mutated after creation (mutableRange.end > first instruction in block), union the phi with all its operands + - This ensures values that flow through control flow and are later mutated are grouped together + +2. **Handle Instructions**: For each instruction: + - Collect mutable operands based on instruction type: + - If lvalue has extended mutable range OR instruction may allocate, include lvalue + - For StoreLocal/StoreContext: Include lvalue if it has extended mutable range, include value if mutable + - For Destructure: Include each pattern operand with extended range, include source if mutable + - For MethodCall: Include all mutable operands plus the computed property (to keep method resolution in same scope) + - For other instructions: Include all mutable operands + - Exclude global variables (mutableRange.start === 0) since they cannot be recreated + - Union all collected operands together + +### Phase 2: Assign Scopes + +1. Iterate over all identifiers in the disjoint set using `forEach(item, groupIdentifier)` +2. For each unique group, create a new ReactiveScope: + - Generate a unique ScopeId from the environment + - Initialize range from the first member's mutableRange + - Set up empty dependencies, declarations, reassignments sets +3. For subsequent members of the same group: + - Expand the scope's range to encompass the member's mutableRange + - Merge source locations +4. Assign the scope to each identifier: `identifier.scope = scope` +5. Update each identifier's mutableRange to match the scope's range + +**Validation**: After scope assignment, validate that all scopes have valid ranges within [1, maxInstruction+1]. + +## Key Data Structures + +### DisjointSet +A Union-Find data structure optimized for grouping items into disjoint sets: + +```typescript +class DisjointSet { + #entries: Map; // Maps each item to its parent (root points to self) + + union(items: Array): void; // Merge items into one set + find(item: T): T | null; // Find the root of item's set (with path compression) + forEach(fn: (item, group) => void): void; // Iterate all items with their group root +} +``` + +Path compression is used during `find()` to flatten the tree structure, improving subsequent lookup performance. + +### ReactiveScope +```typescript +type ReactiveScope = { + id: ScopeId; + range: MutableRange; // [start, end) instruction range + dependencies: Set; // Inputs (populated later) + declarations: Map; // Outputs (populated later) + reassignments: Set; // Reassigned variables (populated later) + earlyReturnValue: {...} | null; // For scopes with early returns + merged: Set; // IDs of scopes merged into this one + loc: SourceLocation; +}; +``` + +## Edge Cases + +### Global Variables +Excluded from scopes (mutableRange.start === 0) since they cannot be recreated during memoization. + +### Phi Nodes After Mutation +When a phi's result is mutated after the join point, all phi operands must be in the same scope to ensure the mutation can be recomputed correctly. + +### MethodCall Property Resolution +The computed property load for a method call is explicitly added to the same scope as the call itself. + +### Allocating Instructions +Instructions that allocate (Array, Object, JSX, etc.) add their lvalue to the scope even if the lvalue has a single-instruction range. + +### Single-Instruction Ranges +Values with range `[n, n+1)` (used exactly once) are only included if they allocate, otherwise they're just read. + +### enableForest Config +When enabled, phi operands are unconditionally unioned with the phi result (even without mutation after the phi). + +## TODOs +1. `// TODO: improve handling of module-scoped variables and globals` - The current approach excludes globals entirely, but a more nuanced handling could be beneficial. + +2. Known issue with aliasing and mutable lifetimes (from header comments): +```javascript +let x = {}; +let y = []; +x.y = y; // RHS is not considered mutable here bc not further mutation +mutate(x); // bc y is aliased here, it should still be considered mutable above +``` +This suggests the pass may miss some co-mutation relationships when aliasing is involved. + +## Example + +### Fixture: `reactive-scope-grouping.js` + +**Input:** +```javascript +function foo() { + let x = {}; + let y = []; + let z = {}; + y.push(z); // y and z co-mutate (z captured into y) + x.y = y; // x and y co-mutate (y captured into x) + return x; +} +``` + +**After InferReactiveScopeVariables:** +``` +[1] mutate? $19_@0[1:14] = Object { } // x's initial object, scope @0 +[2] store $21_@0[1:14] = StoreLocal x // x in scope @0 +[3] mutate? $22_@1[3:11] = Array [] // y's array, scope @1 +[4] store $24_@1[3:11] = StoreLocal y // y in scope @1 +[5] mutate? $25_@2 = Object { } // z's object, scope @2 +[10] MethodCall y.push(z) // Mutates y, captures z +[13] PropertyStore x.y = y // Mutates x, captures y +``` + +The `y.push(z)` joins y and z into scope @1, and `x.y = y` joins x and y into scope @0. Because y is now in @0, and z was captured into y, ultimately x, y, and z all end up in the same scope @0. + +**Compiled Output:** +```javascript +function foo() { + const $ = _c(1); + let x; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + x = {}; + const y = []; + const z = {}; + y.push(z); + x.y = y; + $[0] = x; + } else { + x = $[0]; + } + return x; +} +``` + +All three objects (x, y, z) are created within the same memoization block because they co-mutate and could potentially alias each other. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/12-rewriteInstructionKindsBasedOnReassignment.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/12-rewriteInstructionKindsBasedOnReassignment.md new file mode 100644 index 00000000000..556a80783ca --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/12-rewriteInstructionKindsBasedOnReassignment.md @@ -0,0 +1,151 @@ +# rewriteInstructionKindsBasedOnReassignment + +## File +`src/SSA/RewriteInstructionKindsBasedOnReassignment.ts` + +## Purpose +Rewrites the `InstructionKind` of variable declaration and assignment instructions to correctly reflect whether variables should be declared as `const` or `let` in the final output. It determines this based on whether a variable is subsequently reassigned after its initial declaration. + +The key insight is that this pass runs **after dead code elimination (DCE)**, so a variable that was originally declared with `let` in the source (because it was reassigned) may be converted to `const` if the reassignment was removed by DCE. However, variables originally declared as `const` cannot become `let`. + +## Input Invariants +- SSA form: Each identifier has a unique `IdentifierId` and `DeclarationId` +- Dead code elimination has run: Unused assignments have been removed +- Mutation/aliasing inference complete: Runs after `InferMutationAliasingRanges` and `InferReactivePlaces` in the main pipeline +- All instruction kinds are initially set (typically `Let` for variables that may be reassigned) + +## Output Guarantees +- **First declaration gets `Const` or `Let`**: The first `StoreLocal` for a named variable is marked as: + - `InstructionKind.Const` if the variable is never reassigned after + - `InstructionKind.Let` if the variable has subsequent reassignments +- **Reassignments marked as `Reassign`**: Any subsequent `StoreLocal` to the same `DeclarationId` is marked as `InstructionKind.Reassign` +- **Destructure consistency**: All places in a destructuring pattern must have consistent kinds (all Const or all Reassign) +- **Update operations trigger Let**: `PrefixUpdate` and `PostfixUpdate` operations (like `++x` or `x--`) mark the original declaration as `Let` + +## Algorithm + +1. **Initialize declarations map**: Create a `Map` to track declared variables. + +2. **Seed with parameters and context**: Add all named function parameters and captured context variables to the map with kind `Let` (since they're already "declared" outside the function body). + +3. **Process blocks in order**: Iterate through all blocks and instructions: + + - **DeclareLocal**: Record the declaration in the map (invariant: must not already exist) + + - **StoreLocal**: + - If not in map: This is the first store, add to map with `kind = Const` + - If already in map: This is a reassignment. Update original declaration to `Let`, set current instruction to `Reassign` + + - **Destructure**: + - For each operand in the pattern, check if it's already declared + - All operands must be consistent (all new declarations OR all reassignments) + - Set pattern kind to `Const` for new declarations, `Reassign` for existing ones + + - **PrefixUpdate / PostfixUpdate**: Look up the declaration and mark it as `Let` (these always imply reassignment) + +## Key Data Structures + +```typescript +// Main tracking structure +const declarations = new Map(); + +// InstructionKind enum (from HIR.ts) +enum InstructionKind { + Const = 'Const', // const declaration + Let = 'Let', // let declaration + Reassign = 'Reassign', // reassignment to existing binding + Catch = 'Catch', // catch clause binding + HoistedLet = 'HoistedLet', // hoisted let + HoistedConst = 'HoistedConst', // hoisted const + HoistedFunction = 'HoistedFunction', // hoisted function + Function = 'Function', // function declaration +} +``` + +## Edge Cases + +### DCE Removes Reassignment +A `let x = 0; x = 1;` where `x = 1` is unused becomes `const x = 0;` after DCE. + +### Destructuring with Mixed Operands +The invariant checks ensure all operands in a destructure pattern are either all new declarations or all reassignments. Mixed cases cause a compiler error. + +### Value Blocks with DCE +There's a TODO for handling reassignment in value blocks where the original declaration was removed by DCE. + +### Parameters and Context Variables +These are pre-seeded as `Let` in the declarations map since they're conceptually "declared" at function entry. + +### Update Expressions +`++x` and `x--` always mark the variable as `Let`, even if used inline. + +## TODOs +```typescript +CompilerError.invariant(block.kind !== 'value', { + reason: `TODO: Handle reassignment in a value block where the original + declaration was removed by dead code elimination (DCE)`, + ... +}); +``` + +This indicates an edge case where a destructuring reassignment occurs in a value block but the original declaration was eliminated by DCE. This is currently an invariant violation rather than handled gracefully. + +## Example + +### Fixture: `reassignment.js` + +**Input Source:** +```javascript +function Component(props) { + let x = []; + x.push(props.p0); + let y = x; + + x = []; + let _ = ; + + y.push(props.p1); + + return ; +} +``` + +**Before Pass (InferReactivePlaces output):** +``` +[2] StoreLocal Let x$32 = $31 // x is initially marked Let +[9] StoreLocal Let y$40 = $39 // y is initially marked Let +[11] StoreLocal Reassign x$43 = $42 // reassignment already marked +``` + +**After Pass:** +``` +[2] StoreLocal Let x$32 = $31 // x stays Let (has reassignment at line 11) +[9] StoreLocal Const y$40 = $39 // y becomes Const (never reassigned) +[11] StoreLocal Reassign x$43 = $42 // stays Reassign +``` + +**Final Generated Code:** +```javascript +function Component(props) { + const $ = _c(4); + let t0; + if ($[0] !== props.p0 || $[1] !== props.p1) { + let x = []; // let because reassigned + x.push(props.p0); + const y = x; // const because never reassigned + // ... x = t1; (reassignment) + y.push(props.p1); + t0 = ; + // ... + } + return t0; +} +``` + +The pass correctly identified that `x` needs `let` (since it's reassigned on line 6 of the source) while `y` can use `const` (it's never reassigned after initialization). + +## Where This Pass is Called + +1. **Main Pipeline** (`src/Entrypoint/Pipeline.ts:322`): Called after `InferReactivePlaces` and before `InferReactiveScopeVariables`. + +2. **AnalyseFunctions** (`src/Inference/AnalyseFunctions.ts:58`): Called when lowering inner function expressions as part of the function analysis phase. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/13-alignMethodCallScopes.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/13-alignMethodCallScopes.md new file mode 100644 index 00000000000..8fef182c087 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/13-alignMethodCallScopes.md @@ -0,0 +1,131 @@ +# alignMethodCallScopes + +## File +`src/ReactiveScopes/AlignMethodCallScopes.ts` + +## Purpose +Ensures that `MethodCall` instructions and their associated `PropertyLoad` instructions (which load the method being called) have consistent scope assignments. The pass enforces one of two invariants: +1. Both the MethodCall lvalue and the property have the **same** reactive scope +2. **Neither** has a reactive scope + +This alignment is critical because the PropertyLoad and MethodCall are semantically a single operation (`receiver.method(args)`) and must be memoized together as a unit. If they had different scopes, the generated code would incorrectly try to memoize the property load separately from the method call, which could break correctness. + +## Input Invariants +- The function has been converted to HIR form +- `inferReactiveScopeVariables` has already run, assigning initial reactive scopes to identifiers based on mutation analysis +- Each instruction's lvalue has an `identifier.scope` that is either a `ReactiveScope` or `null` +- For `MethodCall` instructions, the `value.property` field contains a `Place` referencing the loaded method + +## Output Guarantees +After this pass runs: +- For every `MethodCall` instruction in the function: + - If the lvalue has a scope AND the property has a scope, they point to the **same merged scope** + - If only the lvalue has a scope, the property's scope is set to match the lvalue's scope + - If only the property has a scope, the property's scope is set to `null` (so neither has a scope) +- Merged scopes have their `range` extended to cover the union of the original scopes' ranges +- Nested functions (FunctionExpression, ObjectMethod) are recursively processed + +## Algorithm + +### Phase 1: Collect Scope Relationships +``` +For each instruction in all blocks: + If instruction is a MethodCall: + lvalueScope = instruction.lvalue.identifier.scope + propertyScope = instruction.value.property.identifier.scope + + If both have scopes: + Record that these scopes should be merged (using DisjointSet.union) + Else if only lvalue has scope: + Record that property should be assigned to lvalueScope + Else if only property has scope: + Record that property should be assigned to null (no scope) + + If instruction is FunctionExpression or ObjectMethod: + Recursively process the nested function +``` + +### Phase 2: Merge Scopes +``` +For each merged scope group: + Pick a "root" scope + Extend root's range to cover all merged scopes: + root.range.start = min(all scope start points) + root.range.end = max(all scope end points) +``` + +### Phase 3: Apply Changes +``` +For each instruction: + If lvalue was recorded for remapping: + Set identifier.scope to the mapped value + Else if identifier has a scope that was merged: + Set identifier.scope to the merged root scope +``` + +## Key Data Structures + +1. **`scopeMapping: Map`** + - Maps property identifier IDs to their new scope assignment + - Value of `null` means the scope should be removed + +2. **`mergedScopes: DisjointSet`** + - Union-find data structure tracking scopes that need to be merged + - Used when both MethodCall and property have different scopes + +3. **`ReactiveScope`** (from HIR) + - Contains `range: { start: InstructionId, end: InstructionId }` + - The range defines which instructions are part of the scope + +## Edge Cases + +### Both Have the Same Scope Already +No action needed (implicit in the logic). + +### Nested Functions +The pass recursively processes `FunctionExpression` and `ObjectMethod` instructions to handle closures. + +### Multiple MethodCalls Sharing Scopes +The DisjointSet handles transitive merging - if A merges with B, and B merges with C, all three end up in the same scope. + +### Property Without Scope, MethodCall Without Scope +No action needed (both already aligned at `null`). + +## TODOs +There are no explicit TODO comments in the source code. + +## Example + +### Fixture: `alias-capture-in-method-receiver.js` + +**Source code:** +```javascript +function Component() { + let a = someObj(); + let x = []; + x.push(a); + return [x, a]; +} +``` + +**Before AlignMethodCallScopes:** +``` +[7] store $24_@1[4:10]:TFunction = PropertyLoad capture $23_@1.push +[9] mutate? $26:TPrimitive = MethodCall store $23_@1.read $24_@1(capture $25) +``` +- PropertyLoad result `$24_@1` has scope `@1` +- MethodCall result `$26` has no scope (`null`) + +**After AlignMethodCallScopes:** +``` +[7] store $24[4:10]:TFunction = PropertyLoad capture $23_@1.push +[9] mutate? $26:TPrimitive = MethodCall store $23_@1.read $24(capture $25) +``` +- PropertyLoad result `$24` now has **no scope** (the `_@1` suffix removed) +- MethodCall result `$26` still has no scope + +**Why this matters:** +Without this alignment, later passes might try to memoize the `.push` property load separately from the actual `push()` call. This would be incorrect because: +1. Reading a method from an object and calling it are semantically one operation +2. The property load's value (the bound method) is only valid immediately when called on the same receiver +3. Separate memoization could lead to stale method references or incorrect this-binding diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/14-alignObjectMethodScopes.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/14-alignObjectMethodScopes.md new file mode 100644 index 00000000000..ebb78ba16cb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/14-alignObjectMethodScopes.md @@ -0,0 +1,128 @@ +# alignObjectMethodScopes + +## File +`src/ReactiveScopes/AlignObjectMethodScopes.ts` + +## Purpose +Ensures that object method values and their enclosing object expressions share the same reactive scope. This is critical for code generation because JavaScript requires object method definitions to be inlined within their containing object literals. If the object method and object expression were in different reactive scopes (which map to different memoization blocks), the generated code would be invalid since you cannot reference an object method defined in one block from an object literal in a different block. + +From the file's documentation: +> "To produce a well-formed JS program in Codegen, object methods and object expressions must be in the same ReactiveBlock as object method definitions must be inlined." + +## Input Invariants +- Reactive scopes have been inferred: This pass runs after `InferReactiveScopeVariables` +- ObjectMethod and ObjectExpression have non-null scopes: The pass asserts this with an invariant check +- Scopes are disjoint across functions: The pass assumes that scopes do not overlap between parent and nested functions + +## Output Guarantees +- ObjectMethod and ObjectExpression share the same scope: Any ObjectMethod used as a property in an ObjectExpression will have its scope merged with the ObjectExpression's scope +- Merged scope covers both ranges: The resulting merged scope's range is expanded to cover the minimum start and maximum end of all merged scopes +- All identifiers are repointed: All identifiers whose scopes were merged are updated to point to the canonical root scope +- Inner functions are also processed: The pass recursively handles nested ObjectMethod and FunctionExpression values + +## Algorithm + +### Phase 1: Find Scopes to Merge (`findScopesToMerge`) +1. Iterate through all blocks and instructions in the function +2. Track all ObjectMethod declarations in a set by their lvalue identifier +3. When encountering an ObjectExpression, check each operand: + - If an operand's identifier was previously recorded as an ObjectMethod declaration + - Get the scope of both the ObjectMethod operand and the ObjectExpression lvalue + - Assert both scopes are non-null + - Union these two scopes together in a DisjointSet data structure + +### Phase 2: Merge and Repoint Scopes (`alignObjectMethodScopes`) +1. Recursively process inner functions first (ObjectMethod and FunctionExpression values) +2. Canonicalize the DisjointSet to get a mapping from each scope to its root +3. **Step 1 - Merge ranges**: For each scope that maps to a different root: + - Expand the root's range to encompass both the original range and the merged scope's range + - `root.range.start = min(scope.range.start, root.range.start)` + - `root.range.end = max(scope.range.end, root.range.end)` +4. **Step 2 - Repoint identifiers**: For each instruction's lvalue: + - If the identifier has a scope that was merged + - Update the identifier's scope reference to point to the canonical root + +## Key Data Structures + +1. **DisjointSet** - A union-find data structure that tracks which scopes should be merged together. Uses path compression for efficient `find()` operations. + +2. **Set** - Tracks which identifiers are ObjectMethod declarations, used to identify when an ObjectExpression operand is an object method. + +3. **ReactiveScope** - Contains: + - `id: ScopeId` - Unique identifier + - `range: MutableRange` - Start and end instruction IDs + - `dependencies` - Inputs to the scope + - `declarations` - Values produced by the scope + +4. **MutableRange** - Has `start` and `end` InstructionId fields that define the scope's extent. + +## Edge Cases + +### Nested Object Methods +When an object method itself contains another object with methods, the pass recursively processes inner functions first before handling the outer function's scopes. + +### Multiple Object Methods in Same Object +If an object has multiple method properties, all their scopes will be merged with the object's scope through the DisjointSet. + +### Object Methods in Conditional Expressions +Object methods inside ternary expressions still need scope alignment to ensure the method and its containing object are in the same reactive block. + +### Method Call After Object Creation +The pass works in conjunction with `AlignMethodCallScopes` (which runs immediately before) to ensure that method calls on objects with object methods are also properly scoped. + +## TODOs +None explicitly marked in the source file. + +## Example + +### Fixture: `object-method-shorthand.js` + +**Input:** +```javascript +function Component() { + let obj = { + method() { + return 1; + }, + }; + return obj.method(); +} +``` + +**Before AlignObjectMethodScopes:** +``` +InferReactiveScopeVariables: + [1] mutate? $12_@0:TObjectMethod = ObjectMethod ... // scope @0 + [2] mutate? $14_@1[2:7]:TObject = Object { method: ... } // scope @1 (range 2:7) +``` +The ObjectMethod `$12` is in scope `@0` while the ObjectExpression `$14` is in scope `@1` with range `[2:7]`. + +**After AlignObjectMethodScopes:** +``` +AlignObjectMethodScopes: + [1] mutate? $12_@0[1:7]:TObjectMethod = ObjectMethod ... // scope @0, range now 1:7 + [2] mutate? $14_@0[1:7]:TObject = Object { method: ... } // also scope @0, range 1:7 +``` +Both identifiers are in the same scope `@0`, and the scope's range has been expanded to `[1:7]` to cover both instructions. + +**Final Generated Code:** +```javascript +function Component() { + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const obj = { + method() { + return 1; + }, + }; + t0 = obj.method(); + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} +``` + +The object literal with its method and the subsequent method call are all inside the same memoization block, producing valid JavaScript where the method definition is inlined within the object literal. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/15-alignReactiveScopesToBlockScopesHIR.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/15-alignReactiveScopesToBlockScopesHIR.md new file mode 100644 index 00000000000..1230ecbaa83 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/15-alignReactiveScopesToBlockScopesHIR.md @@ -0,0 +1,177 @@ +# alignReactiveScopesToBlockScopesHIR + +## File +`src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts` + +## Purpose +This is the **2nd of 4 passes** that determine how to break a function into discrete reactive scopes (independently memoizable units of code). The pass aligns reactive scope boundaries to control flow (block scope) boundaries. + +The problem it solves: Prior inference passes assign reactive scopes to operands based on mutation ranges at arbitrary instruction points in the control-flow graph. However, to generate memoization blocks around instructions, scopes must be aligned to block-scope boundaries -- you cannot memoize half of a loop or half of an if-block. + +**Example from the source code comments:** +```javascript +function foo(cond, a) { + // original scope end + // expanded scope end + const x = []; | | + if (cond) { | | + ... | | + x.push(a); <--- original scope ended here + ... | + } <--- scope must extend to here +} +``` + +## Input Invariants +- `InferReactiveScopeVariables` has run: Each identifier has been assigned a `ReactiveScope` with a `range` (start/end instruction IDs) based on mutation analysis +- The HIR is in SSA form: Blocks have unique IDs, instructions have unique IDs, and control flow is represented with basic blocks +- Each block has a terminal with possible successors and fallthroughs +- Each scope has a mutable range `{start: InstructionId, end: InstructionId}` indicating when the scope is active + +## Output Guarantees +- **Scopes end at valid block boundaries**: A reactive scope may only end at the same block scope level as it began. The scope's `range.end` is updated to the first instruction of the fallthrough block after any control flow structure that the scope overlaps +- **Scopes start at valid block boundaries**: For labeled breaks (gotos to a label), scopes that extend beyond the goto have their `range.start` extended back to include the label +- **Value blocks (ternary, logical, optional) are handled specially**: Scopes inside value blocks are extended to align with the outer block scope's instruction range + +## Algorithm + +The pass performs a single forward traversal over all blocks: + +### 1. Tracking Active Scopes +- Maintains `activeScopes: Set` - scopes whose range overlaps the current block +- Maintains `activeBlockFallthroughRanges: Array<{range, fallthrough}>` - stack of pending block-fallthrough ranges + +### 2. Per-Block Processing +For each block: +- Prune `activeScopes` to only those that extend past the current block's start +- If this block is a fallthrough target, pop the range from the stack and extend all active scopes' start to the range start + +### 3. Recording Places +For each instruction lvalue and operand: +- If the place has a scope, add it to `activeScopes` +- If inside a value block, extend the scope's range to match the value block's outer range + +### 4. Handling Block Fallthroughs +When a terminal has a fallthrough (not a simple branch): +- Extend all active scopes whose `range.end > terminal.id` to at least the first instruction of the fallthrough block +- Push the fallthrough range onto the stack for future scopes + +### 5. Handling Labeled Breaks (Goto) +When encountering a goto to a label (not the natural fallthrough): +- Find the corresponding fallthrough range on the stack +- Extend all active scopes to span from the label start to its fallthrough end + +### 6. Value Block Handling +For ternary, logical, and optional terminals: +- Create `ValueBlockNode` to track the outer block's instruction range +- Scopes inside value blocks inherit this range, ensuring they align to the outer block scope + +## Key Data Structures + +```typescript +type ValueBlockNode = { + kind: 'node'; + id: InstructionId; + valueRange: MutableRange; // Range of outer block scope + children: Array; +}; + +type ReactiveScopeNode = { + kind: 'scope'; + id: InstructionId; + scope: ReactiveScope; +}; + +// Tracked during traversal: +activeBlockFallthroughRanges: Array<{ + range: InstructionRange; + fallthrough: BlockId; +}>; +activeScopes: Set; +valueBlockNodes: Map; +``` + +## Edge Cases + +### Labeled Breaks +When a `goto` jumps to a label (not the natural fallthrough), scopes must be extended to include the entire labeled block range, preventing the break from jumping out of the scope. + +### Value Blocks (Ternary/Logical/Optional) +These create nested "value" contexts. Scopes inside must be aligned to the outer block scope's boundaries, not the value block's boundaries. + +### Nested Control Flow +Deeply nested if-statements require the scope to be extended through all levels back to the outermost block where the scope started. + +### do-while and try/catch +The terminal's successor might be a block (not value block), which is handled specially. + +## TODOs +1. `// TODO: consider pruning activeScopes per instruction` - Currently, `activeScopes` is only pruned at block start points. Some scopes may no longer be active by the time a goto is encountered. + +2. `// TODO: add a variant of eachTerminalSuccessor() that visits _all_ successors, not just those that are direct successors for normal control-flow ordering.` - The current implementation uses `mapTerminalSuccessors` which may not visit all successors in all cases. + +## Example + +### Fixture: `extend-scopes-if.js` + +**Input:** +```javascript +function foo(a, b, c) { + let x = []; + if (a) { + if (b) { + if (c) { + x.push(0); // Mutation of x ends here (instruction 12-13) + } + } + } + if (x.length) { // instruction 16 + return x; + } + return null; +} +``` + +**Before AlignReactiveScopesToBlockScopesHIR:** +``` +x$23_@0[1:13] // Scope range 1-13 +``` +The scope for `x` ends at instruction 13 (inside the innermost if block). + +**After AlignReactiveScopesToBlockScopesHIR:** +``` +x$23_@0[1:16] // Scope range extended to 1-16 +``` +The scope is extended to instruction 16 (the first instruction after all the nested if-blocks), aligning to the block scope boundary. + +**Generated Code:** +```javascript +function foo(a, b, c) { + const $ = _c(4); + let x; + if ($[0] !== a || $[1] !== b || $[2] !== c) { + x = []; + if (a) { + if (b) { + if (c) { + x.push(0); + } + } + } + // Scope ends here, after ALL the if-blocks + $[0] = a; + $[1] = b; + $[2] = c; + $[3] = x; + } else { + x = $[3]; + } + // Code outside the scope + if (x.length) { + return x; + } + return null; +} +``` + +The memoization block correctly wraps the entire nested if-structure, not just part of it. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/16-mergeOverlappingReactiveScopesHIR.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/16-mergeOverlappingReactiveScopesHIR.md new file mode 100644 index 00000000000..e7b008df8f3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/16-mergeOverlappingReactiveScopesHIR.md @@ -0,0 +1,134 @@ +# mergeOverlappingReactiveScopesHIR + +## File +`src/HIR/MergeOverlappingReactiveScopesHIR.ts` + +## Purpose +This pass ensures that reactive scope ranges form valid, non-overlapping blocks in the output JavaScript program. It merges reactive scopes that would otherwise be inconsistent with each other due to: + +1. **Overlapping ranges**: Scopes whose instruction ranges partially overlap (not disjoint and not nested) must be merged because the compiler cannot produce valid `if-else` memo blocks for overlapping scopes. + +2. **Cross-scope mutations**: When an instruction within one scope mutates a value belonging to a different (outer) scope, those scopes must be merged to maintain correctness. + +The pass guarantees that after execution, any two reactive scopes are either: +- Entirely disjoint (no common instructions) +- Properly nested (one scope is completely contained within the other) + +## Input Invariants +- Reactive scope variables have been inferred (`InferReactiveScopeVariables` pass has run) +- Scopes have been aligned to block scopes (`AlignReactiveScopesToBlockScopesHIR` pass has run) +- Each `Place` may have an associated `ReactiveScope` with a `range` (start/end instruction IDs) +- Scopes may still have overlapping ranges or contain instructions that mutate outer scopes + +## Output Guarantees +- **No overlapping scopes**: All reactive scopes either are disjoint or properly nested +- **Consistent mutation boundaries**: Instructions only mutate their "active" scope (the innermost containing scope) +- **Merged scope ranges**: Merged scopes have their ranges extended to cover the union of all constituent scopes +- **Updated references**: All `Place` references have their `identifier.scope` updated to point to the merged scope + +## Algorithm + +### Phase 1: Collect Scope Information (`collectScopeInfo`) +- Iterates through all instructions and terminals in the function +- Records for each `Place`: + - The scope it belongs to (`placeScopes` map) + - When scopes start and end (`scopeStarts` and `scopeEnds` arrays, sorted in descending order by ID) +- Only records scopes with non-empty ranges (`range.start !== range.end`) + +### Phase 2: Detect Overlapping Scopes (`getOverlappingReactiveScopes`) +Uses a stack-based traversal to track "active" scopes at each instruction: + +1. **For each instruction/terminal**: + - **Handle scope endings**: Pop completed scopes from the active stack. If a scope ends while other scopes that started later are still active (detected by finding the scope is not at the top of the stack), those scopes overlap and must be merged via `DisjointSet.union()`. + + - **Handle scope starts**: Push new scopes onto the active stack (sorted by end time descending so earlier-ending scopes are at the top). Merge any scopes that have identical start/end ranges. + + - **Handle mutations**: For each operand/lvalue, if it: + - Has an associated scope + - Is mutable at the current instruction + - The scope is active but not at the top of the stack (i.e., an outer scope) + + Then merge all scopes from the mutated outer scope to the top of the stack. + +2. **Special case**: Primitive operands in `FunctionExpression` and `ObjectMethod` are skipped. + +### Phase 3: Merge Scopes and Rewrite References +1. For each scope in the disjoint set, compute the merged range as the union (min start, max end) +2. Update all `Place.identifier.scope` references to point to the merged "group" scope + +## Key Data Structures + +### ScopeInfo +```typescript +type ScopeInfo = { + scopeStarts: Array<{id: InstructionId; scopes: Set}>; + scopeEnds: Array<{id: InstructionId; scopes: Set}>; + placeScopes: Map; +}; +``` + +### TraversalState +```typescript +type TraversalState = { + joined: DisjointSet; // Union-find for merged scopes + activeScopes: Array; // Stack of currently active scopes +}; +``` + +### DisjointSet +A union-find data structure that tracks which scopes should be merged into the same group. + +## Edge Cases + +### Identical Scope Ranges +When multiple scopes have the exact same start and end, they are automatically merged since they would produce the same reactive block. + +### Empty Scopes +Scopes where `range.start === range.end` are skipped entirely. + +### Primitive Captures in Functions +When a `FunctionExpression` or `ObjectMethod` captures a primitive operand, it's excluded from scope merging analysis. + +### JSX Single-Instruction Scopes +The comment in the code notes this isn't perfect - mutating scopes may get merged with JSX single-instruction scopes. + +### Non-Mutating Captures +The pass records both mutating and non-mutating scopes to handle cases where still-mutating values are aliased by inner scopes. + +## TODOs +From the comments in the source file, the design constraints arise from the current compiler output design: +- **Instruction ordering is preserved**: If reordering were allowed, disjoint ranges could be produced by reordering mutating instructions +- **One if-else block per scope**: The current design doesn't allow composing a reactive scope from disconnected instruction ranges + +## Example + +### Fixture: `overlapping-scopes-interleaved.js` + +**Input Code:** +```javascript +function foo(a, b) { + let x = []; + let y = []; + x.push(a); + y.push(b); +} +``` + +**Before MergeOverlappingReactiveScopesHIR:** +``` +[1] $20_@0[1:9] = Array [] // x belongs to scope @0, range [1:9] +[2] x$21_@0[1:9] = StoreLocal... +[3] $23_@1[3:13] = Array [] // y belongs to scope @1, range [3:13] +[4] y$24_@1[3:13] = StoreLocal... +``` +Scopes @0 [1:9] and @1 [3:13] overlap: @0 starts at 1, @1 starts at 3, @0 ends at 9, @1 ends at 13. This is invalid. + +**After MergeOverlappingReactiveScopesHIR:** +``` +[1] $20_@0[1:13] = Array [] // Merged scope @0, range [1:13] +[2] x$21_@0[1:13] = StoreLocal... +[3] $23_@0[1:13] = Array [] // Now also scope @0 +[4] y$24_@0[1:13] = StoreLocal... +``` + +Both `x` and `y` now belong to the same merged scope @0 with range [1:13], producing a single `if-else` memo block in the output. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/17-buildReactiveScopeTerminalsHIR.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/17-buildReactiveScopeTerminalsHIR.md new file mode 100644 index 00000000000..a9c31dd1ec6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/17-buildReactiveScopeTerminalsHIR.md @@ -0,0 +1,161 @@ +# buildReactiveScopeTerminalsHIR + +## File +`src/HIR/BuildReactiveScopeTerminalsHIR.ts` + +## Purpose +This pass transforms the HIR by inserting `ReactiveScopeTerminal` nodes to explicitly demarcate the boundaries of reactive scopes within the control flow graph. It converts the implicit scope ranges (stored on identifiers as `identifier.scope.range`) into explicit control flow structure by: + +1. Inserting a `scope` terminal at the **start** of each reactive scope +2. Inserting a `goto` terminal at the **end** of each reactive scope +3. Creating fallthrough blocks to properly connect the scopes to the rest of the CFG + +This transformation makes scope boundaries first-class elements in the CFG, which is essential for later passes that generate the memoization code (the `if ($[n] !== dep)` checks). + +## Input Invariants +- **Properly nested scopes and blocks**: The pass assumes `assertValidBlockNesting` has passed, meaning all program blocks and reactive scopes form a proper tree hierarchy +- **Aligned scope ranges**: Reactive scope ranges have been correctly aligned and merged by previous passes +- **Valid instruction IDs**: All instructions have sequential IDs that define the scope boundaries +- **Scopes attached to identifiers**: Reactive scopes are found by traversing all `Place` operands and collecting unique non-empty scopes + +## Output Guarantees +- **Explicit scope terminals**: Each reactive scope is represented in the CFG as a `ReactiveScopeTerminal` with: + - `block` - The BlockId containing the scope's instructions + - `fallthrough` - The BlockId that executes after the scope +- **Proper block structure**: Original blocks are split at scope boundaries +- **Restored HIR invariants**: The pass restores RPO ordering, predecessor sets, instruction IDs, and scope/identifier ranges +- **Updated phi nodes**: Phi operands are repointed when their source blocks are split + +## Algorithm + +### Step 1: Collect Scope Rewrites +``` +for each reactive scope (in range pre-order): + push StartScope rewrite at scope.range.start + push EndScope rewrite at scope.range.end +``` +The `recursivelyTraverseItems` helper traverses scopes in pre-order (outer scopes before inner scopes). + +### Step 2: Apply Rewrites by Splitting Blocks +``` +reverse queuedRewrites (to pop in ascending instruction order) +for each block: + for each instruction (or terminal): + while there are rewrites <= current instruction ID: + split block at current index + insert scope terminal (for start) or goto terminal (for end) + emit final block segment with original terminal +``` + +### Step 3: Repoint Phi Nodes +When a block is split, its final segment gets a new BlockId. Phi operands that referenced the original block are updated to reference the new final block. + +### Step 4: Restore HIR Invariants +- Recompute RPO (reverse post-order) block traversal +- Recalculate predecessor sets +- Renumber instruction IDs +- Fix scope and identifier ranges to match new instruction IDs + +## Key Data Structures + +### TerminalRewriteInfo +```typescript +type TerminalRewriteInfo = + | { + kind: 'StartScope'; + blockId: BlockId; // New block for scope content + fallthroughId: BlockId; // Block after scope ends + instrId: InstructionId; // Where to insert + scope: ReactiveScope; // The scope being created + } + | { + kind: 'EndScope'; + instrId: InstructionId; // Where to insert + fallthroughId: BlockId; // Same as corresponding StartScope + }; +``` + +### RewriteContext +```typescript +type RewriteContext = { + source: BasicBlock; // Original block being split + instrSliceIdx: number; // Current slice start index + nextPreds: Set; // Predecessors for next emitted block + nextBlockId: BlockId; // BlockId for next emitted block + rewrites: Array; // Accumulated split blocks +}; +``` + +### ScopeTraversalContext +```typescript +type ScopeTraversalContext = { + fallthroughs: Map; // Cache: scope -> its fallthrough block + rewrites: Array; + env: Environment; +}; +``` + +## Edge Cases + +### Multiple Rewrites at Same Instruction ID +The while loop in Step 2 handles multiple scope start/ends at the same instruction ID. + +### Nested Scopes +The pre-order traversal ensures outer scopes are processed before inner scopes, creating proper nesting in the CFG. + +### Empty Blocks After Split +When a scope boundary falls at the start of a block, the split may create a block with no instructions (only a terminal). + +### Control Flow Within Scopes +The pass preserves existing control flow (if/else, loops) within scopes; it only adds scope entry/exit points. + +### Early Returns +When a return occurs within a scope, the scope terminal still has a fallthrough block, but that block may contain `Unreachable` terminal. + +## TODOs +Line 283-284: +```typescript +// TODO make consistent instruction IDs instead of reusing +``` + +## Example + +### Fixture: `reactive-scopes-if.js` + +**Before BuildReactiveScopeTerminalsHIR:** +``` +bb0 (block): + [1] $29_@0[1:22] = Array [] // x with scope @0 range [1:22] + [2] StoreLocal x$30_@0 = $29_@0 + [3] $32 = LoadLocal a$26 + [4] If ($32) then:bb2 else:bb3 fallthrough=bb1 +bb2: + [5] $33_@1[5:11] = Array [] // y with scope @1 range [5:11] + ... +``` + +**After BuildReactiveScopeTerminalsHIR:** +``` +bb0 (block): + [1] Scope @0 [1:28] block=bb9 fallthrough=bb10 // <-- scope terminal inserted +bb9: + [2] $29_@0 = Array [] + [3] StoreLocal x$30_@0 = $29_@0 + [4] $32 = LoadLocal a$26 + [5] If ($32) then:bb2 else:bb3 fallthrough=bb1 +bb2: + [6] Scope @1 [6:14] block=bb11 fallthrough=bb12 // <-- nested scope terminal +bb11: + [7] $33_@1 = Array [] + ... + [13] Goto bb12 // <-- scope end goto +bb12: + ... +bb1: + [27] Goto bb10 // <-- scope @0 end goto +bb10: + [28] $50 = LoadLocal x$30_@0 + [29] Return $50 +``` + +The key transformation is that scope boundaries become explicit control flow: a `Scope` terminal enters the scope content block, and a `Goto` terminal exits to the fallthrough block. This structure is later used to generate the memoization checks. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/18-flattenReactiveLoopsHIR.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/18-flattenReactiveLoopsHIR.md new file mode 100644 index 00000000000..e9a216323cf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/18-flattenReactiveLoopsHIR.md @@ -0,0 +1,158 @@ +# flattenReactiveLoopsHIR + +## File +`src/ReactiveScopes/FlattenReactiveLoopsHIR.ts` + +## Purpose +This pass **prunes reactive scopes that are nested inside loops** (for, for-in, for-of, while, do-while). The compiler does not yet support memoization within loops because: + +1. Loop iterations would require reconciliation across runs (similar to how `key` is used in JSX for lists) +2. There is no way to identify values across iterations +3. The current approach is to memoize *around* the loop rather than *within* it + +When a reactive scope is found inside a loop body, the pass converts its terminal from `scope` to `pruned-scope`. A `pruned-scope` terminal is later treated specially during codegen - its instructions are emitted inline without any memoization guards. + +## Input Invariants +- The HIR has been through `buildReactiveScopeTerminalsHIR`, which creates `scope` terminal nodes for reactive scopes +- The HIR is in valid block form with proper terminal kinds +- The block ordering respects control flow (blocks are iterated in order, with loop fallthroughs appearing after loop bodies) + +## Output Guarantees +- All `scope` terminals that appear inside any loop body are converted to `pruned-scope` terminals +- Scopes outside of loops remain unchanged as `scope` terminals +- The structure of blocks is preserved; only the terminal kind is mutated +- The `pruned-scope` terminal retains all the same fields as `scope` (block, fallthrough, scope, id, loc) + +## Algorithm + +The algorithm uses a **linear scan with a stack-based loop tracking** approach: + +``` +1. Initialize an empty array `activeLoops` to track which loop(s) we are currently inside +2. For each block in the function body (in order): + a. Remove the current block ID from activeLoops (if present) + - This happens when we reach a loop's fallthrough block, exiting the loop + b. Examine the block's terminal: + - If it's a loop terminal (do-while, for, for-in, for-of, while): + Push the loop's fallthrough block ID onto activeLoops + - If it's a scope terminal AND activeLoops is non-empty: + Convert the terminal to pruned-scope (keeping all other fields) + - All other terminal kinds are ignored +``` + +Key insight: The algorithm tracks when we "enter" a loop by pushing the fallthrough ID when encountering a loop terminal, and "exits" the loop when that fallthrough block is visited. + +## Key Data Structures + +### activeLoops: Array +A stack of block IDs representing loop fallthroughs. When non-empty, we are inside one or more nested loops. + +### PrunedScopeTerminal +```typescript +export type PrunedScopeTerminal = { + kind: 'pruned-scope'; + fallthrough: BlockId; + block: BlockId; + scope: ReactiveScope; + id: InstructionId; + loc: SourceLocation; +}; +``` + +### retainWhere +Utility from utils.ts - an in-place array filter that removes elements not matching the predicate. + +## Edge Cases + +### Nested Loops +The algorithm handles nested loops correctly because `activeLoops` is an array that can contain multiple fallthrough IDs. A scope deep inside multiple nested loops will still be pruned. + +### Scope Spanning the Loop +If a scope terminal appears before the loop terminal but its body contains the loop, it is NOT pruned because the scope terminal itself is not inside the loop. + +### Multiple Loops in Sequence +When exiting one loop (reaching its fallthrough) and entering another, `activeLoops` correctly clears the first loop before potentially adding the second. + +### Control Flow That Exits Loops (break/return) +The algorithm relies on block ordering and fallthrough IDs. Early exits via break/return don't affect the tracking since we track by fallthrough block ID. + +## TODOs +No explicit TODOs in this file. However, the docstring mentions future improvements: +> "Eventually we may integrate more deeply into the runtime so that we can do a single level of reconciliation" + +This suggests a potential future feature to support memoization within loops via runtime integration. + +## Example + +### Fixture: `repro-memoize-for-of-collection-when-loop-body-returns.js` + +**Input:** +```javascript +function useHook(nodeID, condition) { + const graph = useContext(GraphContext); + const node = nodeID != null ? graph[nodeID] : null; + + for (const key of Object.keys(node?.fields ?? {})) { + if (condition) { + return new Class(node.fields?.[field]); // <-- Scope @4 is here + } + } + return new Class(); // <-- Scope @5 is here (outside loop) +} +``` + +**Before FlattenReactiveLoopsHIR:** +``` +[45] Scope scope @3 [45:72] ... block=bb35 fallthrough=bb36 +bb35: + [46] ForOf init=bb6 test=bb7 loop=bb8 fallthrough=bb5 + ... + [66] Scope scope @4 [66:69] ... block=bb37 fallthrough=bb38 <-- Inside loop + ... + [73] Scope scope @5 [73:76] ... block=bb39 fallthrough=bb40 <-- Outside loop +``` + +**After FlattenReactiveLoopsHIR:** +``` +[45] Scope scope @3 [45:72] ... block=bb35 fallthrough=bb36 <-- Unchanged +... +[66] Scope scope @4 [66:69] ... block=bb37 fallthrough=bb38 <-- PRUNED! +... +[73] Scope scope @5 [73:76] ... block=bb39 fallthrough=bb40 <-- Unchanged +``` + +**Final Codegen Result:** +```javascript +function useHook(nodeID, condition) { + const $ = _c(7); + // ... memoized Object.keys call (scope @2) + + let t1; + if ($[2] !== condition || $[3] !== node || $[4] !== t0) { + // Scope @3 wraps the loop + t1 = Symbol.for("react.early_return_sentinel"); + bb0: for (const key of t0) { + if (condition) { + t1 = new Class(node.fields?.[field]); // Scope @4 was PRUNED - no memoization + break bb0; + } + } + $[2] = condition; + $[3] = node; + $[4] = t0; + $[5] = t1; + } else { + t1 = $[5]; + } + // ... + + // Scope @5 - memoized (sentinel check) + if ($[6] === Symbol.for("react.memo_cache_sentinel")) { + t2 = new Class(); + $[6] = t2; + } + return t2; +} +``` + +The `new Class(...)` inside the loop has no memoization guards because scope @4 was pruned. The `new Class()` outside the loop retains its memoization via scope @5. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/19-flattenScopesWithHooksOrUseHIR.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/19-flattenScopesWithHooksOrUseHIR.md new file mode 100644 index 00000000000..ff34df45211 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/19-flattenScopesWithHooksOrUseHIR.md @@ -0,0 +1,143 @@ +# flattenScopesWithHooksOrUseHIR + +## File +`src/ReactiveScopes/FlattenScopesWithHooksOrUseHIR.ts` + +## Purpose +This pass removes (flattens) reactive scopes that transitively contain hook calls or `use()` operator calls. The key insight is that: + +1. **Hooks cannot be called conditionally** - wrapping them in a memoized scope would make them conditionally called based on whether the cache is valid +2. **The `use()` operator** - while it can be called conditionally in source code, React requires it to be called consistently if the component needs the returned value. Memoizing a scope containing `use()` would also make it conditionally called. + +By running reactive scope inference first (agnostic of hooks), the compiler knows which values "construct together" in the same scope. The pass then removes ALL memoization for scopes containing hook/use calls to ensure they are always executed unconditionally. + +## Input Invariants +- HIR must have reactive scope terminals already built (pass runs after `BuildReactiveScopeTerminalsHIR`) +- Blocks are visited in order (the pass iterates through `fn.body.blocks`) +- Scope terminals have a `block` (body of the scope) and `fallthrough` (block after the scope) +- Type inference has run so that `getHookKind()` and `isUseOperator()` can identify hooks and use() calls + +## Output Guarantees +- All scopes that transitively contained a hook or `use()` call are either: + - Converted to `LabelTerminal` - if the scope body is trivial (just the hook call and a goto) + - Converted to `PrunedScopeTerminal` - if the scope body contains other instructions besides the hook call +- The `PrunedScopeTerminal` still tracks the original scope information for downstream passes but will not generate memoization code +- The control flow structure is preserved (same blocks, same fallthroughs) + +## Algorithm + +### Phase 1: Identify Scopes Containing Hook/Use Calls +1. Maintain a stack `activeScopes` of currently "open" reactive scopes +2. Iterate through all blocks in order +3. When entering a block: + - Remove any scopes from `activeScopes` whose fallthrough equals the current block (those scopes have ended) +4. For each instruction in the block: + - If it's a `CallExpression` or `MethodCall` and the callee is a hook or use operator: + - Add all currently active scopes to the `prune` list + - Clear `activeScopes` (these scopes are now marked for pruning) +5. If the block's terminal is a `scope`: + - Push it onto `activeScopes` + +### Phase 2: Prune Identified Scopes +For each block ID in `prune`: +1. Get the scope terminal +2. Check if the scope body is trivial (single instruction + goto to fallthrough): + - If trivial: Convert to `LabelTerminal` (will be removed by `PruneUnusedLabels`) + - If non-trivial: Convert to `PrunedScopeTerminal` (preserves scope info but skips memoization) + +## Key Data Structures + +```typescript +// Stack tracking currently open scopes +activeScopes: Array<{block: BlockId; fallthrough: BlockId}> + +// List of block IDs whose scope terminals should be pruned +prune: Array + +// Terminal types used +LabelTerminal: {kind: 'label', block, fallthrough, id, loc} +PrunedScopeTerminal: {kind: 'pruned-scope', block, fallthrough, scope, id, loc} +ReactiveScopeTerminal: {kind: 'scope', block, fallthrough, scope, id, loc} +``` + +## Edge Cases + +### Nested Scopes +When a hook is found in an inner scope, ALL enclosing scopes are also pruned (the hook call would become conditional if any outer scope were memoized). + +### Method Call Hooks +Handles both `CallExpression` (e.g., `useHook(...)`) and `MethodCall` (e.g., `obj.useHook(...)`). + +### Trivial Hook-Only Scopes +If a scope exists just for a hook call (single instruction + goto), it's converted to a `LabelTerminal` which is a simpler structure that gets cleaned up by later passes. + +### Multiple Hooks in Sequence +Once the first hook is encountered, all active scopes are pruned and cleared, so subsequent hooks in outer scopes still work correctly. + +## TODOs +None explicitly marked in the source file. + +## Example + +### Fixture: `nested-scopes-hook-call.js` + +**Input:** +```javascript +function component(props) { + let x = []; + let y = []; + y.push(useHook(props.foo)); + x.push(y); + return x; +} +``` + +**Before FlattenScopesWithHooksOrUseHIR:** +``` +bb0: + [1] Scope @0 [1:22] block=bb6 fallthrough=bb7 // Outer scope for x +bb6: + [2] $22 = Array [] // x = [] + [3] StoreLocal x = $22 + [4] Scope @1 [4:17] block=bb8 fallthrough=bb9 // Inner scope for y +bb8: + [5] $25 = Array [] // y = [] + [6] StoreLocal y = $25 + ... + [10] $33 = Call useHook(...) // <-- Hook call here! + [11] MethodCall y.push($33) +``` + +**After FlattenScopesWithHooksOrUseHIR:** +``` +bb0: + [1] Scope @0 [1:22] block=bb6 fallthrough=bb7 // PRUNED +bb6: + [2] $22 = Array [] + [3] StoreLocal x = $22 + [4] Scope @1 [4:17] block=bb8 fallthrough=bb9 // PRUNED +bb8: + [5] $25 = Array [] + [6] StoreLocal y = $25 + ... + [12] Label block=bb10 fallthrough=bb11 // Hook call converted to label +bb10: + [13] $33 = Call useHook(...) + [14] Goto bb11 +... +``` + +**Final Output (no memoization):** +```javascript +function component(props) { + const x = []; + const y = []; + y.push(useHook(props.foo)); + x.push(y); + return x; +} +``` + +Notice that: +1. Both scope @0 and scope @1 are marked as `` because the hook call is inside scope @1, which is inside scope @0 +2. The final output has no memoization wrappers - just the raw code diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/20-propagateScopeDependenciesHIR.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/20-propagateScopeDependenciesHIR.md new file mode 100644 index 00000000000..88f9016f12e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/20-propagateScopeDependenciesHIR.md @@ -0,0 +1,158 @@ +# propagateScopeDependenciesHIR + +## File +`src/HIR/PropagateScopeDependenciesHIR.ts` + +## Purpose +The `propagateScopeDependenciesHIR` pass is responsible for computing and assigning the **dependencies** for each reactive scope in the compiled function. Dependencies are the external values that a scope reads, which determine when the scope needs to re-execute. This is a critical step for memoization correctness - the compiler must track exactly which values a scope depends on so it can generate proper cache invalidation checks. + +The pass also populates: +- `scope.dependencies` - The set of `ReactiveScopeDependency` objects the scope reads +- `scope.declarations` - Values declared within the scope that are used outside it + +## Input Invariants +- Reactive scopes must be established (pass runs after `BuildReactiveScopeTerminalsHIR`) +- The function must be in SSA form +- `InferMutationAliasingRanges` must have run to establish when values are being mutated +- `InferReactivePlaces` marks which identifiers are reactive +- Scope ranges have been aligned and normalized by earlier passes + +## Output Guarantees +After this pass completes: + +1. Each `ReactiveScope.dependencies` contains the minimal set of dependencies that: + - Were declared before the scope started + - Are read within the scope + - Are not ref values (which are always mutable) + - Are not object methods (which get codegen'd back into object literals) + +2. Each `ReactiveScope.declarations` contains identifiers that: + - Are assigned within the scope + - Are used outside the scope (need to be exposed as scope outputs) + +3. Property load chains are resolved to their root identifiers with paths (e.g., `props.user.name` becomes `{identifier: props, path: ["user", "name"]}`) + +4. Optional chains are handled correctly, distinguishing between `a?.b` and `a.b` access types + +## Algorithm + +### Phase 1: Build Sidemaps + +1. **findTemporariesUsedOutsideDeclaringScope**: Identifies temporaries that are used outside the scope where they were declared (cannot be hoisted/reordered safely) + +2. **collectTemporariesSidemap**: Creates a mapping from temporary IdentifierIds to their source `ReactiveScopeDependency`. For example: + ``` + $0 = LoadLocal 'a' + $1 = PropertyLoad $0.'b' + ``` + Maps `$1.id` to `{identifier: a, path: [{property: 'b', optional: false}]}` + +3. **collectOptionalChainSidemap**: Traverses optional chain blocks to map temporaries within optional chains to their full optional dependency path + +4. **collectHoistablePropertyLoads**: Uses CFG analysis to determine which property loads can be safely hoisted + +### Phase 2: Collect Dependencies + +The `collectDependencies` function traverses the HIR, maintaining a stack of active scopes: + +1. **Scope Entry/Exit**: When entering a scope terminal, push a new dependency array. When exiting, propagate collected dependencies to parent scopes if valid. + +2. **Instruction Processing**: For each instruction: + - Declare the lvalue with its instruction id and current scope + - Visit operands to record them as potential dependencies + - Handle special cases like `StoreLocal` (tracks reassignments), `Destructure`, `PropertyLoad`, etc. + +3. **Dependency Validation** (`#checkValidDependency`): + - Skip ref values (`isRefValueType`) + - Skip object methods (`isObjectMethodType`) + - Only include if declared before scope start + +### Phase 3: Derive Minimal Dependencies + +For each scope, use `ReactiveScopeDependencyTreeHIR` to: +1. Build a tree from hoistable property loads +2. Add all collected dependencies to the tree +3. Truncate dependencies at their maximal safe-to-evaluate subpath +4. Derive the minimal set (removing redundant nested dependencies) + +## Key Data Structures + +### ReactiveScopeDependency +```typescript +type ReactiveScopeDependency = { + identifier: Identifier; // Root identifier + reactive: boolean; // Whether the value is reactive + path: DependencyPathEntry[]; // Chain of property accesses +} +``` + +### DependencyPathEntry +```typescript +type DependencyPathEntry = { + property: PropertyLiteral; // Property name + optional: boolean; // Is this `?.` access? +} +``` + +### DependencyCollectionContext +Maintains: +- `#declarations`: Map of DeclarationId to {id, scope} recording where each value was declared +- `#reassignments`: Map of Identifier to latest assignment info +- `#scopes`: Stack of currently active ReactiveScopes +- `#dependencies`: Stack of dependency arrays (one per active scope) +- `#temporaries`: Sidemap for resolving property loads + +### ReactiveScopeDependencyTreeHIR +A tree structure for efficient dependency deduplication that stores hoistable objects, tracks access types, and computes minimal dependencies. + +## Edge Cases + +### Values Used Outside Declaring Scope +If a temporary is used outside its declaring scope, it cannot be tracked in the sidemap because reordering the read would be invalid. + +### Ref.current Access +Accessing `ref.current` is treated specially - the dependency is truncated to just `ref`. + +### Optional Chains +Optional chains like `a?.b?.c` produce different dependency paths than `a.b.c`. The pass distinguishes them and may merge optional loads into unconditional ones when control flow proves the object is non-null. + +### Inner Functions +Dependencies from inner functions are collected recursively but with special handling for context variables. + +### Phi Nodes +When a value comes from multiple control flow paths, optional chain dependencies from phi operands are also visited. + +## TODOs +1. Line 374-375: `// TODO(mofeiZ): understand optional chaining` - More documentation needed for optional chain handling + +## Example + +### Fixture: `reactive-control-dependency-if.js` + +**Input:** +```javascript +function Component(props) { + let x; + if (props.cond) { + x = 1; + } else { + x = 2; + } + return [x]; +} +``` + +**Before PropagateScopeDependenciesHIR:** +``` +Scope scope @0 [12:15] dependencies=[] declarations=[] reassignments=[] block=bb9 +``` + +**After PropagateScopeDependenciesHIR:** +``` +Scope scope @0 [12:15] dependencies=[x$24:TPrimitive] declarations=[$26_@0] reassignments=[] block=bb9 +``` + +The pass identified that: +- The scope at `[x]` depends on `x$24` (the phi node result from the if/else branches) +- Even though `x` is assigned to constants (1 or 2), its value depends on the reactive control flow condition `props.cond` +- The scope declares `$26_@0` (the array output) diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/21-buildReactiveFunction.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/21-buildReactiveFunction.md new file mode 100644 index 00000000000..d686b4061d7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/21-buildReactiveFunction.md @@ -0,0 +1,180 @@ +# buildReactiveFunction + +## File +`src/ReactiveScopes/BuildReactiveFunction.ts` + +## Purpose +The `buildReactiveFunction` pass converts the compiler's HIR (High-level Intermediate Representation) from a **Control Flow Graph (CFG)** representation to a **tree-based ReactiveFunction** representation that is closer to an AST. This is a critical transformation in the React Compiler pipeline that: + +1. **Restores control flow constructs** - Reconstructs `if`, `while`, `for`, `switch`, and other control flow statements from the CFG's basic blocks and terminals +2. **Eliminates phi nodes** - Replaces SSA phi nodes with compound value expressions (ternaries, logical expressions, sequence expressions) +3. **Handles labeled break/continue** - Tracks control flow targets to emit explicit labeled `break` and `continue` statements when needed +4. **Preserves reactive scope information** - Scope terminals are converted to `ReactiveScopeBlock` nodes in the tree + +## Input Invariants +- HIR is in SSA form (variables have been renamed with unique identifiers) +- Basic blocks are connected (valid predecessor/successor relationships) +- Each block ends with a valid terminal +- Phi nodes exist at merge points for values from different control flow paths +- Reactive scopes have been constructed (`scope` terminals exist) +- Scope dependencies are computed (`PropagateScopeDependenciesHIR` has run) + +## Output Guarantees +- **Tree structure** - The output is a `ReactiveFunction` with a `body: ReactiveBlock` containing a tree of `ReactiveStatement` nodes +- **No CFG structure** - Basic blocks are eliminated; control flow is represented through nested reactive terminals +- **No phi nodes** - Value merges are represented as `ConditionalExpression`, `LogicalExpression`, or `SequenceExpression` values +- **Labels emitted for all control flow** - Every terminal that can be a break/continue target has a label; unnecessary labels are removed by subsequent `PruneUnusedLabels` pass +- **Each block emitted exactly once** - A block cannot be generated twice +- **Scope blocks preserved** - `scope` terminals become `ReactiveScopeBlock` nodes + +## Algorithm + +### Core Classes + +1. **`Driver`** - Traverses blocks and emits ReactiveBlock arrays +2. **`Context`** - Tracks state: + - `emitted: Set` - Which blocks have been generated + - `#scheduled: Set` - Blocks that will be emitted by parent constructs + - `#controlFlowStack: Array` - Stack of active break/continue targets + - `scopeFallthroughs: Set` - Fallthroughs for scope blocks + +### Traversal Strategy + +1. Start at the entry block and call `traverseBlock(entryBlock)` +2. For each block: + - Emit all instructions as `ReactiveInstructionStatement` + - Process the terminal based on its kind + +### Terminal Processing + +**Simple Terminals:** +- `return`, `throw` - Emit directly as `ReactiveTerminal` +- `unreachable` - No-op + +**Control Flow Terminals:** +- `if` - Schedule fallthrough, recursively traverse consequent/alternate, emit `ReactiveIfTerminal` +- `while`, `do-while`, `for`, `for-of`, `for-in` - Use `scheduleLoop()` which tracks continue targets +- `switch` - Process cases in reverse order +- `label` - Schedule fallthrough, traverse inner block + +**Value Terminals (expressions that produce values):** +- `ternary`, `logical`, `optional`, `sequence` - Produce `ReactiveValue` compound expressions + +**Break/Continue:** +- `goto` with `GotoVariant.Break` - Determine if break is implicit, unlabeled, or labeled +- `goto` with `GotoVariant.Continue` - Determine continue type + +**Scope Terminals:** +- `scope`, `pruned-scope` - Schedule fallthrough, traverse inner block, emit as `ReactiveScopeBlock` + +## Key Data Structures + +### ReactiveFunction +```typescript +type ReactiveFunction = { + loc: SourceLocation; + id: ValidIdentifierName | null; + params: Array; + generator: boolean; + async: boolean; + body: ReactiveBlock; + env: Environment; + directives: Array; +}; +``` + +### ReactiveBlock +```typescript +type ReactiveBlock = Array; +``` + +### ReactiveStatement +```typescript +type ReactiveStatement = + | ReactiveInstructionStatement // {kind: 'instruction', instruction} + | ReactiveTerminalStatement // {kind: 'terminal', terminal, label} + | ReactiveScopeBlock // {kind: 'scope', scope, instructions} + | PrunedReactiveScopeBlock; // {kind: 'pruned-scope', ...} +``` + +### ReactiveValue (for compound expressions) +```typescript +type ReactiveValue = + | InstructionValue // Regular instruction values + | ReactiveLogicalValue // a && b, a || b, a ?? b + | ReactiveSequenceValue // (a, b, c) + | ReactiveTernaryValue // a ? b : c + | ReactiveOptionalCallValue; // a?.b() +``` + +### ControlFlowTarget +```typescript +type ControlFlowTarget = + | {type: 'if'; block: BlockId; id: number} + | {type: 'switch'; block: BlockId; id: number} + | {type: 'case'; block: BlockId; id: number} + | {type: 'loop'; block: BlockId; continueBlock: BlockId; ...}; +``` + +## Edge Cases + +### Nested Control Flow +The scheduling mechanism handles arbitrarily nested control flow by pushing/popping from the control flow stack. + +### Value Blocks with Complex Expressions +`SequenceExpression` handles cases where value blocks contain multiple instructions. + +### Scope Fallthroughs +Breaks to scope fallthroughs are treated as implicit (no explicit break needed). + +### Catch Handlers +Scheduled specially via `scheduleCatchHandler()` to prevent re-emission. + +### Unreachable Blocks +The `reachable()` check prevents emitting unreachable blocks. + +## TODOs +The code contains several `CompilerError.throwTodo()` calls for unsupported patterns: +1. Optional chaining test blocks must end in `branch` +2. Logical expression test blocks must end in `branch` +3. Support for value blocks within try/catch statements +4. Support for labeled statements combined with value blocks + +## Example + +### Fixture: `ternary-expression.js` + +**Input:** +```javascript +function ternary(props) { + const a = props.a && props.b ? props.c || props.d : (props.e ?? props.f); + const b = props.a ? (props.b && props.c ? props.d : props.e) : props.f; + return a ? b : null; +} +``` + +**HIR (CFG with many basic blocks):** +The HIR contains 33 basic blocks with `Ternary`, `Logical`, `Branch`, and `Goto` terminals, plus phi nodes at merge points. + +**ReactiveFunction Output (Tree):** +``` +function ternary(props$62{reactive}) { + [1] $84 = Ternary + Sequence + [2] $66 = Logical + Sequence [...] + && Sequence [...] + ? + Sequence [...] // props.c || props.d + : + Sequence [...] // props.e ?? props.f + [40] StoreLocal a$99 = $98 + ... + [82] return $145 +} +``` + +The transformation eliminates: +- 33 basic blocks reduced to a single tree +- Phi nodes replaced with nested `Ternary` and `Logical` value expressions +- CFG edges replaced with tree nesting diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/22-pruneUnusedLabels.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/22-pruneUnusedLabels.md new file mode 100644 index 00000000000..c7524ae5474 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/22-pruneUnusedLabels.md @@ -0,0 +1,145 @@ +# pruneUnusedLabels + +## File +`src/ReactiveScopes/PruneUnusedLabels.ts` + +## Purpose +The `pruneUnusedLabels` pass optimizes control flow by: + +1. **Flattening labeled terminals** where the label is not reachable via a `break` or `continue` statement +2. **Marking labels as implicit** for terminals where the label exists but is never targeted + +This pass removes unnecessary labeled blocks that were introduced during compilation but serve no control flow purpose in the final output. JavaScript labeled statements are only needed when there is a corresponding `break label` or `continue label` that targets them. + +## Input Invariants +- The input is a `ReactiveFunction` (after conversion from HIR) +- All `break` and `continue` terminals have: + - A `target` (BlockId) indicating which label they jump to + - A `targetKind` that is one of: `'implicit'`, `'labeled'`, or `'unlabeled'` +- Each `ReactiveTerminalStatement` has an optional `label` field containing `id` and `implicit` +- The pass runs after `assertWellFormedBreakTargets` which validates break/continue targets + +## Output Guarantees +- Labeled terminals where the label is unreachable are flattened into their parent block +- When flattening, trailing unlabeled `break` statements (that would just fall through) are removed +- Labels that exist but are never targeted have their `implicit` flag set to `true` +- Control flow semantics are preserved - only structurally unnecessary labels are removed + +## Algorithm + +The pass uses a two-phase approach with a single traversal: + +**Phase 1: Collect reachable labels** +```typescript +if ((terminal.kind === 'break' || terminal.kind === 'continue') && + terminal.targetKind === 'labeled') { + state.add(terminal.target); // Mark this label as reachable +} +``` + +**Phase 2: Transform terminals** +```typescript +const isReachableLabel = stmt.label !== null && state.has(stmt.label.id); + +if (stmt.terminal.kind === 'label' && !isReachableLabel) { + // Flatten: extract block contents, removing trailing unlabeled break + const block = [...stmt.terminal.block]; + const last = block.at(-1); + if (last?.kind === 'terminal' && last.terminal.kind === 'break' && + last.terminal.target === null) { + block.pop(); // Remove trailing break + } + return {kind: 'replace-many', value: block}; +} else { + if (!isReachableLabel && stmt.label != null) { + stmt.label.implicit = true; // Mark as implicit + } + return {kind: 'keep'}; +} +``` + +## Edge Cases + +### Trailing Break Removal +When flattening a labeled block, if the last statement is an unlabeled break (`target === null`), it is removed since it would just fall through anyway. + +### Implicit vs Labeled Breaks +Only breaks with `targetKind === 'labeled'` count toward label reachability. Implicit breaks (fallthrough) and unlabeled breaks don't make a label "used". + +### Continue Statements +Both `break` and `continue` with labeled targets mark the label as reachable. + +### Non-Label Terminals with Labels +Other terminal types (like `if`, `while`, `for`) can also have labels. If unreachable, these labels are marked implicit but the terminal is not flattened. + +## TODOs +None in the source file. + +## Example + +### Fixture: `unconditional-break-label.js` + +**Input:** +```javascript +function foo(a) { + let x = 0; + bar: { + x = 1; + break bar; + } + return a + x; +} +``` + +**Output (after full compilation):** +```javascript +function foo(a) { + return a + 1; +} +``` + +The labeled block `bar: { ... }` is removed because after the pass runs, constant propagation and dead code elimination further simplify the code. + +### Fixture: `conditional-break-labeled.js` + +**Input:** +```javascript +function Component(props) { + const a = []; + a.push(props.a); + label: { + if (props.b) { + break label; + } + a.push(props.c); + } + a.push(props.d); + return a; +} +``` + +**Output:** +```javascript +function Component(props) { + const $ = _c(5); + let a; + if ($[0] !== props.a || $[1] !== props.b || + $[2] !== props.c || $[3] !== props.d) { + a = []; + a.push(props.a); + bb0: { + if (props.b) { + break bb0; + } + a.push(props.c); + } + a.push(props.d); + // ... cache updates + } else { + a = $[4]; + } + return a; +} +``` + +The labeled block `bb0: { ... }` is preserved because the `break bb0` inside the conditional targets this label. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/23-pruneNonEscapingScopes.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/23-pruneNonEscapingScopes.md new file mode 100644 index 00000000000..ec2c10ea670 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/23-pruneNonEscapingScopes.md @@ -0,0 +1,130 @@ +# pruneNonEscapingScopes + +## File +`src/ReactiveScopes/PruneNonEscapingScopes.ts` + +## Purpose +This pass prunes (removes) reactive scopes whose outputs do not "escape" the component and therefore do not need to be memoized. A value "escapes" in two ways: + +1. **Returned from the function** - The value is directly returned or transitively aliased by a return value +2. **Passed to a hook** - Any value passed as an argument to a hook may be stored by React internally (e.g., the closure passed to `useEffect`) + +The key insight is that values which never escape the component boundary can be safely recreated on each render without affecting the behavior of consumers. + +## Input Invariants +- The input is a `ReactiveFunction` after scope blocks have been identified +- Reactive scopes have been assigned to instructions +- The pass runs after `BuildReactiveFunction` and `PruneUnusedLabels`, before `PruneNonReactiveDependencies` + +## Output Guarantees +- **Scopes with non-escaping outputs are removed** - Their instructions are inlined back into the parent scope/function body +- **Scopes with escaping outputs are retained** - Values that escape via return or hook arguments remain memoized +- **Transitive dependencies of escaping scopes are preserved** - If an escaping scope depends on a non-escaping value, that value's scope is also retained to prevent unnecessary invalidation +- **`FinishMemoize` instructions are marked `pruned=true`** - When a scope is pruned, the associated memoization instructions are flagged + +## Algorithm + +### Phase 1: Build the Dependency Graph +Using `CollectDependenciesVisitor`, build: +- **Identifier nodes** - Each node tracks memoization level, dependencies, scopes, and whether ultimately memoized +- **Scope nodes** - Each scope tracks its dependencies +- **Escaping values** - Identifiers that escape via return or hook arguments + +### Phase 2: Classify Memoization Levels +Each instruction value is classified: +- `Memoized`: Arrays, objects, function calls, `new` expressions - always potentially aliasing +- `Conditional`: Conditional/logical expressions, property loads - memoized only if dependencies are memoized +- `Unmemoized`: JSX elements (when `memoizeJsxElements` is false), DeclareLocal +- `Never`: Primitives, LoadGlobal, binary/unary expressions - can be cheaply compared + +### Phase 3: Compute Memoized Identifiers +`computeMemoizedIdentifiers()` performs a graph traversal starting from escaping values: +- For each escaping value, recursively visit its dependencies +- Mark values and their scopes based on memoization level +- When marking a scope, force-memoize all its dependencies + +### Phase 4: Prune Scopes +`PruneScopesTransform` visits each scope block: +- If any scope output is in the memoized set, keep the scope +- If no outputs are memoized, replace the scope block with its inlined instructions + +## Edge Cases + +### Interleaved Mutations +```javascript +const a = [props.a]; // independently memoizable, non-escaping +const b = []; +const c = {}; +c.a = a; // c captures a, but c doesn't escape +b.push(props.b); // b escapes via return +return b; +``` +Here `a` does not directly escape, but it is a dependency of the scope containing `b`. The algorithm correctly identifies that `a`'s scope must be preserved. + +### Hook Arguments Escape +Values passed to hooks are treated as escaping because hooks may store references internally. + +### JSX Special Handling +JSX elements are marked as `Unmemoized` by default because React.memo() can handle dynamic memoization. + +### noAlias Functions +If a function signature indicates `noAlias === true`, its arguments are not treated as escaping. + +### Reassignments +When a scope reassigns a variable, the scope is added as a dependency of that variable. + +## TODOs +None explicitly in the source file. + +## Example + +### Fixture: `escape-analysis-non-escaping-interleaved-allocating-dependency.js` + +**Input:** +```javascript +function Component(props) { + const a = [props.a]; + + const b = []; + const c = {}; + c.a = a; + b.push(props.b); + + return b; +} +``` + +**Output:** +```javascript +function Component(props) { + const $ = _c(5); + let t0; + if ($[0] !== props.a) { + t0 = [props.a]; + $[0] = props.a; + $[1] = t0; + } else { + t0 = $[1]; + } + const a = t0; // a is memoized even though it doesn't escape directly + + let b; + if ($[2] !== a || $[3] !== props.b) { + b = []; + const c = {}; // c is NOT memoized - it doesn't escape + c.a = a; + b.push(props.b); + $[2] = a; + $[3] = props.b; + $[4] = b; + } else { + b = $[4]; + } + return b; +} +``` + +Key observations: +- `a` is memoized because it's a dependency of the scope containing `b` +- `c` is not separately memoized because it doesn't escape +- `b` is memoized because it's returned diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/24-pruneNonReactiveDependencies.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/24-pruneNonReactiveDependencies.md new file mode 100644 index 00000000000..c0b524918a1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/24-pruneNonReactiveDependencies.md @@ -0,0 +1,138 @@ +# pruneNonReactiveDependencies + +## File +`src/ReactiveScopes/PruneNonReactiveDependencies.ts` + +## Purpose +This pass removes dependencies from reactive scopes that are guaranteed to be **non-reactive** (i.e., their values cannot change between renders). This optimization reduces unnecessary memoization invalidations by ensuring scopes only depend on values that can actually change. + +The pass complements `PropagateScopeDependencies`, which infers dependencies without considering reactivity. This subsequent pruning step filters out dependencies that are semantically constant. + +## Input Invariants +- The function has been converted to a ReactiveFunction structure +- `InferReactivePlaces` has annotated places with `{reactive: true}` where values can change +- Each `ReactiveScopeBlock` has a `scope.dependencies` set populated by `PropagateScopeDependenciesHIR` +- Type inference has run, so identifiers have type information for `isStableType` checks + +## Output Guarantees +- **Non-reactive dependencies removed**: All dependencies in `scope.dependencies` are reactive after this pass +- **Scope outputs marked reactive if needed**: If a scope has any reactive dependencies remaining, all its outputs are marked reactive +- **Stable types remain non-reactive through property loads**: When loading properties from stable types (like `useReducer` dispatch functions), the result is not added to the reactive set + +## Algorithm + +### Phase 1: Collect Reactive Identifiers +The `collectReactiveIdentifiers` helper builds the initial set of reactive identifiers by: +1. Visiting all places in the ReactiveFunction +2. Adding any place marked `{reactive: true}` to the set +3. For pruned scopes, adding declarations that are not primitives and not stable ref types + +### Phase 2: Propagate Reactivity and Prune Dependencies +The main `Visitor` class traverses the ReactiveFunction and: + +1. **For Instructions** - Propagates reactivity through data flow: + - `LoadLocal`: If source is reactive, mark the lvalue as reactive + - `StoreLocal`: If source value is reactive, mark both the local variable and lvalue as reactive + - `Destructure`: If source is reactive, mark all pattern operands as reactive (except stable types) + - `PropertyLoad`: If object is reactive AND result is not a stable type, mark result as reactive + - `ComputedLoad`: If object OR property is reactive, mark result as reactive + +2. **For Scopes** - Prunes non-reactive dependencies and propagates outputs: + - Delete each dependency from `scope.dependencies` if its identifier is not in the reactive set + - If any dependencies remain after pruning, mark all scope outputs as reactive + +### Key Insight: Stable Types +The pass leverages `isStableType` to prevent reactivity from flowing through certain React-provided stable values: + +```typescript +function isStableType(id: Identifier): boolean { + return ( + isSetStateType(id) || // useState setter + isSetActionStateType(id) || // useActionState setter + isDispatcherType(id) || // useReducer dispatcher + isUseRefType(id) || // useRef result + isStartTransitionType(id) ||// useTransition startTransition + isSetOptimisticType(id) // useOptimistic setter + ); +} +``` + +## Edge Cases + +### Unmemoized Values Spanning Hook Calls +A value created before a hook call and mutated after cannot be memoized. However, if it's non-reactive, it still should not appear as a dependency of downstream scopes. + +### Stable Types from Reactive Containers +When `useReducer` returns `[state, dispatch]`, `state` is reactive but `dispatch` is stable. The pass correctly handles this. + +### Pruned Scopes with Reactive Content +The `CollectReactiveIdentifiers` pass also examines pruned scopes and adds their non-primitive, non-stable-ref declarations to the reactive set. + +### Transitive Reactivity Through Scopes +When a scope retains at least one reactive dependency, ALL its outputs become reactive. + +## TODOs +None in the source file. + +## Example + +### Fixture: `unmemoized-nonreactive-dependency-is-pruned-as-dependency.js` + +**Input:** +```javascript +function Component(props) { + const x = []; + useNoAlias(); + mutate(x); + + return
{x}
; +} +``` + +**Before PruneNonReactiveDependencies:** +``` +scope @2 dependencies=[x$15_@0:TObject] declarations=[$23_@2] +``` + +**After PruneNonReactiveDependencies:** +``` +scope @2 dependencies=[] declarations=[$23_@2] +``` + +The dependency on `x` is removed because `x` is created locally and therefore non-reactive. + +### Fixture: `useReducer-returned-dispatcher-is-non-reactive.js` + +**Input:** +```javascript +function f() { + const [state, dispatch] = useReducer(); + + const onClick = () => { + dispatch(); + }; + + return
; +} +``` + +**Generated Code:** +```javascript +function f() { + const $ = _c(1); + const [, dispatch] = useReducer(); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const onClick = () => { + dispatch(); + }; + t0 =
; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} +``` + +The `onClick` function only captures `dispatch`, which is a stable type. Therefore, `onClick` is non-reactive, and the JSX element can be memoized with zero dependencies. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/25-pruneUnusedScopes.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/25-pruneUnusedScopes.md new file mode 100644 index 00000000000..03ac3362ff3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/25-pruneUnusedScopes.md @@ -0,0 +1,111 @@ +# pruneUnusedScopes + +## File +`src/ReactiveScopes/PruneUnusedScopes.ts` + +## Purpose +This pass converts reactive scopes that have no meaningful outputs into "pruned scopes". A pruned scope is no longer memoized - its instructions are executed unconditionally on every render. This optimization removes unnecessary memoization overhead for scopes that don't produce values that need to be cached. + +## Input Invariants +- The input is a `ReactiveFunction` that has already been transformed into reactive scope form +- Scopes have been created and have `declarations`, `reassignments`, and potentially `earlyReturnValue` populated +- The pass is called after: + - `pruneUnusedLabels` - cleans up unnecessary labels + - `pruneNonEscapingScopes` - removes scopes whose outputs don't escape + - `pruneNonReactiveDependencies` - removes non-reactive dependencies from scopes +- Scopes may already be marked as pruned by earlier passes + +## Output Guarantees +Scopes that meet ALL of the following criteria are converted to `pruned-scope`: +- No return statement within the scope +- No reassignments (`scope.reassignments.size === 0`) +- Either no declarations (`scope.declarations.size === 0`), OR all declarations "bubbled up" from inner scopes + +Pruned scopes: +- Keep their original scope metadata (for debugging/tracking) +- Keep their instructions intact +- Will be executed unconditionally during codegen (no memoization check) + +## Algorithm + +The pass uses the visitor pattern with `ReactiveFunctionTransform`: + +1. **State Tracking**: A `State` object tracks whether a return statement was encountered: + ```typescript + type State = { + hasReturnStatement: boolean; + }; + ``` + +2. **Terminal Visitor** (`visitTerminal`): Checks if any terminal is a `return` statement + +3. **Scope Transform** (`transformScope`): For each scope: + - Creates a fresh state for this scope + - Recursively visits the scope's contents + - Checks pruning criteria: + - `!scopeState.hasReturnStatement` - no early return + - `scope.reassignments.size === 0` - no reassignments + - `scope.declarations.size === 0` OR `!hasOwnDeclaration(scopeBlock)` - no outputs + +4. **hasOwnDeclaration Helper**: Determines if a scope has "own" declarations vs declarations propagated from nested scopes + +## Edge Cases + +### Return Statements +Scopes containing return statements are preserved because early returns need memoization to avoid re-executing the return check on every render. + +### Bubbled-Up Declarations +When nested scopes are flattened or merged, their declarations may be propagated to parent scopes. The `hasOwnDeclaration` check ensures that parent scopes with only inherited declarations can still be pruned. + +### Reassignments +Scopes with reassignments are kept because the reassignment represents a side effect that needs to be tracked for memoization. + +### Already-Pruned Scopes +The pass operates on `ReactiveScopeBlock` (kind: 'scope'), not `PrunedReactiveScopeBlock`. Scopes already pruned by earlier passes are not revisited. + +### Interaction with Subsequent Passes +The `MergeReactiveScopesThatInvalidateTogether` pass explicitly handles pruned scopes - it does not merge across them. + +## TODOs +None in the source file. + +## Example + +### Fixture: `prune-scopes-whose-deps-invalidate-array.js` + +**Input:** +```javascript +function Component(props) { + const x = []; + useHook(); + x.push(props.value); + const y = [x]; + return [y]; +} +``` + +What happens: +- The scope for `x` cannot be memoized because `useHook()` is called inside it +- `FlattenScopesWithHooksOrUseHIR` marks scope @0 as `pruned-scope` +- `PruneUnusedScopes` doesn't change it further since it's already pruned + +**Output (no memoization for x):** +```javascript +function Component(props) { + const x = []; + useHook(); + x.push(props.value); + const y = [x]; + return [y]; +} +``` + +### Key Insight + +The `pruneUnusedScopes` pass is part of a multi-pass pruning strategy: +1. `FlattenScopesWithHooksOrUseHIR` - Prunes scopes that contain hook/use calls +2. `pruneNonEscapingScopes` - Prunes scopes whose outputs don't escape +3. `pruneNonReactiveDependencies` - Removes non-reactive dependencies +4. **`pruneUnusedScopes`** - Prunes scopes with no remaining outputs + +This pass acts as a cleanup for scopes that became "empty" after previous pruning passes removed their outputs. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/26-mergeReactiveScopesThatInvalidateTogether.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/26-mergeReactiveScopesThatInvalidateTogether.md new file mode 100644 index 00000000000..4ed964116bb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/26-mergeReactiveScopesThatInvalidateTogether.md @@ -0,0 +1,213 @@ +# mergeReactiveScopesThatInvalidateTogether + +## File +`src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts` + +## Purpose +This pass is an optimization that reduces memoization overhead in the compiled output by merging reactive scopes that will always invalidate together. The pass operates on the ReactiveFunction representation and works in two main scenarios: + +1. **Consecutive Scopes**: When two scopes appear sequentially in the same reactive block with identical dependencies (or where the output of the first scope is the sole input to the second), they are merged into a single scope. This reduces the number of memo cache slots used and eliminates redundant dependency comparisons. + +2. **Nested Scopes**: When an inner scope has the same dependencies as its parent scope, the inner scope is flattened into the parent. Since PropagateScopeDependencies propagates dependencies upward, nested scopes can only have equal or fewer dependencies than their parents, never more. When they're equal, the inner scope always invalidates with the parent, making it safe and beneficial to flatten. + +## Input Invariants +- The ReactiveFunction has already undergone scope dependency propagation (via `PropagateScopeDependencies`) +- The function has been pruned of unused scopes (via `pruneNonReactiveDependencies` and `pruneUnusedScopes`) +- Scopes have valid `dependencies`, `declarations`, `range`, and `reassignments` fields +- The ReactiveFunction is in a valid structural state with properly formed blocks and instructions + +## Output Guarantees +- **Fewer scopes**: Consecutive and nested scopes with identical dependencies are merged +- **Valid scope ranges**: Merged scopes have their `range.end` updated to cover all merged instructions +- **Updated declarations**: Scope declarations are updated to remove any that are no longer used after the merged scope +- **Merged scope tracking**: The `scope.merged` set tracks which scope IDs were merged into each surviving scope +- **Preserved semantics**: Only safe-to-memoize intermediate instructions are absorbed into merged scopes + +## Algorithm + +The pass operates in multiple phases: + +### Phase 1: Find Last Usage +A visitor (`FindLastUsageVisitor`) collects the last usage instruction ID for each declaration: + +```typescript +class FindLastUsageVisitor extends ReactiveFunctionVisitor { + lastUsage: Map = new Map(); + + override visitPlace(id: InstructionId, place: Place, _state: void): void { + const previousUsage = this.lastUsage.get(place.identifier.declarationId); + const lastUsage = + previousUsage !== undefined + ? makeInstructionId(Math.max(previousUsage, id)) + : id; + this.lastUsage.set(place.identifier.declarationId, lastUsage); + } +} +``` + +### Phase 2: Transform (Nested Scope Flattening) +The `transformScope` method flattens nested scopes with identical dependencies: + +```typescript +override transformScope( + scopeBlock: ReactiveScopeBlock, + state: ReactiveScopeDependencies | null, +): Transformed { + this.visitScope(scopeBlock, scopeBlock.scope.dependencies); + if ( + state !== null && + areEqualDependencies(state, scopeBlock.scope.dependencies) + ) { + return {kind: 'replace-many', value: scopeBlock.instructions}; + } else { + return {kind: 'keep'}; + } +} +``` + +### Phase 3: Visit Block (Consecutive Scope Merging) +Within `visitBlock`, the pass: +1. First traverses nested blocks recursively +2. Iterates through instructions, tracking merge candidates +3. Determines if consecutive scopes can merge based on: + - Identical dependencies, OR + - Output of first scope is input to second scope (with always-invalidating types) +4. Collects intermediate lvalues and ensures they're only used by the next scope +5. Merges eligible scopes by combining instructions and updating range/declarations + +### Key Merging Conditions (`canMergeScopes`): +```typescript +function canMergeScopes( + current: ReactiveScopeBlock, + next: ReactiveScopeBlock, + temporaries: Map, +): boolean { + // Don't merge scopes with reassignments + if (current.scope.reassignments.size !== 0 || next.scope.reassignments.size !== 0) { + return false; + } + // Merge scopes whose dependencies are identical + if (areEqualDependencies(current.scope.dependencies, next.scope.dependencies)) { + return true; + } + // Merge scopes where outputs of previous are inputs of next + // (with always-invalidating type check) + // ... +} +``` + +### Always-Invalidating Types: +```typescript +export function isAlwaysInvalidatingType(type: Type): boolean { + switch (type.kind) { + case 'Object': { + switch (type.shapeId) { + case BuiltInArrayId: + case BuiltInObjectId: + case BuiltInFunctionId: + case BuiltInJsxId: { + return true; + } + } + break; + } + case 'Function': { + return true; + } + } + return false; +} +``` + +## Edge Cases + +### Terminals +The pass does not merge across terminals (control flow boundaries). + +### Pruned Scopes +Merging stops at pruned scopes. + +### Reassignments +Scopes containing reassignments cannot be merged (side-effect ordering concerns). + +### Intermediate Reassignments +Non-const StoreLocal instructions between scopes prevent merging. + +### Safe Intermediate Instructions +Only certain instruction types are allowed between merged scopes: `BinaryExpression`, `ComputedLoad`, `JSXText`, `LoadGlobal`, `LoadLocal`, `Primitive`, `PropertyLoad`, `TemplateLiteral`, `UnaryExpression`, and const `StoreLocal`. + +### Lvalue Usage +Intermediate values must be last-used at or before the next scope to allow merging. + +### Non-Invalidating Outputs +If a scope's output may not change when inputs change (e.g., `foo(x) { return x < 10 }` returns same boolean for different x values), that scope cannot be a merge candidate for subsequent scopes. + +## TODOs +```typescript +/* + * TODO LeaveSSA: use IdentifierId for more precise tracking + * Using DeclarationId is necessary for compatible output but produces suboptimal results + * in cases where a scope defines a variable, but that version is never read and always + * overwritten later. + * see reassignment-separate-scopes.js for example + */ +lastUsage: Map = new Map(); +``` + +## Example + +### Fixture: `merge-consecutive-scopes-deps-subset-of-decls.js` + +**Input:** +```javascript +import {useState} from 'react'; + +function Component() { + const [count, setCount] = useState(0); + return ( +
+ + +
+ ); +} +``` + +**After MergeReactiveScopesThatInvalidateTogether** (from `yarn snap -p merge-consecutive-scopes-deps-subset-of-decls.js -d`): +``` +scope @1 [7:24] dependencies=[count$32:TPrimitive] declarations=[$51_@5] reassignments=[] { + [8] $35_@1 = Function @context[setCount$33, count$32] // decrement callback + [10] $41 = JSXText "Decrement" + [12] $42_@2 = JSX + [15] $43_@3 = Function @context[setCount$33, count$32] // increment callback + [17] $49 = JSXText "Increment" + [19] $50_@4 = JSX + [22] $51_@5 = JSX
{$42_@2}{$50_@4}
+} +``` + +All scopes are merged because they share `count` as a dependency. Without merging, this would have separate scopes for each callback and button element. + +**Generated Code:** +```javascript +function Component() { + const $ = _c(2); + const [count, setCount] = useState(0); + let t0; + if ($[0] !== count) { + t0 = ( +
+ + +
+ ); + $[0] = count; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} +``` + +The merged version uses only 2 cache slots instead of potentially 6-8. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/27-pruneAlwaysInvalidatingScopes.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/27-pruneAlwaysInvalidatingScopes.md new file mode 100644 index 00000000000..38bbc0de313 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/27-pruneAlwaysInvalidatingScopes.md @@ -0,0 +1,143 @@ +# pruneAlwaysInvalidatingScopes + +## File +`src/ReactiveScopes/PruneAlwaysInvalidatingScopes.ts` + +## Purpose +This pass identifies and prunes reactive scopes whose dependencies will *always* invalidate on every render, making memoization pointless. Specifically, it tracks values that are guaranteed to be new allocations (arrays, objects, JSX, new expressions) and checks if those values are used outside of any memoization scope. When a downstream scope depends on such an unmemoized always-invalidating value, the scope is pruned because it would re-execute on every render anyway. + +The optimization avoids wasted comparisons in the generated code. Without this pass, the compiler would emit dependency checks for scopes that will never cache-hit, adding runtime overhead with no benefit. By converting these scopes to `pruned-scope` nodes, the codegen emits the instructions inline without memoization guards. + +## Input Invariants +- The pass expects a `ReactiveFunction` with scopes already formed +- Scopes should have their `dependencies` populated with the identifiers they depend on +- The pass runs after `MergeReactiveScopesThatInvalidateTogether` +- Hook calls have already caused scope flattening via `FlattenScopesWithHooksOrUseHIR` + +## Output Guarantees +- Scopes that depend on unmemoized always-invalidating values are converted to `pruned-scope` nodes +- The `unmemoizedValues` set correctly propagates through `StoreLocal`/`LoadLocal` instructions +- All declarations and reassignments within pruned scopes that are themselves always-invalidating are added to `unmemoizedValues`, enabling cascading pruning of downstream scopes + +## Algorithm + +The pass uses a `ReactiveFunctionTransform` visitor with two key methods: + +### 1. `transformInstruction` - Tracks always-invalidating values: + +```typescript +switch (value.kind) { + case 'ArrayExpression': + case 'ObjectExpression': + case 'JsxExpression': + case 'JsxFragment': + case 'NewExpression': { + if (lvalue !== null) { + this.alwaysInvalidatingValues.add(lvalue.identifier); + if (!withinScope) { + this.unmemoizedValues.add(lvalue.identifier); // Key: only if outside a scope + } + } + break; + } + // Also propagates through StoreLocal and LoadLocal +} +``` + +### 2. `transformScope` - Prunes scopes with unmemoized dependencies: + +```typescript +for (const dep of scopeBlock.scope.dependencies) { + if (this.unmemoizedValues.has(dep.identifier)) { + // Propagate unmemoized status to scope outputs + for (const [_, decl] of scopeBlock.scope.declarations) { + if (this.alwaysInvalidatingValues.has(decl.identifier)) { + this.unmemoizedValues.add(decl.identifier); + } + } + return { + kind: 'replace', + value: { + kind: 'pruned-scope', + scope: scopeBlock.scope, + instructions: scopeBlock.instructions, + }, + }; + } +} +``` + +## Edge Cases + +### Function Calls Not Considered Always-Invalidating +The pass optimistically assumes function calls may return primitives, so `makeArray()` doesn't trigger pruning even though it might return a new array. + +### Conditional Allocations +Code like `x = cond ? [] : 42` doesn't trigger pruning because the value might be a primitive. + +### Propagation Through Locals +The pass correctly tracks values through `StoreLocal` and `LoadLocal` to handle variable reassignments and loads. + +### Cascading Pruning +When a scope is pruned, its always-invalidating outputs become unmemoized, potentially causing downstream scopes to be pruned as well. + +## TODOs +None in the source file. + +## Example + +### Fixture: `prune-scopes-whose-deps-invalidate-array.js` + +**Input:** +```javascript +function Component(props) { + const x = []; + useHook(); + x.push(props.value); + const y = [x]; + return [y]; +} +``` + +**After PruneAlwaysInvalidatingScopes** (from `yarn snap -p prune-scopes-whose-deps-invalidate-array.js -d`): +``` + scope @0 [1:14] dependencies=[] declarations=[x$21_@0] reassignments=[] { + [2] $20_@0 = Array [] + [3] StoreLocal Const x$21_@0 = $20_@0 + [4] $23 = LoadGlobal import { useHook } + [6] $24_@1 = Call $23() // Hook flattens scope + [7] break bb9 (implicit) + [8] $25_@0 = LoadLocal x$21_@0 + [9] $26 = PropertyLoad $25_@0.push + [10] $27 = LoadLocal props$19 + [11] $28 = PropertyLoad $27.value + [12] $29 = MethodCall $25_@0.$26($28) +} +[14] $30 = LoadLocal x$21_@0 + scope @2 [15:23] dependencies=[x$21_@0:TObject] declarations=[$35_@3] { + [16] $31_@2 = Array [$30] + [18] StoreLocal Const y$32 = $31_@2 + [19] $34 = LoadLocal y$32 + [21] $35_@3 = Array [$34] +} +[23] return $35_@3 +``` + +Key observations: +- Scope @0 is pruned because the hook call (`useHook()`) flattens it (hook rules prevent memoization around hooks) +- `x` is an `ArrayExpression` created in the pruned scope @0, making it unmemoized +- Scope @2 depends on `x$21_@0` which is unmemoized and always-invalidating (it's an array) +- Therefore, scope @2 is also pruned - cascading pruning + +**Generated Code:** +```javascript +function Component(props) { + const x = []; + useHook(); + x.push(props.value); + const y = [x]; + return [y]; +} +``` + +The output matches the input because all memoization was pruned - the code runs unconditionally on every render. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/28-propagateEarlyReturns.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/28-propagateEarlyReturns.md new file mode 100644 index 00000000000..7fd578eea77 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/28-propagateEarlyReturns.md @@ -0,0 +1,183 @@ +# propagateEarlyReturns + +## File +`src/ReactiveScopes/PropagateEarlyReturns.ts` + +## Purpose +The `propagateEarlyReturns` pass ensures that reactive scopes (memoization blocks) correctly honor the control flow behavior of the original code, particularly when a function returns early from within a reactive scope. Without this transformation, if a component returned early on the previous render and the inputs have not changed, the cached memoization block would be skipped entirely, but the early return would not occur, causing incorrect behavior. + +The pass solves this by transforming `return` statements inside reactive scopes into assignments to a temporary variable followed by a labeled `break`. After the reactive scope completes, generated code checks whether the early return sentinel value was replaced with an actual return value; if so, the function returns that value. + +## Input Invariants +1. **ReactiveFunction structure**: The input must be a `ReactiveFunction` with scopes already inferred (reactive scope blocks are already established) +2. **Scope earlyReturnValue not set**: The pass expects `scopeBlock.scope.earlyReturnValue === null` for scopes it processes +3. **Return statements within reactive scopes**: The pass specifically targets `return` terminal statements that appear within a `withinReactiveScope` context + +## Output Guarantees +1. **Labeled scope blocks**: Top-level reactive scopes containing early returns are wrapped in a labeled block (e.g., `bb14: { ... }`) +2. **Sentinel initialization**: At the start of each such scope, a temporary variable is initialized to `Symbol.for("react.early_return_sentinel")` +3. **Return-to-break transformation**: All `return` statements inside the scope are replaced with: + - An assignment of the return value to the early return temporary + - A `break` to the scope's label +4. **Early return declaration**: The temporary variable is registered as a declaration of the scope so it gets memoized +5. **Post-scope check**: During codegen, an if-statement is added after the scope to check if the temporary differs from the sentinel and return it if so + +## Algorithm + +The pass uses a visitor pattern with a `ReactiveFunctionTransform` that tracks two pieces of state: + +```typescript +type State = { + withinReactiveScope: boolean; // Are we inside a reactive scope? + earlyReturnValue: ReactiveScope['earlyReturnValue']; // Bubble up early return info +}; +``` + +### Key Steps: + +1. **visitScope** - When entering a reactive scope: + - Create an inner state with `withinReactiveScope: true` + - Traverse the scope's contents + - If any early returns were found (`earlyReturnValue !== null`): + - If this is the **outermost** scope (parent's `withinReactiveScope` is false): + - Store the early return info on the scope + - Add the temporary as a scope declaration + - Prepend sentinel initialization instructions + - Wrap the original instructions in a labeled block + - Otherwise, propagate the early return info to the parent scope + +2. **transformTerminal** - When encountering a `return` inside a reactive scope: + - Create or reuse an early return value identifier + - Replace the return with: + ```typescript + [ + {kind: 'instruction', /* StoreLocal: reassign earlyReturnValue = returnValue */}, + {kind: 'terminal', /* break to earlyReturnValue.label */} + ] + ``` + +### Sentinel Initialization Code (synthesized at scope start): +```typescript +// Load Symbol.for and call it with the sentinel string +let t0 = Symbol.for("react.early_return_sentinel"); +``` + +## Edge Cases + +### Nested Reactive Scopes +When early returns occur in nested scopes, only the **outermost** scope gets the labeled block wrapper. Inner scopes bubble their early return information up via `parentState.earlyReturnValue`. + +### Multiple Early Returns in Same Scope +All returns share the same temporary variable and label. The first return found creates the identifier, subsequent returns reuse it. + +### Partial Early Returns +When only some control flow paths return early (e.g., one branch returns, the other falls through), the sentinel check after the scope allows normal execution to continue if no early return occurred. + +### Already Processed Scopes +If `scopeBlock.scope.earlyReturnValue !== null` on entry, the pass exits early without modification. + +### Returns Outside Reactive Scopes +The pass only transforms returns where `state.withinReactiveScope === true`. Returns outside scopes are left unchanged. + +## TODOs +None in the source file. + +## Example + +### Fixture: `early-return-within-reactive-scope.js` + +**Input:** +```javascript +function Component(props) { + let x = []; + if (props.cond) { + x.push(props.a); + return x; + } else { + return makeArray(props.b); + } +} +``` + +**After PropagateEarlyReturns** (from `yarn snap -p early-return-within-reactive-scope.js -d`): +``` +scope @0 [...] earlyReturn={id: #t34$34, label: 14} { + [0] $36 = LoadGlobal(global) Symbol + [0] $37 = PropertyLoad $36.for + [0] $38 = "react.early_return_sentinel" + [0] $35 = MethodCall $36.$37($38) + [0] StoreLocal Let #t34$34{reactive} = $35 // Initialize sentinel + bb14: { + [2] $19_@0 = Array [] + [3] StoreLocal Const x$20_@0 = $19_@0 + [4] $22{reactive} = LoadLocal props$18 + [5] $23{reactive} = PropertyLoad $22.cond + [6] if ($23) { + [7] $24_@0 = LoadLocal x$20_@0 + [8] $25 = PropertyLoad $24_@0.push + [9] $26 = LoadLocal props$18 + [10] $27 = PropertyLoad $26.a + [11] $28 = MethodCall $24_@0.$25($27) + [12] $29 = LoadLocal x$20_@0 + [0] StoreLocal Reassign #t34$34 = $29 // was: return x + [0] break bb14 (labeled) + } else { + [14] $30 = LoadGlobal import { makeArray } + [15] $31 = LoadLocal props$18 + [16] $32 = PropertyLoad $31.b + scope @1 [...] { + [18] $33_@1 = Call $30($32) + } + [0] StoreLocal Reassign #t34$34 = $33_@1 // was: return makeArray(props.b) + [0] break bb14 (labeled) + } + } +} +``` + +Key observations: +- Scope @0 now has `earlyReturn={id: #t34$34, label: 14}` +- Sentinel initialization code is prepended to the scope +- The scope body is wrapped in `bb14: { ... }` +- Both `return x` and `return makeArray(props.b)` are transformed to `StoreLocal Reassign + break bb14` + +**Generated Code:** +```javascript +function Component(props) { + const $ = _c(6); + let t0; + if ($[0] !== props.a || $[1] !== props.b || $[2] !== props.cond) { + t0 = Symbol.for("react.early_return_sentinel"); + bb0: { + const x = []; + if (props.cond) { + x.push(props.a); + t0 = x; + break bb0; + } else { + let t1; + if ($[4] !== props.b) { + t1 = makeArray(props.b); + $[4] = props.b; + $[5] = t1; + } else { + t1 = $[5]; + } + t0 = t1; + break bb0; + } + } + $[0] = props.a; + $[1] = props.b; + $[2] = props.cond; + $[3] = t0; + } else { + t0 = $[3]; + } + if (t0 !== Symbol.for("react.early_return_sentinel")) { + return t0; + } +} +``` + +This transformation ensures that when inputs don't change, the cached return value is used and returned, preserving referential equality and correct early return behavior. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/29-promoteUsedTemporaries.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/29-promoteUsedTemporaries.md new file mode 100644 index 00000000000..a08c2bf154e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/29-promoteUsedTemporaries.md @@ -0,0 +1,203 @@ +# promoteUsedTemporaries + +## File +`src/ReactiveScopes/PromoteUsedTemporaries.ts` + +## Purpose +This pass promotes temporary variables (identifiers with no name) to named variables when they need to be referenced across scope boundaries or in code generation. Temporaries are intermediate values that the compiler creates during lowering; they are typically inlined at their use sites during codegen. However, some temporaries must be emitted as separate declarations - this pass identifies and names them. + +The pass ensures that: +1. Scope dependencies and declarations have proper names for codegen +2. Variables referenced across reactive scope boundaries are named +3. JSX tag identifiers get special naming (`T0`, `T1`, etc.) +4. Temporaries with interposing side-effects are promoted to preserve ordering + +## Input Invariants +- The ReactiveFunction has undergone scope construction and dependency propagation +- Identifiers may have `name === null` (temporaries) or be named +- Scopes have `dependencies`, `declarations`, and `reassignments` populated +- Pruned scopes are properly marked with `kind: 'pruned-scope'` + +## Output Guarantees +- All scope dependencies have non-null names +- All scope declarations have non-null names +- JSX tag temporaries use uppercase naming (`T0`, `T1`, ...) +- Regular temporaries use lowercase naming (`#t{id}`) +- All instances of a promoted identifier share the same name (via DeclarationId tracking) +- Temporaries with interposing mutating instructions are promoted to preserve source ordering + +## Algorithm + +The pass operates in four phases using visitor classes: + +### Phase 1: CollectPromotableTemporaries +Collects information about which temporaries may need promotion: + +```typescript +class CollectPromotableTemporaries { + // Tracks pruned scope declarations and whether they're used outside their scope + pruned: Map; usedOutsideScope: boolean}> + + // Tracks identifiers used as JSX tags (need uppercase names) + tags: Set +} +``` + +- When visiting a `JsxExpression`, adds the tag identifier to `tags` +- When visiting a `PrunedScope`, records its declarations +- Tracks when pruned declarations are used in different scopes + +### Phase 2: PromoteTemporaries +Promotes temporaries that appear in positions requiring names: + +```typescript +override visitScope(scopeBlock: ReactiveScopeBlock, state: State): void { + // Promote all dependencies without names + for (const dep of scopeBlock.scope.dependencies) { + if (identifier.name == null) { + promoteIdentifier(identifier, state); + } + } + // Promote all declarations without names + for (const [, declaration] of scopeBlock.scope.declarations) { + if (declaration.identifier.name == null) { + promoteIdentifier(declaration.identifier, state); + } + } +} +``` + +Also promotes: +- Function parameters without names +- Pruned scope declarations used outside their scope + +### Phase 3: PromoteInterposedTemporaries +Handles ordering-sensitive promotion: + +```typescript +class PromoteInterposedTemporaries { + // Instructions that emit as statements can interpose between temp defs and uses + // If such an instruction occurs, mark pending temporaries as needing promotion + + override visitInstruction(instruction: ReactiveInstruction, state: InterState): void { + // For instructions that become statements (calls, stores, etc.): + if (willBeStatement && !constStore) { + // Mark all pending temporaries as needing promotion + for (const [key, [ident, _]] of state.entries()) { + state.set(key, [ident, true]); // Mark as needing promotion + } + } + } +} +``` + +This preserves source ordering when side-effects occur between a temporary's definition and use. + +### Phase 4: PromoteAllInstancesOfPromotedTemporaries +Ensures all instances of a promoted identifier share the same name: + +```typescript +class PromoteAllInstancesOfPromotedTemporaries { + override visitPlace(_id: InstructionId, place: Place, state: State): void { + if (place.identifier.name === null && + state.promoted.has(place.identifier.declarationId)) { + promoteIdentifier(place.identifier, state); + } + } +} +``` + +### Naming Convention +```typescript +function promoteIdentifier(identifier: Identifier, state: State): void { + if (state.tags.has(identifier.declarationId)) { + promoteTemporaryJsxTag(identifier); // Uses #T{id} for JSX tags + } else { + promoteTemporary(identifier); // Uses #t{id} for regular temps + } + state.promoted.add(identifier.declarationId); +} +``` + +## Edge Cases + +### JSX Tag Temporaries +JSX tags require uppercase names to be valid JSX syntax. The pass tracks which temporaries are used as JSX tags and uses `T0`, `T1`, etc. instead of `t0`, `t1`. + +### Pruned Scope Declarations +Declarations in pruned scopes are only promoted if they're actually used outside the pruned scope, avoiding unnecessary variable declarations. + +### Const vs Let Temporaries +The pass tracks const identifiers specially - they don't need promotion for ordering purposes since they can't be mutated by interposing instructions. + +### Global Loads +Values loaded from globals (and their property loads) are treated as const-like for promotion purposes. + +### Method Call Properties +The property identifier in a method call is treated as const-like to avoid unnecessary promotion. + +## TODOs +None in the source file. + +## Example + +### Fixture: `simple.js` + +**Input:** +```javascript +export default function foo(x, y) { + if (x) { + return foo(false, y); + } + return [y * 10]; +} +``` + +**Before PromoteUsedTemporaries:** +``` +scope @0 [...] dependencies=[y$14] declarations=[$19_@0] +scope @1 [...] dependencies=[$22] declarations=[$23_@1] +``` + +**After PromoteUsedTemporaries:** +``` +scope @0 [...] dependencies=[y$14] declarations=[#t5$19_@0] +scope @1 [...] dependencies=[#t9$22] declarations=[#t10$23_@1] +``` + +Key observations: +- `$19_@0` is promoted to `#t5$19_@0` because it's a scope declaration +- `$22` is promoted to `#t9$22` because it's a scope dependency +- `$23_@1` is promoted to `#t10$23_@1` because it's a scope declaration +- The `#t` prefix indicates this is a promoted temporary (later renamed by `renameVariables`) + +**Generated Code:** +```javascript +import { c as _c } from "react/compiler-runtime"; +export default function foo(x, y) { + const $ = _c(4); + if (x) { + let t0; + if ($[0] !== y) { + t0 = foo(false, y); + $[0] = y; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; + } + const t0 = y * 10; + let t1; + if ($[2] !== t0) { + t1 = [t0]; + $[2] = t0; + $[3] = t1; + } else { + t1 = $[3]; + } + return t1; +} +``` + +The promoted temporaries (`#t5`, `#t9`, `#t10`) become the named variables (`t0`, `t1`) in the output after `renameVariables` runs. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/30-renameVariables.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/30-renameVariables.md new file mode 100644 index 00000000000..d643618d5a3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/30-renameVariables.md @@ -0,0 +1,200 @@ +# renameVariables + +## File +`src/ReactiveScopes/RenameVariables.ts` + +## Purpose +This pass ensures that every named variable in the function has a unique name that doesn't conflict with other variables in the same block scope or with global identifiers. After scope construction and temporary promotion, variables from different source scopes may end up in the same reactive block - this pass resolves any naming conflicts. + +The pass also converts the `#t{id}` promoted temporary names into clean output names like `t0`, `t1`, etc. + +## Input Invariants +- The ReactiveFunction has been through `promoteUsedTemporaries` +- Variables may have names that conflict with: + - Other variables in the same or ancestor block scope + - Global identifiers referenced by the function + - Promoted temporaries with `#t{id}` or `#T{id}` naming +- The function parameters have names (either from source or promoted) + +## Output Guarantees +- Every named variable has a unique name within its scope +- No variable shadows a global identifier referenced by the function +- Promoted temporaries are renamed to `t0`, `t1`, ... (for regular temps) +- Promoted JSX temporaries are renamed to `T0`, `T1`, ... (for JSX tags) +- Conflicting source names get disambiguated with `$` suffix (e.g., `foo$0`, `foo$1`) +- Returns a `Set` of all unique variable names in the function + +## Algorithm + +### Phase 1: Collect Referenced Globals +Uses `collectReferencedGlobals(fn)` to build a set of all global identifiers referenced by the function. Variable names must not conflict with these. + +### Phase 2: Rename with Scope Stack +The `Scopes` class maintains: + +```typescript +class Scopes { + #seen: Map = new Map(); // Canonical name for each declaration + #stack: Array> = [new Map()]; // Block scope stack + #globals: Set; // Global names to avoid + names: Set = new Set(); // All assigned names +} +``` + +### Renaming Logic +```typescript +visit(identifier: Identifier): void { + // Skip unnamed identifiers + if (originalName === null) return; + + // If we've already named this declaration, reuse that name + const mappedName = this.#seen.get(identifier.declarationId); + if (mappedName !== undefined) { + identifier.name = mappedName; + return; + } + + // Find a unique name + let name = originalName.value; + let id = 0; + + // Promoted temporaries start with t0/T0 + if (isPromotedTemporary(originalName.value)) { + name = `t${id++}`; + } else if (isPromotedJsxTemporary(originalName.value)) { + name = `T${id++}`; + } + + // Increment until we find a unique name + while (this.#lookup(name) !== null || this.#globals.has(name)) { + if (isPromotedTemporary(...)) { + name = `t${id++}`; + } else if (isPromotedJsxTemporary(...)) { + name = `T${id++}`; + } else { + name = `${originalName.value}$${id++}`; // foo$0, foo$1, etc. + } + } + + identifier.name = makeIdentifierName(name); + this.#seen.set(identifier.declarationId, identifier.name); +} +``` + +### Scope Management +```typescript +enter(fn: () => void): void { + this.#stack.push(new Map()); + fn(); + this.#stack.pop(); +} + +#lookup(name: string): DeclarationId | null { + // Search from innermost to outermost scope + for (let i = this.#stack.length - 1; i >= 0; i--) { + const entry = this.#stack[i].get(name); + if (entry !== undefined) return entry; + } + return null; +} +``` + +### Visitor Pattern +```typescript +class Visitor extends ReactiveFunctionVisitor { + override visitBlock(block: ReactiveBlock, state: Scopes): void { + state.enter(() => { + this.traverseBlock(block, state); + }); + } + + override visitScope(scope: ReactiveScopeBlock, state: Scopes): void { + // Visit scope declarations first + for (const [_, declaration] of scope.scope.declarations) { + state.visit(declaration.identifier); + } + this.traverseScope(scope, state); + } + + override visitPlace(id: InstructionId, place: Place, state: Scopes): void { + state.visit(place.identifier); + } +} +``` + +## Edge Cases + +### Shadowed Variables +When the compiler merges scopes that had shadowing in the source: +```javascript +function foo() { + const x = 1; + { + const x = 2; // Shadowed in source + } +} +``` +If both `x` declarations end up in the same compiled scope, they become `x` and `x$0`. + +### Global Name Conflicts +If a local variable would conflict with a referenced global: +```javascript +function foo() { + const Math = 1; // Conflicts with global Math if used +} +``` +The local gets renamed to `Math$0` if `Math` global is referenced. + +### Nested Functions +The pass recursively processes nested function expressions, entering a new scope for each function body. + +### Pruned Scopes +Pruned scopes don't create a new block scope in the output - the pass traverses their instructions without entering a new scope level. + +### DeclarationId Consistency +The pass uses `DeclarationId` to track which identifiers refer to the same variable, ensuring all references get the same renamed name. + +## TODOs +None in the source file. + +## Example + +### Fixture: `simple.js` + +**Before RenameVariables:** +``` +scope @0 [...] declarations=[#t5$19_@0] +scope @1 [...] dependencies=[#t9$22] declarations=[#t10$23_@1] +``` + +**After RenameVariables:** +``` +scope @0 [...] declarations=[t0$19_@0] +scope @1 [...] dependencies=[t0$22] declarations=[t1$23_@1] +``` + +Key observations: +- `#t5$19_@0` becomes `t0$19_@0` (first temporary in scope) +- `#t9$22` becomes `t0$22` (first temporary in a different block scope) +- `#t10$23_@1` becomes `t1$23_@1` (second temporary in that block) +- The `#t` prefix is removed and sequential numbering is applied + +**Generated Code:** +```javascript +export default function foo(x, y) { + const $ = _c(4); + if (x) { + let t0; // Was #t5 + if ($[0] !== y) { + t0 = foo(false, y); + // ... + } + return t0; + } + const t0 = y * 10; // Was #t9, reuses t0 since different block scope + let t1; // Was #t10 + // ... +} +``` + +The pass produces clean, readable output with minimal variable names while avoiding conflicts. diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/31-codegenReactiveFunction.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/31-codegenReactiveFunction.md new file mode 100644 index 00000000000..a2132990ff9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/31-codegenReactiveFunction.md @@ -0,0 +1,289 @@ +# codegenReactiveFunction + +## File +`src/ReactiveScopes/CodegenReactiveFunction.ts` + +## Purpose +This is the final pass that converts the ReactiveFunction representation back into a Babel AST. It generates the memoization code that makes React components and hooks efficient by: +1. Creating the `useMemoCache` call to allocate cache slots +2. Generating dependency comparisons to check if values have changed +3. Emitting conditional blocks that skip computation when cached values are valid +4. Storing computed values in the cache +5. Loading cached values when dependencies haven't changed + +## Input Invariants +- The ReactiveFunction has been through all prior passes +- All identifiers that need names have been promoted and renamed +- Reactive scopes have finalized `dependencies`, `declarations`, and `reassignments` +- Early returns have been transformed with sentinel values (via `propagateEarlyReturns`) +- Pruned scopes are marked with `kind: 'pruned-scope'` +- Unique identifiers set is available to avoid naming conflicts + +## Output Guarantees +- Returns a `CodegenFunction` with Babel AST `body` +- All reactive scopes become if-else blocks checking dependencies +- The `$` cache array is properly sized with `useMemoCache(n)` +- Each dependency and output gets its own cache slot +- Pruned scopes emit their instructions inline without memoization +- Early returns use the sentinel pattern with post-scope checks +- Statistics are collected: `memoSlotsUsed`, `memoBlocks`, `memoValues`, etc. + +## Algorithm + +### Entry Point: codegenFunction +```typescript +export function codegenFunction(fn: ReactiveFunction): Result { + const cx = new Context(...); + + // Optional: Fast Refresh source hash tracking + if (enableResetCacheOnSourceFileChanges) { + fastRefreshState = { cacheIndex: cx.nextCacheIndex, hash: sha256(source) }; + } + + const compiled = codegenReactiveFunction(cx, fn); + + // Prepend useMemoCache call if any cache slots used + if (cacheCount !== 0) { + body.unshift( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('$'), + t.callExpression(t.identifier('useMemoCache'), [t.numericLiteral(cacheCount)]) + ) + ]) + ); + } + + return compiled; +} +``` + +### Context Class +Tracks state during codegen: +```typescript +class Context { + #nextCacheIndex: number = 0; // Allocates cache slots + #declarations: Set = new Set(); // Tracks declared variables + temp: Temporaries; // Maps identifiers to their expressions + errors: CompilerError; + + get nextCacheIndex(): number { + return this.#nextCacheIndex++; // Returns and increments + } +} +``` + +### codegenReactiveScope +The core of memoization code generation: + +```typescript +function codegenReactiveScope(cx: Context, statements: Array, + scope: ReactiveScope, block: ReactiveBlock): void { + const changeExpressions: Array = []; + const cacheStoreStatements: Array = []; + const cacheLoadStatements: Array = []; + + // 1. Generate dependency checks + for (const dep of scope.dependencies) { + const index = cx.nextCacheIndex; + changeExpressions.push( + t.binaryExpression('!==', + t.memberExpression(t.identifier('$'), t.numericLiteral(index), true), + codegenDependency(cx, dep) + ) + ); + cacheStoreStatements.push( + t.assignmentExpression('=', $[index], dep) + ); + } + + // 2. Generate output cache slots + for (const {identifier} of scope.declarations) { + const index = cx.nextCacheIndex; + // Declare variable if not already declared + if (!cx.hasDeclared(identifier)) { + statements.push(t.variableDeclaration('let', [t.variableDeclarator(name, null)])); + } + cacheLoads.push({name, index, value: name}); + } + + // 3. Build test condition + let testCondition = changeExpressions.reduce((acc, expr) => + t.logicalExpression('||', acc, expr) + ); + + // 4. If no dependencies, use sentinel check + if (testCondition === null) { + testCondition = t.binaryExpression('===', + $[firstOutputIndex], + t.callExpression(Symbol.for, ['react.memo_cache_sentinel']) + ); + } + + // 5. Generate the memoization if-else + statements.push( + t.ifStatement( + testCondition, + computationBlock, // Compute + store in cache + cacheLoadBlock // Load from cache + ) + ); +} +``` + +### Generated Structure +For a scope with dependencies `[a, b]` and output `result`: + +```javascript +let result; +if ($[0] !== a || $[1] !== b) { + // Computation block + result = compute(a, b); + + // Store dependencies + $[0] = a; + $[1] = b; + + // Store output + $[2] = result; +} else { + // Load from cache + result = $[2]; +} +``` + +### Early Return Handling +When a scope has an early return (from `propagateEarlyReturns`): + +```typescript +// Before scope: initialize sentinel +t0 = Symbol.for("react.early_return_sentinel"); + +// Scope generates labeled block +bb0: { + // ... computation ... + if (cond) { + t0 = returnValue; + break bb0; + } +} + +// After scope: check for early return +if (t0 !== Symbol.for("react.early_return_sentinel")) { + return t0; +} +``` + +### Pruned Scopes +Pruned scopes emit their instructions inline without memoization: +```typescript +case 'pruned-scope': { + const scopeBlock = codegenBlockNoReset(cx, item.instructions); + statements.push(...scopeBlock.body); // Inline, no memoization + break; +} +``` + +## Edge Cases + +### Zero Dependencies +Scopes with no dependencies use a sentinel value check instead: +```javascript +if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + // First render only +} +``` + +### Fast Refresh / HMR +When `enableResetCacheOnSourceFileChanges` is enabled, the generated code includes a source hash check that resets the cache when the source changes: +```javascript +if ($[0] !== "source_hash_abc123") { + for (let $i = 0; $i < cacheCount; $i++) { + $[$i] = Symbol.for("react.memo_cache_sentinel"); + } + $[0] = "source_hash_abc123"; +} +``` + +### Change Detection for Debugging +When `enableChangeDetectionForDebugging` is configured, additional code is generated to detect when cached values unexpectedly change. + +### Labeled Breaks +Control flow with labeled breaks (for early returns or loop exits) uses `codegenLabel` to generate consistent label names: +```typescript +function codegenLabel(id: BlockId): string { + return `bb${id}`; // e.g., "bb0", "bb1" +} +``` + +### Nested Functions +Function expressions and object methods are recursively processed with their own contexts. + +### FBT/Internationalization +Special handling for FBT operands ensures they're memoized in the same scope for correct internationalization behavior. + +## Statistics Collected +```typescript +type CodegenFunction = { + memoSlotsUsed: number; // Total cache slots allocated + memoBlocks: number; // Number of reactive scopes + memoValues: number; // Total memoized values + prunedMemoBlocks: number; // Scopes that were pruned + prunedMemoValues: number; // Values in pruned scopes + hasInferredEffect: boolean; + hasFireRewrite: boolean; +}; +``` + +## TODOs +None in the source file. + +## Example + +### Fixture: `simple.js` + +**Input:** +```javascript +export default function foo(x, y) { + if (x) { + return foo(false, y); + } + return [y * 10]; +} +``` + +**Generated Code:** +```javascript +import { c as _c } from "react/compiler-runtime"; +export default function foo(x, y) { + const $ = _c(4); // Allocate 4 cache slots + if (x) { + let t0; + if ($[0] !== y) { // Check dependency + t0 = foo(false, y); // Compute + $[0] = y; // Store dependency + $[1] = t0; // Store output + } else { + t0 = $[1]; // Load from cache + } + return t0; + } + const t0 = y * 10; + let t1; + if ($[2] !== t0) { // Check dependency + t1 = [t0]; // Compute + $[2] = t0; // Store dependency + $[3] = t1; // Store output + } else { + t1 = $[3]; // Load from cache + } + return t1; +} +``` + +Key observations: +- `_c(4)` allocates 4 cache slots total +- First scope uses slots 0-1: slot 0 for `y` dependency, slot 1 for `t0` output +- Second scope uses slots 2-3: slot 2 for `t0` (the computed `y * 10`), slot 3 for `t1` (the array) +- Each scope has an if-else structure: compute/store vs load +- The memoization ensures referential equality of the returned array when `y` hasn't changed diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/32-transformFire.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/32-transformFire.md new file mode 100644 index 00000000000..26ee425ceea --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/32-transformFire.md @@ -0,0 +1,203 @@ +# transformFire + +## File +`src/Transform/TransformFire.ts` + +## Purpose +This pass transforms `fire(fn())` calls inside `useEffect` lambdas into calls to a `useFire` hook that provides stable function references. The `fire()` function is a React API that allows effect callbacks to call functions with their current values while maintaining stable effect dependencies. + +Without this transform, if an effect depends on a function that changes every render, the effect would re-run on every render. The `useFire` hook provides a stable wrapper that always calls the latest version of the function. + +## Input Invariants +- The `enableFire` feature flag must be enabled +- `fire()` calls must only appear inside `useEffect` lambdas +- Each `fire()` call must have exactly one argument (a function call expression) +- The function being fired must be consistent across all `fire()` calls in the same effect + +## Output Guarantees +- All `fire(fn(...args))` calls are replaced with direct calls `fired_fn(...args)` +- A `useFire(fn)` hook call is inserted before the `useEffect` +- The fired function is stored in a temporary and captured by the effect +- The original function `fn` is removed from the effect's captured context + +## Algorithm + +### Phase 1: Find Fire Calls +```typescript +function replaceFireFunctions(fn: HIRFunction, context: Context): void { + // For each useEffect call instruction: + // 1. Find all fire() calls in the effect lambda + // 2. Validate they have proper arguments + // 3. Track which functions are being fired + + for (const [, block] of fn.body.blocks) { + for (const instr of block.instructions) { + if (isUseEffectCall(instr)) { + const lambda = getEffectLambda(instr); + findAndReplaceFireCalls(lambda, fireFunctions); + } + } + } +} +``` + +### Phase 2: Insert useFire Hooks +For each function being fired, insert a `useFire` call: +```typescript +// Before: +useEffect(() => { + fire(foo(props)); +}, [foo, props]); + +// After: +const t0 = useFire(foo); +useEffect(() => { + t0(props); +}, [t0, props]); +``` + +### Phase 3: Replace Fire Calls +Transform `fire(fn(...args))` to `firedFn(...args)`: +```typescript +// The fire() wrapper is removed +// The inner function call uses the useFire'd version +fire(foo(x, y)) → t0(x, y) // where t0 = useFire(foo) +``` + +### Phase 4: Validate No Remaining Fire Uses +```typescript +function ensureNoMoreFireUses(fn: HIRFunction, context: Context): void { + // Ensure all fire() uses have been transformed + // Report errors for any remaining fire() calls +} +``` + +## Edge Cases + +### Fire Outside Effect +`fire()` calls outside `useEffect` lambdas cause a validation error: +```javascript +// ERROR: fire() can only be used inside useEffect +function Component() { + fire(callback()); +} +``` + +### Mixed Fire and Non-Fire Calls +All calls to the same function must either all use `fire()` or none: +```javascript +// ERROR: Cannot mix fire() and non-fire calls +useEffect(() => { + fire(foo(x)); + foo(y); // Error: foo is used with and without fire() +}); +``` + +### Multiple Arguments to Fire +`fire()` accepts exactly one argument (the function call): +```javascript +// ERROR: fire() takes exactly one argument +fire(foo, bar) // Invalid +fire() // Invalid +``` + +### Nested Effects +Fire calls in nested effects are validated separately: +```javascript +useEffect(() => { + useEffect(() => { // Error: nested effects not allowed + fire(foo()); + }); +}); +``` + +### Deep Scope Handling +The pass handles fire calls within deeply nested scopes inside effects: +```javascript +useEffect(() => { + if (cond) { + while (x) { + fire(foo(x)); // Still transformed correctly + } + } +}); +``` + +## TODOs +None in the source file. + +## Example + +### Fixture: `transform-fire/basic.js` + +**Input:** +```javascript +// @enableFire +function Component(props) { + const foo = (props_0) => { + console.log(props_0); + }; + useEffect(() => { + fire(foo(props)); + }); + return null; +} +``` + +**After TransformFire:** +``` +bb0 (block): + [1] $25 = Function @context[] ... // foo definition + [2] StoreLocal Const foo$32 = $25 + [3] $45 = LoadGlobal import { useFire } from 'react/compiler-runtime' + [4] $46 = LoadLocal foo$32 + [5] $47 = Call $45($46) // useFire(foo) + [6] StoreLocal Const #t44$44 = $47 + [7] $34 = LoadGlobal(global) useEffect + [8] $35 = Function @context[#t44$44, props$24] ... + <>(): + [1] $37 = LoadLocal #t44$44 // Load the fired function + [2] $38 = LoadLocal props$24 + [3] $39 = Call $37($38) // Call it directly (no fire wrapper) + [4] Return Void + [9] Call $34($35) // useEffect(lambda) + [10] Return null +``` + +**Generated Code:** +```javascript +import { useFire as _useFire } from "react/compiler-runtime"; +function Component(props) { + const $ = _c(4); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = (props_0) => { + console.log(props_0); + }; + $[0] = t0; + } else { + t0 = $[0]; + } + const foo = t0; + const t1 = _useFire(foo); + let t2; + if ($[1] !== props || $[2] !== t1) { + t2 = () => { + t1(props); + }; + $[1] = props; + $[2] = t1; + $[3] = t2; + } else { + t2 = $[3]; + } + useEffect(t2); + return null; +} +``` + +Key observations: +- `useFire` is imported from `react/compiler-runtime` +- `fire(foo(props))` becomes `t1(props)` where `t1 = _useFire(foo)` +- The effect now depends on `t1` (stable) and `props` (reactive) +- The original `foo` function is memoized and passed to `useFire` diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/33-lowerContextAccess.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/33-lowerContextAccess.md new file mode 100644 index 00000000000..e91bd3ec8e8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/33-lowerContextAccess.md @@ -0,0 +1,174 @@ +# lowerContextAccess + +## File +`src/Optimization/LowerContextAccess.ts` + +## Purpose +This pass optimizes `useContext` calls by generating selector functions that extract only the needed properties from the context. Instead of subscribing to the entire context object, components can subscribe to specific slices, enabling more granular re-rendering. + +When a component destructures specific properties from a context, this pass transforms the `useContext` call to use a selector-based API that only triggers re-renders when the selected properties change. + +## Input Invariants +- The `lowerContextAccess` configuration must be set with: + - `source`: The module to import the lowered context hook from + - `importSpecifierName`: The name of the hook function +- The function must use `useContext` with destructuring patterns +- Only object destructuring patterns with identifier values are supported + +## Output Guarantees +- `useContext(Ctx)` calls with destructuring are replaced with selector calls +- A selector function is generated that extracts the needed properties +- The return type is changed from object to array for positional access +- Unused original `useContext` calls are removed by dead code elimination + +## Algorithm + +### Phase 1: Collect Context Access Patterns +```typescript +function lowerContextAccess(fn: HIRFunction, config: ExternalFunction): void { + const contextAccess: Map = new Map(); + const contextKeys: Map> = new Map(); + + for (const [, block] of fn.body.blocks) { + for (const instr of block.instructions) { + // Find useContext calls + if (isUseContextCall(instr)) { + contextAccess.set(instr.lvalue.identifier.id, instr.value); + } + + // Find destructuring patterns that access context results + if (isDestructure(instr) && contextAccess.has(instr.value.value.id)) { + const keys = extractPropertyKeys(instr.value.pattern); + contextKeys.set(instr.value.value.id, keys); + } + } + } +} +``` + +### Phase 2: Generate Selector Functions +For each context access with known keys: +```typescript +// Original: +const {foo, bar} = useContext(MyContext); + +// Selector function generated: +(ctx) => [ctx.foo, ctx.bar] +``` + +### Phase 3: Transform Context Calls +```typescript +// Before: +$0 = useContext(MyContext) +{foo, bar} = $0 + +// After: +$0 = useContext_withSelector(MyContext, (ctx) => [ctx.foo, ctx.bar]) +[foo, bar] = $0 +``` + +### Phase 4: Update Destructuring +Change object destructuring to array destructuring to match selector return: +```typescript +// Before: { foo: foo$15, bar: bar$16 } = $14 +// After: [ foo$15, bar$16 ] = $14 +``` + +## Edge Cases + +### Dynamic Property Access +If context properties are accessed dynamically (not through destructuring), the optimization is skipped: +```javascript +const ctx = useContext(MyContext); +const x = ctx[dynamicKey]; // Cannot optimize +``` + +### Spread in Destructuring +Spread patterns prevent optimization: +```javascript +const {foo, ...rest} = useContext(MyContext); // Cannot optimize +``` + +### Non-Identifier Values +Only simple identifier destructuring is supported: +```javascript +const {foo: bar} = useContext(MyContext); // Supported (rename) +const {foo = defaultVal} = useContext(MyContext); // Not supported +``` + +### Multiple Context Accesses +Each `useContext` call is transformed independently: +```javascript +const {a} = useContext(CtxA); // Transformed +const {b} = useContext(CtxB); // Transformed separately +``` + +### Hook Guards +When `enableEmitHookGuards` is enabled, the selector function includes proper hook guard annotations. + +## TODOs +None in the source file. + +## Example + +### Fixture: `lower-context-selector-simple.js` + +**Input:** +```javascript +// @lowerContextAccess +function App() { + const {foo, bar} = useContext(MyContext); + return ; +} +``` + +**After OptimizePropsMethodCalls (where lowering happens):** +``` +bb0 (block): + [1] $12 = LoadGlobal(global) useContext // Original (now unused) + [2] $13 = LoadGlobal(global) MyContext + [3] $22 = LoadGlobal import { useContext_withSelector } from 'react-compiler-runtime' + [4] $36 = Function @context[] + <>(#t23$30): + [1] $31 = LoadLocal #t23$30 + [2] $32 = PropertyLoad $31.foo + [3] $33 = LoadLocal #t23$30 + [4] $34 = PropertyLoad $33.bar + [5] $35 = Array [$32, $34] // Return [foo, bar] + [6] Return $35 + [5] $14 = Call $22($13, $36) // useContext_withSelector(MyContext, selector) + [6] $17 = Destructure Const { foo: foo$15, bar: bar$16 } = $14 + ... +``` + +**Generated Code:** +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useContext_withSelector } from "react-compiler-runtime"; +function App() { + const $ = _c(2); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = (ctx) => [ctx.foo, ctx.bar]; + $[0] = t0; + } else { + t0 = $[0]; + } + const { foo, bar } = useContext_withSelector(MyContext, t0); + let t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = ; + $[1] = t1; + } else { + t1 = $[1]; + } + return t1; +} +``` + +Key observations: +- `useContext` is replaced with `useContext_withSelector` +- A selector function `(ctx) => [ctx.foo, ctx.bar]` is generated +- The selector function is memoized (first cache slot) +- Only `foo` and `bar` properties are extracted, enabling granular subscriptions +- The selector return type changes from object to array diff --git a/compiler/packages/babel-plugin-react-compiler/docs/passes/34-optimizePropsMethodCalls.md b/compiler/packages/babel-plugin-react-compiler/docs/passes/34-optimizePropsMethodCalls.md new file mode 100644 index 00000000000..942076af170 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/docs/passes/34-optimizePropsMethodCalls.md @@ -0,0 +1,132 @@ +# optimizePropsMethodCalls + +## File +`src/Optimization/OptimizePropsMethodCalls.ts` + +## Purpose +This pass converts method calls on the props object to regular function calls. Method calls like `props.onClick()` are transformed to `const t0 = props.onClick; t0()`. This normalization enables better analysis and optimization by the compiler. + +The transformation is important because method calls have different semantics than regular calls - the receiver (`props`) would normally be passed as `this` to the method. For React props, methods are typically just callback functions where `this` binding doesn't matter, so converting them to regular calls is safe and enables better memoization. + +## Input Invariants +- The function has been through type inference +- Props parameters are typed as `TObject` + +## Output Guarantees +- All `MethodCall` instructions where the receiver has props type are converted to `CallExpression` +- The method property becomes the callee of the call +- Arguments are preserved exactly + +## Algorithm + +```typescript +export function optimizePropsMethodCalls(fn: HIRFunction): void { + for (const [, block] of fn.body.blocks) { + for (let i = 0; i < block.instructions.length; i++) { + const instr = block.instructions[i]!; + + if ( + instr.value.kind === 'MethodCall' && + isPropsType(instr.value.receiver.identifier) + ) { + // Transform: props.onClick(arg) + // To: const t0 = props.onClick; t0(arg) + instr.value = { + kind: 'CallExpression', + callee: instr.value.property, // The method becomes the callee + args: instr.value.args, + loc: instr.value.loc, + }; + } + } + } +} + +function isPropsType(identifier: Identifier): boolean { + return ( + identifier.type.kind === 'Object' && + identifier.type.shapeId === BuiltInPropsId + ); +} +``` + +## Edge Cases + +### Non-Props Method Calls +Method calls on non-props objects are left unchanged: +```javascript +// Unchanged - array.map is not on props +array.map(x => x * 2) + +// Unchanged - obj is not props +obj.method() +``` + +### Props Type Detection +The pass uses type information to identify props: +```javascript +function Component(props) { + // props has type TObject + props.onClick(); // Transformed +} + +function Regular(obj) { + // obj has unknown type + obj.onClick(); // Not transformed +} +``` + +### Nested Props Access +Only direct method calls on props are transformed: +```javascript +props.onClick(); // Transformed +props.nested.onClick(); // Not transformed (receiver is props.nested, not props) +``` + +### Arrow Function Callbacks +Works with any method on props: +```javascript +props.onChange(value); // Transformed +props.onSubmit(data); // Transformed +props.validate(input); // Transformed +``` + +## TODOs +None in the source file. + +## Example + +### Fixture: Using props method + +**Input:** +```javascript +function Component(props) { + return - - - ); -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{}], -}; - -``` - - -## Error - -``` -Found 1 error: - -Error: Cannot access refs during render - -React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). - -error.ref-value-in-custom-component-event-handler-wrapper.ts:31:41 - 29 | <> - 30 | -> 31 | - | ^^^^^^^^ Passing a ref to a function may read its value during render - 32 | - 33 | - 34 | -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.expect.md deleted file mode 100644 index 718e2c81419..00000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.expect.md +++ /dev/null @@ -1,55 +0,0 @@ - -## Input - -```javascript -// @enableInferEventHandlers -import {useRef} from 'react'; - -// Simulates a handler wrapper -function handleClick(value: any) { - return () => { - console.log(value); - }; -} - -function Component() { - const ref = useRef(null); - - // This should still error: passing ref.current directly to a wrapper - // The ref value is accessed during render, not in the event handler - return ( - <> - - - - ); -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{}], -}; - -``` - - -## Error - -``` -Found 1 error: - -Error: Cannot access refs during render - -React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). - -error.ref-value-in-event-handler-wrapper.ts:19:35 - 17 | <> - 18 | -> 19 | - | ^^^^^^^^^^^ Cannot access ref value during render - 20 | - 21 | ); - 22 | } -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useCallback-set-ref-nested-property-ref-modified-later-preserve-memoization.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useCallback-set-ref-nested-property-ref-modified-later-preserve-memoization.expect.md index a0c492120a3..e063342e800 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useCallback-set-ref-nested-property-ref-modified-later-preserve-memoization.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useCallback-set-ref-nested-property-ref-modified-later-preserve-memoization.expect.md @@ -41,7 +41,7 @@ error.todo-useCallback-set-ref-nested-property-ref-modified-later-preserve-memoi 12 | 13 | // The ref is modified later, extending its range and preventing memoization of onChange > 14 | ref.current.inner = null; - | ^^^^^^^^^^^ Cannot update ref during render + | ^^^^^^^^^^^^^^^^^ Cannot update ref during render 15 | 16 | return ; 17 | } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useCallback-accesses-ref-mutated-later-via-function-preserve-memoization.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useCallback-accesses-ref-mutated-later-via-function-preserve-memoization.expect.md index ba5a7407773..9c7fec3b428 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useCallback-accesses-ref-mutated-later-via-function-preserve-memoization.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useCallback-accesses-ref-mutated-later-via-function-preserve-memoization.expect.md @@ -40,14 +40,14 @@ Error: Cannot access refs during render React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). -error.useCallback-accesses-ref-mutated-later-via-function-preserve-memoization.ts:17:2 - 15 | ref.current.inner = null; +error.useCallback-accesses-ref-mutated-later-via-function-preserve-memoization.ts:15:4 + 13 | // The ref is modified later, extending its range and preventing memoization of onChange + 14 | const reset = () => { +> 15 | ref.current.inner = null; + | ^^^^^^^^^^^^^^^^^ Cannot update ref during render 16 | }; -> 17 | reset(); - | ^^^^^ This function accesses a ref value + 17 | reset(); 18 | - 19 | return ; - 20 | } ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useCallback-set-ref-nested-property-dont-preserve-memoization.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useCallback-set-ref-nested-property-dont-preserve-memoization.expect.md index b40b0bbf226..3a9f61b35ae 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useCallback-set-ref-nested-property-dont-preserve-memoization.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.useCallback-set-ref-nested-property-dont-preserve-memoization.expect.md @@ -40,7 +40,7 @@ error.useCallback-set-ref-nested-property-dont-preserve-memoization.ts:13:2 11 | }); 12 | > 13 | ref.current.inner = null; - | ^^^^^^^^^^^ Cannot update ref during render + | ^^^^^^^^^^^^^^^^^ Cannot update ref during render 14 | 15 | return ; 16 | } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.validate-mutate-ref-arg-in-render.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.validate-mutate-ref-arg-in-render.expect.md deleted file mode 100644 index 1d5a4a2284c..00000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.validate-mutate-ref-arg-in-render.expect.md +++ /dev/null @@ -1,39 +0,0 @@ - -## Input - -```javascript -// @validateRefAccessDuringRender:true -function Foo(props, ref) { - console.log(ref.current); - return
{props.bar}
; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Foo, - params: [{bar: 'foo'}, {ref: {cuurrent: 1}}], - isComponent: true, -}; - -``` - - -## Error - -``` -Found 1 error: - -Error: Cannot access refs during render - -React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). - -error.validate-mutate-ref-arg-in-render.ts:3:14 - 1 | // @validateRefAccessDuringRender:true - 2 | function Foo(props, ref) { -> 3 | console.log(ref.current); - | ^^^^^^^^^^^ Passing a ref to a function may read its value during render - 4 | return
{props.bar}
; - 5 | } - 6 | -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps/error.invalid-exhaustive-deps.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps/error.invalid-exhaustive-deps.expect.md index 2c864f56aff..d5674691bb9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps/error.invalid-exhaustive-deps.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps/error.invalid-exhaustive-deps.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @validateExhaustiveMemoizationDependencies +// @validateExhaustiveMemoizationDependencies @validateRefAccessDuringRender:false import {useMemo} from 'react'; import {Stringify} from 'shared-runtime'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps/error.invalid-exhaustive-deps.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps/error.invalid-exhaustive-deps.js index c0f8d28837a..feba85da7d2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps/error.invalid-exhaustive-deps.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/exhaustive-deps/error.invalid-exhaustive-deps.js @@ -1,4 +1,4 @@ -// @validateExhaustiveMemoizationDependencies +// @validateExhaustiveMemoizationDependencies @validateRefAccessDuringRender:false import {useMemo} from 'react'; import {Stringify} from 'shared-runtime'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-indirect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-indirect.expect.md new file mode 100644 index 00000000000..4718ee7f203 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-indirect.expect.md @@ -0,0 +1,56 @@ + +## Input + +```javascript +// @validateNoImpureFunctionsInRender + +import {identity, makeArray} from 'shared-runtime'; + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses an impure value during render. The impurity is lost + * when passed through external function calls. + */ +function Component() { + const getDate = () => Date.now(); + const array = makeArray(getDate()); + const hasDate = identity(array); + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoImpureFunctionsInRender + +import { identity, makeArray } from "shared-runtime"; + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses an impure value during render. The impurity is lost + * when passed through external function calls. + */ +function Component() { + const $ = _c(1); + const getDate = _temp; + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const array = makeArray(getDate()); + const hasDate = identity(array); + t0 = ; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} +function _temp() { + return Date.now(); +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-indirect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-indirect.js new file mode 100644 index 00000000000..285838c7981 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-indirect.js @@ -0,0 +1,15 @@ +// @validateNoImpureFunctionsInRender + +import {identity, makeArray} from 'shared-runtime'; + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses an impure value during render. The impurity is lost + * when passed through external function calls. + */ +function Component() { + const getDate = () => Date.now(); + const array = makeArray(getDate()); + const hasDate = identity(array); + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-via-function-call-2.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-via-function-call-2.expect.md new file mode 100644 index 00000000000..b7ebee784d1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-via-function-call-2.expect.md @@ -0,0 +1,64 @@ + +## Input + +```javascript +// @validateNoImpureFunctionsInRender + +import {identity, makeArray} from 'shared-runtime'; + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses an impure value during render. The impurity is lost + * when passed through external function calls. + */ +function Component() { + const now = () => Date.now(); + const f = () => { + const array = makeArray(now()); + const hasDate = identity(array); + return hasDate; + }; + const hasDate = f(); + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoImpureFunctionsInRender + +import { identity, makeArray } from "shared-runtime"; + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses an impure value during render. The impurity is lost + * when passed through external function calls. + */ +function Component() { + const $ = _c(1); + const now = _temp; + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const f = () => { + const array = makeArray(now()); + const hasDate = identity(array); + return hasDate; + }; + const hasDate_0 = f(); + t0 = ; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} +function _temp() { + return Date.now(); +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-via-function-call-2.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-via-function-call-2.js new file mode 100644 index 00000000000..c34d65bd537 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/impure-functions-in-render-via-function-call-2.js @@ -0,0 +1,19 @@ +// @validateNoImpureFunctionsInRender + +import {identity, makeArray} from 'shared-runtime'; + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses an impure value during render. The impurity is lost + * when passed through external function calls. + */ +function Component() { + const now = () => Date.now(); + const f = () => { + const array = makeArray(now()); + const hasDate = identity(array); + return hasDate; + }; + const hasDate = f(); + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.expect.md index ea5a887b8bf..b585792f2cd 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.expect.md @@ -47,7 +47,7 @@ export const FIXTURE_ENTRYPOINT = { ## Logs ``` -{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":158},"end":{"line":11,"column":1,"index":331},"filename":"mutate-after-useeffect-ref-access.ts"},"detail":{"options":{"category":"Refs","reason":"Cannot access refs during render","description":"React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef)","details":[{"kind":"error","loc":{"start":{"line":9,"column":2,"index":289},"end":{"line":9,"column":16,"index":303},"filename":"mutate-after-useeffect-ref-access.ts"},"message":"Cannot update ref during render"}]}}} +{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":158},"end":{"line":11,"column":1,"index":331},"filename":"mutate-after-useeffect-ref-access.ts"},"detail":{"options":{"category":"Refs","reason":"Cannot access refs during render","description":"React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef)","details":[{"kind":"error","loc":{"start":{"line":9,"column":2,"index":289},"end":{"line":9,"column":20,"index":307},"filename":"mutate-after-useeffect-ref-access.ts"},"message":"Cannot update ref during render"}]}}} {"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":8,"column":2,"index":237},"end":{"line":8,"column":50,"index":285},"filename":"mutate-after-useeffect-ref-access.ts"},"decorations":[{"start":{"line":8,"column":24,"index":259},"end":{"line":8,"column":30,"index":265},"filename":"mutate-after-useeffect-ref-access.ts","identifierName":"arrRef"}]} {"kind":"CompileSuccess","fnLoc":{"start":{"line":6,"column":0,"index":158},"end":{"line":11,"column":1,"index":331},"filename":"mutate-after-useeffect-ref-access.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} ``` diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md index 1241971d827..aabc8d2baea 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md @@ -19,41 +19,65 @@ function Component() { ``` Found 3 errors: -Error: Cannot call impure function during render +Error: Cannot access impure value during render -`Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). +Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). + +error.invalid-impure-functions-in-render.ts:7:20 + 5 | const now = performance.now(); + 6 | const rand = Math.random(); +> 7 | return ; + | ^^^^ Cannot access impure value during render + 8 | } + 9 | error.invalid-impure-functions-in-render.ts:4:15 2 | 3 | function Component() { > 4 | const date = Date.now(); - | ^^^^^^^^^^ Cannot call impure function + | ^^^^^^^^^^ `Date.now` is an impure function. 5 | const now = performance.now(); 6 | const rand = Math.random(); 7 | return ; -Error: Cannot call impure function during render +Error: Cannot access impure value during render -`performance.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). +Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). + +error.invalid-impure-functions-in-render.ts:7:31 + 5 | const now = performance.now(); + 6 | const rand = Math.random(); +> 7 | return ; + | ^^^ Cannot access impure value during render + 8 | } + 9 | error.invalid-impure-functions-in-render.ts:5:14 3 | function Component() { 4 | const date = Date.now(); > 5 | const now = performance.now(); - | ^^^^^^^^^^^^^^^^^ Cannot call impure function + | ^^^^^^^^^^^^^^^^^ `performance.now` is an impure function. 6 | const rand = Math.random(); 7 | return ; 8 | } -Error: Cannot call impure function during render +Error: Cannot access impure value during render -`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). +Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). + +error.invalid-impure-functions-in-render.ts:7:42 + 5 | const now = performance.now(); + 6 | const rand = Math.random(); +> 7 | return ; + | ^^^^ Cannot access impure value during render + 8 | } + 9 | error.invalid-impure-functions-in-render.ts:6:15 4 | const date = Date.now(); 5 | const now = performance.now(); > 6 | const rand = Math.random(); - | ^^^^^^^^^^^^^ Cannot call impure function + | ^^^^^^^^^^^^^ `Math.random` is an impure function. 7 | return ; 8 | } 9 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md index acf9c28cabd..d79ac677067 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md @@ -47,7 +47,7 @@ export const FIXTURE_ENTRYPOINT = { ## Logs ``` -{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":190},"end":{"line":11,"column":1,"index":363},"filename":"mutate-after-useeffect-ref-access.ts"},"detail":{"options":{"category":"Refs","reason":"Cannot access refs during render","description":"React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef)","details":[{"kind":"error","loc":{"start":{"line":9,"column":2,"index":321},"end":{"line":9,"column":16,"index":335},"filename":"mutate-after-useeffect-ref-access.ts"},"message":"Cannot update ref during render"}]}}} +{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":190},"end":{"line":11,"column":1,"index":363},"filename":"mutate-after-useeffect-ref-access.ts"},"detail":{"options":{"category":"Refs","reason":"Cannot access refs during render","description":"React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef)","details":[{"kind":"error","loc":{"start":{"line":9,"column":2,"index":321},"end":{"line":9,"column":20,"index":339},"filename":"mutate-after-useeffect-ref-access.ts"},"message":"Cannot update ref during render"}]}}} {"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":8,"column":2,"index":269},"end":{"line":8,"column":50,"index":317},"filename":"mutate-after-useeffect-ref-access.ts"},"decorations":[{"start":{"line":8,"column":24,"index":291},"end":{"line":8,"column":30,"index":297},"filename":"mutate-after-useeffect-ref-access.ts","identifierName":"arrRef"}]} {"kind":"CompileSuccess","fnLoc":{"start":{"line":6,"column":0,"index":190},"end":{"line":11,"column":1,"index":363},"filename":"mutate-after-useeffect-ref-access.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} ``` diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/pass-ref-to-function.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/pass-ref-to-function.expect.md new file mode 100644 index 00000000000..df4aa13ddb8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/pass-ref-to-function.expect.md @@ -0,0 +1,45 @@ + +## Input + +```javascript +// @validateRefAccessDuringRender + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses a ref during render. The return type of foo() is unknown. + */ +function Component(props) { + const ref = useRef(null); + const x = foo(ref); + return x.current; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateRefAccessDuringRender + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses a ref during render. The return type of foo() is unknown. + */ +function Component(props) { + const $ = _c(1); + const ref = useRef(null); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = foo(ref); + $[0] = t0; + } else { + t0 = $[0]; + } + const x = t0; + return x.current; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/pass-ref-to-function.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/pass-ref-to-function.js new file mode 100644 index 00000000000..4bf588acbc3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/pass-ref-to-function.js @@ -0,0 +1,11 @@ +// @validateRefAccessDuringRender + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses a ref during render. The return type of foo() is unknown. + */ +function Component(props) { + const ref = useRef(null); + const x = foo(ref); + return x.current; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.maybe-mutable-ref-not-preserved.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.maybe-mutable-ref-not-preserved.expect.md deleted file mode 100644 index 77f104cab0c..00000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.maybe-mutable-ref-not-preserved.expect.md +++ /dev/null @@ -1,42 +0,0 @@ - -## Input - -```javascript -// @validatePreserveExistingMemoizationGuarantees:true - -import {useRef, useMemo} from 'react'; -import {makeArray} from 'shared-runtime'; - -function useFoo() { - const r = useRef(); - return useMemo(() => makeArray(r), []); -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [], -}; - -``` - - -## Error - -``` -Found 1 error: - -Error: Cannot access refs during render - -React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). - -error.maybe-mutable-ref-not-preserved.ts:8:33 - 6 | function useFoo() { - 7 | const r = useRef(); -> 8 | return useMemo(() => makeArray(r), []); - | ^ Passing a ref to a function may read its value during render - 9 | } - 10 | - 11 | export const FIXTURE_ENTRYPOINT = { -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.useMemo-with-refs.flow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.useMemo-with-refs.flow.expect.md deleted file mode 100644 index 0269b22a1f2..00000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.useMemo-with-refs.flow.expect.md +++ /dev/null @@ -1,37 +0,0 @@ - -## Input - -```javascript -// @flow @validatePreserveExistingMemoizationGuarantees -import {identity} from 'shared-runtime'; - -component Component(disableLocalRef, ref) { - const localRef = useFooRef(); - const mergedRef = useMemo(() => { - return disableLocalRef ? ref : identity(ref, localRef); - }, [disableLocalRef, ref, localRef]); - return
; -} - -``` - - -## Error - -``` -Found 1 error: - -Error: Cannot access refs during render - -React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). - - 5 | const localRef = useFooRef(); - 6 | const mergedRef = useMemo(() => { -> 7 | return disableLocalRef ? ref : identity(ref, localRef); - | ^^^ Passing a ref to a function may read its value during render - 8 | }, [disableLocalRef, ref, localRef]); - 9 | return
; - 10 | } -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/maybe-mutable-ref-not-preserved.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/maybe-mutable-ref-not-preserved.expect.md new file mode 100644 index 00000000000..f2201cdf288 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/maybe-mutable-ref-not-preserved.expect.md @@ -0,0 +1,59 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees:true + +import {useRef, useMemo} from 'react'; +import {makeArray} from 'shared-runtime'; + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses an impure value during render. + */ +function useFoo() { + const r = useRef(); + return useMemo(() => makeArray(r), []); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees:true + +import { useRef, useMemo } from "react"; +import { makeArray } from "shared-runtime"; + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses an impure value during render. + */ +function useFoo() { + const $ = _c(1); + const r = useRef(); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = makeArray(r); + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; + +``` + +### Eval output +(kind: ok) [{}] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.maybe-mutable-ref-not-preserved.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/maybe-mutable-ref-not-preserved.ts similarity index 69% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.maybe-mutable-ref-not-preserved.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/maybe-mutable-ref-not-preserved.ts index 13b8b445827..82751750504 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.maybe-mutable-ref-not-preserved.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/maybe-mutable-ref-not-preserved.ts @@ -3,6 +3,10 @@ import {useRef, useMemo} from 'react'; import {makeArray} from 'shared-runtime'; +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses an impure value during render. + */ function useFoo() { const r = useRef(); return useMemo(() => makeArray(r), []); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-with-refs.flow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-with-refs.flow.expect.md new file mode 100644 index 00000000000..26551994a3e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-with-refs.flow.expect.md @@ -0,0 +1,52 @@ + +## Input + +```javascript +// @flow @validatePreserveExistingMemoizationGuarantees +import {identity} from 'shared-runtime'; + +component Component(disableLocalRef, ref) { + const localRef = useFooRef(); + const mergedRef = useMemo(() => { + return disableLocalRef ? ref : identity(ref, localRef); + }, [disableLocalRef, ref, localRef]); + return
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { identity } from "shared-runtime"; + +const Component = React.forwardRef(Component_withRef); +function Component_withRef(t0, ref) { + const $ = _c(6); + const { disableLocalRef } = t0; + const localRef = useFooRef(); + let t1; + if ($[0] !== disableLocalRef || $[1] !== localRef || $[2] !== ref) { + t1 = disableLocalRef ? ref : identity(ref, localRef); + $[0] = disableLocalRef; + $[1] = localRef; + $[2] = ref; + $[3] = t1; + } else { + t1 = $[3]; + } + const mergedRef = t1; + let t2; + if ($[4] !== mergedRef) { + t2 =
; + $[4] = mergedRef; + $[5] = t2; + } else { + t2 = $[5]; + } + return t2; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.useMemo-with-refs.flow.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-with-refs.flow.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/error.useMemo-with-refs.flow.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-with-refs.flow.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call-2.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call-2.expect.md new file mode 100644 index 00000000000..6573a4b1cb5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call-2.expect.md @@ -0,0 +1,46 @@ + +## Input + +```javascript +//@flow +import {useRef} from 'react'; + +/** + * Allowed: we aren't sure that the ref.current value flows into the render + * output, so we optimistically assume it's safe + */ +component C() { + const r = useRef(null); + if (r.current == null) { + f(r); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: C, + params: [{}], +}; + +``` + +## Code + +```javascript +import { useRef } from "react"; + +function C() { + const r = useRef(null); + if (r.current == null) { + f(r); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: C, + params: [{}], +}; + +``` + +### Eval output +(kind: exception) f is not defined \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-initialization-call-2.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call-2.js similarity index 58% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-initialization-call-2.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call-2.js index 4e5a53cd3f6..8bfd4cd0e3c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-initialization-call-2.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call-2.js @@ -1,6 +1,10 @@ //@flow import {useRef} from 'react'; +/** + * Allowed: we aren't sure that the ref.current value flows into the render + * output, so we optimistically assume it's safe + */ component C() { const r = useRef(null); if (r.current == null) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call.expect.md new file mode 100644 index 00000000000..3502bff68d1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call.expect.md @@ -0,0 +1,46 @@ + +## Input + +```javascript +//@flow +import {useRef} from 'react'; + +/** + * Allowed: we aren't sure that the ref.current value flows into the render + * output, so we optimistically assume it's safe + */ +component C() { + const r = useRef(null); + if (r.current == null) { + f(r.current); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: C, + params: [{}], +}; + +``` + +## Code + +```javascript +import { useRef } from "react"; + +function C() { + const r = useRef(null); + if (r.current == null) { + f(r.current); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: C, + params: [{}], +}; + +``` + +### Eval output +(kind: exception) f is not defined \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-initialization-call.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call.js similarity index 59% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-initialization-call.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call.js index 50288fafc4a..82192c0681a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-initialization-call.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-call.js @@ -1,6 +1,10 @@ //@flow import {useRef} from 'react'; +/** + * Allowed: we aren't sure that the ref.current value flows into the render + * output, so we optimistically assume it's safe + */ component C() { const r = useRef(null); if (r.current == null) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-post-access-2.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-post-access-2.expect.md new file mode 100644 index 00000000000..eb30bb34445 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-post-access-2.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +//@flow +import {useRef} from 'react'; + +/** + * Allowed: we aren't sure that the ref.current value flows into the render + * output, so we optimistically assume it's safe + */ +component C() { + const r = useRef(null); + if (r.current == null) { + r.current = 1; + } + f(r.current); +} + +export const FIXTURE_ENTRYPOINT = { + fn: C, + params: [{}], +}; + +``` + +## Code + +```javascript +import { useRef } from "react"; + +function C() { + const r = useRef(null); + if (r.current == null) { + r.current = 1; + } + + f(r.current); +} + +export const FIXTURE_ENTRYPOINT = { + fn: C, + params: [{}], +}; + +``` + +### Eval output +(kind: exception) f is not defined \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-initialization-post-access-2.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-post-access-2.js similarity index 61% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-initialization-post-access-2.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-post-access-2.js index a8e3b124bfe..2a3e801b190 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-initialization-post-access-2.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-initialization-post-access-2.js @@ -1,6 +1,10 @@ //@flow import {useRef} from 'react'; +/** + * Allowed: we aren't sure that the ref.current value flows into the render + * output, so we optimistically assume it's safe + */ component C() { const r = useRef(null); if (r.current == null) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-custom-component-event-handler-wrapper.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-custom-component-event-handler-wrapper.expect.md new file mode 100644 index 00000000000..d67a02c57ec --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-custom-component-event-handler-wrapper.expect.md @@ -0,0 +1,121 @@ + +## Input + +```javascript +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates a custom component wrapper +function CustomForm({onSubmit, children}: any) { + return {children}; +} + +// Simulates react-hook-form's handleSubmit +function handleSubmit(callback: (data: T) => void) { + return (event: any) => { + event.preventDefault(); + callback({} as T); + }; +} + +function Component() { + const ref = useRef(null); + + const onSubmit = (data: any) => { + // Allowed: we aren't sure that the ref.current value flows into the render + // output, so we optimistically assume it's safe + 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"; // @enableInferEventHandlers +import { useRef } from "react"; + +// Simulates a custom component wrapper +function CustomForm(t0) { + const $ = _c(3); + const { onSubmit, children } = t0; + let t1; + if ($[0] !== children || $[1] !== onSubmit) { + t1 =
{children}
; + $[0] = children; + $[1] = onSubmit; + $[2] = t1; + } else { + t1 = $[2]; + } + return t1; +} + +// Simulates react-hook-form's handleSubmit +function handleSubmit(callback) { + const $ = _c(2); + let t0; + if ($[0] !== callback) { + t0 = (event) => { + event.preventDefault(); + callback({} as T); + }; + $[0] = callback; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +function Component() { + const $ = _c(1); + const ref = useRef(null); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const onSubmit = (data) => { + if (ref.current !== null) { + console.log(ref.current.value); + } + }; + t0 = ( + <> + + + + + + ); + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +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/error.ref-value-in-custom-component-event-handler-wrapper.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-custom-component-event-handler-wrapper.tsx similarity index 84% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-custom-component-event-handler-wrapper.tsx rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-custom-component-event-handler-wrapper.tsx index b90a1217165..5874c164558 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-custom-component-event-handler-wrapper.tsx +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-custom-component-event-handler-wrapper.tsx @@ -18,8 +18,8 @@ function Component() { const ref = useRef(null); const onSubmit = (data: any) => { - // This should error: passing function with ref access to custom component - // event handler, even though it would be safe on a native
+ // Allowed: we aren't sure that the ref.current value flows into the render + // output, so we optimistically assume it's safe if (ref.current !== null) { console.log(ref.current.value); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-event-handler-wrapper.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-event-handler-wrapper.expect.md new file mode 100644 index 00000000000..1289ade402e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-event-handler-wrapper.expect.md @@ -0,0 +1,83 @@ + +## Input + +```javascript +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates a handler wrapper +function handleClick(value: any) { + return () => { + console.log(value); + }; +} + +function Component() { + const ref = useRef(null); + + // Allowed: we aren't sure that the ref.current value flows into the render + // output, so we optimistically assume it's safe + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableInferEventHandlers +import { useRef } from "react"; + +// Simulates a handler wrapper +function handleClick(value) { + const $ = _c(2); + let t0; + if ($[0] !== value) { + t0 = () => { + console.log(value); + }; + $[0] = value; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +function Component() { + const $ = _c(1); + const ref = useRef(null); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = ( + <> + + + + ); + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +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/error.ref-value-in-event-handler-wrapper.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-event-handler-wrapper.tsx similarity index 74% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.tsx rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-event-handler-wrapper.tsx index 58313e560ce..64410473591 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.tsx +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/ref-value-in-event-handler-wrapper.tsx @@ -11,8 +11,8 @@ function handleClick(value: any) { function Component() { const ref = useRef(null); - // This should still error: passing ref.current directly to a wrapper - // The ref value is accessed during render, not in the event handler + // Allowed: we aren't sure that the ref.current value flows into the render + // output, so we optimistically assume it's safe return ( <> diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/return-ref-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/return-ref-callback.expect.md index ed1dfa39ea5..b5b58ab04c6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/return-ref-callback.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/return-ref-callback.expect.md @@ -6,7 +6,7 @@ import {useRef} from 'react'; -component Foo() { +hook useFoo() { const ref = useRef(); const s = () => { @@ -16,6 +16,10 @@ component Foo() { return s; } +component Foo() { + useFoo(); +} + export const FIXTURE_ENTRYPOINT = { fn: Foo, params: [], @@ -30,7 +34,7 @@ import { c as _c } from "react/compiler-runtime"; import { useRef } from "react"; -function Foo() { +function useFoo() { const $ = _c(1); const ref = useRef(); let t0; @@ -44,6 +48,10 @@ function Foo() { return s; } +function Foo() { + useFoo(); +} + export const FIXTURE_ENTRYPOINT = { fn: Foo, params: [], @@ -52,4 +60,4 @@ export const FIXTURE_ENTRYPOINT = { ``` ### Eval output -(kind: ok) "[[ function params=0 ]]" \ No newline at end of file +(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/return-ref-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/return-ref-callback.js index f1a45ebc4ff..502e7f42fcb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/return-ref-callback.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/return-ref-callback.js @@ -2,7 +2,7 @@ import {useRef} from 'react'; -component Foo() { +hook useFoo() { const ref = useRef(); const s = () => { @@ -12,6 +12,10 @@ component Foo() { return s; } +component Foo() { + useFoo(); +} + export const FIXTURE_ENTRYPOINT = { fn: Foo, params: [], diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/bailout-validate-ref-current-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/bailout-validate-ref-current-access.expect.md index b55526e9211..5e0efa3aa45 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/bailout-validate-ref-current-access.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/bailout-validate-ref-current-access.expect.md @@ -25,22 +25,40 @@ component Component(prop1, ref) { ## Code ```javascript -import { useFire } from "react/compiler-runtime"; +import { c as _c, useFire } from "react/compiler-runtime"; import { fire } from "react"; import { print } from "shared-runtime"; const Component = React.forwardRef(Component_withRef); function Component_withRef(t0, ref) { + const $ = _c(5); const { prop1 } = t0; - const foo = () => { - console.log(prop1); - }; - const t1 = useFire(foo); - useEffect(() => { - t1(prop1); - bar(); - t1(); - }); + let t1; + if ($[0] !== prop1) { + t1 = () => { + console.log(prop1); + }; + $[0] = prop1; + $[1] = t1; + } else { + t1 = $[1]; + } + const foo = t1; + const t2 = useFire(foo); + let t3; + if ($[2] !== prop1 || $[3] !== t2) { + t3 = () => { + t2(prop1); + bar(); + t2(); + }; + $[2] = prop1; + $[3] = t2; + $[4] = t3; + } else { + t3 = $[4]; + } + useEffect(t3); print(ref.current); return null; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-ref-added-to-dep-without-type-info.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-ref-added-to-dep-without-type-info.expect.md new file mode 100644 index 00000000000..f96ef187dda --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-ref-added-to-dep-without-type-info.expect.md @@ -0,0 +1,59 @@ + +## Input + +```javascript +// @validateRefAccessDuringRender + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses a ref during render. Type info is lost when ref is + * stored in an object field. + */ +function Foo({a}) { + const ref = useRef(); + const val = {ref}; + const x = {a, val: val.ref.current}; + + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateRefAccessDuringRender + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses a ref during render. Type info is lost when ref is + * stored in an object field. + */ +function Foo(t0) { + const $ = _c(3); + const { a } = t0; + const ref = useRef(); + let t1; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t1 = { ref }; + $[0] = t1; + } else { + t1 = $[0]; + } + const val = t1; + let t2; + if ($[1] !== a) { + const x = { a, val: val.ref.current }; + t2 = ; + $[1] = a; + $[2] = t2; + } else { + t2 = $[2]; + } + return t2; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-ref-added-to-dep-without-type-info.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-ref-added-to-dep-without-type-info.js new file mode 100644 index 00000000000..2d29c6f15fa --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-ref-added-to-dep-without-type-info.js @@ -0,0 +1,14 @@ +// @validateRefAccessDuringRender + +/** + * Allowed: we don't have sufficient type information to be sure that + * this accesses a ref during render. Type info is lost when ref is + * stored in an object field. + */ +function Foo({a}) { + const ref = useRef(); + const val = {ref}; + const x = {a, val: val.ref.current}; + + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-uncertain-impure-functions-in-render-via-render-helper.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-uncertain-impure-functions-in-render-via-render-helper.expect.md new file mode 100644 index 00000000000..e694e2c542d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-uncertain-impure-functions-in-render-via-render-helper.expect.md @@ -0,0 +1,51 @@ + +## Input + +```javascript +// @validateNoImpureFunctionsInRender + +import {identity, makeArray} from 'shared-runtime'; + +function Component() { + const now = Date.now(); + const renderItem = () => { + const array = makeArray(now); + // we don't have an alias signature for identity(), so we optimistically + // assume this doesn't propagate the impurity + const hasDate = identity(array); + return ; + }; + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoImpureFunctionsInRender + +import { identity, makeArray } from "shared-runtime"; + +function Component() { + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const now = Date.now(); + const renderItem = () => { + const array = makeArray(now); + const hasDate = identity(array); + return ; + }; + t0 = ; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-uncertain-impure-functions-in-render-via-render-helper.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-uncertain-impure-functions-in-render-via-render-helper.js new file mode 100644 index 00000000000..19028b87ddb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-uncertain-impure-functions-in-render-via-render-helper.js @@ -0,0 +1,15 @@ +// @validateNoImpureFunctionsInRender + +import {identity, makeArray} from 'shared-runtime'; + +function Component() { + const now = Date.now(); + const renderItem = () => { + const array = makeArray(now); + // we don't have an alias signature for identity(), so we optimistically + // assume this doesn't propagate the impurity + const hasDate = identity(array); + return ; + }; + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-use-impure-value-in-ref.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-use-impure-value-in-ref.expect.md new file mode 100644 index 00000000000..6e0bf7d018f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-use-impure-value-in-ref.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +// @validateNoImpureFunctionsInRender +import {useIdentity} from 'shared-runtime'; + +function Component() { + const f = () => Math.random(); + const ref = useRef(f()); + return
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoImpureFunctionsInRender +import { useIdentity } from "shared-runtime"; + +function Component() { + const $ = _c(2); + const f = _temp; + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = f(); + $[0] = t0; + } else { + t0 = $[0]; + } + const ref = useRef(t0); + let t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 =
; + $[1] = t1; + } else { + t1 = $[1]; + } + return t1; +} +function _temp() { + return Math.random(); +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-use-impure-value-in-ref.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-use-impure-value-in-ref.js new file mode 100644 index 00000000000..4002865548a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-use-impure-value-in-ref.js @@ -0,0 +1,8 @@ +// @validateNoImpureFunctionsInRender +import {useIdentity} from 'shared-runtime'; + +function Component() { + const f = () => Math.random(); + const ref = useRef(f()); + return
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/validate-mutate-ref-arg-in-render.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/validate-mutate-ref-arg-in-render.expect.md new file mode 100644 index 00000000000..4c2979ca3a7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/validate-mutate-ref-arg-in-render.expect.md @@ -0,0 +1,51 @@ + +## Input + +```javascript +// @validateRefAccessDuringRender:true + +function Foo(props, ref) { + // Allowed: the value is not guaranteed to flow into something that's rendered + console.log(ref.current); + return
{props.bar}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{bar: 'foo'}, {ref: {cuurrent: 1}}], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateRefAccessDuringRender:true + +function Foo(props, ref) { + const $ = _c(2); + + console.log(ref.current); + let t0; + if ($[0] !== props.bar) { + t0 =
{props.bar}
; + $[0] = props.bar; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ bar: "foo" }, { ref: { cuurrent: 1 } }], + isComponent: true, +}; + +``` + +### Eval output +(kind: ok)
foo
+logs: [undefined] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.validate-mutate-ref-arg-in-render.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/validate-mutate-ref-arg-in-render.js similarity index 75% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.validate-mutate-ref-arg-in-render.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/validate-mutate-ref-arg-in-render.js index 10218fc6163..55986513a19 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.validate-mutate-ref-arg-in-render.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/validate-mutate-ref-arg-in-render.js @@ -1,5 +1,7 @@ // @validateRefAccessDuringRender:true + function Foo(props, ref) { + // Allowed: the value is not guaranteed to flow into something that's rendered console.log(ref.current); return
{props.bar}
; } diff --git a/compiler/packages/eslint-plugin-react-compiler/__tests__/ImpureFunctionCallsRule-test.ts b/compiler/packages/eslint-plugin-react-compiler/__tests__/ImpureFunctionCallsRule-test.ts index f89b049d100..aa27b28822b 100644 --- a/compiler/packages/eslint-plugin-react-compiler/__tests__/ImpureFunctionCallsRule-test.ts +++ b/compiler/packages/eslint-plugin-react-compiler/__tests__/ImpureFunctionCallsRule-test.ts @@ -29,9 +29,9 @@ testRule( } `, errors: [ - makeTestCaseError('Cannot call impure function during render'), - makeTestCaseError('Cannot call impure function during render'), - makeTestCaseError('Cannot call impure function during render'), + makeTestCaseError('Cannot access impure value during render'), + makeTestCaseError('Cannot access impure value during render'), + makeTestCaseError('Cannot access impure value during render'), ], }, ], diff --git a/compiler/packages/eslint-plugin-react-compiler/__tests__/NoRefAccessInRender-tests.ts b/compiler/packages/eslint-plugin-react-compiler/__tests__/NoRefAccessInRender-tests.ts index 9953c8c2136..a1ffcf64c06 100644 --- a/compiler/packages/eslint-plugin-react-compiler/__tests__/NoRefAccessInRender-tests.ts +++ b/compiler/packages/eslint-plugin-react-compiler/__tests__/NoRefAccessInRender-tests.ts @@ -27,7 +27,7 @@ testRule( return value; } `, - errors: [makeTestCaseError('Cannot access refs during render')], + errors: [makeTestCaseError('Cannot access ref value during render')], }, ], }, From 2f65d3d545641841ba481331d4b2306cd592e7d4 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Fri, 23 Jan 2026 11:07:42 -0800 Subject: [PATCH 6/6] [compiler][wip] Support `finally` clauses First pass at adding support for `finally` clauses. I took a simple approach where we reify the JS semantics around return shadowing in the HIR and output. Ie, we create a temporary variable that will be the return value, and change returns in the try/catch to assign to that value and then break to the finalizer. In the finalizer returns work as normal, but we put a final extra "if temporary set, return it" statement. It would be more ideal to fully restore the return shadowing, but given that finally clauses are relatively rare this seems like a reasonable compromise. I need to do more analysis to make sure the approach is correct. --- .../src/HIR/BuildHIR.ts | 137 ++++++----- .../src/HIR/HIR.ts | 7 +- .../src/HIR/HIRBuilder.ts | 1 + .../src/HIR/PrintHIR.ts | 6 +- .../src/HIR/visitors.ts | 12 +- .../Inference/InferMutationAliasingEffects.ts | 6 +- .../ReactiveScopes/BuildReactiveFunction.ts | 28 ++- .../ReactiveScopes/CodegenReactiveFunction.ts | 21 +- .../ReactiveScopes/PrintReactiveFunction.ts | 24 +- .../src/ReactiveScopes/visitors.ts | 21 +- .../Validation/ValidateNoJSXInTryStatement.ts | 15 +- ...opes-reactive-scope-overlaps-try.expect.md | 1 + ...-catch-in-outer-try-with-finally.expect.md | 61 ----- ...-invalid-jsx-in-try-with-finally.expect.md | 44 ---- .../bailout-retry/error.todo-syntax.expect.md | 60 ----- .../bailout-retry/todo-syntax.expect.md | 79 +++++++ .../{error.todo-syntax.js => todo-syntax.js} | 0 ...in-catch-in-outer-try-with-catch.expect.md | 1 - ...-catch-in-outer-try-with-finally.expect.md | 56 +++++ ...jsx-in-catch-in-outer-try-with-finally.js} | 2 +- .../invalid-jsx-in-try-with-finally.expect.md | 42 ++++ ....js => invalid-jsx-in-try-with-finally.js} | 2 +- .../try-catch-mutate-outer-value.expect.md | 14 +- .../bailout-retry/error.todo-syntax.expect.md | 48 ---- ...-fire-todo-syntax-shouldnt-throw.expect.md | 43 ++-- .../bailout-retry/todo-syntax.expect.md | 74 ++++++ .../{error.todo-syntax.js => todo-syntax.js} | 0 .../compiler/try-catch-empty-try.expect.md | 7 +- .../compiler/try-catch-finally.expect.md | 56 +++++ .../fixtures/compiler/try-catch-finally.js | 21 ++ .../try-catch-mutate-outer-value.expect.md | 14 +- ...ry-catch-try-immediately-returns.expect.md | 8 +- ...hrows-after-constant-propagation.expect.md | 9 +- .../try-catch-within-mutable-range.expect.md | 12 +- .../compiler/try-finally-all-paths.expect.md | 213 ++++++++++++++++++ .../compiler/try-finally-all-paths.js | 66 ++++++ .../compiler/try-finally-basic.expect.md | 44 ++++ .../fixtures/compiler/try-finally-basic.js | 14 ++ .../try-finally-break-continue.expect.md | 71 ++++++ .../compiler/try-finally-break-continue.js | 22 ++ ...y-catch-fallthrough-to-finalizer.expect.md | 183 +++++++++++++++ ...-finally-catch-fallthrough-to-finalizer.js | 65 ++++++ ...ally-conditional-return-in-catch.expect.md | 147 ++++++++++++ ...try-finally-conditional-return-in-catch.js | 41 ++++ ...ly-conditional-return-in-finally.expect.md | 89 ++++++++ ...y-finally-conditional-return-in-finally.js | 33 +++ ...inally-conditional-return-in-try.expect.md | 88 ++++++++ .../try-finally-conditional-return-in-try.js | 33 +++ ...ally-return-in-catch-and-finally.expect.md | 102 +++++++++ ...try-finally-return-in-catch-and-finally.js | 28 +++ ...inally-return-in-try-and-finally.expect.md | 65 ++++++ .../try-finally-return-in-try-and-finally.js | 23 ++ .../try-finally-return-in-try.expect.md | 40 ++++ .../compiler/try-finally-return-in-try.js | 12 + 54 files changed, 1972 insertions(+), 339 deletions(-) delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-syntax.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/todo-syntax.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/{error.todo-syntax.js => todo-syntax.js} (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-finally.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.js => invalid-jsx-in-catch-in-outer-try-with-finally.js} (79%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-try-with-finally.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{error.todo-invalid-jsx-in-try-with-finally.js => invalid-jsx-in-try-with-finally.js} (63%) delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/error.todo-syntax.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/todo-syntax.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/{error.todo-syntax.js => todo-syntax.js} (100%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-finally.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-finally.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-all-paths.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-all-paths.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-basic.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-basic.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-break-continue.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-break-continue.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-catch-fallthrough-to-finalizer.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-catch-fallthrough-to-finalizer.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-catch.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-catch.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-finally.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-finally.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-try.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-try.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-catch-and-finally.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-catch-and-finally.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try-and-finally.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try-and-finally.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try.js 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 4a170cdbdf3..ad06157a884 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts @@ -1329,85 +1329,109 @@ function lowerStatement( const continuationBlock = builder.reserve('block'); const handlerPath = stmt.get('handler'); - if (!hasNode(handlerPath)) { + const finalizerPath = stmt.get('finalizer'); + + // Must have at least a catch or finally clause + if (!hasNode(handlerPath) && !hasNode(finalizerPath)) { builder.errors.push({ - reason: `(BuildHIR::lowerStatement) Handle TryStatement without a catch clause`, + reason: `(BuildHIR::lowerStatement) Handle TryStatement without a catch or finally clause`, category: ErrorCategory.Todo, loc: stmt.node.loc ?? null, suggestions: null, }); return; } - if (hasNode(stmt.get('finalizer'))) { - builder.errors.push({ - reason: `(BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause`, - category: ErrorCategory.Todo, - loc: stmt.node.loc ?? null, - suggestions: null, + + // Build finalizer (finally) block first, so we know its ID for control flow + let finalizer: BlockId | null = null; + if (hasNode(finalizerPath)) { + finalizer = builder.enter('block', _blockId => { + lowerStatement(builder, finalizerPath); + return { + kind: 'goto', + block: continuationBlock.id, + variant: GotoVariant.Break, + id: makeInstructionId(0), + loc: finalizerPath.node.loc ?? GeneratedSource, + }; }); } - const handlerBindingPath = handlerPath.get('param'); + // Determine the target for try/catch block exits + // If there's a finalizer, go there first; otherwise go to continuation + const afterTryCatchTarget = finalizer ?? continuationBlock.id; + + // Build handler (catch) block if present + let handler: BlockId | null = null; let handlerBinding: { place: Place; path: NodePath; } | null = null; - if (hasNode(handlerBindingPath)) { - const place: Place = { - kind: 'Identifier', - identifier: builder.makeTemporary( - handlerBindingPath.node.loc ?? GeneratedSource, - ), - effect: Effect.Unknown, - reactive: false, - loc: handlerBindingPath.node.loc ?? GeneratedSource, - }; - promoteTemporary(place.identifier); - lowerValueToTemporary(builder, { - kind: 'DeclareLocal', - lvalue: { - kind: InstructionKind.Catch, - place: {...place}, - }, - type: null, - loc: handlerBindingPath.node.loc ?? GeneratedSource, - }); - handlerBinding = { - path: handlerBindingPath, - place, - }; - } + if (hasNode(handlerPath)) { + const handlerBindingPath = handlerPath.get('param'); + if (hasNode(handlerBindingPath)) { + const place: Place = { + kind: 'Identifier', + identifier: builder.makeTemporary( + handlerBindingPath.node.loc ?? GeneratedSource, + ), + effect: Effect.Unknown, + reactive: false, + loc: handlerBindingPath.node.loc ?? GeneratedSource, + }; + promoteTemporary(place.identifier); + lowerValueToTemporary(builder, { + kind: 'DeclareLocal', + lvalue: { + kind: InstructionKind.Catch, + place: {...place}, + }, + type: null, + loc: handlerBindingPath.node.loc ?? GeneratedSource, + }); - const handler = builder.enter('catch', _blockId => { - if (handlerBinding !== null) { - lowerAssignment( - builder, - handlerBinding.path.node.loc ?? GeneratedSource, - InstructionKind.Catch, - handlerBinding.path, - {...handlerBinding.place}, - 'Assignment', - ); + handlerBinding = { + path: handlerBindingPath, + place, + }; } - lowerStatement(builder, handlerPath.get('body')); - return { - kind: 'goto', - block: continuationBlock.id, - variant: GotoVariant.Break, - id: makeInstructionId(0), - loc: handlerPath.node.loc ?? GeneratedSource, - }; - }); + + handler = builder.enter('catch', _blockId => { + if (handlerBinding !== null) { + lowerAssignment( + builder, + handlerBinding.path.node.loc ?? GeneratedSource, + InstructionKind.Catch, + handlerBinding.path, + {...handlerBinding.place}, + 'Assignment', + ); + } + lowerStatement(builder, handlerPath.get('body')); + return { + kind: 'goto', + block: afterTryCatchTarget, + variant: GotoVariant.Break, + id: makeInstructionId(0), + loc: handlerPath.node.loc ?? GeneratedSource, + }; + }); + } const block = builder.enter('block', _blockId => { const block = stmt.get('block'); - builder.enterTryCatch(handler, () => { + // If there's a handler, exceptions go there; otherwise they propagate normally + if (handler !== null) { + builder.enterTryCatch(handler, () => { + lowerStatement(builder, block); + }); + } else { lowerStatement(builder, block); - }); + } return { kind: 'goto', - block: continuationBlock.id, + block: afterTryCatchTarget, variant: GotoVariant.Try, id: makeInstructionId(0), loc: block.node.loc ?? GeneratedSource, @@ -1421,6 +1445,7 @@ function lowerStatement( handlerBinding: handlerBinding !== null ? {...handlerBinding.place} : null, handler, + finalizer, fallthrough: continuationBlock.id, id: makeInstructionId(0), loc: stmt.node.loc ?? GeneratedSource, 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 9e8dae0abd0..abca2cf3efb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -273,7 +273,8 @@ export type ReactiveTryTerminal = { kind: 'try'; block: ReactiveBlock; handlerBinding: Place | null; - handler: ReactiveBlock; + handler: ReactiveBlock | null; + finalizer: ReactiveBlock | null; id: InstructionId; loc: SourceLocation; }; @@ -602,8 +603,8 @@ export type TryTerminal = { kind: 'try'; block: BlockId; handlerBinding: Place | null; - handler: BlockId; - // TODO: support `finally` + handler: BlockId | null; + finalizer: BlockId | null; fallthrough: BlockId; id: InstructionId; loc: SourceLocation; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts index 1a2a56a7f79..7a7e81cbbfc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts @@ -954,6 +954,7 @@ export function removeUnnecessaryTryCatch(fn: HIR): void { for (const [, block] of fn.blocks) { if ( block.terminal.kind === 'try' && + block.terminal.handler !== null && !fn.blocks.has(block.terminal.handler) ) { const handlerId = block.terminal.handler; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts index 0fcbb8c4de4..9593f7da631 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts @@ -310,12 +310,14 @@ export function printTerminal(terminal: Terminal): Array | string { break; } case 'try': { - value = `[${terminal.id}] Try block=bb${terminal.block} handler=bb${ - terminal.handler + value = `[${terminal.id}] Try block=bb${terminal.block}${ + terminal.handler !== null ? ` handler=bb${terminal.handler}` : '' }${ terminal.handlerBinding !== null ? ` handlerBinding=(${printPlace(terminal.handlerBinding)})` : '' + }${ + terminal.finalizer !== null ? ` finalizer=bb${terminal.finalizer}` : '' } fallthrough=${ terminal.fallthrough != null ? `bb${terminal.fallthrough}` : '' }`; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts index 5735358cecf..8e4a0d2eda9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts @@ -921,13 +921,17 @@ export function mapTerminalSuccessors( } case 'try': { const block = fn(terminal.block); - const handler = fn(terminal.handler); + const handler = + terminal.handler !== null ? fn(terminal.handler) : null; + const finalizer = + terminal.finalizer !== null ? fn(terminal.finalizer) : null; const fallthrough = fn(terminal.fallthrough); return { kind: 'try', block, handlerBinding: terminal.handlerBinding, handler, + finalizer, fallthrough, id: makeInstructionId(0), loc: terminal.loc, @@ -1088,6 +1092,12 @@ export function* eachTerminalSuccessor(terminal: Terminal): Iterable { } case 'try': { yield terminal.block; + if (terminal.handler !== null) { + yield terminal.handler; + } + if (terminal.finalizer !== null) { + yield terminal.finalizer; + } break; } case 'scope': diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts index 98ab8a03a52..78261cb4058 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts @@ -522,7 +522,11 @@ function inferBlock( instr.effects = effects; } const terminal = block.terminal; - if (terminal.kind === 'try' && terminal.handlerBinding != null) { + if ( + terminal.kind === 'try' && + terminal.handler !== null && + terminal.handlerBinding != null + ) { context.catchHandlers.set(terminal.handler, terminal.handlerBinding); } else if (terminal.kind === 'maybe-throw') { const handlerParam = context.catchHandlers.get(terminal.handler); diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts index 2574116234a..fdc6f3bb496 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts @@ -844,14 +844,33 @@ class Driver { const scheduleId = this.cx.schedule(fallthroughId, 'if'); scheduleIds.push(scheduleId); } - this.cx.scheduleCatchHandler(terminal.handler); + if (terminal.handler !== null) { + this.cx.scheduleCatchHandler(terminal.handler); + } + let finalizerScheduleId: number | null = null; + if (terminal.finalizer !== null) { + finalizerScheduleId = this.cx.schedule(terminal.finalizer, 'if'); + scheduleIds.push(finalizerScheduleId); + } const block = this.traverseBlock( this.cx.ir.blocks.get(terminal.block)!, ); - const handler = this.traverseBlock( - this.cx.ir.blocks.get(terminal.handler)!, - ); + const handler = + terminal.handler !== null + ? this.traverseBlock(this.cx.ir.blocks.get(terminal.handler)!) + : null; + + // Unschedule finalizer before traversing it, so its own goto + // doesn't become a break to itself + if (finalizerScheduleId !== null) { + this.cx.unschedule(finalizerScheduleId); + scheduleIds.pop(); + } + const finalizer = + terminal.finalizer !== null + ? this.traverseBlock(this.cx.ir.blocks.get(terminal.finalizer)!) + : null; this.cx.unscheduleAll(scheduleIds); blockValue.push({ @@ -864,6 +883,7 @@ class Driver { block, handlerBinding: terminal.handlerBinding, handler, + finalizer, id: terminal.id, }, }); 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 123a69afc21..74e549d43c9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -1307,15 +1307,26 @@ function codegenTerminal( return codegenBlock(cx, terminal.block); } case 'try': { - let catchParam = null; - if (terminal.handlerBinding !== null) { - catchParam = convertIdentifier(terminal.handlerBinding.identifier); - cx.temp.set(terminal.handlerBinding.identifier.declarationId, null); + let catchClause: t.CatchClause | null = null; + if (terminal.handler !== null) { + let catchParam = null; + if (terminal.handlerBinding !== null) { + catchParam = convertIdentifier(terminal.handlerBinding.identifier); + cx.temp.set(terminal.handlerBinding.identifier.declarationId, null); + } + catchClause = t.catchClause(catchParam, codegenBlock(cx, terminal.handler)); } + + const finalizer = + terminal.finalizer !== null + ? codegenBlock(cx, terminal.finalizer) + : null; + return createTryStatement( terminal.loc, codegenBlock(cx, terminal.block), - t.catchClause(catchParam, codegenBlock(cx, terminal.handler)), + catchClause, + finalizer, ); } default: { diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction.ts index 60287b3cde9..55c57d0cf01 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PrintReactiveFunction.ts @@ -395,14 +395,24 @@ function writeTerminal(writer: Writer, terminal: ReactiveTerminal): void { case 'try': { writer.writeLine(`[${terminal.id}] try {`); writeReactiveInstructions(writer, terminal.block); - writer.write(`} catch `); - if (terminal.handlerBinding !== null) { - writer.writeLine(`(${printPlace(terminal.handlerBinding)}) {`); - } else { - writer.writeLine(`{`); + if (terminal.handler !== null) { + writer.write(`} catch `); + if (terminal.handlerBinding !== null) { + writer.writeLine(`(${printPlace(terminal.handlerBinding)}) {`); + } else { + writer.writeLine(`{`); + } + writeReactiveInstructions(writer, terminal.handler); + writer.writeLine('}'); + } + if (terminal.finalizer !== null) { + writer.writeLine(`} finally {`); + writeReactiveInstructions(writer, terminal.finalizer); + writer.writeLine('}'); + } + if (terminal.handler === null && terminal.finalizer === null) { + writer.writeLine('}'); } - writeReactiveInstructions(writer, terminal.handler); - writer.writeLine('}'); break; } default: diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/visitors.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/visitors.ts index 4ad05aa302a..a8e42755956 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/visitors.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/visitors.ts @@ -169,7 +169,12 @@ export class ReactiveFunctionVisitor { } case 'try': { this.visitBlock(terminal.block, state); - this.visitBlock(terminal.handler, state); + if (terminal.handler !== null) { + this.visitBlock(terminal.handler, state); + } + if (terminal.finalizer !== null) { + this.visitBlock(terminal.finalizer, state); + } break; } default: { @@ -559,7 +564,12 @@ export class ReactiveFunctionTransform< if (terminal.handlerBinding !== null) { this.visitPlace(terminal.id, terminal.handlerBinding, state); } - this.visitBlock(terminal.handler, state); + if (terminal.handler !== null) { + this.visitBlock(terminal.handler, state); + } + if (terminal.finalizer !== null) { + this.visitBlock(terminal.finalizer, state); + } break; } default: { @@ -653,7 +663,12 @@ export function mapTerminalBlocks( } case 'try': { terminal.block = fn(terminal.block); - terminal.handler = fn(terminal.handler); + if (terminal.handler !== null) { + terminal.handler = fn(terminal.handler); + } + if (terminal.finalizer !== null) { + terminal.finalizer = fn(terminal.finalizer); + } break; } default: { diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoJSXInTryStatement.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoJSXInTryStatement.ts index 00ffca556f4..4eb002d311c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoJSXInTryStatement.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoJSXInTryStatement.ts @@ -27,9 +27,8 @@ export function validateNoJSXInTryStatement( const activeTryBlocks: Array = []; const errors = new CompilerError(); for (const [, block] of fn.body.blocks) { - retainWhere(activeTryBlocks, id => id !== block.id); - - if (activeTryBlocks.length !== 0) { + // Check for JSX BEFORE removing the current block from activeTryBlocks + if (activeTryBlocks.includes(block.id)) { for (const instr of block.instructions) { const {value} = instr; switch (value.kind) { @@ -52,8 +51,16 @@ export function validateNoJSXInTryStatement( } } + // Remove the current block from activeTryBlocks after checking + retainWhere(activeTryBlocks, id => id !== block.id); + if (block.terminal.kind === 'try') { - activeTryBlocks.push(block.terminal.handler); + // Add the try block itself to activeTryBlocks + activeTryBlocks.push(block.terminal.block); + // Also add handler if present + if (block.terminal.handler !== null) { + activeTryBlocks.push(block.terminal.handler); + } } } return errors.asResult(); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md index 1bc6b9b51eb..10980dd63a9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md @@ -37,6 +37,7 @@ function useFoo(t0) { const { value } = t0; let items; if ($[0] !== value) { + items = null; try { items = []; arrayPush(items, value); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.expect.md deleted file mode 100644 index b5c079f5423..00000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.expect.md +++ /dev/null @@ -1,61 +0,0 @@ - -## Input - -```javascript -// @validateNoJSXInTryStatements @outputMode:"lint" -import {identity} from 'shared-runtime'; - -function Component(props) { - let el; - try { - let value; - try { - value = identity(props.foo); - } catch { - el =
; - } - } finally { - console.log(el); - } - return el; -} - -``` - - -## Error - -``` -Found 1 error: - -Todo: (BuildHIR::lowerStatement) Handle TryStatement without a catch clause - -error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.ts:6:2 - 4 | function Component(props) { - 5 | let el; -> 6 | try { - | ^^^^^ -> 7 | let value; - | ^^^^^^^^^^^^^^ -> 8 | try { - | ^^^^^^^^^^^^^^ -> 9 | value = identity(props.foo); - | ^^^^^^^^^^^^^^ -> 10 | } catch { - | ^^^^^^^^^^^^^^ -> 11 | el =
; - | ^^^^^^^^^^^^^^ -> 12 | } - | ^^^^^^^^^^^^^^ -> 13 | } finally { - | ^^^^^^^^^^^^^^ -> 14 | console.log(el); - | ^^^^^^^^^^^^^^ -> 15 | } - | ^^^^ (BuildHIR::lowerStatement) Handle TryStatement without a catch clause - 16 | return el; - 17 | } - 18 | -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.expect.md deleted file mode 100644 index 79ae59e64c1..00000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.expect.md +++ /dev/null @@ -1,44 +0,0 @@ - -## Input - -```javascript -// @validateNoJSXInTryStatements @outputMode:"lint" -function Component(props) { - let el; - try { - el =
; - } finally { - console.log(el); - } - return el; -} - -``` - - -## Error - -``` -Found 1 error: - -Todo: (BuildHIR::lowerStatement) Handle TryStatement without a catch clause - -error.todo-invalid-jsx-in-try-with-finally.ts:4:2 - 2 | function Component(props) { - 3 | let el; -> 4 | try { - | ^^^^^ -> 5 | el =
; - | ^^^^^^^^^^^^^^^^^ -> 6 | } finally { - | ^^^^^^^^^^^^^^^^^ -> 7 | console.log(el); - | ^^^^^^^^^^^^^^^^^ -> 8 | } - | ^^^^ (BuildHIR::lowerStatement) Handle TryStatement without a catch clause - 9 | return el; - 10 | } - 11 | -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-syntax.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-syntax.expect.md deleted file mode 100644 index 00af7ec6ad5..00000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-syntax.expect.md +++ /dev/null @@ -1,60 +0,0 @@ - -## Input - -```javascript -// @inferEffectDependencies @panicThreshold:"none" -import {useSpecialEffect} from 'shared-runtime'; -import {AUTODEPS} from 'react'; - -/** - * Note that a react compiler-based transform still has limitations on JS syntax. - * We should surface these as actionable lint / build errors to devs. - */ -function Component({prop1}) { - 'use memo'; - useSpecialEffect( - () => { - try { - console.log(prop1); - } finally { - console.log('exiting'); - } - }, - [prop1], - AUTODEPS - ); - return
{prop1}
; -} - -``` - - -## Error - -``` -Found 1 error: - -Error: Cannot infer dependencies of this effect. This will break your build! - -To resolve, either pass a dependency array or fix reported compiler bailout diagnostics Todo: (BuildHIR::lowerStatement) Handle TryStatement without a catch clause (13:6). - -error.todo-syntax.ts:11:2 - 9 | function Component({prop1}) { - 10 | 'use memo'; -> 11 | useSpecialEffect( - | ^^^^^^^^^^^^^^^^^ -> 12 | () => { - | ^^^^^^^^^^^ -> 13 | try { - … - | ^^^^^^^^^^^ -> 20 | AUTODEPS - | ^^^^^^^^^^^ -> 21 | ); - | ^^^^ Cannot infer dependencies - 22 | return
{prop1}
; - 23 | } - 24 | -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/todo-syntax.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/todo-syntax.expect.md new file mode 100644 index 00000000000..cd9b0d3bfb2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/todo-syntax.expect.md @@ -0,0 +1,79 @@ + +## Input + +```javascript +// @inferEffectDependencies @panicThreshold:"none" +import {useSpecialEffect} from 'shared-runtime'; +import {AUTODEPS} from 'react'; + +/** + * Note that a react compiler-based transform still has limitations on JS syntax. + * We should surface these as actionable lint / build errors to devs. + */ +function Component({prop1}) { + 'use memo'; + useSpecialEffect( + () => { + try { + console.log(prop1); + } finally { + console.log('exiting'); + } + }, + [prop1], + AUTODEPS + ); + return
{prop1}
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies @panicThreshold:"none" +import { useSpecialEffect } from "shared-runtime"; +import { AUTODEPS } from "react"; + +/** + * Note that a react compiler-based transform still has limitations on JS syntax. + * We should surface these as actionable lint / build errors to devs. + */ +function Component(t0) { + "use memo"; + const $ = _c(5); + const { prop1 } = t0; + let t1; + let t2; + if ($[0] !== prop1) { + t1 = () => { + try { + console.log(prop1); + } finally { + console.log("exiting"); + } + }; + t2 = [prop1]; + $[0] = prop1; + $[1] = t1; + $[2] = t2; + } else { + t1 = $[1]; + t2 = $[2]; + } + useSpecialEffect(t1, t2, [prop1]); + let t3; + if ($[3] !== prop1) { + t3 =
{prop1}
; + $[3] = prop1; + $[4] = t3; + } else { + t3 = $[4]; + } + return t3; +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-syntax.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/todo-syntax.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-syntax.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/todo-syntax.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-catch.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-catch.expect.md index 6ac06c1df23..935c5e2f566 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-catch.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-catch.expect.md @@ -48,7 +48,6 @@ function Component(props) { ## Logs ``` -{"kind":"CompileError","detail":{"options":{"category":"ErrorBoundaries","reason":"Avoid constructing JSX within try/catch","description":"React does not immediately render components when JSX is rendered, so any errors from this component will not be caught by the try/catch. To catch errors in rendering a given component, wrap that component in an error boundary. (https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)","details":[{"kind":"error","loc":{"start":{"line":11,"column":11,"index":241},"end":{"line":11,"column":32,"index":262},"filename":"invalid-jsx-in-catch-in-outer-try-with-catch.ts"},"message":"Avoid constructing JSX within try/catch"}]}},"fnLoc":null} {"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":110},"end":{"line":17,"column":1,"index":317},"filename":"invalid-jsx-in-catch-in-outer-try-with-catch.ts"},"fnName":"Component","memoSlots":4,"memoBlocks":2,"memoValues":2,"prunedMemoBlocks":0,"prunedMemoValues":0} ``` diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-finally.expect.md new file mode 100644 index 00000000000..2d431d40a7d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-finally.expect.md @@ -0,0 +1,56 @@ + +## Input + +```javascript +// @loggerTestOnly @validateNoJSXInTryStatements @outputMode:"lint" +import {identity} from 'shared-runtime'; + +function Component(props) { + let el; + try { + let value; + try { + value = identity(props.foo); + } catch { + el =
; + } + } finally { + console.log(el); + } + return el; +} + +``` + +## Code + +```javascript +// @loggerTestOnly @validateNoJSXInTryStatements @outputMode:"lint" +import { identity } from "shared-runtime"; + +function Component(props) { + let el; + try { + let value; + try { + value = identity(props.foo); + } catch { + el =
; + } + } finally { + console.log(el); + } + return el; +} + +``` + +## Logs + +``` +{"kind":"CompileError","detail":{"options":{"category":"ErrorBoundaries","reason":"Avoid constructing JSX within try/catch","description":"React does not immediately render components when JSX is rendered, so any errors from this component will not be caught by the try/catch. To catch errors in rendering a given component, wrap that component in an error boundary. (https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)","details":[{"kind":"error","loc":{"start":{"line":11,"column":11,"index":241},"end":{"line":11,"column":32,"index":262},"filename":"invalid-jsx-in-catch-in-outer-try-with-finally.ts"},"message":"Avoid constructing JSX within try/catch"}]}},"fnLoc":null} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":110},"end":{"line":17,"column":1,"index":323},"filename":"invalid-jsx-in-catch-in-outer-try-with-finally.ts"},"fnName":"Component","memoSlots":4,"memoBlocks":2,"memoValues":2,"prunedMemoBlocks":0,"prunedMemoValues":0} +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-finally.js similarity index 79% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-finally.js index fbc0d292ce6..64d2aaf6c69 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-catch-in-outer-try-with-finally.js @@ -1,4 +1,4 @@ -// @validateNoJSXInTryStatements @outputMode:"lint" +// @loggerTestOnly @validateNoJSXInTryStatements @outputMode:"lint" import {identity} from 'shared-runtime'; function Component(props) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-try-with-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-try-with-finally.expect.md new file mode 100644 index 00000000000..5f3c9fa284b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-try-with-finally.expect.md @@ -0,0 +1,42 @@ + +## Input + +```javascript +// @loggerTestOnly @validateNoJSXInTryStatements @outputMode:"lint" +function Component(props) { + let el; + try { + el =
; + } finally { + console.log(el); + } + return el; +} + +``` + +## Code + +```javascript +// @loggerTestOnly @validateNoJSXInTryStatements @outputMode:"lint" +function Component(props) { + let el; + try { + el =
; + } finally { + console.log(el); + } + return el; +} + +``` + +## Logs + +``` +{"kind":"CompileError","detail":{"options":{"category":"ErrorBoundaries","reason":"Avoid constructing JSX within try/catch","description":"React does not immediately render components when JSX is rendered, so any errors from this component will not be caught by the try/catch. To catch errors in rendering a given component, wrap that component in an error boundary. (https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)","details":[{"kind":"error","loc":{"start":{"line":5,"column":9,"index":123},"end":{"line":5,"column":16,"index":130},"filename":"invalid-jsx-in-try-with-finally.ts"},"message":"Avoid constructing JSX within try/catch"}]}},"fnLoc":null} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":68},"end":{"line":10,"column":1,"index":185},"filename":"invalid-jsx-in-try-with-finally.ts"},"fnName":"Component","memoSlots":1,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":0,"prunedMemoValues":0} +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-try-with-finally.js similarity index 63% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-try-with-finally.js index da168277214..b9601dfde47 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-jsx-in-try-with-finally.js @@ -1,4 +1,4 @@ -// @validateNoJSXInTryStatements @outputMode:"lint" +// @loggerTestOnly @validateNoJSXInTryStatements @outputMode:"lint" function Component(props) { let el; try { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.expect.md index 4fef6820554..4f624850fe6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/propagate-scope-deps-hir-fork/try-catch-mutate-outer-value.expect.md @@ -35,21 +35,21 @@ function Component(props) { x = []; try { let t0; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + if ($[4] === Symbol.for("react.memo_cache_sentinel")) { t0 = throwErrorWithMessage("oops"); - $[2] = t0; + $[4] = t0; } else { - t0 = $[2]; + t0 = $[4]; } x.push(t0); } catch { let t0; - if ($[3] !== props.a) { + if ($[2] !== props.a) { t0 = shallowCopy({ a: props.a }); - $[3] = props.a; - $[4] = t0; + $[2] = props.a; + $[3] = t0; } else { - t0 = $[4]; + t0 = $[3]; } x.push(t0); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/error.todo-syntax.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/error.todo-syntax.expect.md deleted file mode 100644 index e823939d3ff..00000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/error.todo-syntax.expect.md +++ /dev/null @@ -1,48 +0,0 @@ - -## Input - -```javascript -// @enableFire @panicThreshold:"none" -import {fire} from 'react'; - -/** - * Note that a react compiler-based transform still has limitations on JS syntax. - * In practice, we expect to surface these as actionable errors to the user, in - * the same way that invalid `fire` calls error. - */ -function Component({prop1}) { - const foo = () => { - try { - console.log(prop1); - } finally { - console.log('jbrown215'); - } - }; - useEffect(() => { - fire(foo()); - }); -} - -``` - - -## Error - -``` -Found 1 error: - -Error: [Fire] Untransformed reference to compiler-required feature. - -Either remove this `fire` call or ensure it is successfully transformed by the compiler Todo: (BuildHIR::lowerStatement) Handle TryStatement without a catch clause (11:4). - -error.todo-syntax.ts:18:4 - 16 | }; - 17 | useEffect(() => { -> 18 | fire(foo()); - | ^^^^ Untransformed `fire` call - 19 | }); - 20 | } - 21 | -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/no-fire-todo-syntax-shouldnt-throw.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/no-fire-todo-syntax-shouldnt-throw.expect.md index 06268ea8541..d98e6073205 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/no-fire-todo-syntax-shouldnt-throw.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/no-fire-todo-syntax-shouldnt-throw.expect.md @@ -49,20 +49,35 @@ import { fire } from "react"; /** * Compilation of this file should succeed. */ -function NonFireComponent({ prop1 }) { - /** - * This component bails out but does not use fire - */ - const foo = () => { - try { - console.log(prop1); - } finally { - console.log("jbrown215"); - } - }; - useEffect(() => { - foo(); - }); +function NonFireComponent(t0) { + const $ = _c(4); + const { prop1 } = t0; + let t1; + if ($[0] !== prop1) { + t1 = () => { + try { + console.log(prop1); + } finally { + console.log("jbrown215"); + } + }; + $[0] = prop1; + $[1] = t1; + } else { + t1 = $[1]; + } + const foo = t1; + let t2; + if ($[2] !== foo) { + t2 = () => { + foo(); + }; + $[2] = foo; + $[3] = t2; + } else { + t2 = $[3]; + } + useEffect(t2); } function FireComponent(props) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/todo-syntax.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/todo-syntax.expect.md new file mode 100644 index 00000000000..13177904df2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/todo-syntax.expect.md @@ -0,0 +1,74 @@ + +## Input + +```javascript +// @enableFire @panicThreshold:"none" +import {fire} from 'react'; + +/** + * Note that a react compiler-based transform still has limitations on JS syntax. + * In practice, we expect to surface these as actionable errors to the user, in + * the same way that invalid `fire` calls error. + */ +function Component({prop1}) { + const foo = () => { + try { + console.log(prop1); + } finally { + console.log('jbrown215'); + } + }; + useEffect(() => { + fire(foo()); + }); +} + +``` + +## Code + +```javascript +import { c as _c, useFire } from "react/compiler-runtime"; // @enableFire @panicThreshold:"none" +import { fire } from "react"; + +/** + * Note that a react compiler-based transform still has limitations on JS syntax. + * In practice, we expect to surface these as actionable errors to the user, in + * the same way that invalid `fire` calls error. + */ +function Component(t0) { + const $ = _c(4); + const { prop1 } = t0; + let t1; + if ($[0] !== prop1) { + t1 = () => { + try { + console.log(prop1); + } finally { + console.log("jbrown215"); + } + }; + $[0] = prop1; + $[1] = t1; + } else { + t1 = $[1]; + } + const foo = t1; + const t2 = useFire(foo); + let t3; + if ($[2] !== t2) { + t3 = () => { + t2(); + }; + $[2] = t2; + $[3] = t3; + } else { + t3 = $[3]; + } + useEffect(t3); +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/error.todo-syntax.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/todo-syntax.js similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/error.todo-syntax.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/transform-fire/bailout-retry/todo-syntax.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-empty-try.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-empty-try.expect.md index 522325e2311..654e33ab09a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-empty-try.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-empty-try.expect.md @@ -22,7 +22,12 @@ export const FIXTURE_ENTRYPOINT = { ```javascript function Component(props) { - const x = props.default; + let x = props.default; + try { + } catch (t0) { + const e = t0; + x = e; + } return x; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-finally.expect.md new file mode 100644 index 00000000000..a2d5c0d86a5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-finally.expect.md @@ -0,0 +1,56 @@ + +## Input + +```javascript +function Component(props) { + try { + if (props.cond) { + return 1; + } + return 2; + } catch (e) { + return 3; + } finally { + console.log('cleanup'); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{cond: true}], + sequentialRenders: [ + {cond: true}, + {cond: false}, + ], +}; + +``` + +## Code + +```javascript +function Component(props) { + try { + if (props.cond) { + return 1; + } + return 2; + } catch (t0) { + return 3; + } finally { + console.log("cleanup"); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ cond: true }], + sequentialRenders: [{ cond: true }, { cond: false }], +}; + +``` + +### Eval output +(kind: ok) 1 +2 +logs: ['cleanup','cleanup'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-finally.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-finally.js new file mode 100644 index 00000000000..bede6e9e8a2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-finally.js @@ -0,0 +1,21 @@ +function Component(props) { + try { + if (props.cond) { + return 1; + } + return 2; + } catch (e) { + return 3; + } finally { + console.log('cleanup'); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{cond: true}], + sequentialRenders: [ + {cond: true}, + {cond: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-mutate-outer-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-mutate-outer-value.expect.md index cab72226d27..4e58a1c80f3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-mutate-outer-value.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-mutate-outer-value.expect.md @@ -34,21 +34,21 @@ function Component(props) { x = []; try { let t0; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + if ($[4] === Symbol.for("react.memo_cache_sentinel")) { t0 = throwErrorWithMessage("oops"); - $[2] = t0; + $[4] = t0; } else { - t0 = $[2]; + t0 = $[4]; } x.push(t0); } catch { let t0; - if ($[3] !== props.a) { + if ($[2] !== props.a) { t0 = shallowCopy({ a: props.a }); - $[3] = props.a; - $[4] = t0; + $[2] = props.a; + $[3] = t0; } else { - t0 = $[4]; + t0 = $[3]; } x.push(t0); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-try-immediately-returns.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-try-immediately-returns.expect.md index 61eb45f84ab..f3acab3681a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-try-immediately-returns.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-try-immediately-returns.expect.md @@ -27,7 +27,13 @@ export const FIXTURE_ENTRYPOINT = { ```javascript function Component(props) { let x; - return 42; + try { + return 42; + } catch (t0) { + const e = t0; + x = e; + } + return x; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-try-immediately-throws-after-constant-propagation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-try-immediately-throws-after-constant-propagation.expect.md index c09c6707dbf..9b70fc7dc00 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-try-immediately-throws-after-constant-propagation.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-try-immediately-throws-after-constant-propagation.expect.md @@ -27,7 +27,14 @@ export const FIXTURE_ENTRYPOINT = { ```javascript function Component(props) { let x; - return 42; + + try { + return 42; + } catch (t0) { + const e = t0; + x = e; + } + return x; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-within-mutable-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-within-mutable-range.expect.md index 2b4fab6fefb..4bc1bb297bb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-within-mutable-range.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-catch-within-mutable-range.expect.md @@ -35,20 +35,20 @@ function Component(props) { x = []; try { let t0; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + if ($[3] === Symbol.for("react.memo_cache_sentinel")) { t0 = throwErrorWithMessage("oops"); - $[2] = t0; + $[3] = t0; } else { - t0 = $[2]; + t0 = $[3]; } x.push(t0); } catch { let t0; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { t0 = shallowCopy({}); - $[3] = t0; + $[2] = t0; } else { - t0 = $[3]; + t0 = $[2]; } x.push(t0); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-all-paths.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-all-paths.expect.md new file mode 100644 index 00000000000..beeb4fd88be --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-all-paths.expect.md @@ -0,0 +1,213 @@ + +## Input + +```javascript +import {throwErrorWithMessage} from 'shared-runtime'; + +/** + * Comprehensive test covering all paths through try-catch-finally. + * Tests: + * 1. Try succeeds with return + * 2. Try throws, catch returns + * 3. Try throws, catch doesn't return, falls through + * 4. Finally conditional return (overrides everything) + */ +function Component(props) { + 'use memo'; + try { + if (props.shouldThrow) { + throwErrorWithMessage(props.message); + } + return props.tryValue; + } catch (e) { + if (props.returnFromCatch) { + return props.catchValue; + } + } finally { + console.log('finally'); + if (props.returnFromFinally) { + return props.finallyValue; + } + } + return props.fallbackValue; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ + shouldThrow: false, + returnFromCatch: false, + returnFromFinally: false, + message: 'error', + tryValue: 'try', + catchValue: 'catch', + finallyValue: 'finally', + fallbackValue: 'fallback', + }], + sequentialRenders: [ + // Path 1: Try succeeds with return + {shouldThrow: false, returnFromCatch: false, returnFromFinally: false, + message: 'e', tryValue: 'try-success', catchValue: 'c', finallyValue: 'f', fallbackValue: 'fb'}, + // Path 2: Try throws, catch returns + {shouldThrow: true, returnFromCatch: true, returnFromFinally: false, + message: 'oops', tryValue: 't', catchValue: 'catch-return', finallyValue: 'f', fallbackValue: 'fb'}, + // Path 3: Try throws, catch doesn't return, falls through + {shouldThrow: true, returnFromCatch: false, returnFromFinally: false, + message: 'oops', tryValue: 't', catchValue: 'c', finallyValue: 'f', fallbackValue: 'fell-through'}, + // Path 4a: Finally return overrides try return + {shouldThrow: false, returnFromCatch: false, returnFromFinally: true, + message: 'e', tryValue: 'try-ignored', catchValue: 'c', finallyValue: 'finally-wins', fallbackValue: 'fb'}, + // Path 4b: Finally return overrides catch return + {shouldThrow: true, returnFromCatch: true, returnFromFinally: true, + message: 'oops', tryValue: 't', catchValue: 'catch-ignored', finallyValue: 'finally-wins-again', fallbackValue: 'fb'}, + // Path 4c: Finally return overrides fallthrough + {shouldThrow: true, returnFromCatch: false, returnFromFinally: true, + message: 'oops', tryValue: 't', catchValue: 'c', finallyValue: 'finally-overrides', fallbackValue: 'fb-ignored'}, + // Same as Path 1, verify memoization works + {shouldThrow: false, returnFromCatch: false, returnFromFinally: false, + message: 'e', tryValue: 'try-success', catchValue: 'c', finallyValue: 'f', fallbackValue: 'fb'}, + ], +}; + +``` + +## Code + +```javascript +import { throwErrorWithMessage } from "shared-runtime"; + +/** + * Comprehensive test covering all paths through try-catch-finally. + * Tests: + * 1. Try succeeds with return + * 2. Try throws, catch returns + * 3. Try throws, catch doesn't return, falls through + * 4. Finally conditional return (overrides everything) + */ +function Component(props) { + "use memo"; + + try { + if (props.shouldThrow) { + throwErrorWithMessage(props.message); + } + return props.tryValue; + } catch (t0) { + if (props.returnFromCatch) { + return props.catchValue; + } + } finally { + console.log("finally"); + if (props.returnFromFinally) { + return props.finallyValue; + } + } + return props.fallbackValue; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [ + { + shouldThrow: false, + returnFromCatch: false, + returnFromFinally: false, + message: "error", + tryValue: "try", + catchValue: "catch", + finallyValue: "finally", + fallbackValue: "fallback", + }, + ], + sequentialRenders: [ + // Path 1: Try succeeds with return + { + shouldThrow: false, + returnFromCatch: false, + returnFromFinally: false, + message: "e", + tryValue: "try-success", + catchValue: "c", + finallyValue: "f", + fallbackValue: "fb", + }, + // Path 2: Try throws, catch returns + { + shouldThrow: true, + returnFromCatch: true, + returnFromFinally: false, + message: "oops", + tryValue: "t", + catchValue: "catch-return", + finallyValue: "f", + fallbackValue: "fb", + }, + // Path 3: Try throws, catch doesn't return, falls through + { + shouldThrow: true, + returnFromCatch: false, + returnFromFinally: false, + message: "oops", + tryValue: "t", + catchValue: "c", + finallyValue: "f", + fallbackValue: "fell-through", + }, + // Path 4a: Finally return overrides try return + { + shouldThrow: false, + returnFromCatch: false, + returnFromFinally: true, + message: "e", + tryValue: "try-ignored", + catchValue: "c", + finallyValue: "finally-wins", + fallbackValue: "fb", + }, + // Path 4b: Finally return overrides catch return + { + shouldThrow: true, + returnFromCatch: true, + returnFromFinally: true, + message: "oops", + tryValue: "t", + catchValue: "catch-ignored", + finallyValue: "finally-wins-again", + fallbackValue: "fb", + }, + // Path 4c: Finally return overrides fallthrough + { + shouldThrow: true, + returnFromCatch: false, + returnFromFinally: true, + message: "oops", + tryValue: "t", + catchValue: "c", + finallyValue: "finally-overrides", + fallbackValue: "fb-ignored", + }, + // Same as Path 1, verify memoization works + { + shouldThrow: false, + returnFromCatch: false, + returnFromFinally: false, + message: "e", + tryValue: "try-success", + catchValue: "c", + finallyValue: "f", + fallbackValue: "fb", + }, + ], +}; + +``` + +### Eval output +(kind: ok) "try-success" +"catch-return" +"fell-through" +"finally-wins" +"finally-wins-again" +"finally-overrides" +"try-success" +logs: ['finally','finally','finally','finally','finally','finally','finally'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-all-paths.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-all-paths.js new file mode 100644 index 00000000000..053f76798c4 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-all-paths.js @@ -0,0 +1,66 @@ +import {throwErrorWithMessage} from 'shared-runtime'; + +/** + * Comprehensive test covering all paths through try-catch-finally. + * Tests: + * 1. Try succeeds with return + * 2. Try throws, catch returns + * 3. Try throws, catch doesn't return, falls through + * 4. Finally conditional return (overrides everything) + */ +function Component(props) { + 'use memo'; + try { + if (props.shouldThrow) { + throwErrorWithMessage(props.message); + } + return props.tryValue; + } catch (e) { + if (props.returnFromCatch) { + return props.catchValue; + } + } finally { + console.log('finally'); + if (props.returnFromFinally) { + return props.finallyValue; + } + } + return props.fallbackValue; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ + shouldThrow: false, + returnFromCatch: false, + returnFromFinally: false, + message: 'error', + tryValue: 'try', + catchValue: 'catch', + finallyValue: 'finally', + fallbackValue: 'fallback', + }], + sequentialRenders: [ + // Path 1: Try succeeds with return + {shouldThrow: false, returnFromCatch: false, returnFromFinally: false, + message: 'e', tryValue: 'try-success', catchValue: 'c', finallyValue: 'f', fallbackValue: 'fb'}, + // Path 2: Try throws, catch returns + {shouldThrow: true, returnFromCatch: true, returnFromFinally: false, + message: 'oops', tryValue: 't', catchValue: 'catch-return', finallyValue: 'f', fallbackValue: 'fb'}, + // Path 3: Try throws, catch doesn't return, falls through + {shouldThrow: true, returnFromCatch: false, returnFromFinally: false, + message: 'oops', tryValue: 't', catchValue: 'c', finallyValue: 'f', fallbackValue: 'fell-through'}, + // Path 4a: Finally return overrides try return + {shouldThrow: false, returnFromCatch: false, returnFromFinally: true, + message: 'e', tryValue: 'try-ignored', catchValue: 'c', finallyValue: 'finally-wins', fallbackValue: 'fb'}, + // Path 4b: Finally return overrides catch return + {shouldThrow: true, returnFromCatch: true, returnFromFinally: true, + message: 'oops', tryValue: 't', catchValue: 'catch-ignored', finallyValue: 'finally-wins-again', fallbackValue: 'fb'}, + // Path 4c: Finally return overrides fallthrough + {shouldThrow: true, returnFromCatch: false, returnFromFinally: true, + message: 'oops', tryValue: 't', catchValue: 'c', finallyValue: 'finally-overrides', fallbackValue: 'fb-ignored'}, + // Same as Path 1, verify memoization works + {shouldThrow: false, returnFromCatch: false, returnFromFinally: false, + message: 'e', tryValue: 'try-success', catchValue: 'c', finallyValue: 'f', fallbackValue: 'fb'}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-basic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-basic.expect.md new file mode 100644 index 00000000000..691c181eaa4 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-basic.expect.md @@ -0,0 +1,44 @@ + +## Input + +```javascript +function Component() { + let x; + try { + x = 1; + } finally { + console.log('cleanup'); + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; + +``` + +## Code + +```javascript +function Component() { + let x; + try { + x = 1; + } finally { + console.log("cleanup"); + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; + +``` + +### Eval output +(kind: ok) 1 +logs: ['cleanup'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-basic.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-basic.js new file mode 100644 index 00000000000..1eb602e1b46 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-basic.js @@ -0,0 +1,14 @@ +function Component() { + let x; + try { + x = 1; + } finally { + console.log('cleanup'); + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-break-continue.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-break-continue.expect.md new file mode 100644 index 00000000000..a84c3109c84 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-break-continue.expect.md @@ -0,0 +1,71 @@ + +## Input + +```javascript +function Component(props) { + const results = []; + for (const item of props.items) { + try { + if (item === 'skip') { + continue; + } + if (item === 'stop') { + break; + } + results.push(item); + } finally { + console.log('processed', item); + } + } + return results; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{items: ['a', 'skip', 'b', 'stop', 'c']}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component(props) { + const $ = _c(2); + let results; + if ($[0] !== props.items) { + results = []; + for (const item of props.items) { + try { + if (item === "skip") { + continue; + } + + if (item === "stop") { + break; + } + + results.push(item); + } finally { + console.log("processed", item); + } + } + $[0] = props.items; + $[1] = results; + } else { + results = $[1]; + } + return results; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ items: ["a", "skip", "b", "stop", "c"] }], +}; + +``` + +### Eval output +(kind: ok) ["a","b"] +logs: ['processed','a','processed','skip','processed','b','processed','stop'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-break-continue.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-break-continue.js new file mode 100644 index 00000000000..d438593a4e9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-break-continue.js @@ -0,0 +1,22 @@ +function Component(props) { + const results = []; + for (const item of props.items) { + try { + if (item === 'skip') { + continue; + } + if (item === 'stop') { + break; + } + results.push(item); + } finally { + console.log('processed', item); + } + } + return results; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{items: ['a', 'skip', 'b', 'stop', 'c']}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-catch-fallthrough-to-finalizer.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-catch-fallthrough-to-finalizer.expect.md new file mode 100644 index 00000000000..8208ed6cefb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-catch-fallthrough-to-finalizer.expect.md @@ -0,0 +1,183 @@ + +## Input + +```javascript +import {throwErrorWithMessage} from 'shared-runtime'; + +/** + * Test that catch block's fallthrough correctly flows to finalizer. + * This specifically tests the control flow fix where catch block's goto + * must target the finalizer block (not the continuation) when there's + * a finally clause. + * + * The conditional in catch creates two paths: + * 1. Return from catch (returnFromCatch=true) + * 2. Fall through catch -> finalizer -> continuation (returnFromCatch=false) + * + * Without the fix, path 2 would fail with "Expected a break target" because + * the finalizer wasn't scheduled as a break target for catch block gotos. + */ +function Component(props) { + 'use memo'; + let result = 'initial'; + try { + if (props.shouldThrow) { + throwErrorWithMessage(props.message); + } + result = props.tryValue; + } catch { + // This conditional creates a fallthrough path that needs to reach finalizer + if (props.returnFromCatch) { + return props.catchReturnValue; + } + // Fallthrough path - must go through finalizer before continuation + result = props.catchFallthrough; + } finally { + console.log('finally ran'); + } + return result; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ + shouldThrow: true, + returnFromCatch: false, + message: 'test', + tryValue: 'try', + catchReturnValue: 'catch-return', + catchFallthrough: 'catch-fell-through', + }], + sequentialRenders: [ + // Path: throw -> catch returns + {shouldThrow: true, returnFromCatch: true, message: 'e1', + tryValue: 't', catchReturnValue: 'returned-from-catch', catchFallthrough: 'x'}, + // Path: throw -> catch falls through -> finalizer -> return result + // This is the critical path that tests the fix + {shouldThrow: true, returnFromCatch: false, message: 'e2', + tryValue: 't', catchReturnValue: 'x', catchFallthrough: 'fell-through-catch'}, + // Path: no throw -> try succeeds + {shouldThrow: false, returnFromCatch: false, message: 'e3', + tryValue: 'try-succeeded', catchReturnValue: 'x', catchFallthrough: 'x'}, + // Same as previous - verify memoization + {shouldThrow: false, returnFromCatch: false, message: 'e3', + tryValue: 'try-succeeded', catchReturnValue: 'x', catchFallthrough: 'x'}, + // Back to fallthrough path + {shouldThrow: true, returnFromCatch: false, message: 'e4', + tryValue: 't', catchReturnValue: 'x', catchFallthrough: 'another-fallthrough'}, + ], +}; + +``` + +## Code + +```javascript +import { throwErrorWithMessage } from "shared-runtime"; + +/** + * Test that catch block's fallthrough correctly flows to finalizer. + * This specifically tests the control flow fix where catch block's goto + * must target the finalizer block (not the continuation) when there's + * a finally clause. + * + * The conditional in catch creates two paths: + * 1. Return from catch (returnFromCatch=true) + * 2. Fall through catch -> finalizer -> continuation (returnFromCatch=false) + * + * Without the fix, path 2 would fail with "Expected a break target" because + * the finalizer wasn't scheduled as a break target for catch block gotos. + */ +function Component(props) { + "use memo"; + + let result = "initial"; + try { + if (props.shouldThrow) { + throwErrorWithMessage(props.message); + } + + result = props.tryValue; + } catch { + if (props.returnFromCatch) { + return props.catchReturnValue; + } + + result = props.catchFallthrough; + } finally { + console.log("finally ran"); + } + return result; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [ + { + shouldThrow: true, + returnFromCatch: false, + message: "test", + tryValue: "try", + catchReturnValue: "catch-return", + catchFallthrough: "catch-fell-through", + }, + ], + sequentialRenders: [ + // Path: throw -> catch returns + { + shouldThrow: true, + returnFromCatch: true, + message: "e1", + tryValue: "t", + catchReturnValue: "returned-from-catch", + catchFallthrough: "x", + }, + // Path: throw -> catch falls through -> finalizer -> return result + // This is the critical path that tests the fix + { + shouldThrow: true, + returnFromCatch: false, + message: "e2", + tryValue: "t", + catchReturnValue: "x", + catchFallthrough: "fell-through-catch", + }, + // Path: no throw -> try succeeds + { + shouldThrow: false, + returnFromCatch: false, + message: "e3", + tryValue: "try-succeeded", + catchReturnValue: "x", + catchFallthrough: "x", + }, + // Same as previous - verify memoization + { + shouldThrow: false, + returnFromCatch: false, + message: "e3", + tryValue: "try-succeeded", + catchReturnValue: "x", + catchFallthrough: "x", + }, + // Back to fallthrough path + { + shouldThrow: true, + returnFromCatch: false, + message: "e4", + tryValue: "t", + catchReturnValue: "x", + catchFallthrough: "another-fallthrough", + }, + ], +}; + +``` + +### Eval output +(kind: ok) "returned-from-catch" +"fell-through-catch" +"try-succeeded" +"try-succeeded" +"another-fallthrough" +logs: ['finally ran','finally ran','finally ran','finally ran','finally ran'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-catch-fallthrough-to-finalizer.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-catch-fallthrough-to-finalizer.js new file mode 100644 index 00000000000..c12627c9053 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-catch-fallthrough-to-finalizer.js @@ -0,0 +1,65 @@ +import {throwErrorWithMessage} from 'shared-runtime'; + +/** + * Test that catch block's fallthrough correctly flows to finalizer. + * This specifically tests the control flow fix where catch block's goto + * must target the finalizer block (not the continuation) when there's + * a finally clause. + * + * The conditional in catch creates two paths: + * 1. Return from catch (returnFromCatch=true) + * 2. Fall through catch -> finalizer -> continuation (returnFromCatch=false) + * + * Without the fix, path 2 would fail with "Expected a break target" because + * the finalizer wasn't scheduled as a break target for catch block gotos. + */ +function Component(props) { + 'use memo'; + let result = 'initial'; + try { + if (props.shouldThrow) { + throwErrorWithMessage(props.message); + } + result = props.tryValue; + } catch { + // This conditional creates a fallthrough path that needs to reach finalizer + if (props.returnFromCatch) { + return props.catchReturnValue; + } + // Fallthrough path - must go through finalizer before continuation + result = props.catchFallthrough; + } finally { + console.log('finally ran'); + } + return result; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ + shouldThrow: true, + returnFromCatch: false, + message: 'test', + tryValue: 'try', + catchReturnValue: 'catch-return', + catchFallthrough: 'catch-fell-through', + }], + sequentialRenders: [ + // Path: throw -> catch returns + {shouldThrow: true, returnFromCatch: true, message: 'e1', + tryValue: 't', catchReturnValue: 'returned-from-catch', catchFallthrough: 'x'}, + // Path: throw -> catch falls through -> finalizer -> return result + // This is the critical path that tests the fix + {shouldThrow: true, returnFromCatch: false, message: 'e2', + tryValue: 't', catchReturnValue: 'x', catchFallthrough: 'fell-through-catch'}, + // Path: no throw -> try succeeds + {shouldThrow: false, returnFromCatch: false, message: 'e3', + tryValue: 'try-succeeded', catchReturnValue: 'x', catchFallthrough: 'x'}, + // Same as previous - verify memoization + {shouldThrow: false, returnFromCatch: false, message: 'e3', + tryValue: 'try-succeeded', catchReturnValue: 'x', catchFallthrough: 'x'}, + // Back to fallthrough path + {shouldThrow: true, returnFromCatch: false, message: 'e4', + tryValue: 't', catchReturnValue: 'x', catchFallthrough: 'another-fallthrough'}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-catch.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-catch.expect.md new file mode 100644 index 00000000000..473eaf0bd16 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-catch.expect.md @@ -0,0 +1,147 @@ + +## Input + +```javascript +import {throwErrorWithMessage} from 'shared-runtime'; + +/** + * Test conditional return in catch block. + * When shouldThrow is true and returnFromCatch is true, returns from catch. + * When shouldThrow is true and returnFromCatch is false, falls through. + * When shouldThrow is false, try block succeeds. + */ +function Component(props) { + 'use memo'; + try { + if (props.shouldThrow) { + throwErrorWithMessage(props.message); + } + return props.tryValue; + } catch { + if (props.returnFromCatch) { + return props.catchValue; + } + } finally { + console.log('finally'); + } + return props.fallbackValue; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{shouldThrow: true, returnFromCatch: true, message: 'err', tryValue: 't', catchValue: 'caught', fallbackValue: 'fb'}], + sequentialRenders: [ + // Throw + return from catch + {shouldThrow: true, returnFromCatch: true, message: 'err1', tryValue: 't', catchValue: 'caught1', fallbackValue: 'fb'}, + // Throw + don't return from catch -> falls through to fallback + {shouldThrow: true, returnFromCatch: false, message: 'err2', tryValue: 't', catchValue: 'ignored', fallbackValue: 'fallback1'}, + // Don't throw - try returns + {shouldThrow: false, returnFromCatch: true, message: 'err3', tryValue: 'try-success', catchValue: 'not-used', fallbackValue: 'fb'}, + // Same as previous, should reuse + {shouldThrow: false, returnFromCatch: true, message: 'err3', tryValue: 'try-success', catchValue: 'not-used', fallbackValue: 'fb'}, + // Throw + return from catch again + {shouldThrow: true, returnFromCatch: true, message: 'err4', tryValue: 't', catchValue: 'caught4', fallbackValue: 'fb'}, + ], +}; + +``` + +## Code + +```javascript +import { throwErrorWithMessage } from "shared-runtime"; + +/** + * Test conditional return in catch block. + * When shouldThrow is true and returnFromCatch is true, returns from catch. + * When shouldThrow is true and returnFromCatch is false, falls through. + * When shouldThrow is false, try block succeeds. + */ +function Component(props) { + "use memo"; + + try { + if (props.shouldThrow) { + throwErrorWithMessage(props.message); + } + return props.tryValue; + } catch { + if (props.returnFromCatch) { + return props.catchValue; + } + } finally { + console.log("finally"); + } + return props.fallbackValue; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [ + { + shouldThrow: true, + returnFromCatch: true, + message: "err", + tryValue: "t", + catchValue: "caught", + fallbackValue: "fb", + }, + ], + sequentialRenders: [ + // Throw + return from catch + { + shouldThrow: true, + returnFromCatch: true, + message: "err1", + tryValue: "t", + catchValue: "caught1", + fallbackValue: "fb", + }, + // Throw + don't return from catch -> falls through to fallback + { + shouldThrow: true, + returnFromCatch: false, + message: "err2", + tryValue: "t", + catchValue: "ignored", + fallbackValue: "fallback1", + }, + // Don't throw - try returns + { + shouldThrow: false, + returnFromCatch: true, + message: "err3", + tryValue: "try-success", + catchValue: "not-used", + fallbackValue: "fb", + }, + // Same as previous, should reuse + { + shouldThrow: false, + returnFromCatch: true, + message: "err3", + tryValue: "try-success", + catchValue: "not-used", + fallbackValue: "fb", + }, + // Throw + return from catch again + { + shouldThrow: true, + returnFromCatch: true, + message: "err4", + tryValue: "t", + catchValue: "caught4", + fallbackValue: "fb", + }, + ], +}; + +``` + +### Eval output +(kind: ok) "caught1" +"fallback1" +"try-success" +"try-success" +"caught4" +logs: ['finally','finally','finally','finally','finally'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-catch.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-catch.js new file mode 100644 index 00000000000..2d10c8a3222 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-catch.js @@ -0,0 +1,41 @@ +import {throwErrorWithMessage} from 'shared-runtime'; + +/** + * Test conditional return in catch block. + * When shouldThrow is true and returnFromCatch is true, returns from catch. + * When shouldThrow is true and returnFromCatch is false, falls through. + * When shouldThrow is false, try block succeeds. + */ +function Component(props) { + 'use memo'; + try { + if (props.shouldThrow) { + throwErrorWithMessage(props.message); + } + return props.tryValue; + } catch { + if (props.returnFromCatch) { + return props.catchValue; + } + } finally { + console.log('finally'); + } + return props.fallbackValue; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{shouldThrow: true, returnFromCatch: true, message: 'err', tryValue: 't', catchValue: 'caught', fallbackValue: 'fb'}], + sequentialRenders: [ + // Throw + return from catch + {shouldThrow: true, returnFromCatch: true, message: 'err1', tryValue: 't', catchValue: 'caught1', fallbackValue: 'fb'}, + // Throw + don't return from catch -> falls through to fallback + {shouldThrow: true, returnFromCatch: false, message: 'err2', tryValue: 't', catchValue: 'ignored', fallbackValue: 'fallback1'}, + // Don't throw - try returns + {shouldThrow: false, returnFromCatch: true, message: 'err3', tryValue: 'try-success', catchValue: 'not-used', fallbackValue: 'fb'}, + // Same as previous, should reuse + {shouldThrow: false, returnFromCatch: true, message: 'err3', tryValue: 'try-success', catchValue: 'not-used', fallbackValue: 'fb'}, + // Throw + return from catch again + {shouldThrow: true, returnFromCatch: true, message: 'err4', tryValue: 't', catchValue: 'caught4', fallbackValue: 'fb'}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-finally.expect.md new file mode 100644 index 00000000000..9949a1f7040 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-finally.expect.md @@ -0,0 +1,89 @@ + +## Input + +```javascript +/** + * Test conditional return in finally block. + * When returnFromFinally is true, returns from finally (overriding any try return). + * When returnFromFinally is false, the try return takes effect. + */ +function Component(props) { + 'use memo'; + try { + return props.tryValue; + } finally { + console.log('finally'); + if (props.returnFromFinally) { + return props.finallyValue; + } + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{tryValue: 'try', returnFromFinally: true, finallyValue: 'finally'}], + sequentialRenders: [ + // Finally returns - overrides try return + {tryValue: 'try1', returnFromFinally: true, finallyValue: 'finally1'}, + // Finally doesn't return - try return takes effect + {tryValue: 'try2', returnFromFinally: false, finallyValue: 'ignored'}, + // Same as previous + {tryValue: 'try2', returnFromFinally: false, finallyValue: 'ignored'}, + // Finally returns again + {tryValue: 'try3', returnFromFinally: true, finallyValue: 'finally3'}, + // Finally doesn't return + {tryValue: 'try4', returnFromFinally: false, finallyValue: 'ignored'}, + ], +}; + +``` + +## Code + +```javascript +/** + * Test conditional return in finally block. + * When returnFromFinally is true, returns from finally (overriding any try return). + * When returnFromFinally is false, the try return takes effect. + */ +function Component(props) { + "use memo"; + + try { + return props.tryValue; + } finally { + console.log("finally"); + if (props.returnFromFinally) { + return props.finallyValue; + } + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [ + { tryValue: "try", returnFromFinally: true, finallyValue: "finally" }, + ], + sequentialRenders: [ + // Finally returns - overrides try return + { tryValue: "try1", returnFromFinally: true, finallyValue: "finally1" }, + // Finally doesn't return - try return takes effect + { tryValue: "try2", returnFromFinally: false, finallyValue: "ignored" }, + // Same as previous + { tryValue: "try2", returnFromFinally: false, finallyValue: "ignored" }, + // Finally returns again + { tryValue: "try3", returnFromFinally: true, finallyValue: "finally3" }, + // Finally doesn't return + { tryValue: "try4", returnFromFinally: false, finallyValue: "ignored" }, + ], +}; + +``` + +### Eval output +(kind: ok) "finally1" +"try2" +"try2" +"finally3" +"try4" +logs: ['finally','finally','finally','finally','finally'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-finally.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-finally.js new file mode 100644 index 00000000000..fbae4e5e0fa --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-finally.js @@ -0,0 +1,33 @@ +/** + * Test conditional return in finally block. + * When returnFromFinally is true, returns from finally (overriding any try return). + * When returnFromFinally is false, the try return takes effect. + */ +function Component(props) { + 'use memo'; + try { + return props.tryValue; + } finally { + console.log('finally'); + if (props.returnFromFinally) { + return props.finallyValue; + } + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{tryValue: 'try', returnFromFinally: true, finallyValue: 'finally'}], + sequentialRenders: [ + // Finally returns - overrides try return + {tryValue: 'try1', returnFromFinally: true, finallyValue: 'finally1'}, + // Finally doesn't return - try return takes effect + {tryValue: 'try2', returnFromFinally: false, finallyValue: 'ignored'}, + // Same as previous + {tryValue: 'try2', returnFromFinally: false, finallyValue: 'ignored'}, + // Finally returns again + {tryValue: 'try3', returnFromFinally: true, finallyValue: 'finally3'}, + // Finally doesn't return + {tryValue: 'try4', returnFromFinally: false, finallyValue: 'ignored'}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-try.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-try.expect.md new file mode 100644 index 00000000000..bfd9f21a7e9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-try.expect.md @@ -0,0 +1,88 @@ + +## Input + +```javascript +/** + * Test conditional return in try block. + * When condition is true, returns from try (but finally runs first). + * When condition is false, falls through to after try-finally. + */ +function Component(props) { + 'use memo'; + try { + if (props.cond) { + return props.tryValue; + } + } finally { + console.log('finally'); + } + return props.fallbackValue; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{cond: true, tryValue: 'early-return', fallbackValue: 'fallback'}], + sequentialRenders: [ + // Condition true - returns from try + {cond: true, tryValue: 'early1', fallbackValue: 'fb1'}, + {cond: true, tryValue: 'early2', fallbackValue: 'fb2'}, + // Condition false - falls through + {cond: false, tryValue: 'ignored', fallbackValue: 'used1'}, + {cond: false, tryValue: 'also-ignored', fallbackValue: 'used2'}, + // Same as previous, should reuse + {cond: false, tryValue: 'also-ignored', fallbackValue: 'used2'}, + // Back to true + {cond: true, tryValue: 'early3', fallbackValue: 'fb3'}, + ], +}; + +``` + +## Code + +```javascript +/** + * Test conditional return in try block. + * When condition is true, returns from try (but finally runs first). + * When condition is false, falls through to after try-finally. + */ +function Component(props) { + "use memo"; + + try { + if (props.cond) { + return props.tryValue; + } + } finally { + console.log("finally"); + } + return props.fallbackValue; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ cond: true, tryValue: "early-return", fallbackValue: "fallback" }], + sequentialRenders: [ + // Condition true - returns from try + { cond: true, tryValue: "early1", fallbackValue: "fb1" }, + { cond: true, tryValue: "early2", fallbackValue: "fb2" }, + // Condition false - falls through + { cond: false, tryValue: "ignored", fallbackValue: "used1" }, + { cond: false, tryValue: "also-ignored", fallbackValue: "used2" }, + // Same as previous, should reuse + { cond: false, tryValue: "also-ignored", fallbackValue: "used2" }, + // Back to true + { cond: true, tryValue: "early3", fallbackValue: "fb3" }, + ], +}; + +``` + +### Eval output +(kind: ok) "early1" +"early2" +"used1" +"used2" +"used2" +"early3" +logs: ['finally','finally','finally','finally','finally','finally'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-try.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-try.js new file mode 100644 index 00000000000..7216c200d10 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-conditional-return-in-try.js @@ -0,0 +1,33 @@ +/** + * Test conditional return in try block. + * When condition is true, returns from try (but finally runs first). + * When condition is false, falls through to after try-finally. + */ +function Component(props) { + 'use memo'; + try { + if (props.cond) { + return props.tryValue; + } + } finally { + console.log('finally'); + } + return props.fallbackValue; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{cond: true, tryValue: 'early-return', fallbackValue: 'fallback'}], + sequentialRenders: [ + // Condition true - returns from try + {cond: true, tryValue: 'early1', fallbackValue: 'fb1'}, + {cond: true, tryValue: 'early2', fallbackValue: 'fb2'}, + // Condition false - falls through + {cond: false, tryValue: 'ignored', fallbackValue: 'used1'}, + {cond: false, tryValue: 'also-ignored', fallbackValue: 'used2'}, + // Same as previous, should reuse + {cond: false, tryValue: 'also-ignored', fallbackValue: 'used2'}, + // Back to true + {cond: true, tryValue: 'early3', fallbackValue: 'fb3'}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-catch-and-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-catch-and-finally.expect.md new file mode 100644 index 00000000000..ba38aa6d409 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-catch-and-finally.expect.md @@ -0,0 +1,102 @@ + +## Input + +```javascript +import {throwErrorWithMessage} from 'shared-runtime'; + +/** + * Test returning in catch and finally, with a throw in try. + * Per JS semantics, the finally return overrides the catch return. + */ +function Component(props) { + 'use memo'; + try { + throwErrorWithMessage(props.message); + return props.tryValue; // never reached + } catch { + return props.catchValue; + } finally { + return props.finallyValue; + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{message: 'error', tryValue: 'try', catchValue: 'catch', finallyValue: 'finally'}], + sequentialRenders: [ + {message: 'err1', tryValue: 'try1', catchValue: 'catch1', finallyValue: 'finally1'}, + {message: 'err2', tryValue: 'try2', catchValue: 'catch2', finallyValue: 'finally2'}, + {message: 'err2', tryValue: 'try2', catchValue: 'catch2', finallyValue: 'finally2'}, // same props + {message: 'err3', tryValue: 'try3', catchValue: 'catch3', finallyValue: 'finally3'}, + ], +}; + +``` + +## Code + +```javascript +import { throwErrorWithMessage } from "shared-runtime"; + +/** + * Test returning in catch and finally, with a throw in try. + * Per JS semantics, the finally return overrides the catch return. + */ +function Component(props) { + "use memo"; + + try { + throwErrorWithMessage(props.message); + return props.tryValue; + } catch { + return props.catchValue; + } finally { + return props.finallyValue; + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [ + { + message: "error", + tryValue: "try", + catchValue: "catch", + finallyValue: "finally", + }, + ], + sequentialRenders: [ + { + message: "err1", + tryValue: "try1", + catchValue: "catch1", + finallyValue: "finally1", + }, + { + message: "err2", + tryValue: "try2", + catchValue: "catch2", + finallyValue: "finally2", + }, + { + message: "err2", + tryValue: "try2", + catchValue: "catch2", + finallyValue: "finally2", + }, // same props + { + message: "err3", + tryValue: "try3", + catchValue: "catch3", + finallyValue: "finally3", + }, + ], +}; + +``` + +### Eval output +(kind: ok) "finally1" +"finally2" +"finally2" +"finally3" \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-catch-and-finally.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-catch-and-finally.js new file mode 100644 index 00000000000..180fb94ebed --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-catch-and-finally.js @@ -0,0 +1,28 @@ +import {throwErrorWithMessage} from 'shared-runtime'; + +/** + * Test returning in catch and finally, with a throw in try. + * Per JS semantics, the finally return overrides the catch return. + */ +function Component(props) { + 'use memo'; + try { + throwErrorWithMessage(props.message); + return props.tryValue; // never reached + } catch { + return props.catchValue; + } finally { + return props.finallyValue; + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{message: 'error', tryValue: 'try', catchValue: 'catch', finallyValue: 'finally'}], + sequentialRenders: [ + {message: 'err1', tryValue: 'try1', catchValue: 'catch1', finallyValue: 'finally1'}, + {message: 'err2', tryValue: 'try2', catchValue: 'catch2', finallyValue: 'finally2'}, + {message: 'err2', tryValue: 'try2', catchValue: 'catch2', finallyValue: 'finally2'}, // same props + {message: 'err3', tryValue: 'try3', catchValue: 'catch3', finallyValue: 'finally3'}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try-and-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try-and-finally.expect.md new file mode 100644 index 00000000000..c0c427c7e43 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try-and-finally.expect.md @@ -0,0 +1,65 @@ + +## Input + +```javascript +/** + * Test returning in both try and finally. + * Per JS semantics, the finally return overrides the try return. + */ +function Component(props) { + 'use memo'; + try { + return props.tryValue; + } finally { + return props.finallyValue; + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{tryValue: 'try', finallyValue: 'finally'}], + sequentialRenders: [ + {tryValue: 'try1', finallyValue: 'finally1'}, + {tryValue: 'try2', finallyValue: 'finally2'}, + {tryValue: 'try2', finallyValue: 'finally2'}, // same props, should reuse + {tryValue: 'try3', finallyValue: 'finally3'}, + ], +}; + +``` + +## Code + +```javascript +/** + * Test returning in both try and finally. + * Per JS semantics, the finally return overrides the try return. + */ +function Component(props) { + "use memo"; + + try { + return props.tryValue; + } finally { + return props.finallyValue; + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ tryValue: "try", finallyValue: "finally" }], + sequentialRenders: [ + { tryValue: "try1", finallyValue: "finally1" }, + { tryValue: "try2", finallyValue: "finally2" }, + { tryValue: "try2", finallyValue: "finally2" }, // same props, should reuse + { tryValue: "try3", finallyValue: "finally3" }, + ], +}; + +``` + +### Eval output +(kind: ok) "finally1" +"finally2" +"finally2" +"finally3" \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try-and-finally.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try-and-finally.js new file mode 100644 index 00000000000..91bc83121f3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try-and-finally.js @@ -0,0 +1,23 @@ +/** + * Test returning in both try and finally. + * Per JS semantics, the finally return overrides the try return. + */ +function Component(props) { + 'use memo'; + try { + return props.tryValue; + } finally { + return props.finallyValue; + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{tryValue: 'try', finallyValue: 'finally'}], + sequentialRenders: [ + {tryValue: 'try1', finallyValue: 'finally1'}, + {tryValue: 'try2', finallyValue: 'finally2'}, + {tryValue: 'try2', finallyValue: 'finally2'}, // same props, should reuse + {tryValue: 'try3', finallyValue: 'finally3'}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try.expect.md new file mode 100644 index 00000000000..243a69ec6b8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try.expect.md @@ -0,0 +1,40 @@ + +## Input + +```javascript +function Component() { + try { + return 1; + } finally { + console.log('cleanup'); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; + +``` + +## Code + +```javascript +function Component() { + try { + return 1; + } finally { + console.log("cleanup"); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; + +``` + +### Eval output +(kind: ok) 1 +logs: ['cleanup'] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try.js new file mode 100644 index 00000000000..041a121c7b1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/try-finally-return-in-try.js @@ -0,0 +1,12 @@ +function Component() { + try { + return 1; + } finally { + console.log('cleanup'); + } +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +};