Skip to content

Commit a7962e5

Browse files
committed
[compiler] Run compiler pipeline on 'use no forget'
This PR updates the babel plugin to continue the compilation pipeline as normal on components/hooks that have been opted out using a directive. Instead, we no longer emit the compiled function when the directive is present. Previously, we would skip over the entire pipeline. By continuing to enter the pipeline, we'll be able to detect if there are unused directives. The end result is: - (no change) 'use forget' will always opt into compilation - (new) 'use no forget' will opt out of compilation but continue to log errors without throwing them. This means that a Program containing multiple functions (some of which are opted out) will continue to compile correctly ghstack-source-id: 895dc05 Pull Request resolved: #30720
1 parent fa6eab5 commit a7962e5

11 files changed

+212
-154
lines changed

compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ export type LoggerEvent =
165165
fnLoc: t.SourceLocation | null;
166166
detail: Omit<Omit<CompilerErrorDetailOptions, 'severity'>, 'suggestions'>;
167167
}
168+
| {
169+
kind: 'CompileSkip';
170+
fnLoc: t.SourceLocation | null;
171+
reason: string;
172+
loc: t.SourceLocation | null;
173+
}
168174
| {
169175
kind: 'CompileSuccess';
170176
fnLoc: t.SourceLocation | null;

compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts

Lines changed: 87 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -43,34 +43,35 @@ export type CompilerPass = {
4343
comments: Array<t.CommentBlock | t.CommentLine>;
4444
code: string | null;
4545
};
46+
const OPT_IN_DIRECTIVES = new Set(['use forget', 'use memo']);
47+
export const OPT_OUT_DIRECTIVES = new Set(['use no forget', 'use no memo']);
4648

4749
function findDirectiveEnablingMemoization(
48-
directives: Array<t.Directive>,
49-
): t.Directive | null {
50-
for (const directive of directives) {
51-
const directiveValue = directive.value.value;
52-
if (directiveValue === 'use forget' || directiveValue === 'use memo') {
53-
return directive;
54-
}
50+
node: NodePath<t.Program> | NodePath<t.BlockStatement>,
51+
): Array<NodePath<t.Directive>> {
52+
const directives = node.get('directives');
53+
if (Array.isArray(directives)) {
54+
return (directives as Array<NodePath<t.Directive>>).filter(
55+
directive =>
56+
directive.isDirective() &&
57+
OPT_IN_DIRECTIVES.has(directive.node.value.value),
58+
);
5559
}
56-
return null;
60+
return [];
5761
}
5862

5963
function findDirectiveDisablingMemoization(
60-
directives: Array<t.Directive>,
61-
options: PluginOptions,
62-
): t.Directive | null {
63-
for (const directive of directives) {
64-
const directiveValue = directive.value.value;
65-
if (
66-
(directiveValue === 'use no forget' ||
67-
directiveValue === 'use no memo') &&
68-
!options.ignoreUseNoForget
69-
) {
70-
return directive;
71-
}
64+
node: NodePath<t.Program> | NodePath<t.BlockStatement>,
65+
): Array<NodePath<t.Directive>> {
66+
const directives = node.get('directives');
67+
if (Array.isArray(directives)) {
68+
return (directives as Array<NodePath<t.Directive>>).filter(
69+
directive =>
70+
directive.isDirective() &&
71+
OPT_OUT_DIRECTIVES.has(directive.node.value.value),
72+
);
7273
}
73-
return null;
74+
return [];
7475
}
7576

7677
function isCriticalError(err: unknown): boolean {
@@ -102,7 +103,7 @@ export type CompileResult = {
102103
compiledFn: CodegenFunction;
103104
};
104105

105-
function handleError(
106+
function logError(
106107
err: unknown,
107108
pass: CompilerPass,
108109
fnLoc: t.SourceLocation | null,
@@ -131,6 +132,13 @@ function handleError(
131132
});
132133
}
133134
}
135+
}
136+
function handleError(
137+
err: unknown,
138+
pass: CompilerPass,
139+
fnLoc: t.SourceLocation | null,
140+
): void {
141+
logError(err, pass, fnLoc);
134142
if (
135143
pass.opts.panicThreshold === 'all_errors' ||
136144
(pass.opts.panicThreshold === 'critical_errors' && isCriticalError(err)) ||
@@ -408,6 +416,13 @@ export function compileProgram(
408416
}
409417
}
410418

419+
// TS doesn't seem to be able to resolve this type correctly
420+
const body = fn.get('body');
421+
CompilerError.invariant(Array.isArray(body) === false, {
422+
reason: 'Expected fn to only have one body',
423+
description: `Got ${body}`,
424+
loc: fn.node.loc ?? null,
425+
});
411426
let compiledFn: CodegenFunction;
412427
try {
413428
compiledFn = compileFn(
@@ -430,11 +445,52 @@ export function compileProgram(
430445
prunedMemoValues: compiledFn.prunedMemoValues,
431446
});
432447
} catch (err) {
448+
if (body.isBlockStatement()) {
449+
const optOutDirectives = findDirectiveDisablingMemoization(body);
450+
if (optOutDirectives.length > 0) {
451+
logError(err, pass, fn.node.loc ?? null);
452+
return null;
453+
}
454+
}
433455
hasCriticalError ||= isCriticalError(err);
434456
handleError(err, pass, fn.node.loc ?? null);
435457
return null;
436458
}
437459

460+
if (body.isBlockStatement()) {
461+
const optInDirectives = findDirectiveEnablingMemoization(body);
462+
const optOutDirectives = findDirectiveDisablingMemoization(body);
463+
464+
/**
465+
* Always compile functions with opt in directives.
466+
*/
467+
if (optInDirectives.length > 0) {
468+
return compiledFn;
469+
} else if (pass.opts.compilationMode === 'annotation') {
470+
return null;
471+
}
472+
473+
/**
474+
* Otherwise if 'use no forget/memo' is present, we still run the code through the compiler
475+
* for validation but we don't mutate the babel AST. This allows us to flag if there is an
476+
* unused 'use no forget/memo' directive.
477+
*/
478+
if (
479+
pass.opts.ignoreUseNoForget === false &&
480+
optOutDirectives.length > 0
481+
) {
482+
for (const directive of optOutDirectives) {
483+
pass.opts.logger?.logEvent(pass.filename, {
484+
kind: 'CompileSkip',
485+
fnLoc: fn.node.body.loc ?? null,
486+
reason: `Skipped due to '${directive.node.value.value}' directive.`,
487+
loc: directive.node.loc ?? null,
488+
});
489+
}
490+
return null;
491+
}
492+
}
493+
438494
if (!pass.opts.noEmit && !hasCriticalError) {
439495
return compiledFn;
440496
}
@@ -481,6 +537,12 @@ export function compileProgram(
481537
});
482538
}
483539

540+
const moduleScopeOptOutDirectives =
541+
findDirectiveDisablingMemoization(program);
542+
if (moduleScopeOptOutDirectives.length > 0) {
543+
return;
544+
}
545+
484546
if (pass.opts.gating != null) {
485547
const error = checkFunctionReferencedBeforeDeclarationAtTopLevel(
486548
program,
@@ -596,24 +658,6 @@ function shouldSkipCompilation(
596658
}
597659
}
598660

599-
// Top level "use no forget", skip this file entirely
600-
const useNoForget = findDirectiveDisablingMemoization(
601-
program.node.directives,
602-
pass.opts,
603-
);
604-
if (useNoForget != null) {
605-
pass.opts.logger?.logEvent(pass.filename, {
606-
kind: 'CompileError',
607-
fnLoc: null,
608-
detail: {
609-
severity: ErrorSeverity.Todo,
610-
reason: 'Skipped due to "use no forget" directive.',
611-
loc: useNoForget.loc ?? null,
612-
suggestions: null,
613-
},
614-
});
615-
return true;
616-
}
617661
const moduleName = pass.opts.runtimeModule ?? 'react/compiler-runtime';
618662
if (hasMemoCacheFunctionImport(program, moduleName)) {
619663
return true;
@@ -630,29 +674,10 @@ function getReactFunctionType(
630674
environment: EnvironmentConfig,
631675
): ReactFunctionType | null {
632676
const hookPattern = environment.hookPattern;
633-
if (fn.node.body.type === 'BlockStatement') {
634-
// Opt-outs disable compilation regardless of mode
635-
const useNoForget = findDirectiveDisablingMemoization(
636-
fn.node.body.directives,
637-
pass.opts,
638-
);
639-
if (useNoForget != null) {
640-
pass.opts.logger?.logEvent(pass.filename, {
641-
kind: 'CompileError',
642-
fnLoc: fn.node.body.loc ?? null,
643-
detail: {
644-
severity: ErrorSeverity.Todo,
645-
reason: 'Skipped due to "use no forget" directive.',
646-
loc: useNoForget.loc ?? null,
647-
suggestions: null,
648-
},
649-
});
650-
return null;
651-
}
652-
// Otherwise opt-ins enable compilation regardless of mode
653-
if (findDirectiveEnablingMemoization(fn.node.body.directives) != null) {
677+
const body = fn.get('body');
678+
if (Array.isArray(body) === false && body.isBlockStatement()) {
679+
if (findDirectiveEnablingMemoization(body).length > 0)
654680
return getComponentOrHookLike(fn, hookPattern) ?? 'Other';
655-
}
656681
}
657682

658683
// Component and hook declarations are known components/hooks
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {useRef} from 'react';
6+
7+
const useControllableState = options => {};
8+
function NoopComponent() {}
9+
10+
function Component() {
11+
'use no forget';
12+
const ref = useRef(null);
13+
// eslint-disable-next-line react-hooks/rules-of-hooks
14+
ref.current = 'bad';
15+
return <button ref={ref} />;
16+
}
17+
18+
export const FIXTURE_ENTRYPOINT = {
19+
fn: Component,
20+
params: [],
21+
};
22+
23+
```
24+
25+
26+
## Error
27+
28+
```
29+
7 | 'use no forget';
30+
8 | const ref = useRef(null);
31+
> 9 | // eslint-disable-next-line react-hooks/rules-of-hooks
32+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ InvalidReact: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. eslint-disable-next-line react-hooks/rules-of-hooks (9:9)
33+
10 | ref.current = 'bad';
34+
11 | return <button ref={ref} />;
35+
12 | }
36+
```
37+
38+
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {useRef} from 'react';
6+
7+
function Component() {
8+
'use no forget';
9+
const ref = useRef(null);
10+
// eslint-disable-next-line react-hooks/rules-of-hooks
11+
ref.current = 'bad';
12+
return <button ref={ref} />;
13+
}
14+
15+
export const FIXTURE_ENTRYPOINT = {
16+
fn: Component,
17+
params: [],
18+
};
19+
20+
```
21+
22+
23+
## Error
24+
25+
```
26+
4 | 'use no forget';
27+
5 | const ref = useRef(null);
28+
> 6 | // eslint-disable-next-line react-hooks/rules-of-hooks
29+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ InvalidReact: React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior. eslint-disable-next-line react-hooks/rules-of-hooks (6:6)
30+
7 | ref.current = 'bad';
31+
8 | return <button ref={ref} />;
32+
9 | }
33+
```
34+
35+

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-no-forget-multiple-with-eslint-suppression.expect.md

Lines changed: 0 additions & 49 deletions
This file was deleted.

0 commit comments

Comments
 (0)