Skip to content

Commit db40a5b

Browse files
committedMar 5, 2025
[compiler][rfc] Enable hook guards in dev mode by default
This validation ensures that React compiler-enabled apps remain correct. That is, code that errors with this validation is most likely ***invalid*** with React compiler is enabled (specifically, hook calls will be compiled to if-else memo blocks). Hook guards are used extensively for Meta's react compiler rollouts. There, they're enabled for developers (for dev builds) and on e2e test runs. Let's enable by default for oss as well ### Examples of inputs this rule throws on * Components should not be invoked directly as React Compiler could memoize the call to AnotherComponent, which introduces conditional hook calls in its compiled output. ```js function Invalid1(props) { const myJsx = AnotherComponent(props); return <div> { myJsx } </div>; } ``` * Hooks must be named as hooks. Similarly, hook calls may not appear in functions that are not components or hooks. ```js const renamedHook = useState; function Invalid2() { const [state, setState] = renamedHook(0); } function Invalid3() { const myFunc = () => useContext(...); myFunc(); } ``` * Hooks must be directly called (from the body of a component or hook) ``` function call(fn) { return fn(); } function Invalid4() { const result = call(useMyHook); } ``` ### Example of hook guard error (in dev build) <img width="1237" alt="image" src="https://github.com/user-attachments/assets/e9ada403-b0d7-4840-b6d5-ad600519c6e6" />
1 parent e9252bc commit db40a5b

File tree

6 files changed

+95
-18
lines changed

6 files changed

+95
-18
lines changed
 

‎compiler/packages/babel-plugin-react-compiler/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"pretty-format": "^24",
5151
"react": "0.0.0-experimental-4beb1fd8-20241118",
5252
"react-dom": "0.0.0-experimental-4beb1fd8-20241118",
53+
"react-compiler-runtime": "0.0.1",
5354
"ts-jest": "^29.1.1",
5455
"ts-node": "^10.9.2",
5556
"zod": "^3.22.4",

‎compiler/packages/babel-plugin-react-compiler/scripts/jest/makeTransform.ts

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const e2eTransformerCacheKey = 1;
2929
const forgetOptions: EnvironmentConfig = validateEnvironmentConfig({
3030
enableAssumeHooksFollowRulesOfReact: true,
3131
enableFunctionOutlining: false,
32+
enableEmitHookGuards: null,
3233
});
3334
const debugMode = process.env['DEBUG_FORGET_COMPILER'] != null;
3435

‎compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts

