Compare commits

..

1 Commits

Author SHA1 Message Date
Joe Savona
71cd5802aa [compiler] ValidateExhaustiveDeps disallows unnecessary non-reactive deps
Just to be consistent, we disallow unnecessary deps even if they're known to be non-reactive.
2025-11-20 19:29:05 -08:00
19 changed files with 103 additions and 906 deletions

View File

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

View File

@@ -1067,15 +1067,7 @@ 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.',
/**
* 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,
preset: LintRulePreset.RecommendedLatest,
};
}
case ErrorCategory.IncompatibleLibrary: {

View File

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

View File

@@ -400,15 +400,7 @@ export function compileProgram(
*/
const suppressions = findProgramSuppressions(
pass.comments,
/*
* If the compiler is validating hooks rules and exhaustive memo dependencies, we don't need to check
* for React ESLint suppressions
*/
pass.opts.environment.validateExhaustiveMemoizationDependencies &&
pass.opts.environment.validateHooksUsage
? null
: (pass.opts.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS),
// Always bail on Flow suppressions
pass.opts.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS,
pass.opts.flowSuppressions,
);

View File

@@ -78,7 +78,7 @@ export function filterSuppressionsThatAffectFunction(
export function findProgramSuppressions(
programComments: Array<t.Comment>,
ruleNames: Array<string> | null,
ruleNames: Array<string>,
flowSuppressions: boolean,
): Array<SuppressionRange> {
const suppressionRanges: Array<SuppressionRange> = [];
@@ -89,7 +89,7 @@ export function findProgramSuppressions(
let disableNextLinePattern: RegExp | null = null;
let disablePattern: RegExp | null = null;
let enablePattern: RegExp | null = null;
if (ruleNames != null && ruleNames.length !== 0) {
if (ruleNames.length !== 0) {
const rulePattern = `(${ruleNames.join('|')})`;
disableNextLinePattern = new RegExp(
`eslint-disable-next-line ${rulePattern}`,

View File

@@ -24,7 +24,6 @@ import {
InstructionKind,
isStableType,
isSubPath,
isSubPathIgnoringOptionals,
isUseRefType,
LoadGlobal,
ManualMemoDependency,
@@ -43,37 +42,20 @@ import {retainWhere} from '../Utils/utils';
const DEBUG = false;
/**
* 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.
* 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.
*
* We consider a value V as missing if ALL of the following conditions are met:
* - V is reactive
* - There is no manual dependency path P such that whenever V would change,
* P would also change. If V is `x.y.z`, this means there must be some
* path P that is either `x.y.z`, `x.y`, or `x`. Note that we assume no
* interior mutability, such that a shorter path "covers" changes to longer
* more precise paths.
*
* We consider a value V extraneous if either of the folowing are true:
* - V is a reactive local that is unreferenced
* - V is a global that is unreferenced
*
* In other words, we allow extraneous non-reactive values since we know they cannot
* impact how often the memoization would run.
*
* ## 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:
* 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:
*
* StartMemoize
* t0 = <value to memoize>
@@ -226,9 +208,31 @@ export function validateExhaustiveDependencies(
reason: 'Unexpected function dependency',
loc: value.loc,
});
const isRequiredDependency = reactive.has(
inferredDependency.identifier.id,
);
/**
* 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 (
@@ -236,10 +240,7 @@ export function validateExhaustiveDependencies(
manualDependency.root.value.identifier.id ===
inferredDependency.identifier.id &&
(areEqualPaths(manualDependency.path, inferredDependency.path) ||
isSubPathIgnoringOptionals(
manualDependency.path,
inferredDependency.path,
))
isSubPath(manualDependency.path, inferredDependency.path))
) {
hasMatchingManualDependency = true;
matched.add(manualDependency);
@@ -260,15 +261,6 @@ export function validateExhaustiveDependencies(
extra.push(dep);
}
/**
* Per docblock, we only consider dependencies as extraneous if
* they are unused globals or reactive locals. Notably, this allows
* non-reactive locals.
*/
retainWhere(extra, dep => {
return dep.root.kind === 'Global' || dep.root.value.reactive;
});
if (missing.length !== 0 || extra.length !== 0) {
let suggestions: Array<CompilerSuggestion> | null = null;
if (startMemo.depsLoc != null && typeof startMemo.depsLoc !== 'symbol') {
@@ -284,7 +276,7 @@ export function validateExhaustiveDependencies(
if (missing.length !== 0) {
const diagnostic = CompilerDiagnostic.create({
category: ErrorCategory.MemoDependencies,
reason: 'Found missing memoization dependencies',
reason: 'Found non-exhaustive dependencies',
description:
'Missing dependencies can cause a value not to update when those inputs change, ' +
'resulting in stale UI',
@@ -309,7 +301,7 @@ export function validateExhaustiveDependencies(
reason: 'Found unnecessary memoization dependencies',
description:
'Unnecessary dependencies can cause a value to update more often than necessary, ' +
'causing performance regressions and effects to fire more often than expected',
'which can cause effects to run more than expected',
});
diagnostic.withDetails({
kind: 'error',

View File

@@ -1,91 +0,0 @@
## Input
```javascript
// @validateExhaustiveMemoizationDependencies
import {useMemo} from 'react';
import {ValidateMemoization} from 'shared-runtime';
function Component({x}) {
useEffect(
() => {
console.log(x);
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[
/* intentionally missing deps */
]
);
const memo = useMemo(() => {
return [x];
}, [x]);
return <ValidateMemoization inputs={[x]} output={memo} />;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveMemoizationDependencies
import { useMemo } from "react";
import { ValidateMemoization } from "shared-runtime";
function Component(t0) {
const $ = _c(10);
const { x } = t0;
let t1;
if ($[0] !== x) {
t1 = () => {
console.log(x);
};
$[0] = x;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = [];
$[2] = t2;
} else {
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== x) {
t3 = [x];
$[3] = x;
$[4] = t3;
} else {
t3 = $[4];
}
const memo = t3;
let t4;
if ($[5] !== x) {
t4 = [x];
$[5] = x;
$[6] = t4;
} else {
t4 = $[6];
}
let t5;
if ($[7] !== memo || $[8] !== t4) {
t5 = <ValidateMemoization inputs={t4} output={memo} />;
$[7] = memo;
$[8] = t4;
$[9] = t5;
} else {
t5 = $[9];
}
return t5;
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -1,22 +0,0 @@
// @validateExhaustiveMemoizationDependencies
import {useMemo} from 'react';
import {ValidateMemoization} from 'shared-runtime';
function Component({x}) {
useEffect(
() => {
console.log(x);
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[
/* intentionally missing deps */
]
);
const memo = useMemo(() => {
return [x];
}, [x]);
return <ValidateMemoization inputs={[x]} output={memo} />;
}

View File

@@ -9,29 +9,23 @@ import {Stringify} from 'shared-runtime';
function Component({x, y, z}) {
const a = useMemo(() => {
return x?.y.z?.a;
// error: too precise
}, [x?.y.z?.a.b]);
const b = useMemo(() => {
return x.y.z?.a;
// ok, not our job to type check nullability
}, [x.y.z.a]);
const c = useMemo(() => {
return x?.y.z.a?.b;
// error: too precise
}, [x?.y.z.a?.b.z]);
const d = useMemo(() => {
return x?.y?.[(console.log(y), z?.b)];
// ok
}, [x?.y, y, z?.b]);
const e = useMemo(() => {
const e = [];
e.push(x);
return e;
// ok
}, [x]);
const f = useMemo(() => {
return [];
// error: unnecessary
}, [x, y.z, z?.y?.a, UNUSED_GLOBAL]);
const ref1 = useRef(null);
const ref2 = useRef(null);
@@ -40,7 +34,6 @@ function Component({x, y, z}) {
return () => {
return ref.current;
};
// error: ref is a stable type but reactive
}, []);
return <Stringify results={[a, b, c, d, e, f, cb]} />;
}
@@ -51,9 +44,9 @@ function Component({x, y, z}) {
## Error
```
Found 4 errors:
Found 5 errors:
Error: Found missing memoization dependencies
Error: Found non-exhaustive dependencies
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
@@ -62,48 +55,61 @@ error.invalid-exhaustive-deps.ts:7:11
6 | const a = useMemo(() => {
> 7 | return x?.y.z?.a;
| ^^^^^^^^^ Missing dependency `x?.y.z?.a`
8 | // error: too precise
9 | }, [x?.y.z?.a.b]);
10 | const b = useMemo(() => {
8 | }, [x?.y.z?.a.b]);
9 | const b = useMemo(() => {
10 | return x.y.z?.a;
Error: Found missing memoization dependencies
Error: Found non-exhaustive dependencies
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
error.invalid-exhaustive-deps.ts:15:11
13 | }, [x.y.z.a]);
14 | const c = useMemo(() => {
> 15 | return x?.y.z.a?.b;
error.invalid-exhaustive-deps.ts:10:11
8 | }, [x?.y.z?.a.b]);
9 | const b = useMemo(() => {
> 10 | return x.y.z?.a;
| ^^^^^^^^ Missing dependency `x.y.z?.a`
11 | }, [x.y.z.a]);
12 | const c = useMemo(() => {
13 | return x?.y.z.a?.b;
Error: Found non-exhaustive dependencies
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
error.invalid-exhaustive-deps.ts:13:11
11 | }, [x.y.z.a]);
12 | const c = useMemo(() => {
> 13 | return x?.y.z.a?.b;
| ^^^^^^^^^^^ Missing dependency `x?.y.z.a?.b`
16 | // error: too precise
17 | }, [x?.y.z.a?.b.z]);
18 | const d = useMemo(() => {
14 | }, [x?.y.z.a?.b.z]);
15 | const d = useMemo(() => {
16 | return x?.y?.[(console.log(y), z?.b)];
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.
Unnecessary dependencies can cause a value to update more often than necessary, which can cause effects to run more than expected.
error.invalid-exhaustive-deps.ts:31:5
29 | return [];
30 | // error: unnecessary
> 31 | }, [x, y.z, z?.y?.a, UNUSED_GLOBAL]);
error.invalid-exhaustive-deps.ts:25:5
23 | const f = useMemo(() => {
24 | return [];
> 25 | }, [x, y.z, z?.y?.a, UNUSED_GLOBAL]);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Unnecessary dependencies `x`, `y.z`, `z?.y?.a`, `UNUSED_GLOBAL`
32 | const ref1 = useRef(null);
33 | const ref2 = useRef(null);
34 | const ref = z ? ref1 : ref2;
26 | const ref1 = useRef(null);
27 | const ref2 = useRef(null);
28 | const ref = z ? ref1 : ref2;
Error: Found missing memoization dependencies
Error: Found non-exhaustive dependencies
Missing dependencies can cause a value not to update when those inputs change, resulting in stale UI.
error.invalid-exhaustive-deps.ts:37:13
35 | const cb = useMemo(() => {
36 | return () => {
> 37 | return ref.current;
error.invalid-exhaustive-deps.ts:31:13
29 | const cb = useMemo(() => {
30 | return () => {
> 31 | return ref.current;
| ^^^ Missing dependency `ref`. Refs, setState functions, and other "stable" values generally do not need to be added as dependencies, but this variable may change over time to point to different values
38 | };
39 | // error: ref is a stable type but reactive
40 | }, []);
32 | };
33 | }, []);
34 | return <Stringify results={[a, b, c, d, e, f, cb]} />;
```

View File

@@ -5,29 +5,23 @@ import {Stringify} from 'shared-runtime';
function Component({x, y, z}) {
const a = useMemo(() => {
return x?.y.z?.a;
// error: too precise
}, [x?.y.z?.a.b]);
const b = useMemo(() => {
return x.y.z?.a;
// ok, not our job to type check nullability
}, [x.y.z.a]);
const c = useMemo(() => {
return x?.y.z.a?.b;
// error: too precise
}, [x?.y.z.a?.b.z]);
const d = useMemo(() => {
return x?.y?.[(console.log(y), z?.b)];
// ok
}, [x?.y, y, z?.b]);
const e = useMemo(() => {
const e = [];
e.push(x);
return e;
// ok
}, [x]);
const f = useMemo(() => {
return [];
// error: unnecessary
}, [x, y.z, z?.y?.a, UNUSED_GLOBAL]);
const ref1 = useRef(null);
const ref2 = useRef(null);
@@ -36,7 +30,6 @@ function Component({x, y, z}) {
return () => {
return ref.current;
};
// error: ref is a stable type but reactive
}, []);
return <Stringify results={[a, b, c, d, e, f, cb]} />;
}

View File

@@ -1,100 +0,0 @@
## Input
```javascript
// @validateExhaustiveMemoizationDependencies
import {
useCallback,
useTransition,
useState,
useOptimistic,
useActionState,
useRef,
useReducer,
} from 'react';
function useFoo() {
const [s, setState] = useState();
const ref = useRef(null);
const [t, startTransition] = useTransition();
const [u, addOptimistic] = useOptimistic();
const [v, dispatch] = useReducer(() => {}, null);
const [isPending, dispatchAction] = useActionState(() => {}, null);
return useCallback(() => {
dispatch();
startTransition(() => {});
addOptimistic();
setState(null);
dispatchAction();
ref.current = true;
}, [
// intentionally adding unnecessary deps on nonreactive stable values
// to check that they're allowed
dispatch,
startTransition,
addOptimistic,
setState,
dispatchAction,
ref,
]);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveMemoizationDependencies
import {
useCallback,
useTransition,
useState,
useOptimistic,
useActionState,
useRef,
useReducer,
} from "react";
function useFoo() {
const $ = _c(1);
const [, setState] = useState();
const ref = useRef(null);
const [, startTransition] = useTransition();
const [, addOptimistic] = useOptimistic();
const [, dispatch] = useReducer(_temp, null);
const [, dispatchAction] = useActionState(_temp2, null);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
dispatch();
startTransition(_temp3);
addOptimistic();
setState(null);
dispatchAction();
ref.current = true;
};
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
function _temp3() {}
function _temp2() {}
function _temp() {}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```
### Eval output
(kind: ok) "[[ function params=0 ]]"

View File

@@ -1,42 +0,0 @@
// @validateExhaustiveMemoizationDependencies
import {
useCallback,
useTransition,
useState,
useOptimistic,
useActionState,
useRef,
useReducer,
} from 'react';
function useFoo() {
const [s, setState] = useState();
const ref = useRef(null);
const [t, startTransition] = useTransition();
const [u, addOptimistic] = useOptimistic();
const [v, dispatch] = useReducer(() => {}, null);
const [isPending, dispatchAction] = useActionState(() => {}, null);
return useCallback(() => {
dispatch();
startTransition(() => {});
addOptimistic();
setState(null);
dispatchAction();
ref.current = true;
}, [
// intentionally adding unnecessary deps on nonreactive stable values
// to check that they're allowed
dispatch,
startTransition,
addOptimistic,
setState,
dispatchAction,
ref,
]);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};

View File

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

View File

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

View File

@@ -53,10 +53,8 @@ const opts: RunnerOptions = yargs
.default('worker-threads', true)
.boolean('watch')
.describe('watch', 'Run compiler in watch mode, re-running after changes')
.alias('w', 'watch')
.default('watch', false)
.boolean('update')
.alias('u', 'update')
.describe('update', 'Update fixtures')
.default('update', false)
.boolean('filter')

View File

@@ -1,347 +0,0 @@
#!/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();

View File

@@ -584,75 +584,4 @@ describe('ProfilerContext', () => {
await utils.actAsync(() => context.selectFiber(childID, 'Child'));
expect(inspectedElementID).toBe(parentID);
});
it('should toggle profiling when the keyboard shortcut is pressed', async () => {
// Context providers
const Profiler =
require('react-devtools-shared/src/devtools/views/Profiler/Profiler').default;
const {
TimelineContextController,
} = require('react-devtools-timeline/src/TimelineContext');
const {
SettingsContextController,
} = require('react-devtools-shared/src/devtools/views/Settings/SettingsContext');
const {
ModalDialogContextController,
} = require('react-devtools-shared/src/devtools/views/ModalDialog');
// Dom component for profiling to be enabled
const Component = () => null;
utils.act(() => render(<Component />));
const profilerContainer = document.createElement('div');
document.body.appendChild(profilerContainer);
// Create a root for the profiler
const profilerRoot = ReactDOMClient.createRoot(profilerContainer);
// Render the profiler
utils.act(() => {
profilerRoot.render(
<Contexts>
<SettingsContextController browserTheme="light">
<ModalDialogContextController>
<TimelineContextController>
<Profiler />
</TimelineContextController>
</ModalDialogContextController>
</SettingsContextController>
</Contexts>,
);
});
// Verify that the profiler is not profiling.
expect(store.profilerStore.isProfilingBasedOnUserInput).toBe(false);
// Trigger the keyboard shortcut.
const ownerWindow = profilerContainer.ownerDocument.defaultView;
const isMac =
typeof navigator !== 'undefined' &&
navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const keyEvent = new KeyboardEvent('keydown', {
key: 'e',
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
});
// Dispatch keyboard event to toggle profiling on
// Try utils.actAsync with recursivelyFlush=false
await utils.actAsync(() => {
ownerWindow.dispatchEvent(keyEvent);
}, false);
expect(store.profilerStore.isProfilingBasedOnUserInput).toBe(true);
// Dispatch keyboard event to toggle profiling off
await utils.actAsync(() => {
ownerWindow.dispatchEvent(keyEvent);
}, false);
expect(store.profilerStore.isProfilingBasedOnUserInput).toBe(false);
document.body.removeChild(profilerContainer);
});
});

View File

@@ -8,7 +8,7 @@
*/
import * as React from 'react';
import {Fragment, useContext, useEffect, useRef, useEffectEvent} from 'react';
import {Fragment, useContext} from 'react';
import {ModalDialog} from '../ModalDialog';
import {ProfilerContext} from './ProfilerContext';
import TabBar from '../TabBar';
@@ -38,11 +38,6 @@ import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';
import styles from './Profiler.css';
function Profiler(_: {}) {
const profilerRef = useRef<HTMLDivElement | null>(null);
const isMac =
typeof navigator !== 'undefined' &&
navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const {
didRecordCommits,
isProcessingData,
@@ -52,8 +47,6 @@ function Profiler(_: {}) {
selectedTabID,
selectTab,
supportsProfiling,
startProfiling,
stopProfiling,
} = useContext(ProfilerContext);
const {file: timelineTraceEventData, searchInputContainerRef} =
@@ -63,32 +56,6 @@ function Profiler(_: {}) {
const isLegacyProfilerSelected = selectedTabID !== 'timeline';
// Cmd+E to start/stop profiler recording
const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
const correctModifier = isMac ? event.metaKey : event.ctrlKey;
if (correctModifier && event.key === 'e') {
if (isProfiling) {
stopProfiling();
} else {
startProfiling();
}
event.preventDefault();
event.stopPropagation();
}
});
useEffect(() => {
const div = profilerRef.current;
if (!div) {
return;
}
const ownerWindow = div.ownerDocument.defaultView;
ownerWindow.addEventListener('keydown', handleKeyDown);
return () => {
ownerWindow.removeEventListener('keydown', handleKeyDown);
};
}, []);
let view = null;
if (didRecordCommits || selectedTabID === 'timeline') {
switch (selectedTabID) {
@@ -145,7 +112,7 @@ function Profiler(_: {}) {
return (
<SettingsModalContextController>
<div ref={profilerRef} className={styles.Profiler}>
<div className={styles.Profiler}>
<div className={styles.LeftColumn}>
<div className={styles.Toolbar}>
<RecordToggle disabled={!supportsProfiling} />

View File

@@ -30,19 +30,13 @@ export default function RecordToggle({disabled}: Props): React.Node {
className = styles.ActiveRecordToggle;
}
const isMac =
typeof navigator !== 'undefined' &&
navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const shortcut = isMac ? '⌘E' : 'Ctrl+E';
const title = `${isProfiling ? 'Stop' : 'Start'} profiling - ${shortcut}`;
return (
<Button
className={className}
disabled={disabled}
onClick={isProfiling ? stopProfiling : startProfiling}
testName="ProfilerToggleButton"
title={title}>
title={isProfiling ? 'Stop profiling' : 'Start profiling'}>
<ButtonIcon type="record" />
</Button>
);