Compare commits

...

2 Commits

Author SHA1 Message Date
Joe Savona
f49ce79b26 [compiler] Improve snap usability
A whole bunch of changes to snap aimed at making it more usable for humans and agents. Here's the new CLI interface:

```
node dist/main.js --help
Options:
      --version         Show version number                            [boolean]
      --sync            Run compiler in main thread (instead of using worker
                        threads or subprocesses). Defaults to false.
                                                      [boolean] [default: false]
      --worker-threads  Run compiler in worker threads (instead of
                        subprocesses). Defaults to true.
                                                       [boolean] [default: true]
      --help            Show help                                      [boolean]
  -w, --watch           Run compiler in watch mode, re-running after changes
                                                                       [boolean]
  -u, --update          Update fixtures                                [boolean]
  -p, --pattern         Optional glob pattern to filter fixtures (e.g.,
                        "error.*", "use-memo")                          [string]
  -d, --debug           Enable debug logging to print HIR for each pass[boolean]
```

Key changes:
* Added abbreviations for common arguments
* No more testfilter.txt! Filtering/debugging works more like Jest, see below.
* The `--debug` flag (`-d`) controls whether to emit debug information. In watch mode, this flag sets the initial debug value, and it can be toggled by pressing the 'd' key while watching.
* The `--pattern` flag (`-p`) sets a filter pattern. In watch mode, this flag sets the initial filter. It can be changed by pressing 'p' and typing a new pattern, or pressing 'a' to switch to running all tests.
* As before, we only actually enable debugging if debug mode is enabled _and_ there is only one test selected.
2026-01-22 14:34:50 -08:00
Joe Savona
9eb8b04a87 [compiler] Claude file/settings
Initializes CLAUDE.md and a settings file for the compiler/ directory to help use claude with the compiler. Note that some of the commands here depend on changes to snap from the next PR.
2026-01-22 14:34:47 -08:00
7 changed files with 334 additions and 104 deletions

View File