+12
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ export default function BabelPluginReactCompiler(
6161
},
6262
};
6363
}
64+
if (opts.environment.enableEmitHookGuards != null) {
65+
const enableEmitHookGuards = opts.environment.enableEmitHookGuards;
66+
if (enableEmitHookGuards.devonly === true && !isDev) {
67+
opts = {
68+
...opts,
69+
environment: {
70+
...opts.environment,
71+
enableEmitHookGuards: null,
72+
},
73+
};
74+
}
75+
}
6476
compileProgram(prog, {
6577
opts,
6678
filename: pass.filename ?? null,

‎compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,27 @@ const EnvironmentConfigSchema = z.object({
396396
*/
397397
enableEmitFreeze: ExternalFunctionSchema.nullable().default(null),
398398

399-
enableEmitHookGuards: ExternalFunctionSchema.nullable().default(null),
399+
enableEmitHookGuards: z
400+
.intersection(
401+
ExternalFunctionSchema,
402+
z.object({
403+
devonly: z.union([
404+
z.literal(true),
405+
z.literal(false),
406+
/**
407+
* Backwards compatibility with previous configuration, which did not
408+
* have this field.
409+
*/
410+
z.literal(undefined),
411+
]),
412+
}),
413+
)
414+
.nullable()
415+
.default({
416+
source: 'react-compiler-runtime',
417+
importSpecifierName: '$dispatcherGuard',
418+
devonly: true,
419+
}),
400420

401421
/**
402422
* Enable instruction reordering. See InstructionReordering.ts for the details
@@ -668,6 +688,7 @@ const testComplexConfigDefaults: PartialEnvironmentConfig = {
668688
enableEmitHookGuards: {
669689
source: 'react-compiler-runtime',
670690
importSpecifierName: '$dispatcherGuard',
691+
devonly: false,
671692
},
672693
inlineJsxTransform: {
673694
elementSymbol: 'react.transitional.element',
@@ -711,6 +732,7 @@ function parseConfigPragmaEnvironmentForTest(
711732
const maybeConfig: any = {};
712733
// Get the defaults to programmatically check for boolean properties
713734
const defaultConfig = EnvironmentConfigSchema.parse({});
735+
(maybeConfig as PartialEnvironmentConfig).enableEmitHookGuards = null;
714736

715737
for (const token of pragma.split(' ')) {
716738
if (!token.startsWith('@')) {

‎compiler/packages/babel-plugin-react-compiler/src/__tests__/parseConfigPragma-test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('parseConfigPragmaForTests()', () => {
2727
panicThreshold: 'all_errors',
2828
environment: {
2929
...defaultOptions.environment,
30+
enableEmitHookGuards: null,
3031
enableUseTypeAnnotations: true,
3132
validateNoSetStateInPassiveEffects: true,
3233
validateNoSetStateInRender: false,

‎compiler/packages/react-compiler-runtime/src/index.ts

+57-17
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ export const c =
4141

4242
const LazyGuardDispatcher: {[key: string]: (...args: Array<any>) => any} = {};
4343
[
44-
'readContext',
4544
'useCallback',
4645
'useContext',
4746
'useEffect',
@@ -58,10 +57,8 @@ const LazyGuardDispatcher: {[key: string]: (...args: Array<any>) => any} = {};
5857
'useMutableSource',
5958
'useSyncExternalStore',
6059
'useId',
61-
'unstable_isNewReconciler',
62-
'getCacheSignal',
63-
'getCacheForType',
6460
'useCacheRefresh',
61+
'useOptimistic',
6562
].forEach(name => {
6663
LazyGuardDispatcher[name] = () => {
6764
throw new Error(
@@ -74,15 +71,29 @@ const LazyGuardDispatcher: {[key: string]: (...args: Array<any>) => any} = {};
7471
let originalDispatcher: unknown = null;
7572

7673
// Allow guards are not emitted for useMemoCache
77-
LazyGuardDispatcher['useMemoCache'] = (count: number) => {
78-
if (originalDispatcher == null) {
79-
throw new Error(
80-
'React Compiler internal invariant violation: unexpected null dispatcher',
81-
);
82-
} else {
83-
return (originalDispatcher as any).useMemoCache(count);
84-
}
85-
};
74+
75+
for (const key of [
76+
// Allow guards may not be emitted for useMemoCache
77+
'useMemoCache',
78+
'unstable_useMemoCache',
79+
// Not named as hooks
80+
'readContext',
81+
'unstable_isNewReconciler',
82+
'getCacheSignal',
83+
'getCacheForType',
84+
// Not 'real' hooks (i.e. not implemented with an index based id)
85+
'use',
86+
]) {
87+
LazyGuardDispatcher[key] = (...args) => {
88+
if (originalDispatcher == null) {
89+
throw new Error(
90+
'React Compiler internal invariant violation: unexpected null dispatcher',
91+
);
92+
} else {
93+
return (originalDispatcher as any).useMemoCache(...args);
94+
}
95+
};
96+
}
8697

8798
enum GuardKind {
8899
PushGuardContext = 0,
@@ -92,8 +103,13 @@ enum GuardKind {
92103
}
93104

94105
function setCurrent(newDispatcher: any) {
95-
ReactSecretInternals.ReactCurrentDispatcher.current = newDispatcher;
96-
return ReactSecretInternals.ReactCurrentDispatcher.current;
106+
if (ReactSecretInternals.ReactCurrentDispatcher != null) {
107+
ReactSecretInternals.ReactCurrentDispatcher.current = newDispatcher;
108+
return ReactSecretInternals.ReactCurrentDispatcher.current;
109+
} else {
110+
ReactSecretInternals.H = newDispatcher;
111+
return ReactSecretInternals.H;
112+
}
97113
}
98114

99115
const guardFrames: Array<unknown> = [];
@@ -134,7 +150,9 @@ const guardFrames: Array<unknown> = [];
134150
* ```
135151
*/
136152
export function $dispatcherGuard(kind: GuardKind) {
137-
const curr = ReactSecretInternals.ReactCurrentDispatcher.current;
153+
const curr =
154+
ReactSecretInternals.H ??
155+
ReactSecretInternals.ReactCurrentDispatcher.current;
138156
if (kind === GuardKind.PushGuardContext) {
139157
// Push before checking invariant or errors
140158
guardFrames.push(curr);
@@ -169,7 +187,29 @@ export function $dispatcherGuard(kind: GuardKind) {
169187
// ExpectHooks could be nested, so we save the current dispatcher
170188
// for the matching PopExpectHook to restore.
171189
guardFrames.push(curr);
172-
setCurrent(originalDispatcher);
190+
if (originalDispatcher != null) {
191+
/**
192+
* originalDispatcher could be null in the following case.
193+
* ```js
194+
* function Component() {
195+
* "use no memo";
196+
* const useFn = useMakeHook();
197+
* useFn();
198+
* }
199+
* function useMakeHook() {
200+
* // ...
201+
* return () => {
202+
* useState(...);
203+
* };
204+
* }
205+
* ```
206+
*
207+
* React compiler currently does not memoize within inner functions. While this
208+
* code breaks the programming model (hooks as first class values), let's
209+
* either ignore this or error with a better message (within another dispatcher)
210+
*/
211+
setCurrent(originalDispatcher);
212+
}
173213
} else if (kind === GuardKind.PopExpectHook) {
174214
const lastFrame = guardFrames.pop();
175215
if (lastFrame == null) {

0 commit comments

Comments
 (0)
Failed to load comments.