Compare commits

..

8 Commits

Author SHA1 Message Date
Joe Savona
cca2c4beda [compiler] Simplify validation against writing refs during render
Uses a simple algorithm for detecting writes of refs during render, since the ref _read_ during render is now handled as part of ValidateNoImpureValuesInRender.
2026-01-14 17:21:24 -08:00
Joe Savona
13f6508c21 [compiler] Update snap to support --nodebug
Passing `--nodebug` will disable debug mode, even if only a single fixture is filtered. This can help when running tests one by one and checking if their output is correct - debug mode causes tests to fail because there is extra/different error output.
2026-01-14 13:40:15 -08:00
Joe Savona
ae3c5a707f [compiler] Revert error description 2026-01-14 13:18:15 -08:00
Joe Savona
cb021861a5 [compiler] Simplify ref mutation validation to single forward pass
Rewrites ValidateNoRefAccessInRender to use a simpler single-pass algorithm
that only validates ref mutations (reads are now handled separately by
ValidateNoImpureValuesInRender).

The new approach:
- Tracks refs and ref values through the function
- Identifies functions that mutate refs (directly or transitively)
- Only errors when ref-mutating functions are called at the top level
- Supports null-guard exception: mutations inside `if (ref.current == null)`
  are allowed for the initialization pattern

This reduces ~700 lines of complex fixpoint iteration to ~400 lines of
straightforward forward data-flow analysis.
2026-01-14 13:11:17 -08:00
Joe Savona
7505808cd4 [compiler] Claude file/settings 2026-01-13 18:57:40 -08:00
Joe Savona
a974753ddf [compiler] Use aliasing effects for impurity inference 2026-01-13 18:56:35 -08:00
Joe Savona
2c4a3b9587 [compiler] Validate ref reads in render via improved impure value validation
Updates to guard against *reading* refs during render via the improved validateNoImpureValuesInRender() pass. InferMutationAliasingEffects generates `Impure` effects for ref reads, and then this pass validates that those accesses don't flow into `Render` effects. We now call the impure value validation first so that it takes priority over validateNoRefAccessInRender - the latter still has all the existing logic for now, but we can dramatically simplify it in a follow-up PR so that it only has to validate against ref writes.
2026-01-12 16:38:51 -08:00
Joe Savona
8428a7ec5f [compiler] Improve impurity/ref tracking
note: This implements the idea discussed in https://github.com/reactwg/react/discussions/389#discussioncomment-14252280

Currently we create `Impure` effects for impure functions like `Date.now()` or `Math.random()`, and then throw if the effect is reachable during render. However, impurity is a property of the resulting value: if the value isn't accessed during render then it's okay: maybe you're console-logging the time while debugging (fine), or storing the impure value into a ref and only accessing it in an effect or event handler (totally ok).

This PR updates to validate that impure values are not transitively consumed during render, building on the `Render` effect. The validation currently uses an algorithm very similar to that of InferReactivePlaces - building a set of known impure values, starting with `Impure` effects as the sources and then flowing outward via data flow and mutations. If a transitively impure value reaches a `Render` effect, it's an error. We record both the source of the impure value and where it gets rendered to make it easier to understand and fix errors:

```
Error: Cannot access impure value during render

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-via-render-helper.ts:10:25
   8 |     const array = makeArray(now);
   9 |     const hasDate = identity(array);
> 10 |     return <Bar hasDate={hasDate} />;
     |                          ^^^^^^^ Cannot access impure value during render
  11 |   };
  12 |   return <Foo renderItem={renderItem} />;
  13 | }

error.invalid-impure-functions-in-render-via-render-helper.ts:6:14
  4 |
  5 | function Component() {
> 6 |   const now = Date.now();
    |               ^^^^^^^^^^ `Date.now` is an impure function.
  7 |   const renderItem = () => {
  8 |     const array = makeArray(now);
  9 |     const hasDate = identity(array);
```

Impure values are allowed to flow into refs, meaning that we now allow `useRef(Date.now())` or `useRef(localFunctionThatReturnsMathDotRandom())` which would have errored previously. The next PR reuses this improved impurity tracking to validate ref access in render as well.
2026-01-12 16:38:51 -08:00
26 changed files with 338 additions and 641 deletions

View File

@@ -9,10 +9,7 @@
"Bash(done)",
"Bash(cat:*)",
"Bash(sl revert:*)",
"Bash(yarn workspace snap run build:*)",
"Bash(yarn tsc:*)",
"Bash(yarn snap:build)",
"Bash(timeout 30 yarn snap:*)"
"Bash(yarn workspace snap run build:*)"
],
"deny": [],
"ask": []

View File