@@ -1,6 +1,8 @@
{
"permissions": {
"allow": [
"Bash(yarn snap:*)",
"Bash(yarn snap:build)",
"Bash(node scripts/enable-feature-flag.js:*)"
],
"deny": [],

2
compiler/.gitignore vendored
View File

@@ -8,7 +8,9 @@ dist
.vscode
!packages/playground/.vscode
testfilter.txt
.claude/settings.local.json
# forgive
*.vsix
.vscode-test

221
compiler/CLAUDE.md Normal file
View File

@@ -0,0 +1,221 @@
# React Compiler Knowledge Base
This document contains knowledge about the React Compiler gathered during development sessions. It serves as a reference for understanding the codebase architecture and key concepts.
## Project Structure
- `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.)
- `src/Validation/` - Validation passes that check for errors
- `src/Entrypoint/Pipeline.ts` - Main compilation pipeline with pass ordering
- `src/__tests__/fixtures/compiler/` - Test fixtures
- `error.todo-*.js` - Unsupported feature, correctly throws Todo error (graceful bailout)
- `error.bug-*.js` - Known bug, throws wrong error type or incorrect behavior
- `*.expect.md` - Expected output for each fixture
## Running Tests
```bash
# Run all tests
yarn snap
# Run tests matching a pattern
# Example: yarn snap -p 'error.*'
yarn snap -p <pattern>
# Run a single fixture in debug mode. Use the path relative to the __tests__/fixtures/compiler directory
# For each step of compilation, outputs the step name and state of the compiled program
# Example: yarn snap -p simple.js -d
yarn snap -p <file-basename> -d
# Update fixture outputs (also works with -p)
yarn snap -u
```
## Version Control
This repository uses Sapling (`sl`) for version control. Sapling is similar to Mercurial: there is not staging area, but new/deleted files must be explicitlyu added/removed.
```bash
# Check status
sl status
# Add new files, remove deleted files
sl addremove
# Commit all changes
sl commit -m "Your commit message"
# Commit with multi-line message using heredoc
sl commit -m "$(cat <<'EOF'
Summary line
Detailed description here
EOF
)"
```
## Key Concepts
### HIR (High-level Intermediate Representation)
The compiler converts source code to HIR for analysis. Key types in `src/HIR/HIR.ts`:
- **HIRFunction** - A function being compiled
- `body.blocks` - Map of BasicBlocks
- `context` - Captured variables from outer scope
- `params` - Function parameters
- `returns` - The function's return place
- `aliasingEffects` - Effects that describe the function's behavior when called
- **Instruction** - A single operation
- `lvalue` - The place being assigned to
- `value` - The instruction kind (CallExpression, FunctionExpression, LoadLocal, etc.)
- `effects` - Array of AliasingEffects for this instruction
- **Terminal** - Block terminators (return, branch, etc.)
- `effects` - Array of AliasingEffects
- **Place** - A reference to a value
- `identifier.id` - Unique IdentifierId
- **Phi nodes** - Join points for values from different control flow paths
- Located at `block.phis`
- `phi.place` - The result place
- `phi.operands` - Map of predecessor block to source place
### AliasingEffects System
Effects describe data flow and operations. Defined in `src/Inference/AliasingEffects.ts`:
**Data Flow Effects:**
- `Impure` - Marks a place as containing an impure value (e.g., Date.now() result, ref.current)
- `Capture a -> b` - Value from `a` is captured into `b` (mutable capture)
- `Alias a -> b` - `b` aliases `a`
- `ImmutableCapture a -> b` - Immutable capture (like Capture but read-only)
- `Assign a -> b` - Direct assignment
- `MaybeAlias a -> b` - Possible aliasing
- `CreateFrom a -> b` - Created from source
**Mutation Effects:**
- `Mutate value` - Value is mutated
- `MutateTransitive value` - Value and transitive captures are mutated
- `MutateConditionally value` - May mutate
- `MutateTransitiveConditionally value` - May mutate transitively
**Other Effects:**
- `Render place` - Place is used in render context (JSX props, component return)
- `Freeze place` - Place is frozen (made immutable)
- `Create place` - New value created
- `CreateFunction` - Function expression created, includes `captures` array
- `Apply` - Function application with receiver, function, args, and result
### Hook Aliasing Signatures
Located in `src/HIR/Globals.ts`, hooks can define custom aliasing signatures to control how data flows through them.
**Structure:**
```typescript
aliasing: {
receiver: '@receiver', // The hook function itself
params: ['@param0'], // Named positional parameters
rest: '@rest', // Rest parameters (or null)
returns: '@returns', // Return value
temporaries: [], // Temporary values during execution
effects: [ // Array of effects to apply when hook is called
{kind: 'Freeze', value: '@param0', reason: ValueReason.HookCaptured},
{kind: 'Assign', from: '@param0', into: '@returns'},
],
}
```
**Common patterns:**
1. **RenderHookAliasing** (useState, useContext, useMemo, useCallback):
- Freezes arguments (`Freeze @rest`)
- Marks arguments as render-time (`Render @rest`)
- Creates frozen return value
- Aliases arguments to return
2. **EffectHookAliasing** (useEffect, useLayoutEffect, useInsertionEffect):
- Freezes function and deps
- Creates internal effect object
- Captures function and deps into effect
- Returns undefined
3. **Event handler hooks** (useEffectEvent):
- Freezes callback (`Freeze @fn`)
- Aliases input to return (`Assign @fn -> @returns`)
- NO Render effect (callback not called during render)
**Example: useEffectEvent**
```typescript
const UseEffectEventHook = addHook(
DEFAULT_SHAPES,
{
positionalParams: [Effect.Freeze], // Takes one positional param
restParam: null,
returnType: {kind: 'Function', ...},
calleeEffect: Effect.Read,
hookKind: 'useEffectEvent',
returnValueKind: ValueKind.Frozen,
aliasing: {
receiver: '@receiver',
params: ['@fn'], // Name for the callback parameter
rest: null,
returns: '@returns',
temporaries: [],
effects: [
{kind: 'Freeze', value: '@fn', reason: ValueReason.HookCaptured},
{kind: 'Assign', from: '@fn', into: '@returns'},
// Note: NO Render effect - callback is not called during render
],
},
},
BuiltInUseEffectEventId,
);
// Add as both names for compatibility
['useEffectEvent', UseEffectEventHook],
['experimental_useEffectEvent', UseEffectEventHook],
```
**Key insight:** If a hook is missing an `aliasing` config, it falls back to `DefaultNonmutatingHook` which includes a `Render` effect on all arguments. This can cause false positives for hooks like `useEffectEvent` whose callbacks are not called during render.
## Feature Flags
Feature flags are configured in `src/HIR/Environment.ts`, for example `enableJsxOutlining`. Test fixtures can override the active feature flags used for that fixture via a comment pragma on the first line of the fixture input, for example:
```javascript
// enableJsxOutlining @enableChangeVariableCodegen:false
...code...
```
Would enable the `enableJsxOutlining` feature and disable the `enableChangeVariableCodegen` feature.
## Debugging Tips
1. Run `yarn snap -p <fixture>` to see full HIR output with effects
2. Look for `@aliasingEffects=` on FunctionExpressions
3. Look for `Impure`, `Render`, `Capture` effects on instructions
4. Check the pass ordering in Pipeline.ts to understand when effects are populated vs validated
## Error Handling for Unsupported Features
When the compiler encounters an unsupported but known pattern, use `CompilerError.throwTodo()` instead of `CompilerError.invariant()`. Todo errors cause graceful bailouts in production; Invariant errors are hard failures indicating unexpected/invalid states.
```typescript
// Unsupported but expected pattern - graceful bailout
CompilerError.throwTodo({
reason: `Support [description of unsupported feature]`,
loc: terminal.loc,
});
// Invariant is for truly unexpected/invalid states - hard failure
CompilerError.invariant(false, {
reason: `Unexpected [thing]`,
loc: terminal.loc,
});
```

View File

@@ -26,5 +26,3 @@ export const FIXTURES_PATH = path.join(
'compiler',
);
export const SNAPSHOT_EXTENSION = '.expect.md';
export const FILTER_FILENAME = 'testfilter.txt';
export const FILTER_PATH = path.join(PROJECT_ROOT, FILTER_FILENAME);

View File

@@ -8,7 +8,7 @@
import fs from 'fs/promises';
import * as glob from 'glob';
import path from 'path';
import {FILTER_PATH, FIXTURES_PATH, SNAPSHOT_EXTENSION} from './constants';
import {FIXTURES_PATH, SNAPSHOT_EXTENSION} from './constants';
const INPUT_EXTENSIONS = [
'.js',
@@ -22,19 +22,9 @@ const INPUT_EXTENSIONS = [
];
export type TestFilter = {
debug: boolean;
paths: Array<string>;
};
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
function stripExtension(filename: string, extensions: Array<string>): string {
for (const ext of extensions) {
if (filename.endsWith(ext)) {
@@ -44,37 +34,6 @@ function stripExtension(filename: string, extensions: Array<string>): string {
return filename;
}
export async function readTestFilter(): Promise<TestFilter | null> {
if (!(await exists(FILTER_PATH))) {
throw new Error(`testfilter file not found at \`${FILTER_PATH}\``);
}
const input = await fs.readFile(FILTER_PATH, 'utf8');
const lines = input.trim().split('\n');
let debug: boolean = false;
const line0 = lines[0];
if (line0 != null) {
// Try to parse pragmas
let consumedLine0 = false;
if (line0.indexOf('@only') !== -1) {
consumedLine0 = true;
}
if (line0.indexOf('@debug') !== -1) {
debug = true;
consumedLine0 = true;
}
if (consumedLine0) {
lines.shift();
}
}
return {
debug,
paths: lines.filter(line => !line.trimStart().startsWith('//')),
};
}
export function getBasename(fixture: TestFixture): string {
return stripExtension(path.basename(fixture.inputPath), INPUT_EXTENSIONS);
}

