Skip to content

Commit 4c48a87

Browse files
committed
[compiler][gating] Experimental directive based gating
(todo: fill in summary)
1 parent b13f5b3 commit 4c48a87

18 files changed

+582
-22
lines changed

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ const PanicThresholdOptionsSchema = z.enum([
3737
]);
3838

3939
export type PanicThresholdOptions = z.infer<typeof PanicThresholdOptionsSchema>;
40+
const DynamicGatingOptionsSchema = z.object({
41+
source: z.string(),
42+
});
43+
export type DynamicGatingOptions = z.infer<typeof DynamicGatingOptionsSchema>;
4044

4145
export type PluginOptions = {
4246
environment: EnvironmentConfig;
@@ -65,6 +69,28 @@ export type PluginOptions = {
6569
*/
6670
gating: ExternalFunction | null;
6771

72+
/**
73+
* If specified, this enables dynamic gating which matches `use memo if(...)`
74+
* directives.
75+
*
76+
* Example usage:
77+
* ```js
78+
* // @dynamicGating:{"source":"myModule"}
79+
* export function MyComponent() {
80+
* 'use memo if(isEnabled)';
81+
* return <div>...</div>;
82+
* }
83+
* ```
84+
* This will emit:
85+
* ```js
86+
* import {isEnabled} from 'myModule';
87+
* export const MyComponent = isEnabled()
88+
* ? <optimized version>
89+
* : <original version>;
90+
* ```
91+
*/
92+
dynamicGating: DynamicGatingOptions | null;
93+
6894
panicThreshold: PanicThresholdOptions;
6995

7096
/*
@@ -244,6 +270,7 @@ export const defaultOptions: PluginOptions = {
244270
logger: null,
245271
gating: null,
246272
noEmit: false,
273+
dynamicGating: null,
247274
eslintSuppressionRules: null,
248275
flowSuppressions: true,
249276
ignoreUseNoForget: false,
@@ -292,6 +319,25 @@ export function parsePluginOptions(obj: unknown): PluginOptions {
292319
}
293320
break;
294321
}
322+
case 'dynamicGating': {
323+
if (value == null) {
324+
parsedOptions[key] = null;
325+
} else {
326+
const result = DynamicGatingOptionsSchema.safeParse(value);
327+
if (result.success) {
328+
parsedOptions[key] = result.data;
329+
} else {
330+
CompilerError.throwInvalidConfig({
331+
reason:
332+
'Could not parse dynamic gating. Update React Compiler config to fix the error',
333+
description: `${fromZodError(result.error)}`,
334+
loc: null,
335+
suggestions: null,
336+
});
337+
}
338+
}
339+
break;
340+
}
295341
default: {
296342
parsedOptions[key] = value;
297343
}

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

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
CompilerErrorDetail,
1313
ErrorSeverity,
1414
} from '../CompilerError';
15-
import {ReactFunctionType} from '../HIR/Environment';
15+
import {ExternalFunction, ReactFunctionType} from '../HIR/Environment';
1616
import {CodegenFunction} from '../ReactiveScopes';
1717
import {isComponentDeclaration} from '../Utils/ComponentDeclaration';
1818
import {isHookDeclaration} from '../Utils/HookDeclaration';
@@ -31,6 +31,7 @@ import {
3131
suppressionsToCompilerError,
3232
} from './Suppression';
3333
import {GeneratedSource} from '../HIR';
34+
import {Err, Ok, Result} from '../Utils/Result';
3435

3536
export type CompilerPass = {
3637
opts: PluginOptions;
@@ -40,15 +41,24 @@ export type CompilerPass = {
4041
};
4142
export const OPT_IN_DIRECTIVES = new Set(['use forget', 'use memo']);
4243
export const OPT_OUT_DIRECTIVES = new Set(['use no forget', 'use no memo']);
44+
const DYNAMIC_GATING_DIRECTIVE = new RegExp('^use memo if\\(([^\\)]*)\\)$');
4345

44-
export function findDirectiveEnablingMemoization(
46+
export function tryFindDirectiveEnablingMemoization(
4547
directives: Array<t.Directive>,
46-
): t.Directive | null {
47-
return (
48-
directives.find(directive =>
49-
OPT_IN_DIRECTIVES.has(directive.value.value),
50-
) ?? null
48+
opts: PluginOptions,
49+
): Result<t.Directive | null, CompilerError> {
50+
const optIn = directives.find(directive =>
51+
OPT_IN_DIRECTIVES.has(directive.value.value),
5152
);
53+
if (optIn != null) {
54+
return Ok(optIn);
55+
}
56+
const dynamicGating = findDirectivesDynamicGating(directives, opts);
57+
if (dynamicGating.isOk()) {
58+
return Ok(dynamicGating.unwrap()?.directive ?? null);
59+
} else {
60+
return Err(dynamicGating.unwrapErr());
61+
}
5262
}
5363

5464
export function findDirectiveDisablingMemoization(
@@ -60,6 +70,64 @@ export function findDirectiveDisablingMemoization(
6070
) ?? null
6171
);
6272
}
73+
function findDirectivesDynamicGating(
74+
directives: Array<t.Directive>,
75+
opts: PluginOptions,
76+
): Result<
77+
{
78+
gating: ExternalFunction;
79+
directive: t.Directive;
80+
} | null,
81+
CompilerError
82+
> {
83+
if (opts.dynamicGating === null) {
84+
return Ok(null);
85+
}
86+
const errors = new CompilerError();
87+
const result: Array<{directive: t.Directive; match: string}> = [];
88+
89+
for (const directive of directives) {
90+
const maybeMatch = DYNAMIC_GATING_DIRECTIVE.exec(directive.value.value);
91+
if (maybeMatch != null && maybeMatch[1] != null) {
92+
if (t.isValidIdentifier(maybeMatch[1])) {
93+
result.push({directive, match: maybeMatch[1]});
94+
} else {
95+
errors.push({
96+
reason: `Dynamic gating directive is not a valid JavaScript identifier`,
97+
description: `Found '${directive.value.value}'`,
98+
severity: ErrorSeverity.InvalidReact,
99+
loc: directive.loc ?? null,
100+
suggestions: null,
101+
});
102+
}
103+
}
104+
}
105+
if (errors.hasErrors()) {
106+
return Err(errors);
107+
} else if (result.length > 1) {
108+
const error = new CompilerError();
109+
error.push({
110+
reason: `Multiple dynamic gating directives found`,
111+
description: `Expected a single directive but found [${result
112+
.map(r => r.directive.value.value)
113+
.join(', ')}]`,
114+
severity: ErrorSeverity.InvalidReact,
115+
loc: result[0].directive.loc ?? null,
116+
suggestions: null,
117+
});
118+
return Err(error);
119+
} else if (result.length === 1) {
120+
return Ok({
121+
gating: {
122+
source: opts.dynamicGating.source,
123+
importSpecifierName: result[0].match,
124+
},
125+
directive: result[0].directive,
126+
});
127+
} else {
128+
return Ok(null);
129+
}
130+
}
63131

64132
function isCriticalError(err: unknown): boolean {
65133
return !(err instanceof CompilerError) || err.isCritical();
@@ -477,12 +545,32 @@ function processFn(
477545
fnType: ReactFunctionType,
478546
programContext: ProgramContext,
479547
): null | CodegenFunction {
480-
let directives;
548+
let directives: {
549+
optIn: t.Directive | null;
550+
optOut: t.Directive | null;
551+
};
481552
if (fn.node.body.type !== 'BlockStatement') {
482-
directives = {optIn: null, optOut: null};
553+
directives = {
554+
optIn: null,
555+
optOut: null,
556+
};
483557
} else {
558+
const optIn = tryFindDirectiveEnablingMemoization(
559+
fn.node.body.directives,
560+
programContext.opts,
561+
);
562+
if (optIn.isErr()) {
563+
/**
564+
* If parsing opt-in directive fails, it's most likely that React Compiler
565+
* was not tested or rolled out on this function. In that case, we handle
566+
* the error and fall back to the safest option which is to not optimize
567+
* the function.
568+
*/
569+
handleError(optIn.unwrapErr(), programContext, fn.node.loc ?? null);
570+
return null;
571+
}
484572
directives = {
485-
optIn: findDirectiveEnablingMemoization(fn.node.body.directives),
573+
optIn: optIn.unwrapOr(null),
486574
optOut: findDirectiveDisablingMemoization(fn.node.body.directives),
487575
};
488576
}
@@ -661,25 +749,31 @@ function applyCompiledFunctions(
661749
pass: CompilerPass,
662750
programContext: ProgramContext,
663751
): void {
664-
const referencedBeforeDeclared =
665-
pass.opts.gating != null
666-
? getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns)
667-
: null;
752+
let referencedBeforeDeclared = null;
668753
for (const result of compiledFns) {
669754
const {kind, originalFn, compiledFn} = result;
670755
const transformedFn = createNewFunctionNode(originalFn, compiledFn);
671756
programContext.alreadyCompiled.add(transformedFn);
672757

673-
if (referencedBeforeDeclared != null && kind === 'original') {
674-
CompilerError.invariant(pass.opts.gating != null, {
675-
reason: "Expected 'gating' import to be present",
676-
loc: null,
677-
});
758+
let dynamicGating: ExternalFunction | null = null;
759+
if (originalFn.node.body.type === 'BlockStatement') {
760+
const result = findDirectivesDynamicGating(
761+
originalFn.node.body.directives,
762+
pass.opts,
763+
);
764+
if (result.isOk()) {
765+
dynamicGating = result.unwrap()?.gating ?? null;
766+
}
767+
}
768+
const functionGating = dynamicGating ?? pass.opts.gating;
769+
if (kind === 'original' && functionGating != null) {
770+
referencedBeforeDeclared ??=
771+
getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns);
678772
insertGatedFunctionDeclaration(
679773
originalFn,
680774
transformedFn,
681775
programContext,
682-
pass.opts.gating,
776+
functionGating,
683777
referencedBeforeDeclared.has(result),
684778
);
685779
} else {
@@ -735,8 +829,13 @@ function getReactFunctionType(
735829
): ReactFunctionType | null {
736830
const hookPattern = pass.opts.environment.hookPattern;
737831
if (fn.node.body.type === 'BlockStatement') {
738-
if (findDirectiveEnablingMemoization(fn.node.body.directives) != null)
832+
const optInDirectives = tryFindDirectiveEnablingMemoization(
833+
fn.node.body.directives,
834+
pass.opts,
835+
);
836+
if (optInDirectives.unwrapOr(null) != null) {
739837
return getComponentOrHookLike(fn, hookPattern) ?? 'Other';
838+
}
740839
}
741840

742841
// Component and hook declarations are known components/hooks
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly
6+
7+
import {useMemo} from 'react';
8+
import {identity} from 'shared-runtime';
9+
10+
function Foo({value}) {
11+
'use memo if(getTrue)';
12+
13+
const initialValue = useMemo(() => identity(value), []);
14+
return (
15+
<>
16+
<div>initial value {initialValue}</div>
17+
<div>current value {value}</div>
18+
</>
19+
);
20+
}
21+
22+
export const FIXTURE_ENTRYPOINT = {
23+
fn: Foo,
24+
params: [{value: 1}],
25+
sequentialRenders: [{value: 1}, {value: 2}],
26+
};
27+
28+
```
29+
30+
## Code
31+
32+
```javascript
33+
// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly
34+
35+
import { useMemo } from "react";
36+
import { identity } from "shared-runtime";
37+
38+
function Foo({ value }) {
39+
"use memo if(getTrue)";
40+
41+
const initialValue = useMemo(() => identity(value), []);
42+
return (
43+
<>
44+
<div>initial value {initialValue}</div>
45+
<div>current value {value}</div>
46+
</>
47+
);
48+
}
49+
50+
export const FIXTURE_ENTRYPOINT = {
51+
fn: Foo,
52+
params: [{ value: 1 }],
53+
sequentialRenders: [{ value: 1 }, { value: 2 }],
54+
};
55+
56+
```
57+
58+
## Logs
59+
60+
```
61+
{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":206},"end":{"line":16,"column":1,"index":433},"filename":"dynamic-gating-bailout-nopanic.ts"},"detail":{"reason":"React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected","description":"The inferred dependency was `value`, but the source dependencies were []. Inferred dependency not present in source","severity":"CannotPreserveMemoization","suggestions":null,"loc":{"start":{"line":9,"column":31,"index":288},"end":{"line":9,"column":52,"index":309},"filename":"dynamic-gating-bailout-nopanic.ts"}}}
62+
```
63+
64+
### Eval output
65+
(kind: ok) <div>initial value 1</div><div>current value 1</div>
66+
<div>initial value 1</div><div>current value 2</div>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly
2+
3+
import {useMemo} from 'react';
4+
import {identity} from 'shared-runtime';
5+
6+
function Foo({value}) {
7+
'use memo if(getTrue)';
8+
9+
const initialValue = useMemo(() => identity(value), []);
10+
return (
11+
<>
12+
<div>initial value {initialValue}</div>
13+
<div>current value {value}</div>
14+
</>
15+
);
16+
}
17+
18+
export const FIXTURE_ENTRYPOINT = {
19+
fn: Foo,
20+
params: [{value: 1}],
21+
sequentialRenders: [{value: 1}, {value: 2}],
22+
};

0 commit comments

Comments
 (0)