Compare commits

..

5 Commits

Author SHA1 Message Date
Joe Savona
87af9cdb63 [compiler] Distingush optional/extraneous deps
In ValidateExhaustiveDependencies, I previously changed to allow extraneous dependencies as long as they were non-reactive. Here we make that more precise, and distinguish between values that are definitely referenced in the memo function but optional as dependencies vs values that are not even referenced in the memo function. The latter now error as extraneous even if they're non-reactive. This also turned up a case where constant-folded primitives could show up as false positives of the latter category, so now we track manual deps which quality for constant folding and don't error on them.
2025-11-24 16:47:29 -08:00
Joseph Savona
16e16ec6ff [compiler] Script to enable a feature by default and update tests (#35202)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35202).
* #35201
* __->__ #35202
2025-11-24 12:21:35 -08:00
Joseph Savona
9599e7a787 [compiler] Adjustments to exhaustive deps messages, disable the lint rule (#35192)
Similar to ValidateHookUsage, we implement this check in the compiler
for safety but (for now) continue to rely on the existing rule for
actually reporting errors to users.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35192).
* #35201
* #35202
* __->__ #35192
2025-11-24 12:20:12 -08:00
Joseph Savona
67c1487ffd [compiler] Allow extraneous non-reactive locals (#35190)
The existing exhaustive-deps rule allows omitting non-reactive
dependencies, even if they're not memoized. Conceptually, if a value is
non-reactive then it cannot semantically change. Even if the value is a
new object, that object represents the exact same value and doesn't
necessitate redoing downstream computation. Thus its fine to exclude
nonreactive dependencies, whether they're a stable type or not.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35190).
* #35201
* #35202
* #35192
* __->__ #35190
2025-11-24 12:18:49 -08:00
Joseph Savona
454e01e603 [compiler] Allow manual dependencies to have different optionality than inferred deps (#35186)
Since adding this validation we've already changed our inference to use
knowledge from manual memoization to inform when values are frozen and
which values are non-nullable. To align with that, if the user chooses
to use different optionality btw the deps and the memo block/callback,
that's fine. The key is that eg `x?.y` will invalidate whenever `x.y`
would, so from a memoization correctness perspective its fine. It's not
our job to be a type checker: if a value is potentially nullable, it
should likely use a nullable property access in both places but
TypeScript/Flow can check that.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35186).
* #35201
* #35202
* #35192
* #35190
* __->__ #35186
2025-11-24 12:17:03 -08:00
22 changed files with 624 additions and 84 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(node scripts/enable-feature-flag.js:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -1067,7 +1067,15 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'memo-dependencies',
description:
'Validates that useMemo() and useCallback() specify comprehensive dependencies without extraneous values. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
preset: LintRulePreset.RecommendedLatest,
/**
* TODO: the "MemoDependencies" rule largely reimplements the "exhaustive-deps" non-compiler rule,
* allowing the compiler to ensure it does not regress change behavior due to different dependencies.
* We previously relied on the source having ESLint suppressions for any exhaustive-deps violations,
* but it's more reliable to verify it within the compiler.
*
* Long-term we should de-duplicate these implementations.
*/
preset: LintRulePreset.Off,
};
}
case ErrorCategory.IncompatibleLibrary: {

View File

@@ -303,9 +303,11 @@ function runWithEnvironment(
inferReactivePlaces(hir);
log({kind: 'hir', name: 'InferReactivePlaces', value: hir});
if (env.config.validateExhaustiveMemoizationDependencies) {
// NOTE: this relies on reactivity inference running first
validateExhaustiveDependencies(hir).unwrap();
if (env.enableValidations) {
if (env.config.validateExhaustiveMemoizationDependencies) {
// NOTE: this relies on reactivity inference running first
validateExhaustiveDependencies(hir).unwrap();
}
}
rewriteInstructionKindsBasedOnReassignment(hir);

View File

@@ -803,6 +803,7 @@ export type ManualMemoDependency = {
| {
kind: 'NamedLocal';
value: Place;
constant: boolean;
}
| {kind: 'Global'; identifierName: string};
path: DependencyPath;

View File

@@ -92,6 +92,7 @@ export function collectMaybeMemoDependencies(
root: {
kind: 'NamedLocal',
value: {...value.place},
constant: false,
},
path: [],
};

View File

@@ -609,6 +609,19 @@ function evaluateInstruction(
constantPropagationImpl(value.loweredFunc.func, constants);
return null;
}
case 'StartMemoize': {
if (value.deps != null) {
for (const dep of value.deps) {
if (dep.root.kind === 'NamedLocal') {
const placeValue = read(constants, dep.root.value);
if (placeValue != null && placeValue.kind === 'Primitive') {
dep.root.constant = true;
}
}
}
}
return null;
}
default: {
// TODO: handle more cases
return null;

View File

@@ -22,6 +22,7 @@ import {
Identifier,
IdentifierId,
InstructionKind,
isPrimitiveType,
isStableType,
isSubPath,
isSubPathIgnoringOptionals,
@@ -43,20 +44,35 @@ import {retainWhere} from '../Utils/utils';
const DEBUG = false;
/**
* Validates that existing manual memoization had exhaustive dependencies.
* Memoization with missing or extra reactive dependencies is invalid React
* and compilation can change behavior, causing a value to be computed more
* or less times.
* Validates that existing manual memoization is exhaustive and does not
* have extraneous dependencies. The goal of the validation is to ensure
* that auto-memoization will not substantially change the behavior of
* the program:
* - If the manual dependencies were non-exhaustive (missing important deps)
* then auto-memoization will include those dependencies, and cause the
* value to update *more* frequently.
* - If the manual dependencies had extraneous deps, then auto memoization
* will remove them and cause the value to update *less* frequently.
*
* TODOs:
* - Handle cases of mixed optional and non-optional versions of the same path,
* eg referecing both x.y.z and x.y?.z in the same memo block. we should collapse
* this into a single canonical dep that we look for in the manual deps. see the
* existing exhaustive deps rule for implementation.
* - Handle cases where the user deps were not simple identifiers + property chains.
* We try to detect this in ValidateUseMemo but we miss some cases. The problem
* is that invalid forms can be value blocks or function calls that don't get
* removed by DCE, leaving a structure like:
* The implementation compares the manual dependencies against the values
* actually used within the memoization function
* - For each value V referenced in the memo function, either:
* - If the value is non-reactive *and* a known stable type, then the
* value may optionally be specified as an exact dependency.
* - Otherwise, report an error unless there is a manual dependency that will
* invalidate whenever V invalidates. If `x.y.z` is referenced, there must
* be a manual dependency for `x.y.z`, `x.y`, or `x`. Note that we assume
* no interior mutability, ie we assume that any changes to inner paths must
* always cause the other path to change as well.
* - Any dependencies that do not correspond to a value referenced in the memo
* function are considered extraneous and throw an error
*
* ## TODO: Invalid, Complex Deps
*
* Handle cases where the user deps were not simple identifiers + property chains.
* We try to detect this in ValidateUseMemo but we miss some cases. The problem
* is that invalid forms can be value blocks or function calls that don't get
* removed by DCE, leaving a structure like:
*
* StartMemoize
* t0 = <value to memoize>
@@ -209,31 +225,6 @@ export function validateExhaustiveDependencies(
reason: 'Unexpected function dependency',
loc: value.loc,
});
/**
* Dependencies technically only need to include reactive values. However,
* reactivity inference for general values is subtle since it involves all
* of our complex control and data flow analysis. To keep results more
* stable and predictable to developers, we intentionally stay closer to
* the rules of the classic exhaustive-deps rule. Values should be included
* as dependencies if either of the following is true:
* - They're reactive
* - They're non-reactive and not a known-stable value type.
*
* Thus `const ref: Ref = cond ? ref1 : ref2` has to be a dependency
* (assuming `cond` is reactive) since it's reactive despite being a ref.
*
* Similarly, `const x = [1,2,3]` has to be a dependency since even
* though it's non reactive, it's not a known stable type.
*
* TODO: consider reimplementing a simpler form of reactivity inference.
* Ideally we'd consider `const ref: Ref = cond ? ref1 : ref2` as a required
* dependency even if our data/control flow tells us that `cond` is non-reactive.
* It's simpler for developers to reason about based on a more structural/AST
* driven approach.
*/
const isRequiredDependency =
reactive.has(inferredDependency.identifier.id) ||
!isStableType(inferredDependency.identifier);
let hasMatchingManualDependency = false;
for (const manualDependency of manualDependencies) {
if (
@@ -248,37 +239,40 @@ export function validateExhaustiveDependencies(
) {
hasMatchingManualDependency = true;
matched.add(manualDependency);
if (!isRequiredDependency) {
extra.push(manualDependency);
}
}
}
if (isRequiredDependency && !hasMatchingManualDependency) {
missing.push(inferredDependency);
const isOptionalDependency =
!reactive.has(inferredDependency.identifier.id) &&
(isStableType(inferredDependency.identifier) ||
isPrimitiveType(inferredDependency.identifier));
if (hasMatchingManualDependency || isOptionalDependency) {
continue;
}
missing.push(inferredDependency);
}
for (const dep of startMemo.deps ?? []) {
if (matched.has(dep)) {
continue;
}
if (dep.root.kind === 'NamedLocal' && dep.root.constant) {
CompilerError.simpleInvariant(
!dep.root.value.reactive &&
isPrimitiveType(dep.root.value.identifier),
{
reason: 'Expected constant-folded dependency to be non-reactive',
loc: dep.root.value.loc,
},
);
/*
* Constant primitives can get constant-folded, which means we won't
* see a LoadLocal for the value within the memo function.
*/
continue;
}
extra.push(dep);
}
/*
* For compatiblity with the existing exhaustive-deps rule, we allow
* known-stable values as dependencies even if the value is not reactive.
* This allows code that takes a dep on a non-reactive setState function
* to pass, for example.
*/
retainWhere(extra, dep => {
const isNonReactiveStableValue =
dep.root.kind === 'NamedLocal' &&
!dep.root.value.reactive &&
isStableType(dep.root.value.identifier);
return !isNonReactiveStableValue;
});
if (missing.length !== 0 || extra.length !== 0) {
let suggestions: Array<CompilerSuggestion> | null = null;
if (startMemo.depsLoc != null && typeof startMemo.depsLoc !== 'symbol') {
@@ -294,7 +288,7 @@ export function validateExhaustiveDependencies(
if (missing.length !== 0) {
const diagnostic = CompilerDiagnostic.create({
category: ErrorCategory.MemoDependencies,
reason: 'Found non-exhaustive dependencies',
reason: 'Found missing memoization dependencies',
description:
'Missing dependencies can cause a value not to update when those inputs change, ' +
'resulting in stale UI',
@@ -319,7 +313,7 @@ export function validateExhaustiveDependencies(
reason: 'Found unnecessary memoization dependencies',
description:
'Unnecessary dependencies can cause a value to update more often than necessary, ' +
'which can cause effects to run more than expected',
'causing performance regressions and effects to fire more often than expected',
});
diagnostic.withDetails({
kind: 'error',

View File

@@ -267,6 +267,7 @@ function validateInferredDep(
effect: Effect.Read,
reactive: false,
},
constant: false,
},
path: [...dep.path],
};
@@ -379,6 +380,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
root: {
kind: 'NamedLocal',
value: storeTarget,
constant: false,
},
path: [],
});
@@ -408,6 +410,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
root: {
kind: 'NamedLocal',
value: {...lvalue},
constant: false,
},
path: [],
});

View File

@@ -11,7 +11,7 @@ function Component(props) {
Component = useMemo(() => {
return Component;
});
}, [Component]);
return <Component {...props} />;
}
@@ -36,6 +36,7 @@ function Component(props) {
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
Component = Stringify;
Component;
Component = Component;
$[0] = Component;
} else {

View File

@@ -7,7 +7,7 @@ function Component(props) {
Component = useMemo(() => {
return Component;
});
}, [Component]);
return <Component {...props} />;
}

View File

@@ -0,0 +1,42 @@
## Input
```javascript
// @validateExhaustiveMemoizationDependencies
import {useState} from 'react';
import {Stringify} from 'shared-runtime';
function Component() {
const [state, setState] = useState(0);
const x = useMemo(() => {
return [state];
// error: `setState` is a stable type, but not actually referenced
}, [state, setState]);
return 'oops';
}
```
## Error
```
Found 1 error:
Error: Found unnecessary memoization dependencies
Unnecessary dependencies can cause a value to update more often than necessary, causing performance regressions and effects to fire more often than expected.
error.invalid-exhaustive-deps-disallow-unused-stable-types.ts:11:5
9 | return [state];
10 | // error: `setState` is a stable type, but not actually referenced
> 11 | }, [state, setState]);
| ^^^^^^^^^^^^^^^^^ Unnecessary dependencies `setState`
12 |
13 | return 'oops';
14 | }
```

View File

@@ -0,0 +1,14 @@
// @validateExhaustiveMemoizationDependencies
import {useState} from 'react';
import {Stringify} from 'shared-runtime';
function Component() {
const [state, setState] = useState(0);
const x = useMemo(() => {
return [state];
// error: `setState` is a stable type, but not actually referenced
}, [state, setState]);
return 'oops';
}

View File

@@ -53,7 +53,7 @@ function Component({x, y, z}) {
```
Found 4 errors:
Error: Found non-exhaustive dependencies
Error: Found missing memoization dependencies
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
@@ -66,7 +66,7 @@ error.invalid-exhaustive-deps.ts:7:11
9 | }, [x?.y.z?.a.b]);
10 | const b = useMemo(() => {
Error: Found non-exhaustive dependencies
Error: Found missing memoization dependencies
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
@@ -81,7 +81,7 @@ error.invalid-exhaustive-deps.ts:15:11
Error: Found unnecessary memoization dependencies
Unnecessary dependencies can cause a value to update more often than necessary, which can cause effects to run more than expected.
Unnecessary dependencies can cause a value to update more often than necessary, causing performance regressions and effects to fire more often than expected.
error.invalid-exhaustive-deps.ts:31:5
29 | return [];
@@ -92,7 +92,7 @@ error.invalid-exhaustive-deps.ts:31:5
33 | const ref2 = useRef(null);
34 | const ref = z ? ref1 : ref2;
Error: Found non-exhaustive dependencies
Error: Found missing memoization dependencies
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.

View File

@@ -0,0 +1,41 @@
## Input
```javascript
// @validateExhaustiveMemoizationDependencies
function Component() {
const x = 0;
const y = useMemo(() => {
return [x];
// x gets constant-folded but shouldn't count as extraneous,
// it was referenced in the memo block
}, [x]);
return y;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveMemoizationDependencies
function Component() {
const $ = _c(1);
const x = 0;
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = [0];
$[0] = t0;
} else {
t0 = $[0];
}
const y = t0;
return y;
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,11 @@
// @validateExhaustiveMemoizationDependencies
function Component() {
const x = 0;
const y = useMemo(() => {
return [x];
// x gets constant-folded but shouldn't count as extraneous,
// it was referenced in the memo block
}, [x]);
return y;
}

View File

@@ -3,7 +3,7 @@
```javascript
// @validateExhaustiveMemoizationDependencies
import {useMemo} from 'react';
import {useCallback, useMemo} from 'react';
import {makeObject_Primitives, Stringify} from 'shared-runtime';
function useHook1(x) {
@@ -47,6 +47,16 @@ function useHook6(x) {
}, [x]);
}
function useHook7(x) {
const [state, setState] = useState(true);
const f = () => {
setState(x => !x);
};
return useCallback(() => {
f();
}, [f]);
}
function Component({x, y, z}) {
const a = useHook1(x);
const b = useHook2(x);
@@ -54,7 +64,8 @@ function Component({x, y, z}) {
const d = useHook4(x, y, z);
const e = useHook5(x);
const f = useHook6(x);
return <Stringify results={[a, b, c, d, e, f]} />;
const g = useHook7(x);
return <Stringify results={[a, b, c, d, e, f, g]} />;
}
```
@@ -63,7 +74,7 @@ function Component({x, y, z}) {
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveMemoizationDependencies
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { makeObject_Primitives, Stringify } from "shared-runtime";
function useHook1(x) {
@@ -121,8 +132,36 @@ function useHook6(x) {
return f;
}
function useHook7(x) {
const $ = _c(2);
const [, setState] = useState(true);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
setState(_temp);
};
$[0] = t0;
} else {
t0 = $[0];
}
const f = t0;
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = () => {
f();
};
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
function _temp(x_0) {
return !x_0;
}
function Component(t0) {
const $ = _c(7);
const $ = _c(8);
const { x, y, z } = t0;
const a = useHook1(x);
const b = useHook2(x);
@@ -130,6 +169,7 @@ function Component(t0) {
const d = useHook4(x, y, z);
const e = useHook5(x);
const f = useHook6(x);
const g = useHook7(x);
let t1;
if (
$[0] !== a ||
@@ -137,18 +177,20 @@ function Component(t0) {
$[2] !== c ||
$[3] !== d ||
$[4] !== e ||
$[5] !== f
$[5] !== f ||
$[6] !== g
) {
t1 = <Stringify results={[a, b, c, d, e, f]} />;
t1 = <Stringify results={[a, b, c, d, e, f, g]} />;
$[0] = a;
$[1] = b;
$[2] = c;
$[3] = d;
$[4] = e;
$[5] = f;
$[6] = t1;
$[6] = g;
$[7] = t1;
} else {
t1 = $[6];
t1 = $[7];
}
return t1;
}

View File

@@ -1,5 +1,5 @@
// @validateExhaustiveMemoizationDependencies
import {useMemo} from 'react';
import {useCallback, useMemo} from 'react';
import {makeObject_Primitives, Stringify} from 'shared-runtime';
function useHook1(x) {
@@ -43,6 +43,16 @@ function useHook6(x) {
}, [x]);
}
function useHook7(x) {
const [state, setState] = useState(true);
const f = () => {
setState(x => !x);
};
return useCallback(() => {
f();
}, [f]);
}
function Component({x, y, z}) {
const a = useHook1(x);
const b = useHook2(x);
@@ -50,5 +60,6 @@ function Component({x, y, z}) {
const d = useHook4(x, y, z);
const e = useHook5(x);
const f = useHook6(x);
return <Stringify results={[a, b, c, d, e, f]} />;
const g = useHook7(x);
return <Stringify results={[a, b, c, d, e, f, g]} />;
}

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validatePreserveExistingMemoizationGuarantees
// @validatePreserveExistingMemoizationGuarantees @validateExhaustiveMemoizationDependencies:false
import {useMemo} from 'react';
@@ -27,7 +27,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees
import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees @validateExhaustiveMemoizationDependencies:false
import { useMemo } from "react";

View File

@@ -1,4 +1,4 @@
// @validatePreserveExistingMemoizationGuarantees
// @validatePreserveExistingMemoizationGuarantees @validateExhaustiveMemoizationDependencies:false
import {useMemo} from 'react';

View File

@@ -15,7 +15,7 @@ function Component(props) {
const x = makeObject_Primitives();
x.value = props.value;
mutate(x, free, part);
}, [props.value]);
}, [props.value, free, part]);
mutate(free, part);
return callback;
}

View File

@@ -11,7 +11,7 @@ function Component(props) {
const x = makeObject_Primitives();
x.value = props.value;
mutate(x, free, part);
}, [props.value]);
}, [props.value, free, part]);
mutate(free, part);
return callback;
}

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env node
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const {execSync} = require('child_process');
const yargs = require('yargs/yargs');
const {hideBin} = require('yargs/helpers');
// Constants
const COMPILER_ROOT = path.resolve(__dirname, '..');
const ENVIRONMENT_TS_PATH = path.join(
COMPILER_ROOT,
'packages/babel-plugin-react-compiler/src/HIR/Environment.ts'
);
const FIXTURES_PATH = path.join(
COMPILER_ROOT,
'packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler'
);
const FIXTURE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx'];
/**
* Parse command line arguments
*/
function parseArgs() {
const argv = yargs(hideBin(process.argv))
.usage('Usage: $0 <flag-name>')
.command('$0 <flag-name>', 'Enable a feature flag by default', yargs => {
yargs.positional('flag-name', {
describe: 'Name of the feature flag to enable',
type: 'string',
});
})
.example(
'$0 validateExhaustiveMemoizationDependencies',
'Enable the validateExhaustiveMemoizationDependencies flag'
)
.help('h')
.alias('h', 'help')
.strict()
.parseSync();
return argv['flag-name'];
}
/**
* Enable a feature flag in Environment.ts by changing default(false) to default(true)
*/
function enableFlagInEnvironment(flagName) {
console.log(`\nEnabling flag "${flagName}" in Environment.ts...`);
const content = fs.readFileSync(ENVIRONMENT_TS_PATH, 'utf8');
// Check if the flag exists with default(false)
const flagPatternFalse = new RegExp(
`(${escapeRegex(flagName)}:\\s*z\\.boolean\\(\\)\\.default\\()false(\\))`,
'g'
);
if (!flagPatternFalse.test(content)) {
// Check if flag exists at all
const flagExistsPattern = new RegExp(
`${escapeRegex(flagName)}:\\s*z\\.boolean\\(\\)`,
'g'
);
if (flagExistsPattern.test(content)) {
// Check if it's already true
const flagPatternTrue = new RegExp(
`${escapeRegex(flagName)}:\\s*z\\.boolean\\(\\)\\.default\\(true\\)`,
'g'
);
if (flagPatternTrue.test(content)) {
console.error(`Error: Flag "${flagName}" already has default(true)`);
process.exit(1);
}
console.error(
`Error: Flag "${flagName}" exists but doesn't have default(false)`
);
process.exit(1);
}
console.error(`Error: Flag "${flagName}" not found in Environment.ts`);
process.exit(1);
}
// Perform the replacement
const newContent = content.replace(flagPatternFalse, '$1true$2');
// Verify the replacement worked
if (content === newContent) {
console.error(`Error: Failed to replace flag "${flagName}"`);
process.exit(1);
}
fs.writeFileSync(ENVIRONMENT_TS_PATH, newContent, 'utf8');
console.log(`Successfully enabled "${flagName}" in Environment.ts`);
}
/**
* Helper to escape regex special characters
*/
function escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Run yarn snap and capture output
*/
function runTests() {
console.log('\nRunning test suite (yarn snap)...');
try {
const output = execSync('yarn snap', {
cwd: COMPILER_ROOT,
encoding: 'utf8',
stdio: 'pipe',
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
});
return {success: true, output};
} catch (error) {
// yarn snap exits with code 1 when tests fail, which throws an error
return {success: false, output: error.stdout || error.message};
}
}
/**
* Parse failing test names from test output
*/
function parseFailingTests(output) {
const failingTests = [];
// Look for lines that contain "FAIL:" followed by the test name
// Format: "FAIL: test-name" or with ANSI codes
const lines = output.split('\n');
for (const line of lines) {
// Remove ANSI codes for easier parsing
const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, '');
// Match "FAIL: test-name"
const match = cleanLine.match(/^FAIL:\s*(.+)$/);
if (match) {
failingTests.push(match[1].trim());
}
}
return failingTests;
}
/**
* Find the fixture file for a given test name
*/
function findFixtureFile(testName) {
const basePath = path.join(FIXTURES_PATH, testName);
for (const ext of FIXTURE_EXTENSIONS) {
const filePath = basePath + ext;
if (fs.existsSync(filePath)) {
return filePath;
}
}
return null;
}
/**
* Add pragma to disable the feature flag in a fixture file
*/
function addPragmaToFixture(filePath, flagName) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
if (lines.length === 0) {
console.warn(`Warning: Empty file ${filePath}`);
return false;
}
const firstLine = lines[0];
const pragma = `@${flagName}:false`;
// Check if pragma already exists
if (firstLine.includes(pragma)) {
return false; // Already has the pragma
}
// Check if first line is a single-line comment
if (firstLine.trim().startsWith('//')) {
// Append pragma to existing comment
lines[0] = firstLine + ' ' + pragma;
} else if (firstLine.trim().startsWith('/*')) {
// Multi-line comment - insert new line before it
lines.unshift('// ' + pragma);
} else {
// No comment - insert new comment as first line
lines.unshift('// ' + pragma);
}
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
return true;
}
/**
* Update snapshot files
*/
function updateSnapshots() {
console.log('\nUpdating snapshots (yarn snap -u)...');
try {
execSync('yarn snap -u', {
cwd: COMPILER_ROOT,
encoding: 'utf8',
stdio: 'pipe',
maxBuffer: 10 * 1024 * 1024,
});
console.log('Snapshots updated successfully');
return true;
} catch (error) {
console.error('Error updating snapshots:', error.message);
return false;
}
}
/**
* Verify all tests pass
*/
function verifyAllTestsPass() {
console.log('\nRunning final verification (yarn snap)...');
const {success, output} = runTests();
// Parse summary line: "N Tests, N Passed, N Failed"
const summaryMatch = output.match(
/(\d+)\s+Tests,\s+(\d+)\s+Passed,\s+(\d+)\s+Failed/
);
if (summaryMatch) {
const [, total, passed, failed] = summaryMatch;
console.log(
`\nTest Results: ${total} Tests, ${passed} Passed, ${failed} Failed`
);
if (failed === '0') {
console.log('All tests passed!');
return true;
} else {
console.error(`${failed} tests still failing`);
const failingTests = parseFailingTests(output);
if (failingTests.length > 0) {
console.error('\nFailing tests:');
failingTests.forEach(test => console.error(` - ${test}`));
}
return false;
}
}
return success;
}
/**
* Main function
*/
async function main() {
const flagName = parseArgs();
console.log(`\nEnabling flag: '${flagName}'`);
try {
// Step 1: Enable flag in Environment.ts
enableFlagInEnvironment(flagName);
// Step 2: Run tests to find failures
const {output} = runTests();
const failingTests = parseFailingTests(output);
console.log(`\nFound ${failingTests.length} failing tests`);
if (failingTests.length === 0) {
console.log('No failing tests! Feature flag enabled successfully.');
process.exit(0);
}
// Step 3: Add pragma to each failing fixture
console.log(`\nAdding '@${flagName}:false' pragma to failing fixtures...`);
const notFound = [];
let notFoundCount = 0;
for (const testName of failingTests) {
const fixturePath = findFixtureFile(testName);
if (!fixturePath) {
console.warn(`Could not find fixture file for: ${testName}`);
notFound.push(fixturePath);
continue;
}
const updated = addPragmaToFixture(fixturePath, flagName);
if (updated) {
updatedCount++;
console.log(` Updated: ${testName}`);
}
}
console.log(
`\nSummary: Updated ${updatedCount} fixtures, ${notFoundCount} not found`
);
if (notFoundCount.length !== 0) {
console.error(
'\nFailed to update snapshots, could not find:\n' + notFound.join('\n')
);
process.exit(1);
}
// Step 4: Update snapshots
if (!updateSnapshots()) {
console.error('\nFailed to update snapshots');
process.exit(1);
}
// Step 5: Verify all tests pass
if (!verifyAllTestsPass()) {
console.error('\nVerification failed: Some tests are still failing');
process.exit(1);
}
console.log('\nSuccess! Feature flag enabled and all tests passing.');
console.log(`\nSummary:`);
console.log(` - Enabled "${flagName}" in Environment.ts`);
console.log(` - Updated ${updatedCount} fixture files with pragma`);
console.log(` - All tests passing`);
process.exit(0);
} catch (error) {
console.error('\nFatal error:', error.message);
console.error(error.stack);
process.exit(1);
}
}
// Run the script
main();