Compare commits

..

5 Commits

Author SHA1 Message Date
Joe Savona
f9929a43ca [compiler] More claude config 2026-01-15 18:22:04 -08:00
Joe Savona
7f292dea2e [compiler] Improve snap, no more testfilter.txt file 2026-01-15 18:22:02 -08:00
Joe Savona
28f77b42dd [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-15 18:22:00 -08:00
Joe Savona
d963d422d0 [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-15 18:12:25 -08:00
Joe Savona
79badc68c4 [compiler] Claude file/settings 2026-01-15 18:12:14 -08:00
22 changed files with 603 additions and 304 deletions

View File

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

View File

@@ -822,6 +822,22 @@ 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'},
],
},
}),
],
[
@@ -852,7 +868,12 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
addHook(DEFAULT_SHAPES, {
positionalParams: [],
restParam: Effect.Freeze,
returnType: {kind: 'Poly'},
returnType: {
kind: 'Function',
isConstructor: false,
return: {kind: 'Poly'},
shapeId: null,
},
calleeEffect: Effect.Read,
hookKind: 'useCallback',
returnValueKind: ValueKind.Frozen,

View File

@@ -983,15 +983,15 @@ export function printAliasingEffect(effect: AliasingEffect): string {
return `...${printPlaceForAliasEffect(arg.place)}`;
})
.join(', ');
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}`;
// 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})`;
}
case 'Freeze': {
return `Freeze ${printPlaceForAliasEffect(effect.value)} ${effect.reason}`;

View File

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

View File

@@ -20,6 +20,7 @@ import {
Place,
isPrimitiveType,
isUseRefType,
isJsxOrJsxUnionType,
} from '../HIR/HIR';
import {
eachInstructionLValue,
@@ -396,17 +397,7 @@ export function inferMutationAliasingRanges(
break;
}
case 'Apply': {
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,
},
],
});
break;
}
case 'MutateTransitive':
case 'MutateConditionally':
@@ -588,7 +579,12 @@ export function inferMutationAliasingRanges(
}
}
if (errors.hasAnyErrors() && !isFunctionExpression) {
if (
errors.hasAnyErrors() &&
(fn.fnType === 'Component' ||
isJsxOrJsxUnionType(fn.returns.identifier.type) ||
!isFunctionExpression)
) {
return Err(errors);
}
return Ok(functionEffects);

View File

@@ -5,7 +5,6 @@
* LICENSE file in the root directory of this source tree.
*/
import prettyFormat from 'pretty-format';
import {CompilerDiagnostic, CompilerError, Effect} from '..';
import {
areEqualSourceLocations,
@@ -13,35 +12,30 @@ import {
IdentifierId,
InstructionId,
isJsxType,
isRefValueType,
isUseRefType,
} from '../HIR';
import {
eachInstructionLValue,
eachInstructionValueOperand,
} from '../HIR/visitors';
import {AliasingEffect} from '../Inference/AliasingEffects';
import {AliasingEffect, hashEffect} from '../Inference/AliasingEffects';
import {createControlDominators} from '../Inference/ControlDominators';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
import {Err, Ok, Result} from '../Utils/Result';
import {
assertExhaustive,
getOrInsertWith,
Set_filter,
Set_subtract,
} from '../Utils/utils';
import {printInstruction} from '../HIR/PrintHIR';
import {getOrInsertWith} from '../Utils/utils';
import {printFunction} 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: Array<ImpureEffect>; error: CompilerError};
type ImpuritySignature = {
effects: Map<IdentifierId, ImpureEffect>;
error: CompilerError;
returns: IdentifierId;
};
export function validateNoImpureValuesInRender(
fn: HIRFunction,
): Result<void, CompilerError> {
const impure = new Map<IdentifierId, ImpureEffect>();
const result = inferImpureValues(fn, impure, new Map());
const impureFunctions = new Map<IdentifierId, ImpuritySignature>();
const result = inferImpureValues(fn, impure, impureFunctions, new Map());
if (result.error.hasAnyErrors()) {
return Err(result.error);
@@ -52,15 +46,23 @@ 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)}`)
.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(',')}`,
)
.join(',');
return getOrInsertWith(
getOrInsertWith(cache, fn, () => new Map()),
key,
() => inferImpureValues(fn, impure, cache),
() => inferImpureValues(fn, impure, impureFunctions, cache),
);
}
@@ -68,6 +70,7 @@ function processEffects(
id: InstructionId,
effects: Array<AliasingEffect>,
impure: Map<IdentifierId, ImpureEffect>,
impureFunctions: Map<IdentifierId, ImpuritySignature>,
cache: FunctionCache,
): boolean {
let hasChanges = false;
@@ -92,6 +95,9 @@ function processEffects(
!isUseRefType(effect.into.identifier) &&
!isJsxType(effect.into.identifier.type)
) {
// console.log(
// `${effect.kind} $${effect.into.identifier.id} <= $${effect.from.identifier.id} ($${sourceEffect.into.identifier.id} forward)`,
// );
impure.set(effect.into.identifier.id, sourceEffect);
hasChanges = true;
}
@@ -105,14 +111,42 @@ function processEffects(
) {
const destinationEffect = impure.get(effect.into.identifier.id);
if (destinationEffect != null) {
// console.log(
// `${effect.kind} $${effect.into.identifier.id} => $${effect.from.identifier.id} ($${destinationEffect.into.identifier.id} backward)`,
// );
impure.set(effect.from.identifier.id, destinationEffect);
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)
// ||
// !areEqualFunctionSignatures(
// impureFunctions.get(effect.into.identifier.id)!.effects,
// functionEffect.effects,
// )
) {
// console.log(
// `${effect.kind} $${effect.into.identifier.id} <= $${effect.from.identifier.id} (function)`,
// );
impureFunctions.set(effect.into.identifier.id, functionEffect);
hasChanges = true;
}
}
break;
}
case 'Impure': {
if (!impure.has(effect.into.identifier.id)) {
// console.log(`Impure $${effect.into.identifier.id}`);
impure.set(effect.into.identifier.id, effect);
hasChanges = true;
}
@@ -125,26 +159,45 @@ function processEffects(
const result = inferFunctionExpressionMemo(
effect.function.loweredFunc.func,
impure,
impureFunctions,
cache,
);
if (result.error.hasAnyErrors()) {
break;
}
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);
const previousResult = impureFunctions.get(effect.into.identifier.id);
if (
previousResult == null ||
!areEqualFunctionSignatures(result.effects, previousResult.effects)
) {
// console.log(`Function $${effect.into.identifier.id}`);
impureFunctions.set(effect.into.identifier.id, result);
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':
@@ -163,6 +216,7 @@ function processEffects(
function inferImpureValues(
fn: HIRFunction,
impure: Map<IdentifierId, ImpureEffect>,
impureFunctions: Map<IdentifierId, ImpuritySignature>,
cache: FunctionCache,
): ImpuritySignature {
const getBlockControl = createControlDominators(fn, place => {
@@ -170,9 +224,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 =
@@ -212,8 +271,13 @@ function inferImpureValues(
for (const instr of block.instructions) {
const _impure = new Set(impure.keys());
hasChanges =
processEffects(instr.id, instr.effects ?? [], impure, cache) ||
hasChanges;
processEffects(
instr.id,
instr.effects ?? [],
impure,
impureFunctions,
cache,
) || hasChanges;
}
if (block.terminal.kind === 'return' && block.terminal.effects != null) {
hasChanges =
@@ -221,6 +285,7 @@ function inferImpureValues(
block.terminal.id,
block.terminal.effects,
impure,
impureFunctions,
cache,
) || hasChanges;
}
@@ -232,10 +297,19 @@ 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 {
const impureEffect = impure.get(effect.place.identifier.id);
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);
}
if (impureEffect == null) {
return;
}
@@ -267,6 +341,7 @@ function inferImpureValues(
const result = inferFunctionExpressionMemo(
value.loweredFunc.func,
impure,
impureFunctions,
cache,
);
if (result.error.hasAnyErrors()) {
@@ -287,21 +362,26 @@ function inferImpureValues(
}
}
}
const impureEffects: Array<ImpureEffect> = [];
const impureEffects: Map<IdentifierId, ImpureEffect> = new Map();
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.push({
kind: 'Impure',
into: impureEffect.into,
category: impureEffect.category,
reason: impureEffect.reason,
description: impureEffect.description,
sourceMessage: impureEffect.sourceMessage,
usageMessage: impureEffect.usageMessage,
});
impureEffects.set(place.identifier.id, impureEffect);
}
}
return {effects: impureEffects, error};
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)!),
)
);
}