View File

@@ -8,8 +8,8 @@
import watcher from '@parcel/watcher';
import path from 'path';
import ts from 'typescript';
import {FILTER_FILENAME, FIXTURES_PATH, PROJECT_ROOT} from './constants';
import {TestFilter, readTestFilter} from './fixture-utils';
import {FIXTURES_PATH, PROJECT_ROOT} from './constants';
import {TestFilter} from './fixture-utils';
import {execSync} from 'child_process';
export function watchSrc(
@@ -117,6 +117,10 @@ export type RunnerState = {
lastUpdate: number;
mode: RunnerMode;
filter: TestFilter | null;
debug: boolean;
// Input mode for interactive pattern entry
inputMode: 'none' | 'pattern';
inputBuffer: string;
};
function subscribeFixtures(
@@ -142,26 +146,6 @@ function subscribeFixtures(
});
}
function subscribeFilterFile(
state: RunnerState,
onChange: (state: RunnerState) => void,
) {
watcher.subscribe(PROJECT_ROOT, async (err, events) => {
if (err) {
console.error(err);
process.exit(1);
} else if (
events.findIndex(event => event.path.includes(FILTER_FILENAME)) !== -1
) {
if (state.mode.filter) {
state.filter = await readTestFilter();
state.mode.action = RunnerAction.Test;
onChange(state);
}
}
});
}
function subscribeTsc(
state: RunnerState,
onChange: (state: RunnerState) => void,
@@ -200,15 +184,67 @@ function subscribeKeyEvents(
onChange: (state: RunnerState) => void,
) {
process.stdin.on('keypress', async (str, key) => {
// Handle input mode (pattern entry)
if (state.inputMode !== 'none') {
if (key.name === 'return') {
// Enter pressed - process input
const pattern = state.inputBuffer.trim();
state.inputMode = 'none';
state.inputBuffer = '';
process.stdout.write('\n');
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');
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');
}
return;
} else if (str && !key.ctrl && !key.meta) {
// Regular character - accumulate and echo
state.inputBuffer += str;
process.stdout.write(str);
return;
}
return; // Ignore other keys in input mode
}
// Normal mode keypress handling
if (key.name === 'u') {
// u => update fixtures
state.mode.action = RunnerAction.Update;
} else if (key.name === 'q') {
process.exit(0);
} else if (key.name === 'f') {
state.mode.filter = !state.mode.filter;
state.filter = state.mode.filter ? await readTestFilter() : null;
} else if (key.name === 'a') {
// a => exit filter mode and run all tests
state.mode.filter = false;
state.filter = null;
state.mode.action = RunnerAction.Test;
} else if (key.name === 'd') {
// d => toggle debug logging
state.debug = !state.debug;
state.mode.action = RunnerAction.Test;
} else if (key.name === 'p') {
// p => enter pattern input mode
state.inputMode = 'pattern';
state.inputBuffer = '';
process.stdout.write('Pattern: ');
return; // Don't trigger onChange yet
} else {
// any other key re-runs tests
state.mode.action = RunnerAction.Test;
@@ -219,21 +255,33 @@ function subscribeKeyEvents(
export async function makeWatchRunner(
onChange: (state: RunnerState) => void,
filterMode: boolean,
debugMode: boolean,
initialPattern?: string,
): Promise<void> {
const state = {
// Determine initial filter state
let filter: TestFilter | null = null;
let filterEnabled = false;
if (initialPattern) {
filter = {paths: [initialPattern]};
filterEnabled = true;
}
const state: RunnerState = {
compilerVersion: 0,
isCompilerBuildValid: false,
lastUpdate: -1,
mode: {
action: RunnerAction.Test,
filter: filterMode,
filter: filterEnabled,
},
filter: filterMode ? await readTestFilter() : null,
filter,
debug: debugMode,
inputMode: 'none',
inputBuffer: '',
};
subscribeTsc(state, onChange);
subscribeFixtures(state, onChange);
subscribeKeyEvents(state, onChange);
subscribeFilterFile(state, onChange);
}

View File

@@ -12,8 +12,8 @@ import * as readline from 'readline';
import ts from 'typescript';
import yargs from 'yargs';
import {hideBin} from 'yargs/helpers';
import {FILTER_PATH, PROJECT_ROOT} from './constants';
import {TestFilter, getFixtures, readTestFilter} from './fixture-utils';
import {PROJECT_ROOT} from './constants';
import {TestFilter, getFixtures} from './fixture-utils';
import {TestResult, TestResults, report, update} from './reporter';
import {
RunnerAction,
@@ -33,9 +33,9 @@ type RunnerOptions = {
sync: boolean;
workerThreads: boolean;
watch: boolean;
filter: boolean;
update: boolean;
pattern?: string;
debug: boolean;
};
const opts: RunnerOptions = yargs
@@ -59,18 +59,16 @@ const opts: RunnerOptions = yargs
.alias('u', 'update')
.describe('update', 'Update fixtures')
.default('update', false)
.boolean('filter')
.describe(
'filter',
'Only run fixtures which match the contents of testfilter.txt',
)
.default('filter', false)
.string('pattern')
.alias('p', 'pattern')
.describe(
'pattern',
'Optional glob pattern to filter fixtures (e.g., "error.*", "use-memo")',
)
.boolean('debug')
.alias('d', 'debug')
.describe('debug', 'Enable debug logging to print HIR for each pass')
.default('debug', false)
.help('help')
.strict()
.parseSync(hideBin(process.argv)) as RunnerOptions;
@@ -82,12 +80,15 @@ async function runFixtures(
worker: Worker & typeof runnerWorker,
filter: TestFilter | null,
compilerVersion: number,
debug: boolean,
requireSingleFixture: boolean,
): Promise<TestResults> {
// We could in theory be fancy about tracking the contents of the fixtures
// directory via our file subscription, but it's simpler to just re-read
// the directory each time.
const fixtures = await getFixtures(filter);
const isOnlyFixture = filter !== null && fixtures.size === 1;
const shouldLog = debug && (!requireSingleFixture || isOnlyFixture);
let entries: Array<[string, TestResult]>;
if (!opts.sync) {
@@ -96,12 +97,7 @@ async function runFixtures(
for (const [fixtureName, fixture] of fixtures) {
work.push(
worker
.transformFixture(
fixture,
compilerVersion,
(filter?.debug ?? false) && isOnlyFixture,
true,
)
.transformFixture(fixture, compilerVersion, shouldLog, true)
.then(result => [fixtureName, result]),
);
}
@@ -113,7 +109,7 @@ async function runFixtures(
let output = await runnerWorker.transformFixture(
fixture,
compilerVersion,
(filter?.debug ?? false) && isOnlyFixture,
shouldLog,
true,
);
entries.push([fixtureName, output]);
@@ -128,7 +124,7 @@ async function onChange(
worker: Worker & typeof runnerWorker,
state: RunnerState,
) {
const {compilerVersion, isCompilerBuildValid, mode, filter} = state;
const {compilerVersion, isCompilerBuildValid, mode, filter, debug} = state;
if (isCompilerBuildValid) {
const start = performance.now();
@@ -142,6 +138,8 @@ async function onChange(
worker,
mode.filter ? filter : null,
compilerVersion,
debug,
true, // requireSingleFixture in watch mode
);
const end = performance.now();
if (mode.action === RunnerAction.Update) {
@@ -159,11 +157,13 @@ async function onChange(
console.log(
'\n' +
(mode.filter
? `Current mode = FILTER, filter test fixtures by "${FILTER_PATH}".`
? `Current mode = FILTER, pattern = "${filter?.paths[0] ?? ''}".`
: 'Current mode = NORMAL, run all test fixtures.') +
'\nWaiting for input or file changes...\n' +
'u - update all fixtures\n' +
`f - toggle (turn ${mode.filter ? 'off' : 'on'}) filter mode\n` +
`d - toggle (turn ${debug ? 'off' : 'on'}) debug logging\n` +
'p - enter pattern to filter fixtures\n' +
(mode.filter ? 'a - run all tests (exit filter mode)\n' : '') +
'q - quit\n' +
'[any] - rerun tests\n',
);
@@ -180,15 +180,12 @@ export async function main(opts: RunnerOptions): Promise<void> {
worker.getStderr().pipe(process.stderr);
worker.getStdout().pipe(process.stdout);
// If pattern is provided, force watch mode off and use pattern filter
const shouldWatch = opts.watch && opts.pattern == null;
if (opts.watch && opts.pattern != null) {
console.warn('NOTE: --watch is ignored when a --pattern is supplied');
}
// Check if watch mode should be enabled
const shouldWatch = opts.watch;
if (shouldWatch) {
makeWatchRunner(state => onChange(worker, state), opts.filter);
if (opts.filter) {
makeWatchRunner(state => onChange(worker, state), opts.debug, opts.pattern);
if (opts.pattern) {
/**
* Warm up wormers when in watch mode. Loading the Forget babel plugin
* and all of its transitive dependencies takes 1-3s (per worker) on a M1.
@@ -236,14 +233,17 @@ export async function main(opts: RunnerOptions): Promise<void> {
let testFilter: TestFilter | null = null;
if (opts.pattern) {
testFilter = {
debug: true,
paths: [opts.pattern],
};
} else if (opts.filter) {
testFilter = await readTestFilter();
}
const results = await runFixtures(worker, testFilter, 0);
const results = await runFixtures(
worker,
testFilter,
0,
opts.debug,
false, // no requireSingleFixture in non-watch mode
);
if (opts.update) {
update(results);
isSuccess = true;