Compare commits

..

7 Commits

Author SHA1 Message Date
Mofei Zhang
51620ac79c [compiler][gating] Custom opt out directives (experimental option)
Adding an experimental / unstable compiler config to enable custom opt-out directives
2025-05-23 13:12:02 -04:00
Jordan Brown
99efc627a5 [eslint] Add an option to require dependencies on effect hooks (#33344)
Summary:

To prepare for automatic effect dependencies, some codebases may want to
codemod
existing useEffect calls with no deps to include an explicit undefined
second argument
in order to preserve the "run on every render" behavior. In sufficiently
large codebases,
this may require a temporary enforcement period where all effects
provide an explicit
dependencies argument.

Outside of migration, relying on a component to render can lead to real
bugs,
especially when working with memoization.
2025-05-23 10:09:41 -04:00
0xFango
bfaeb4a461 Fix incorrect use of NoLanes in executionContext check (#33170)
## Summary

This PR fixes a likely incorrect condition in the
`scheduleUpdateOnFiber` function inside `ReactFiberWorkLoop.js`.

Previously, the code checked:

```js
(executionContext & RenderContext) !== NoLanes
````

However, `NoLanes` is part of the lane priority system, not the
execution context flags. The intent here seems to be to detect whether
the current execution context includes `RenderContext`, which should be
compared against `NoContext`, not `NoLanes`.

This fix replaces `NoLanes` with `NoContext` for semantic correctness
and consistency with other checks throughout the codebase.

**Fixes
[[#33169](https://github.com/facebook/react/issues/33169)](https://github.com/facebook/react/issues/33169)**

---

## How did you test this change?

I ran the following commands to validate correctness and ensure nothing
was broken:

* `yarn lint`
* `yarn linc`
* `yarn test`
* `yarn test --prod`
* `yarn flow`
* `yarn prettier`

All checks passed. Since this is a minor internal logic fix and doesn't
change public behavior or APIs, no additional tests are necessary at
this time.
2025-05-22 22:02:39 -04:00
Christoph Nakazawa
3e9db65fc3 Fix typo in error message. (#33313)
## Summary

I am writing code that isn't so good, so I saw this error message many
times. It appears to have a typo. This PR fixes the typo.

## How did you test this change?

Ran the tests
2025-05-22 16:18:23 -04:00
mofeiZ
0d072884f9 [compiler] Inferred effect dependencies now include optional chains (#33326)
Inferred effect dependencies now include optional chains.

This is a temporary solution while
https://github.com/facebook/react/pull/32099 and its followups are
worked on. Ideally, we should model reactive scope dependencies in the
IR similarly to `ComputeIR` -- dependencies should be hoisted and all
references rewritten to use the hoisted dependencies.

`
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33326).
* __->__ #33326
* #33325
* #32286
2025-05-22 16:14:49 -04:00
mofeiZ
abf9fd559d [compiler] Add reactive flag on scope dependencies (#33325)
When collecting scope dependencies, mark each dependency with `reactive:
true | false`. This prepares for later PRs
https://github.com/facebook/react/pull/33326 and
https://github.com/facebook/react/pull/32099 which rewrite scope
dependencies into instructions.

Note that some reactive objects may have non-reactive properties, but we
do not currently track this.

Technically, state[0] is reactive and state[1] is not. Currently, both
would be marked as reactive.
```js
const state = useState();
```
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33325).
* #33326
* __->__ #33325
* #32286
2025-05-22 16:14:05 -04:00
mofeiZ
13f20044f3 [compiler] Prepare HIRBuilder to be used by later passes (#32286)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32286).
* #33326
* #33325
* __->__ #32286
2025-05-22 16:13:50 -04:00
12 changed files with 142 additions and 27 deletions

View File

@@ -41,6 +41,10 @@ const DynamicGatingOptionsSchema = z.object({
source: z.string(),
});
export type DynamicGatingOptions = z.infer<typeof DynamicGatingOptionsSchema>;
const CustomOptOutDirectiveSchema = z
.nullable(z.array(z.string()))
.default(null);
type CustomOptOutDirective = z.infer<typeof CustomOptOutDirectiveSchema>;
export type PluginOptions = {
environment: EnvironmentConfig;
@@ -132,6 +136,11 @@ export type PluginOptions = {
*/
ignoreUseNoForget: boolean;
/**
* Unstable / do not use
*/
customOptOutDirectives: CustomOptOutDirective;
sources: Array<string> | ((filename: string) => boolean) | null;
/**
@@ -278,6 +287,7 @@ export const defaultOptions: PluginOptions = {
return filename.indexOf('node_modules') === -1;
},
enableReanimatedCheck: true,
customOptOutDirectives: null,
target: '19',
} as const;
@@ -338,6 +348,21 @@ export function parsePluginOptions(obj: unknown): PluginOptions {
}
break;
}
case 'customOptOutDirectives': {
const result = CustomOptOutDirectiveSchema.safeParse(value);
if (result.success) {
parsedOptions[key] = result.data;
} else {
CompilerError.throwInvalidConfig({
reason:
'Could not parse custom opt out directives. Update React Compiler config to fix the error',
description: `${fromZodError(result.error)}`,
loc: null,
suggestions: null,
});
}
break;
}
default: {
parsedOptions[key] = value;
}

View File

@@ -63,7 +63,16 @@ export function tryFindDirectiveEnablingMemoization(
export function findDirectiveDisablingMemoization(
directives: Array<t.Directive>,
{customOptOutDirectives}: PluginOptions,
): t.Directive | null {
if (customOptOutDirectives != null) {
return (
directives.find(
directive =>
customOptOutDirectives.indexOf(directive.value.value) !== -1,
) ?? null
);
}
return (
directives.find(directive =>
OPT_OUT_DIRECTIVES.has(directive.value.value),
@@ -394,7 +403,8 @@ export function compileProgram(
code: pass.code,
suppressions,
hasModuleScopeOptOut:
findDirectiveDisablingMemoization(program.node.directives) != null,
findDirectiveDisablingMemoization(program.node.directives, pass.opts) !=
null,
});
const queue: Array<CompileSource> = findFunctionsToCompile(
@@ -571,7 +581,10 @@ function processFn(
}
directives = {
optIn: optIn.unwrapOr(null),
optOut: findDirectiveDisablingMemoization(fn.node.body.directives),
optOut: findDirectiveDisablingMemoization(
fn.node.body.directives,
programContext.opts,
),
};
}

View File

@@ -93,6 +93,21 @@ const testComplexConfigDefaults: PartialEnvironmentConfig = {
},
],
};
function* splitPragma(
pragma: string,
): Generator<{key: string; value: string | null}> {
for (const entry of pragma.split('@')) {
const keyVal = entry.trim();
const valIdx = keyVal.indexOf(':');
if (valIdx === -1) {
yield {key: keyVal.split(' ', 1)[0], value: null};
} else {
yield {key: keyVal.slice(0, valIdx), value: keyVal.slice(valIdx + 1)};
}
}
}
/**
* For snap test fixtures and playground only.
*/
@@ -101,19 +116,11 @@ function parseConfigPragmaEnvironmentForTest(
): EnvironmentConfig {
const maybeConfig: Partial<Record<keyof EnvironmentConfig, unknown>> = {};
for (const token of pragma.split(' ')) {
if (!token.startsWith('@')) {
continue;
}
const keyVal = token.slice(1);
const valIdx = keyVal.indexOf(':');
const key = valIdx === -1 ? keyVal : keyVal.slice(0, valIdx);
const val = valIdx === -1 ? undefined : keyVal.slice(valIdx + 1);
const isSet = val === undefined || val === 'true';
for (const {key, value: val} of splitPragma(pragma)) {
if (!hasOwnProperty(EnvironmentConfigSchema.shape, key)) {
continue;
}
const isSet = val == null || val === 'true';
if (isSet && key in testComplexConfigDefaults) {
maybeConfig[key] = testComplexConfigDefaults[key];
} else if (isSet) {
@@ -176,18 +183,11 @@ export function parseConfigPragmaForTests(
compilationMode: defaults.compilationMode,
environment,
};
for (const token of pragma.split(' ')) {
if (!token.startsWith('@')) {
continue;
}
const keyVal = token.slice(1);
const idx = keyVal.indexOf(':');
const key = idx === -1 ? keyVal : keyVal.slice(0, idx);
const val = idx === -1 ? undefined : keyVal.slice(idx + 1);
for (const {key, value: val} of splitPragma(pragma)) {
if (!hasOwnProperty(defaultOptions, key)) {
continue;
}
const isSet = val === undefined || val === 'true';
const isSet = val == null || val === 'true';
if (isSet && key in testComplexPluginOptionDefaults) {
options[key] = testComplexPluginOptionDefaults[key];
} else if (isSet) {

View File

@@ -0,0 +1,35 @@
## Input
```javascript
// @customOptOutDirectives:["use todo memo"]
function Component() {
'use todo memo';
return <div>hello world!</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
};
```
## Code
```javascript
// @customOptOutDirectives:["use todo memo"]
function Component() {
"use todo memo";
return <div>hello world!</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
};
```
### Eval output
(kind: ok) <div>hello world!</div>

View File

@@ -0,0 +1,10 @@
// @customOptOutDirectives:["use todo memo"]
function Component() {
'use todo memo';
return <div>hello world!</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
};

View File

@@ -8344,6 +8344,23 @@ const testsTypescript = {
},
],
},
{
code: normalizeIndent`
function MyComponent(props) {
useEffect(() => {
console.log(props.foo);
});
}
`,
options: [{requireExplicitEffectDeps: true}],
errors: [
{
message:
'React Hook useEffect always requires dependencies. Please add a dependency array or an explicit `undefined`',
suggestions: undefined,
},
],
},
],
};

View File

@@ -67,6 +67,9 @@ const rule = {
type: 'string',
},
},
requireExplicitEffectDeps: {
type: 'boolean',
}
},
},
],
@@ -90,10 +93,13 @@ const rule = {
? rawOptions.experimental_autoDependenciesHooks
: [];
const requireExplicitEffectDeps: boolean = rawOptions && rawOptions.requireExplicitEffectDeps || false;
const options = {
additionalHooks,
experimental_autoDependenciesHooks,
enableDangerousAutofixThisMayCauseInfiniteLoops,
requireExplicitEffectDeps,
};
function reportProblem(problem: Rule.ReportDescriptor) {
@@ -1340,6 +1346,15 @@ const rule = {
return;
}
if (!maybeNode && isEffect && options.requireExplicitEffectDeps) {
reportProblem({
node: reactiveHook,
message:
`React Hook ${reactiveHookName} always requires dependencies. ` +
`Please add a dependency array or an explicit \`undefined\``
});
}
const isAutoDepsHook =
options.experimental_autoDependenciesHooks.includes(reactiveHookName);

View File

@@ -573,7 +573,7 @@ describe('ReactCompositeComponent-state', () => {
assertConsoleErrorDev([
"Can't perform a React state update on a component that hasn't mounted yet. " +
'This indicates that you have a side-effect in your render function that ' +
'asynchronously later calls tries to update the component. ' +
'asynchronously tries to update the component. ' +
'Move this work to useEffect instead.\n' +
' in B (at **)',
]);

View File

@@ -1922,7 +1922,7 @@ describe('ReactDOMServerPartialHydration', () => {
assertConsoleErrorDev([
"Can't perform a React state update on a component that hasn't mounted yet. " +
'This indicates that you have a side-effect in your render function that ' +
'asynchronously later calls tries to update the component. Move this work to useEffect instead.\n' +
'asynchronously tries to update the component. Move this work to useEffect instead.\n' +
' in App (at **)',
]);

View File

@@ -1883,7 +1883,7 @@ describe('ReactDOMServerPartialHydrationActivity', () => {
assertConsoleErrorDev([
"Can't perform a React state update on a component that hasn't mounted yet. " +
'This indicates that you have a side-effect in your render function that ' +
'asynchronously later calls tries to update the component. Move this work to useEffect instead.\n' +
'asynchronously tries to update the component. Move this work to useEffect instead.\n' +
' in App (at **)',
]);

View File

@@ -908,7 +908,7 @@ export function scheduleUpdateOnFiber(
markRootUpdated(root, lane);
if (
(executionContext & RenderContext) !== NoLanes &&
(executionContext & RenderContext) !== NoContext &&
root === workInProgressRoot
) {
// This update was dispatched during the render phase. This is a mistake
@@ -4802,7 +4802,7 @@ export function warnAboutUpdateOnNotYetMountedFiberInDEV(fiber: Fiber) {
console.error(
"Can't perform a React state update on a component that hasn't mounted yet. " +
'This indicates that you have a side-effect in your render function that ' +
'asynchronously later calls tries to update the component. Move this work to ' +
'asynchronously tries to update the component. Move this work to ' +
'useEffect instead.',
);
});

View File

@@ -764,7 +764,7 @@ describe('Activity', () => {
assertConsoleErrorDev([
"Can't perform a React state update on a component that hasn't mounted yet. " +
'This indicates that you have a side-effect in your render function that ' +
'asynchronously later calls tries to update the component. ' +
'asynchronously tries to update the component. ' +
'Move this work to useEffect instead.\n' +
' in Child (at **)',
]);