View File

@@ -0,0 +1,137 @@
# 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

@@ -22,7 +22,7 @@ export const FIXTURE_ENTRYPOINT = {
## Error
```
Found 1 error:
Found 2 errors:
Error: Cannot access ref value during render
@@ -37,6 +37,28 @@ 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 1 error:
Found 2 errors:
Error: Cannot access ref value during render
@@ -37,6 +37,28 @@ 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

@@ -0,0 +1,52 @@
## 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,19 +29,20 @@ 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:13:26
11 | return <Bar hasDate={hasDate} />;
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
12 | };
> 13 | return <Foo renderItem={renderItem} />;
| ^^^^^^^^^^ Cannot access impure value during render
13 | return <Foo renderItem={renderItem} />;
14 | }
15 |
error.invalid-impure-functions-in-render-via-render-helper-typed.ts:6:14
error.invalid-impure-functions-in-render-via-render-helper-typed.ts:6:20
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

@@ -1,49 +0,0 @@
## 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

@@ -17,7 +17,7 @@ function Component() {
## Error
```
Found 1 error:
Found 2 errors:
Error: Cannot access impure value during render
@@ -32,6 +32,27 @@ 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

@@ -1,58 +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} />;
}
```
## 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

@@ -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 <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

@@ -6,6 +6,8 @@ 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

@@ -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,10 +33,9 @@ type RunnerOptions = {
sync: boolean;
workerThreads: boolean;
watch: boolean;
filter: boolean;
update: boolean;
pattern?: string;
nodebug: boolean;
debug: boolean;
};
const opts: RunnerOptions = yargs
@@ -60,24 +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('nodebug')
.describe(
'nodebug',
'Do not enable debug logging, even if only one test is matched',
)
.default('nodebug', false)
.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;
@@ -89,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) {
@@ -103,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]),
);
}
@@ -120,7 +109,7 @@ async function runFixtures(
let output = await runnerWorker.transformFixture(
fixture,
compilerVersion,
(filter?.debug ?? false) && isOnlyFixture,
shouldLog,
true,
);
entries.push([fixtureName, output]);
@@ -135,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();
@@ -149,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) {
@@ -166,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',
);
@@ -187,15 +180,16 @@ 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.
@@ -243,14 +237,17 @@ 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);
const results = await runFixtures(
worker,
testFilter,
0,
opts.debug,
false, // no requireSingleFixture in non-watch mode
);
if (opts.update) {
update(results);
isSuccess = true;