Skip to content

Commit cfaef1c

Browse files
committed
[compiler] Detect known incompatible libraries
A few libraries are known to be incompatible with memoization, whether manually via `useMemo()` or via React Compiler. This puts us in a tricky situation. On the one hand, we understand that these libraries were developed prior to our documenting the [Rules of React](https://react.dev/reference/rules), and their designs were the result of trying to deliver a great experience for their users and balance multiple priorities around DX, performance, etc. At the same time, using these libraries with memoization — and in particular with automatic memoization via React Compiler — can break apps by causing the components using these APIs not to update. Concretely, the APIs have in common that they return a function which returns different values over time, but where the function itself does not change. Memoizing the result on the identity of the function will mean that the value never changes. Developers reasonable interpret this as "React Compiler broke my code". Of course, the best solution is to work with developers of these libraries to address the root cause, and we're doing that. We've previously discussed this situation with both of the respective libraries: * React Hook Form: react-hook-form/react-hook-form#11910 (comment) * TanStack Table: #33057 (comment) and TanStack/table#5567 In the meantime we need to make sure that React Compiler can work out of the box as much as possible. This means teaching it about popular libraries that cannot be memoized. We also can't silently skip compilation, as this confuses users, so we need these error messages to be visible to users. To that end, this PR adds: * A flag to mark functions/hooks as incompatible * Validation against use of such functions * A default type provider to provide declarations for two known-incompatible libraries Note that Mobx is also incompatible, but the `observable()` function is called outside of the component itself, so the compiler cannot currently detect it. We may add validation for such APIs in the future. Again, we really empathize with the developers of these libraries. We've tried to word the error message non-judgementally, because we get that it's hard! We're open to feedback about the error message, please let us know.
1 parent e1f907d commit cfaef1c

13 files changed

+276
-1
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {Effect, ValueKind} from '..';
9+
import {TypeConfig} from './TypeSchema';
10+
11+
/**
12+
* Libraries developed before we officially documented the [Rules of React](https://react.dev/reference/rules)
13+
* implement APIs which cannot be memoized safely, either via manual or automatic memoization.
14+
*
15+
* Any non-hook API that is designed to be called during render (not events/effects) should be safe to memoize:
16+
*
17+
* ```js
18+
* function Component() {
19+
* const {someFunction} = useLibrary();
20+
* // it should always be safe to memoize functions like this
21+
* const result = useMemo(() => someFunction(), [someFunction]);
22+
* }
23+
* ```
24+
*
25+
* However, some APIs implement "interior mutability" — mutating values rather than copying into a new value
26+
* and setting state with the new value — which defaults such memoization. With this pattern, the function
27+
* (`someFunction()` in the example) could return different values even though the function itself is the same.
28+
*
29+
* Given that we didn't have the Rules of React precisely documented prior to the introduction of React compiler,
30+
* it's understandable that some libraries accidentally shipped APIs that break this rule. However, developers
31+
* can easily run into pitfalls with these APIs. They may manually memoize them, which can break their app. Or
32+
* they may try using React Compiler, and think that the compiler has broken their code.
33+
*
34+
* The React team is open to collaborating with library authors to help develop compatible versions of these APIs,
35+
* and we have already reached out to the teams who own any API listed here to ensure they are aware of the issue.
36+
*/
37+
export function defaultModuleTypeProvider(
38+
moduleName: string,
39+
): TypeConfig | null {
40+
switch (moduleName) {
41+
case 'react-hook-form': {
42+
return {
43+
kind: 'object',
44+
properties: {
45+
useForm: {
46+
kind: 'hook',
47+
returnType: {
48+
kind: 'object',
49+
properties: {
50+
watch: {
51+
kind: 'function',
52+
positionalParams: [],
53+
restParam: Effect.Read,
54+
calleeEffect: Effect.Read,
55+
returnType: {kind: 'type', name: 'Any'},
56+
returnValueKind: ValueKind.Mutable,
57+
knownIncompatible: `React Hook Form's \`useForm()\` API returns a \`watch()\` function which cannot be memoized safely.`,
58+
},
59+
},
60+
},
61+
},
62+
},
63+
};
64+
}
65+
case '@tanstack/react-table': {
66+
return {
67+
kind: 'object',
68+
properties: {
69+
useReactTable: {
70+
kind: 'hook',
71+
positionalParams: [],
72+
restParam: Effect.Read,
73+
returnType: {kind: 'type', name: 'Any'},
74+
knownIncompatible: `TanStack Table's \`useReactTable()\` API returns functions that cannot be memoized safely`,
75+
},
76+
},
77+
};
78+
}
79+
}
80+
return null;
81+
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
} from './ObjectShape';
5050
import {Scope as BabelScope, NodePath} from '@babel/traverse';
5151
import {TypeSchema} from './TypeSchema';
52+
import {defaultModuleTypeProvider} from './DefaultModuleTypeProvider';
5253

5354
export const ReactElementSymbolSchema = z.object({
5455
elementSymbol: z.union([
@@ -157,7 +158,9 @@ export const EnvironmentConfigSchema = z.object({
157158
* A function that, given the name of a module, can optionally return a description
158159
* of that module's type signature.
159160
*/
160-
moduleTypeProvider: z.nullable(z.function().args(z.string())).default(null),
161+
moduleTypeProvider: z
162+
.nullable(z.function().args(z.string()))
163+
.default(defaultModuleTypeProvider),
161164

162165
/**
163166
* A list of functions which the application compiles as macros, where

compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,7 @@ export function installTypeConfig(
908908
mutableOnlyIfOperandsAreMutable:
909909
typeConfig.mutableOnlyIfOperandsAreMutable === true,
910910
aliasing: typeConfig.aliasing,
911+
knownIncompatible: typeConfig.knownIncompatible ?? null,
911912
});
912913
}
913914
case 'hook': {
@@ -926,6 +927,7 @@ export function installTypeConfig(
926927
returnValueKind: typeConfig.returnValueKind ?? ValueKind.Frozen,
927928
noAlias: typeConfig.noAlias === true,
928929
aliasing: typeConfig.aliasing,
930+
knownIncompatible: typeConfig.knownIncompatible ?? null,
929931
});
930932
}
931933
case 'object': {

compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ export type FunctionSignature = {
331331
mutableOnlyIfOperandsAreMutable?: boolean;
332332

333333
impure?: boolean;
334+
knownIncompatible?: string | null | undefined;
334335

335336
canonicalName?: string;
336337

compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export type FunctionTypeConfig = {
236236
impure?: boolean | null | undefined;
237237
canonicalName?: string | null | undefined;
238238
aliasing?: AliasingSignatureConfig | null | undefined;
239+
knownIncompatible?: string | null | undefined;
239240
};
240241
export const FunctionTypeSchema: z.ZodType<FunctionTypeConfig> = z.object({
241242
kind: z.literal('function'),
@@ -249,6 +250,7 @@ export const FunctionTypeSchema: z.ZodType<FunctionTypeConfig> = z.object({
249250
impure: z.boolean().nullable().optional(),
250251
canonicalName: z.string().nullable().optional(),
251252
aliasing: AliasingSignatureSchema.nullable().optional(),
253+
knownIncompatible: z.string().nullable().optional(),
252254
});
253255

254256
export type HookTypeConfig = {
@@ -259,6 +261,7 @@ export type HookTypeConfig = {
259261
returnValueKind?: ValueKind | null | undefined;
260262
noAlias?: boolean | null | undefined;
261263
aliasing?: AliasingSignatureConfig | null | undefined;
264+
knownIncompatible?: string | null | undefined;
262265
};
263266
export const HookTypeSchema: z.ZodType<HookTypeConfig> = z.object({
264267
kind: z.literal('hook'),
@@ -268,6 +271,7 @@ export const HookTypeSchema: z.ZodType<HookTypeConfig> = z.object({
268271
returnValueKind: ValueKindSchema.nullable().optional(),
269272
noAlias: z.boolean().nullable().optional(),
270273
aliasing: AliasingSignatureSchema.nullable().optional(),
274+
knownIncompatible: z.string().nullable().optional(),
271275
});
272276

273277
export type BuiltInTypeConfig =

compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,6 +2120,26 @@ function computeEffectsForLegacySignature(
21202120
}),
21212121
});
21222122
}
2123+
if (signature.knownIncompatible != null) {
2124+
const errors = new CompilerError();
2125+
errors.pushDiagnostic(
2126+
CompilerDiagnostic.create({
2127+
severity: ErrorSeverity.InvalidReact,
2128+
category: 'Use of incompatible library',
2129+
description: [
2130+
'This API returns functions which cannot be memoized without leading to stale UI. ' +
2131+
'To prevent this, by default React Compiler will skip memoizing this component/hook. ' +
2132+
'However, you may see issues if values from this API are passed to other components/hooks that are ' +
2133+
'memoized.',
2134+
].join(''),
2135+
}).withDetail({
2136+
kind: 'error',
2137+
loc: receiver.loc,
2138+
message: signature.knownIncompatible,
2139+
}),
2140+
);
2141+
throw errors;
2142+
}
21232143
const stores: Array<Place> = [];
21242144
const captures: Array<Place> = [];
21252145
function visit(place: Place, effect: Effect): void {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {knownIncompatible} from 'ReactCompilerKnownIncompatibleTest';
6+
7+
function Component() {
8+
const data = knownIncompatible();
9+
return <div>Error</div>;
10+
}
11+
12+
```
13+
14+
15+
## Error
16+
17+
```
18+
Found 1 error:
19+
20+
Error: Use of incompatible library
21+
22+
This API returns functions which cannot be memoized without leading to stale UI. To prevent this, by default React Compiler will skip memoizing this component/hook. However, you may see issues if values from this API are passed to other components/hooks that are memoized.
23+
24+
error.invalid-known-incompatible-function.ts:4:15
25+
2 |
26+
3 | function Component() {
27+
> 4 | const data = knownIncompatible();
28+
| ^^^^^^^^^^^^^^^^^ useKnownIncompatible is known to be incompatible
29+
5 | return <div>Error</div>;
30+
6 | }
31+
7 |
32+
```
33+
34+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {knownIncompatible} from 'ReactCompilerKnownIncompatibleTest';
2+
3+
function Component() {
4+
const data = knownIncompatible();
5+
return <div>Error</div>;
6+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {useKnownIncompatibleIndirect} from 'ReactCompilerKnownIncompatibleTest';
6+
7+
function Component() {
8+
const {incompatible} = useKnownIncompatibleIndirect();
9+
return <div>{incompatible()}</div>;
10+
}
11+
12+
```
13+
14+
15+
## Error
16+
17+
```
18+
Found 1 error:
19+
20+
Error: Use of incompatible library
21+
22+
This API returns functions which cannot be memoized without leading to stale UI. To prevent this, by default React Compiler will skip memoizing this component/hook. However, you may see issues if values from this API are passed to other components/hooks that are memoized.
23+
24+
error.invalid-known-incompatible-hook-return-property.ts:5:15
25+
3 | function Component() {
26+
4 | const {incompatible} = useKnownIncompatibleIndirect();
27+
> 5 | return <div>{incompatible()}</div>;
28+
| ^^^^^^^^^^^^ useKnownIncompatibleIndirect returns an incompatible() function that is known incompatible
29+
6 | }
30+
7 |
31+
```
32+
33+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {useKnownIncompatibleIndirect} from 'ReactCompilerKnownIncompatibleTest';
2+
3+
function Component() {
4+
const {incompatible} = useKnownIncompatibleIndirect();
5+
return <div>{incompatible()}</div>;
6+
}

0 commit comments

Comments
 (0)