Skip to content

Commit 459a2c4

Browse files
authored
[compiler][gating] Experimental directive based gating (#33149)
Adds `dynamicGating` as an experimental option for testing rollout DX at Meta. If specified, this enables dynamic gating which matches `use memo if(...)` directives. #### Example usage Input file ```js // @dynamicGating:{"source":"myModule"} export function MyComponent() { 'use memo if(isEnabled)'; return <div>...</div>; } ``` Compiler output ```js import {isEnabled} from 'myModule'; export const MyComponent = isEnabled() ? <optimized version> : <original version>; ``` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33149). * __->__ #33149 * #33148
1 parent 1c43d0a commit 459a2c4

26 files changed

+813
-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
}
@@ -659,25 +747,31 @@ function applyCompiledFunctions(
659747
pass: CompilerPass,
660748
programContext: ProgramContext,
661749
): void {
662-
const referencedBeforeDeclared =
663-
pass.opts.gating != null
664-
? getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns)
665-
: null;
750+
let referencedBeforeDeclared = null;
666751
for (const result of compiledFns) {
667752
const {kind, originalFn, compiledFn} = result;
668753
const transformedFn = createNewFunctionNode(originalFn, compiledFn);
669754
programContext.alreadyCompiled.add(transformedFn);
670755

671-
if (referencedBeforeDeclared != null && kind === 'original') {
672-
CompilerError.invariant(pass.opts.gating != null, {
673-
reason: "Expected 'gating' import to be present",
674-
loc: null,
675-
});
756+
let dynamicGating: ExternalFunction | null = null;
757+
if (originalFn.node.body.type === 'BlockStatement') {
758+
const result = findDirectivesDynamicGating(
759+
originalFn.node.body.directives,
760+
pass.opts,
761+
);
762+
if (result.isOk()) {
763+
dynamicGating = result.unwrap()?.gating ?? null;
764+
}
765+
}
766+
const functionGating = dynamicGating ?? pass.opts.gating;
767+
if (kind === 'original' && functionGating != null) {
768+
referencedBeforeDeclared ??=
769+
getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns);
676770
insertGatedFunctionDeclaration(
677771
originalFn,
678772
transformedFn,
679773
programContext,
680-
pass.opts.gating,
774+
functionGating,
681775
referencedBeforeDeclared.has(result),
682776
);
683777
} else {
@@ -733,8 +827,13 @@ function getReactFunctionType(
733827
): ReactFunctionType | null {
734828
const hookPattern = pass.opts.environment.hookPattern;
735829
if (fn.node.body.type === 'BlockStatement') {
736-
if (findDirectiveEnablingMemoization(fn.node.body.directives) != null)
830+
const optInDirectives = tryFindDirectiveEnablingMemoization(
831+
fn.node.body.directives,
832+
pass.opts,
833+
);
834+
if (optInDirectives.unwrapOr(null) != null) {
737835
return getComponentOrHookLike(fn, hookPattern) ?? 'Other';
836+
}
738837
}
739838

740839
// Component and hook declarations are known components/hooks
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @dynamicGating:{"source":"shared-runtime"} @compilationMode:"annotation"
6+
7+
function Foo() {
8+
'use memo if(getTrue)';
9+
return <div>hello world</div>;
10+
}
11+
12+
export const FIXTURE_ENTRYPOINT = {
13+
fn: Foo,
14+
params: [{}],
15+
};
16+
17+
```
18+
19+
## Code
20+
21+
```javascript
22+
import { c as _c } from "react/compiler-runtime";
23+
import { getTrue } from "shared-runtime"; // @dynamicGating:{"source":"shared-runtime"} @compilationMode:"annotation"
24+
const Foo = getTrue()
25+
? function Foo() {
26+
"use memo if(getTrue)";
27+
const $ = _c(1);
28+
let t0;
29+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
30+
t0 = <div>hello world</div>;
31+
$[0] = t0;
32+
} else {
33+
t0 = $[0];
34+
}
35+
return t0;
36+
}
37+
: function Foo() {
38+
"use memo if(getTrue)";
39+
return <div>hello world</div>;
40+
};
41+
42+
export const FIXTURE_ENTRYPOINT = {
43+
fn: Foo,
44+
params: [{}],
45+
};
46+
47+
```
48+
49+
### Eval output
50+
(kind: ok) <div>hello world</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// @dynamicGating:{"source":"shared-runtime"} @compilationMode:"annotation"
2+
3+
function Foo() {
4+
'use memo if(getTrue)';
5+
return <div>hello world</div>;
6+
}
7+
8+
export const FIXTURE_ENTRYPOINT = {
9+
fn: Foo,
10+
params: [{}],
11+
};

0 commit comments

Comments
 (0)