@@ -19,16 +19,10 @@ This document contains knowledge about the React Compiler gathered during develo
# Run all tests
yarn snap
# Run tests matching a pattern
# Example: yarn snap -p 'error.*'
# Run specific test by pattern
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)
# Update fixture outputs
yarn snap -u
```

View File

@@ -132,6 +132,12 @@ export class CompilerDiagnostic {
return new CompilerDiagnostic({...options, details: []});
}
clone(): CompilerDiagnostic {
const cloned = CompilerDiagnostic.create({...this.options});
cloned.options.details = [...this.options.details];
return cloned;
}
get reason(): CompilerDiagnosticOptions['reason'] {
return this.options.reason;
}

View File

@@ -626,7 +626,7 @@ const TYPED_GLOBALS: Array<[string, BuiltInType]> = [
// TODO: rest of Global objects
];
const createRenderHookAliasing: (
const RenderHookAliasing: (
reason: ValueReason,
) => AliasingSignatureConfig = reason => ({
receiver: '@receiver',
@@ -745,12 +745,12 @@ const useEffectEvent = addHook(
returns: '@return',
temporaries: [],
effects: [
{kind: 'Assign', from: '@value', into: '@return'},
{
kind: 'Freeze',
value: '@value',
reason: ValueReason.HookCaptured,
},
{kind: 'Assign', from: '@value', into: '@return'},
],
},
},
@@ -769,7 +769,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useContext',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.Context,
aliasing: createRenderHookAliasing(ValueReason.Context),
aliasing: RenderHookAliasing(ValueReason.Context),
},
BuiltInUseContextHookId,
),
@@ -784,7 +784,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useState',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: createRenderHookAliasing(ValueReason.State),
aliasing: RenderHookAliasing(ValueReason.State),
}),
],
[
@@ -797,7 +797,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useActionState',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -810,7 +810,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useReducer',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.ReducerState,
aliasing: createRenderHookAliasing(ValueReason.ReducerState),
aliasing: RenderHookAliasing(ValueReason.ReducerState),
}),
],
[
@@ -822,22 +822,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useRef',
returnValueKind: ValueKind.Mutable,
aliasing: {
receiver: '@receiver',
params: [],
rest: '@rest',
returns: '@return',
temporaries: [],
effects: [
{
kind: 'Create',
into: '@return',
value: ValueKind.Mutable,
reason: ValueReason.KnownReturnSignature,
},
{kind: 'Capture', from: '@rest', into: '@return'},
],
},
}),
],
[
@@ -860,7 +844,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useMemo',
returnValueKind: ValueKind.Frozen,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -868,16 +852,11 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
addHook(DEFAULT_SHAPES, {
positionalParams: [],
restParam: Effect.Freeze,
returnType: {
kind: 'Function',
isConstructor: false,
return: {kind: 'Poly'},
shapeId: null,
},
returnType: {kind: 'Poly'},
calleeEffect: Effect.Read,
hookKind: 'useCallback',
returnValueKind: ValueKind.Frozen,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -937,7 +916,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useTransition',
returnValueKind: ValueKind.Frozen,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -950,7 +929,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useOptimistic',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -964,7 +943,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
returnType: {kind: 'Poly'},
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Frozen,
aliasing: createRenderHookAliasing(ValueReason.HookCaptured),
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
},
BuiltInUseOperatorId,
),

View File

@@ -983,7 +983,15 @@ export function printAliasingEffect(effect: AliasingEffect): string {
return `...${printPlaceForAliasEffect(arg.place)}`;
})
.join(', ');
return `Apply ${printPlaceForAliasEffect(effect.into)} = ${receiverCallee}(${args})`;
let signature = '';
if (effect.signature != null) {
if (effect.signature.aliasing != null) {
signature = printAliasingSignature(effect.signature.aliasing);
} else {
signature = JSON.stringify(effect.signature, null, 2);
}
}
return `Apply ${printPlaceForAliasEffect(effect.into)} = ${receiverCallee}(${args})${signature != '' ? '\n ' : ''}${signature}`;
}
case 'Freeze': {
return `Freeze ${printPlaceForAliasEffect(effect.value)} ${effect.reason}`;

View File

@@ -29,6 +29,7 @@ import {
isArrayType,
isJsxOrJsxUnionType,
isMapType,
isMutableEffect,
isPrimitiveType,
isRefOrRefValue,
isSetType,
@@ -1093,8 +1094,6 @@ function applyEffect(
break;
}
case 'Apply': {
effects.push(effect);
const functionValues = state.values(effect.function);
if (
functionValues.length === 1 &&
@@ -2202,8 +2201,14 @@ function computeSignatureForInstruction(
if (isUseRefType(place.identifier)) {
continue;
}
if (place.identifier.type.kind !== 'Function') {
// Functions are checked independently
if (place.identifier.type.kind === 'Function') {
if (isJsxOrJsxUnionType(place.identifier.type.return)) {
effects.push({
kind: 'Render',
place,
});
}
} else {
effects.push({
kind: 'Render',
place,

View File

@@ -20,7 +20,6 @@ import {
Place,
isPrimitiveType,
isUseRefType,
isJsxOrJsxUnionType,
} from '../HIR/HIR';
import {
eachInstructionLValue,
@@ -397,7 +396,17 @@ export function inferMutationAliasingRanges(
break;
}
case 'Apply': {
break;
CompilerError.invariant(false, {
reason: `[AnalyzeFunctions] Expected Apply effects to be replaced with more precise effects`,
description: null,
details: [
{
kind: 'error',
loc: effect.function.loc,
message: null,
},
],
});
}
case 'MutateTransitive':
case 'MutateConditionally':
@@ -579,10 +588,7 @@ export function inferMutationAliasingRanges(
}
}
if (
errors.hasAnyErrors() &&
(!isFunctionExpression || isJsxOrJsxUnionType(fn.returns.identifier.type))
) {
if (errors.hasAnyErrors() && !isFunctionExpression) {
return Err(errors);
}
return Ok(functionEffects);

View File

@@ -5,36 +5,43 @@
* LICENSE file in the root directory of this source tree.
*/
import {CompilerDiagnostic, CompilerError} from '..';
import prettyFormat from 'pretty-format';
import {CompilerDiagnostic, CompilerError, Effect} from '..';
import {
areEqualSourceLocations,
HIRFunction,
IdentifierId,
InstructionId,
isJsxType,
isRefValueType,
isUseRefType,
} from '../HIR';
import {AliasingEffect, hashEffect} from '../Inference/AliasingEffects';
import {
eachInstructionLValue,
eachInstructionValueOperand,
} from '../HIR/visitors';
import {AliasingEffect} from '../Inference/AliasingEffects';
import {createControlDominators} from '../Inference/ControlDominators';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
import {Err, Ok, Result} from '../Utils/Result';
import {getOrInsertWith} from '../Utils/utils';
import {
assertExhaustive,
getOrInsertWith,
Set_filter,
Set_subtract,
} from '../Utils/utils';
import {printInstruction} from '../HIR/PrintHIR';
type ImpureEffect = Extract<AliasingEffect, {kind: 'Impure'}>;
type RenderEffect = Extract<AliasingEffect, {kind: 'Render'}>;
type FunctionCache = Map<HIRFunction, Map<string, ImpuritySignature>>;
type ImpuritySignature = {
effects: Map<IdentifierId, ImpureEffect>;
error: CompilerError;
returns: IdentifierId;
};
type ImpuritySignature = {effects: Array<ImpureEffect>; error: CompilerError};
export function validateNoImpureValuesInRender(
fn: HIRFunction,
): Result<void, CompilerError> {
const impure = new Map<IdentifierId, ImpureEffect>();
const impureFunctions = new Map<IdentifierId, ImpuritySignature>();
const result = inferImpureValues(fn, impure, impureFunctions, new Map());
const result = inferImpureValues(fn, impure, new Map());
if (result.error.hasAnyErrors()) {
return Err(result.error);
@@ -45,23 +52,15 @@ export function validateNoImpureValuesInRender(
function inferFunctionExpressionMemo(
fn: HIRFunction,
impure: Map<IdentifierId, ImpureEffect>,
impureFunctions: Map<IdentifierId, ImpuritySignature>,
cache: FunctionCache,
): ImpuritySignature {
const key = fn.context
.map(
place =>
`${place.identifier.id}:${impure.has(place.identifier.id)}:${Array.from(
impureFunctions.get(place.identifier.id)?.effects ?? new Map(),
)
.map(([id, effect]) => `${id}=>${effect.into.identifier.id}`)
.join(',')}`,
)
.map(place => `${place.identifier.id}:${impure.has(place.identifier.id)}`)
.join(',');
return getOrInsertWith(
getOrInsertWith(cache, fn, () => new Map()),
key,
() => inferImpureValues(fn, impure, impureFunctions, cache),
() => inferImpureValues(fn, impure, cache),
);
}
@@ -69,7 +68,6 @@ function processEffects(
id: InstructionId,
effects: Array<AliasingEffect>,
impure: Map<IdentifierId, ImpureEffect>,
impureFunctions: Map<IdentifierId, ImpuritySignature>,
cache: FunctionCache,
): boolean {
let hasChanges = false;
@@ -111,30 +109,6 @@ function processEffects(
hasChanges = true;
}
}
if (
(effect.kind === 'Alias' ||
effect.kind === 'Assign' ||
effect.kind === 'ImmutableCapture') &&
!rendered.has(effect.into.identifier.id) &&
!isJsxType(effect.into.identifier.type)
) {
const functionEffect = impureFunctions.get(effect.from.identifier.id);
if (
functionEffect != null &&
!impureFunctions.has(effect.into.identifier.id)
/*
* TODO: check if the function signature has changed (should be rare)
* ||
* !areEqualFunctionSignatures(
* impureFunctions.get(effect.into.identifier.id)!.effects,
* functionEffect.effects,
* )
*/
) {
impureFunctions.set(effect.into.identifier.id, functionEffect);
hasChanges = true;
}
}
break;
}
case 'Impure': {
@@ -151,44 +125,26 @@ function processEffects(
const result = inferFunctionExpressionMemo(
effect.function.loweredFunc.func,
impure,
impureFunctions,
cache,
);
if (result.error.hasAnyErrors()) {
break;
}
const previousResult = impureFunctions.get(effect.into.identifier.id);
if (
previousResult == null ||
!areEqualFunctionSignatures(result.effects, previousResult.effects)
) {
impureFunctions.set(effect.into.identifier.id, result);
const impureEffect: ImpureEffect | null =
result.effects.find(
(functionEffect: AliasingEffect): functionEffect is ImpureEffect =>
functionEffect.kind === 'Impure' &&
functionEffect.into.identifier.id ===
effect.function.loweredFunc.func.returns.identifier.id,
) ?? null;
if (impureEffect != null) {
impure.set(effect.into.identifier.id, impureEffect);
hasChanges = true;
}
break;
}
case 'Apply': {
const functionSignature = impureFunctions.get(
effect.function.identifier.id,
);
if (functionSignature != null) {
for (const [id, functionEffect] of functionSignature.effects) {
if (!impure.has(id)) {
impure.set(id, functionEffect);
hasChanges = true;
}
if (
id === functionSignature.returns &&
!impure.has(effect.into.identifier.id)
) {
impure.set(effect.into.identifier.id, functionEffect);
hasChanges = true;
}
}
}
break;
}
case 'MaybeAlias':
case 'Apply':
case 'Create':
case 'Freeze':
case 'Mutate':
@@ -207,7 +163,6 @@ function processEffects(
function inferImpureValues(
fn: HIRFunction,
impure: Map<IdentifierId, ImpureEffect>,
impureFunctions: Map<IdentifierId, ImpuritySignature>,
cache: FunctionCache,
): ImpuritySignature {
const getBlockControl = createControlDominators(fn, place => {
@@ -215,15 +170,14 @@ function inferImpureValues(
});
let hasChanges = false;
let iterations = 0;
do {
hasChanges = false;
if (iterations++ > 100) {
throw new Error('too many iterations');
}
for (const block of fn.body.blocks.values()) {
const controlPlace = getBlockControl(block.id);
const controlImpureEffect =
controlPlace != null ? impure.get(controlPlace.identifier.id) : null;
for (const phi of block.phis) {
if (impure.has(phi.place.identifier.id)) {
// Already marked impure on a previous pass
@@ -255,38 +209,11 @@ function inferImpureValues(
}
}
/**
* TODO: consider propagating impurity for assignments/mutations that
* are controlled by an impure value.
*
* ```
* const controlPlace = getBlockControl(block.id);
* const controlImpureEffect =
* controlPlace != null ? impure.get(controlPlace.identifier.id) : null;
* ```
*
* Example
*
* This should error since we know the semantics of array.push, it's a definite
* Mutate and definite Capture, not maybemutate+maybecapture:
*
* ```
* let x = [];
* if (Date.now() < START_DATE) {
* x.push(1);
* }
* return <Foo x={x} />
* ```
*/
for (const instr of block.instructions) {
const _impure = new Set(impure.keys());
hasChanges =
processEffects(
instr.id,
instr.effects ?? [],
impure,
impureFunctions,
cache,
) || hasChanges;
processEffects(instr.id, instr.effects ?? [], impure, cache) ||
hasChanges;
}
if (block.terminal.kind === 'return' && block.terminal.effects != null) {
hasChanges =
@@ -294,7 +221,6 @@ function inferImpureValues(
block.terminal.id,
block.terminal.effects,
impure,
impureFunctions,
cache,
) || hasChanges;
}
@@ -306,19 +232,10 @@ function inferImpureValues(
name: 'ValidateNoImpureValuesInRender',
value: JSON.stringify(Array.from(impure.keys()).sort(), null, 2),
});
fn.env.logger?.debugLogIRs?.({
kind: 'debug',
name: 'ValidateNoImpureValuesInRender (function)',
value: JSON.stringify(Array.from(impureFunctions.keys()).sort(), null, 2),
});
const error = new CompilerError();
function validateRenderEffect(effect: RenderEffect): void {
let impureEffect = impure.get(effect.place.identifier.id);
if (impureEffect == null) {
const functionSignature = impureFunctions.get(effect.place.identifier.id);
impureEffect = functionSignature?.effects.get(functionSignature.returns);
}
const impureEffect = impure.get(effect.place.identifier.id);
if (impureEffect == null) {
return;
}
@@ -350,7 +267,6 @@ function inferImpureValues(
const result = inferFunctionExpressionMemo(
value.loweredFunc.func,
impure,
impureFunctions,
cache,
);
if (result.error.hasAnyErrors()) {
@@ -371,26 +287,21 @@ function inferImpureValues(
}
}
}
const impureEffects: Map<IdentifierId, ImpureEffect> = new Map();
const impureEffects: Array<ImpureEffect> = [];
for (const param of [...fn.context, ...fn.params, fn.returns]) {
const place = param.kind === 'Identifier' ? param : param.place;
const impureEffect = impure.get(place.identifier.id);
if (impureEffect != null) {
impureEffects.set(place.identifier.id, impureEffect);
impureEffects.push({
kind: 'Impure',
into: impureEffect.into,
category: impureEffect.category,
reason: impureEffect.reason,
description: impureEffect.description,
sourceMessage: impureEffect.sourceMessage,
usageMessage: impureEffect.usageMessage,
});
}
}
return {effects: impureEffects, error, returns: fn.returns.identifier.id};
}
function areEqualFunctionSignatures(
sig1: Map<IdentifierId, ImpureEffect>,
sig2: Map<IdentifierId, ImpureEffect>,
): boolean {
return (
sig1.size === sig2.size &&
Array.from(sig1).every(
([id, effect]) =>
sig2.has(id) && hashEffect(effect) === hashEffect(sig2.get(id)!),
)
);
return {effects: impureEffects, error};
}

View File

@@ -1,137 +0,0 @@
# ValidateNoRefAccessInRender
This document summarizes the design and key learnings for the ref mutation validation pass.
## Purpose
Validates that a function does not mutate a ref value during render. This ensures React components follow the rules of React by not writing to `ref.current` during the render phase.
## Key Concepts
### Ref vs RefValue
- **Ref**: The ref object itself (e.g., `useRef()` return value). Has type `React.RefObject<T>`.
- **RefValue**: The `.current` property of a ref. This is the mutable value that should not be accessed during render.
The validation tracks both using a `RefInfo` type with a `refId` that correlates refs with their `.current` values.
### What Constitutes a Mutation
A mutation is any `PropertyStore` or `ComputedStore` instruction where:
1. The target object is a known ref (tracked in the `refs` map)
2. OR the target object has a ref type (`isUseRefType`)
### Allowed Patterns
1. **Event handlers and effect callbacks**: Functions that are not called at the top level during render can mutate refs freely.
2. **Null-guard initialization**: The pattern `if (ref.current == null) { ref.current = value; }` is allowed because it's a common lazy initialization pattern that only runs once.
## Algorithm: Single Forward Data-Flow Pass
The validation uses a single forward pass over all blocks:
### Phase 1: Track Refs
- Initialize refs from function params and context (captured variables)
- Process phi nodes to propagate ref info through control flow joins
- Track refs through LoadLocal, StoreLocal, PropertyLoad operations
### Phase 2: Detect Null Guards
- Track nullable values (null literals, undefined)
- Track binary comparisons of `ref.current` to null (`==`, `===`, `!=`, `!==`)
- Mark blocks as "safe" for specific refs when inside null-guard branches
- Propagate safety through control flow until fallthrough
### Phase 3: Validate Mutations
- For PropertyStore/ComputedStore on refs:
- If inside a null-guard for this ref: allow (but track for duplicate detection)
- If at top level: error immediately
- If in nested function: track for later (error if function is called)
### Phase 4: Track Ref-Mutating Functions
- When a FunctionExpression mutates a ref, track it in `refMutatingFunctions`
- When such a function is called at top level, report the error at the mutation site
## Key Data Structures
```typescript
// Correlates refs with their .current values
type RefInfo = {
kind: 'Ref' | 'RefValue';
refId: number;
};
// Tracks null-guard conditions
type GuardInfo = {
refId: number;
isEquality: boolean; // true for ==, ===; false for !=, !==
};
// Information about a mutation (for error reporting)
type MutationInfo = {
loc: SourceLocation;
isCurrentProperty: boolean;
};
```
## Error Reporting
### Error Location
Errors highlight the **entire instruction** (e.g., `ref.current = value`), not just the ref identifier. This is achieved by using `instr.loc` instead of `value.object.loc`.
### Duplicate Initialization
When a ref is initialized more than once inside a null-guard:
1. Primary error: Points to the second initialization
2. Secondary error: Points to the first initialization with "Ref was first initialized here"
### Transitive Mutations
When a function that mutates refs is called during render:
- The error points to the mutation site inside the function
- Not the call site (the call site is what triggers the check)
## Edge Cases and Patterns
### Unary NOT on Guards
The `!` operator inverts guard polarity:
```javascript
if (!ref.current) { ... } // Same as: if (ref.current == null)
```
### Nested Functions
Functions defined during render but not called are allowed to mutate refs:
```javascript
// OK - onClick is not called during render
const onClick = () => { ref.current = value; };
return <button onClick={onClick} />;
```
### Props with Ref Type
Refs can come from props. The validation handles `props.ref` by checking type information.
## Limitations / Known Gaps
The following patterns are NOT currently validated by this pass:
1. **Impure values in render**: `Date.now()`, `Math.random()` flowing into render context (handled by `ValidateNoImpureValuesInRender`)
2. **useState/useReducer callbacks**: These hooks call their initializer functions during render, so ref access inside them should error. This requires special hook semantics.
3. **Ref reads during render**: This pass focuses on mutations. Ref reads are handled separately.
## Testing
Test fixtures use naming conventions:
- `error.*.ts` - Fixtures expected to produce compilation errors
- Regular names - Fixtures expected to compile successfully
Run tests with:
```bash
yarn snap -p <pattern> --nodebug # Run specific tests
yarn snap -p <pattern> --nodebug -u # Update expected output
```

View File

@@ -177,20 +177,16 @@ function validateFunction(
if (block.terminal.kind === 'if') {
const guard = guards.get(block.terminal.test.identifier.id);
if (guard != null) {
/*
* For equality checks (==, ===), consequent is safe (condition true = ref is null)
* For inequality checks (!=, !==), alternate is safe (condition false = ref is null)
*/
// For equality checks (==, ===), consequent is safe (condition true = ref is null)
// For inequality checks (!=, !==), alternate is safe (condition false = ref is null)
const safeBlock = guard.isEquality
? block.terminal.consequent
: block.terminal.alternate;
const fallthrough = block.terminal.fallthrough;
/*
* Propagate safety through control flow using a queue
* Stop when we reach the fallthrough (end of the guarded region)
*/
const queue: Array<BlockId> = [safeBlock];
// Propagate safety through control flow using a queue
// Stop when we reach the fallthrough (end of the guarded region)
const queue: BlockId[] = [safeBlock];
const visited = new Set<BlockId>();
while (queue.length > 0) {
const blockId = queue.shift()!;
@@ -392,10 +388,8 @@ function processInstruction(
case 'FunctionExpression':
case 'ObjectMethod': {
/*
* Recursively validate function expressions
* Pass isTopLevel=false since these are nested functions
*/
// Recursively validate function expressions
// Pass isTopLevel=false since these are nested functions
const mutation = validateFunction(
value.loweredFunc.func,
refs,

View File

@@ -22,7 +22,7 @@ export const FIXTURE_ENTRYPOINT = {
## Error
```
Found 2 errors:
Found 1 error:
Error: Cannot access ref value during render
@@ -37,28 +37,6 @@ error.invalid-access-ref-in-reducer.ts:5:29
7 | return <Stringify state={state} />;
8 | }
error.invalid-access-ref-in-reducer.ts:5:35
3 | function Component(props) {
4 | const ref = useRef(props.value);
> 5 | const [state] = useReducer(() => ref.current, null);
| ^^^^^^^^^^^ Ref is initially accessed
6 |
7 | return <Stringify state={state} />;
8 | }
Error: Cannot access ref value 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.invalid-access-ref-in-reducer.ts:7:27
5 | const [state] = useReducer(() => ref.current, null);
6 |
> 7 | return <Stringify state={state} />;
| ^^^^^ Ref value is used during render
8 | }
9 |
10 | export const FIXTURE_ENTRYPOINT = {
error.invalid-access-ref-in-reducer.ts:5:35
3 | function Component(props) {
4 | const ref = useRef(props.value);

View File

@@ -22,7 +22,7 @@ export const FIXTURE_ENTRYPOINT = {
## Error
```
Found 2 errors:
Found 1 error:
Error: Cannot access ref value during render
@@ -37,28 +37,6 @@ error.invalid-access-ref-in-state-initializer.ts:5:27
7 | return <Stringify state={state} />;
8 | }
error.invalid-access-ref-in-state-initializer.ts:5:33
3 | function Component(props) {
4 | const ref = useRef(props.value);
> 5 | const [state] = useState(() => ref.current);
| ^^^^^^^^^^^ Ref is initially accessed
6 |
7 | return <Stringify state={state} />;
8 | }
Error: Cannot access ref value 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.invalid-access-ref-in-state-initializer.ts:7:27
5 | const [state] = useState(() => ref.current);
6 |
> 7 | return <Stringify state={state} />;
| ^^^^^ Ref value is used during render
8 | }
9 |
10 | export const FIXTURE_ENTRYPOINT = {
error.invalid-access-ref-in-state-initializer.ts:5:33
3 | function Component(props) {
4 | const ref = useRef(props.value);

View File

@@ -1,52 +0,0 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {arrayPush, 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 now = getDate();
const array = [];
arrayPush(array, now);
return <Foo hasDate={array} />;
}
```
## Error
```
Found 1 error:
Error: Cannot access impure value during render
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-indirect-via-mutation.ts:15:23
13 | const array = [];
14 | arrayPush(array, now);
> 15 | return <Foo hasDate={array} />;
| ^^^^^ Cannot access impure value during render
16 | }
17 |
error.invalid-impure-functions-in-render-indirect-via-mutation.ts:11:24
9 | */
10 | function Component() {
> 11 | const getDate = () => Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
12 | const now = getDate();
13 | const array = [];
14 | arrayPush(array, now);
```

View File

@@ -7,7 +7,7 @@
import {typedArrayPush, typedIdentity} from 'shared-runtime';
function Component() {
const now = () => Date.now();
const now = Date.now();
const renderItem = () => {
const array = [];
typedArrayPush(array, now());
@@ -29,20 +29,19 @@ Error: Cannot access impure value during render
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-via-render-helper-typed.ts:11:25
9 | typedArrayPush(array, now());
10 | const hasDate = typedIdentity(array);
> 11 | return <Bar hasDate={hasDate} />;
| ^^^^^^^ Cannot access impure value during render
error.invalid-impure-functions-in-render-via-render-helper-typed.ts:13:26
11 | return <Bar hasDate={hasDate} />;
12 | };
13 | return <Foo renderItem={renderItem} />;
> 13 | return <Foo renderItem={renderItem} />;
| ^^^^^^^^^^ Cannot access impure value during render
14 | }
15 |
error.invalid-impure-functions-in-render-via-render-helper-typed.ts:6:20
error.invalid-impure-functions-in-render-via-render-helper-typed.ts:6:14
4 |
5 | function Component() {
> 6 | const now = () => Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
> 6 | const now = Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
7 | const renderItem = () => {
8 | const array = [];
9 | typedArrayPush(array, now());

View File

@@ -3,7 +3,7 @@
import {typedArrayPush, typedIdentity} from 'shared-runtime';
function Component() {
const now = () => Date.now();
const now = Date.now();
const renderItem = () => {
const array = [];
typedArrayPush(array, now());

View File

@@ -0,0 +1,49 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {identity, makeArray} from 'shared-runtime';
function Component() {
const now = Date.now();
const renderItem = () => {
const array = makeArray(now);
const hasDate = identity(array);
return <Bar hasDate={hasDate} />;
};
return <Foo renderItem={renderItem} />;
}
```
## Error
```
Found 1 error:
Error: Cannot access impure value during render
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-via-render-helper.ts:12:26
10 | return <Bar hasDate={hasDate} />;
11 | };
> 12 | return <Foo renderItem={renderItem} />;
| ^^^^^^^^^^ Cannot access impure value during render
13 | }
14 |
error.invalid-impure-functions-in-render-via-render-helper.ts:6:14
4 |
5 | function Component() {
> 6 | const now = Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
7 | const renderItem = () => {
8 | const array = makeArray(now);
9 | const hasDate = identity(array);
```

View File

@@ -6,8 +6,6 @@ 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 <Bar hasDate={hasDate} />;
};

View File

@@ -17,7 +17,7 @@ function Component() {
## Error
```
Found 2 errors:
Found 1 error:
Error: Cannot access impure value during render
@@ -32,27 +32,6 @@ error.invalid-impure-value-in-render-helper.ts:5:17
7 | return <div>{render()}</div>;
8 | }
error.invalid-impure-value-in-render-helper.ts:3:20
1 | // @validateNoImpureFunctionsInRender
2 | function Component() {
> 3 | const now = () => Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
4 | const render = () => {
5 | return <div>{now()}</div>;
6 | };
Error: Cannot access impure value during render
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-value-in-render-helper.ts:7:15
5 | return <div>{now()}</div>;
6 | };
> 7 | return <div>{render()}</div>;
| ^^^^^^^^ Cannot access impure value during render
8 | }
9 |
error.invalid-impure-value-in-render-helper.ts:3:20
1 | // @validateNoImpureFunctionsInRender
2 | function Component() {

View File

@@ -0,0 +1,58 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {arrayPush, 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 now = getDate();
const array = [];
arrayPush(array, now);
return <Foo hasDate={array} />;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoImpureFunctionsInRender
import { arrayPush, 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 now = getDate();
const array = [];
arrayPush(array, now);
t0 = <Foo hasDate={array} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
function _temp() {
return Date.now();
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -1,51 +0,0 @@
## 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 <Bar hasDate={hasDate} />;
};
return <Foo renderItem={renderItem} />;
}
```
## 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 <Bar hasDate={hasDate} />;
};
t0 = <Foo renderItem={renderItem} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -27,7 +27,7 @@ testRule(
return value;
}
`,
errors: [makeTestCaseError('Cannot access ref value during render')],
errors: [makeTestCaseError('Cannot access refs during render')],
},
],
},

View File

@@ -26,3 +26,5 @@ 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 {FIXTURES_PATH, SNAPSHOT_EXTENSION} from './constants';
import {FILTER_PATH, FIXTURES_PATH, SNAPSHOT_EXTENSION} from './constants';
const INPUT_EXTENSIONS = [
'.js',
@@ -22,9 +22,19 @@ 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)) {
@@ -34,6 +44,37 @@ 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 {FIXTURES_PATH, PROJECT_ROOT} from './constants';
import {TestFilter} from './fixture-utils';
import {FILTER_FILENAME, FIXTURES_PATH, PROJECT_ROOT} from './constants';
import {TestFilter, readTestFilter} from './fixture-utils';
import {execSync} from 'child_process';
export function watchSrc(
@@ -117,10 +117,6 @@ 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(
@@ -146,6 +142,26 @@ 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,
@@ -184,67 +200,15 @@ 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 === 'a') {
// a => exit filter mode and run all tests
state.mode.filter = false;
state.filter = null;
} else if (key.name === 'f') {
state.mode.filter = !state.mode.filter;
state.filter = state.mode.filter ? await readTestFilter() : 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;
@@ -255,33 +219,21 @@ function subscribeKeyEvents(
export async function makeWatchRunner(
onChange: (state: RunnerState) => void,
debugMode: boolean,
initialPattern?: string,
filterMode: boolean,
): Promise<void> {
// Determine initial filter state
let filter: TestFilter | null = null;
let filterEnabled = false;
if (initialPattern) {
filter = {paths: [initialPattern]};
filterEnabled = true;
}
const state: RunnerState = {
const state = {
compilerVersion: 0,
isCompilerBuildValid: false,
lastUpdate: -1,
mode: {
action: RunnerAction.Test,
filter: filterEnabled,
filter: filterMode,
},
filter,
debug: debugMode,
inputMode: 'none',
inputBuffer: '',
filter: filterMode ? await readTestFilter() : null,
};
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 {PROJECT_ROOT} from './constants';
import {TestFilter, getFixtures} from './fixture-utils';
import {FILTER_PATH, PROJECT_ROOT} from './constants';
import {TestFilter, getFixtures, readTestFilter} from './fixture-utils';
import {TestResult, TestResults, report, update} from './reporter';
import {
RunnerAction,
@@ -33,9 +33,10 @@ type RunnerOptions = {
sync: boolean;
workerThreads: boolean;
watch: boolean;
filter: boolean;
update: boolean;
pattern?: string;
debug: boolean;
nodebug: boolean;
};
const opts: RunnerOptions = yargs
@@ -59,16 +60,24 @@ 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)
.boolean('nodebug')
.describe(
'nodebug',
'Do not enable debug logging, even if only one test is matched',
)
.default('nodebug', false)
.help('help')
.strict()
.parseSync(hideBin(process.argv)) as RunnerOptions;
@@ -80,15 +89,12 @@ 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) {
@@ -97,7 +103,12 @@ async function runFixtures(
for (const [fixtureName, fixture] of fixtures) {
work.push(
worker
.transformFixture(fixture, compilerVersion, shouldLog, true)
.transformFixture(
fixture,
compilerVersion,
(filter?.debug ?? false) && isOnlyFixture,
true,
)
.then(result => [fixtureName, result]),
);
}
@@ -109,7 +120,7 @@ async function runFixtures(
let output = await runnerWorker.transformFixture(
fixture,
compilerVersion,
shouldLog,
(filter?.debug ?? false) && isOnlyFixture,
true,
);
entries.push([fixtureName, output]);
@@ -124,7 +135,7 @@ async function onChange(
worker: Worker & typeof runnerWorker,
state: RunnerState,
) {
const {compilerVersion, isCompilerBuildValid, mode, filter, debug} = state;
const {compilerVersion, isCompilerBuildValid, mode, filter} = state;
if (isCompilerBuildValid) {
const start = performance.now();
@@ -138,8 +149,6 @@ async function onChange(
worker,
mode.filter ? filter : null,
compilerVersion,
debug,
true, // requireSingleFixture in watch mode
);
const end = performance.now();
if (mode.action === RunnerAction.Update) {
@@ -157,13 +166,11 @@ async function onChange(
console.log(
'\n' +
(mode.filter
? `Current mode = FILTER, pattern = "${filter?.paths[0] ?? ''}".`
? `Current mode = FILTER, filter test fixtures by "${FILTER_PATH}".`
: 'Current mode = NORMAL, run all test fixtures.') +
'\nWaiting for input or file changes...\n' +
'u - update all fixtures\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' : '') +
`f - toggle (turn ${mode.filter ? 'off' : 'on'}) filter mode\n` +
'q - quit\n' +
'[any] - rerun tests\n',
);
@@ -180,16 +187,15 @@ export async function main(opts: RunnerOptions): Promise<void> {
worker.getStderr().pipe(process.stderr);
worker.getStdout().pipe(process.stdout);
// Check if watch mode should be enabled
const shouldWatch = opts.watch;
// 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');
}
if (shouldWatch) {
makeWatchRunner(
state => onChange(worker, state),
opts.debug,
opts.pattern,
);
if (opts.pattern) {
makeWatchRunner(state => onChange(worker, state), opts.filter);
if (opts.filter) {
/**
* 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.
@@ -237,17 +243,14 @@ export async function main(opts: RunnerOptions): Promise<void> {
let testFilter: TestFilter | null = null;
if (opts.pattern) {
testFilter = {
debug: !opts.nodebug,
paths: [opts.pattern],
};
} else if (opts.filter) {
testFilter = await readTestFilter();
}
const results = await runFixtures(
worker,
testFilter,
0,
opts.debug,
false, // no requireSingleFixture in non-watch mode
);
const results = await runFixtures(worker, testFilter, 0);
if (opts.update) {
update(results);
isSuccess = true;