Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9929a43ca | ||
|
|
7f292dea2e | ||
|
|
28f77b42dd | ||
|
|
d963d422d0 | ||
|
|
79badc68c4 |
@@ -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": []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)!),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user