diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/DefaultModuleTypeProvider.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/DefaultModuleTypeProvider.ts new file mode 100644 index 0000000000..0cd65d947f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/DefaultModuleTypeProvider.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {Effect, ValueKind} from '..'; +import {TypeConfig} from './TypeSchema'; + +/** + * Libraries developed before we officially documented the [Rules of React](https://react.dev/reference/rules) + * implement APIs which cannot be memoized safely, either via manual or automatic memoization. + * + * Any non-hook API that is designed to be called during render (not events/effects) should be safe to memoize: + * + * ```js + * function Component() { + * const {someFunction} = useLibrary(); + * // it should always be safe to memoize functions like this + * const result = useMemo(() => someFunction(), [someFunction]); + * } + * ``` + * + * However, some APIs implement "interior mutability" — mutating values rather than copying into a new value + * and setting state with the new value — which defaults such memoization. With this pattern, the function + * (`someFunction()` in the example) could return different values even though the function itself is the same. + * + * Given that we didn't have the Rules of React precisely documented prior to the introduction of React compiler, + * it's understandable that some libraries accidentally shipped APIs that break this rule. However, developers + * can easily run into pitfalls with these APIs. They may manually memoize them, which can break their app. Or + * they may try using React Compiler, and think that the compiler has broken their code. + * + * The React team is open to collaborating with library authors to help develop compatible versions of these APIs, + * and we have already reached out to the teams who own any API listed here to ensure they are aware of the issue. + */ +export function defaultModuleTypeProvider( + moduleName: string, +): TypeConfig | null { + switch (moduleName) { + case 'react-hook-form': { + return { + kind: 'object', + properties: { + useForm: { + kind: 'hook', + returnType: { + kind: 'object', + properties: { + watch: { + kind: 'function', + positionalParams: [], + restParam: Effect.Read, + calleeEffect: Effect.Read, + returnType: {kind: 'type', name: 'Any'}, + returnValueKind: ValueKind.Mutable, + knownIncompatible: `React Hook Form's \`useForm()\` API returns a \`watch()\` function which cannot be memoized safely.`, + }, + }, + }, + }, + }, + }; + } + case '@tanstack/react-table': { + return { + kind: 'object', + properties: { + useReactTable: { + kind: 'hook', + positionalParams: [], + restParam: Effect.Read, + returnType: {kind: 'type', name: 'Any'}, + knownIncompatible: `TanStack Table's \`useReactTable()\` API returns functions that cannot be memoized safely`, + }, + }, + }; + } + } + return null; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index f94870fc03..80ea2180fb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -49,6 +49,7 @@ import { } from './ObjectShape'; import {Scope as BabelScope, NodePath} from '@babel/traverse'; import {TypeSchema} from './TypeSchema'; +import {defaultModuleTypeProvider} from './DefaultModuleTypeProvider'; export const ReactElementSymbolSchema = z.object({ elementSymbol: z.union([ @@ -157,7 +158,9 @@ export const EnvironmentConfigSchema = z.object({ * A function that, given the name of a module, can optionally return a description * of that module's type signature. */ - moduleTypeProvider: z.nullable(z.function().args(z.string())).default(null), + moduleTypeProvider: z + .nullable(z.function().args(z.string())) + .default(defaultModuleTypeProvider), /** * A list of functions which the application compiles as macros, where diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts index c3eadb89f5..89d1529180 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts @@ -908,6 +908,7 @@ export function installTypeConfig( mutableOnlyIfOperandsAreMutable: typeConfig.mutableOnlyIfOperandsAreMutable === true, aliasing: typeConfig.aliasing, + knownIncompatible: typeConfig.knownIncompatible ?? null, }); } case 'hook': { @@ -926,6 +927,7 @@ export function installTypeConfig( returnValueKind: typeConfig.returnValueKind ?? ValueKind.Frozen, noAlias: typeConfig.noAlias === true, aliasing: typeConfig.aliasing, + knownIncompatible: typeConfig.knownIncompatible ?? null, }); } case 'object': { diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts index eaf728db95..b20bebbae3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -331,6 +331,7 @@ export type FunctionSignature = { mutableOnlyIfOperandsAreMutable?: boolean; impure?: boolean; + knownIncompatible?: string | null | undefined; canonicalName?: string; diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts index 5945e3a078..8f28a1357b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts @@ -236,6 +236,7 @@ export type FunctionTypeConfig = { impure?: boolean | null | undefined; canonicalName?: string | null | undefined; aliasing?: AliasingSignatureConfig | null | undefined; + knownIncompatible?: string | null | undefined; }; export const FunctionTypeSchema: z.ZodType = z.object({ kind: z.literal('function'), @@ -249,6 +250,7 @@ export const FunctionTypeSchema: z.ZodType = z.object({ impure: z.boolean().nullable().optional(), canonicalName: z.string().nullable().optional(), aliasing: AliasingSignatureSchema.nullable().optional(), + knownIncompatible: z.string().nullable().optional(), }); export type HookTypeConfig = { @@ -259,6 +261,7 @@ export type HookTypeConfig = { returnValueKind?: ValueKind | null | undefined; noAlias?: boolean | null | undefined; aliasing?: AliasingSignatureConfig | null | undefined; + knownIncompatible?: string | null | undefined; }; export const HookTypeSchema: z.ZodType = z.object({ kind: z.literal('hook'), @@ -268,6 +271,7 @@ export const HookTypeSchema: z.ZodType = z.object({ returnValueKind: ValueKindSchema.nullable().optional(), noAlias: z.boolean().nullable().optional(), aliasing: AliasingSignatureSchema.nullable().optional(), + knownIncompatible: z.string().nullable().optional(), }); export type BuiltInTypeConfig = diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts index 2adf78fe05..0edde82db9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts @@ -2120,6 +2120,26 @@ function computeEffectsForLegacySignature( }), }); } + if (signature.knownIncompatible != null) { + const errors = new CompilerError(); + errors.pushDiagnostic( + CompilerDiagnostic.create({ + severity: ErrorSeverity.InvalidReact, + category: 'Use of incompatible library', + description: [ + '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.', + ].join(''), + }).withDetail({ + kind: 'error', + loc: receiver.loc, + message: signature.knownIncompatible, + }), + ); + throw errors; + } const stores: Array = []; const captures: Array = []; function visit(place: Place, effect: Effect): void { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-function.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-function.expect.md new file mode 100644 index 0000000000..fc1afa7b66 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-function.expect.md @@ -0,0 +1,34 @@ + +## Input + +```javascript +import {knownIncompatible} from 'ReactCompilerKnownIncompatibleTest'; + +function Component() { + const data = knownIncompatible(); + return
Error
; +} + +``` + + +## Error + +``` +Found 1 error: + +Error: Use of incompatible library + +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. + +error.invalid-known-incompatible-function.ts:4:15 + 2 | + 3 | function Component() { +> 4 | const data = knownIncompatible(); + | ^^^^^^^^^^^^^^^^^ useKnownIncompatible is known to be incompatible + 5 | return
Error
; + 6 | } + 7 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-function.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-function.js new file mode 100644 index 0000000000..778b6dd045 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-function.js @@ -0,0 +1,6 @@ +import {knownIncompatible} from 'ReactCompilerKnownIncompatibleTest'; + +function Component() { + const data = knownIncompatible(); + return
Error
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook-return-property.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook-return-property.expect.md new file mode 100644 index 0000000000..7ef43ca82b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook-return-property.expect.md @@ -0,0 +1,33 @@ + +## Input + +```javascript +import {useKnownIncompatibleIndirect} from 'ReactCompilerKnownIncompatibleTest'; + +function Component() { + const {incompatible} = useKnownIncompatibleIndirect(); + return
{incompatible()}
; +} + +``` + + +## Error + +``` +Found 1 error: + +Error: Use of incompatible library + +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. + +error.invalid-known-incompatible-hook-return-property.ts:5:15 + 3 | function Component() { + 4 | const {incompatible} = useKnownIncompatibleIndirect(); +> 5 | return
{incompatible()}
; + | ^^^^^^^^^^^^ useKnownIncompatibleIndirect returns an incompatible() function that is known incompatible + 6 | } + 7 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook-return-property.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook-return-property.js new file mode 100644 index 0000000000..1160ccb4dc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook-return-property.js @@ -0,0 +1,6 @@ +import {useKnownIncompatibleIndirect} from 'ReactCompilerKnownIncompatibleTest'; + +function Component() { + const {incompatible} = useKnownIncompatibleIndirect(); + return
{incompatible()}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook.expect.md new file mode 100644 index 0000000000..4a2e85581c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook.expect.md @@ -0,0 +1,34 @@ + +## Input + +```javascript +import {useKnownIncompatible} from 'ReactCompilerKnownIncompatibleTest'; + +function Component() { + const data = useKnownIncompatible(); + return
Error
; +} + +``` + + +## Error + +``` +Found 1 error: + +Error: Use of incompatible library + +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. + +error.invalid-known-incompatible-hook.ts:4:15 + 2 | + 3 | function Component() { +> 4 | const data = useKnownIncompatible(); + | ^^^^^^^^^^^^^^^^^^^^ useKnownIncompatible is known to be incompatible + 5 | return
Error
; + 6 | } + 7 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook.js new file mode 100644 index 0000000000..618516c55c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-known-incompatible-hook.js @@ -0,0 +1,6 @@ +import {useKnownIncompatible} from 'ReactCompilerKnownIncompatibleTest'; + +function Component() { + const data = useKnownIncompatible(); + return
Error
; +} diff --git a/compiler/packages/snap/src/sprout/shared-runtime-type-provider.ts b/compiler/packages/snap/src/sprout/shared-runtime-type-provider.ts index 58b007c1c7..b01a204e78 100644 --- a/compiler/packages/snap/src/sprout/shared-runtime-type-provider.ts +++ b/compiler/packages/snap/src/sprout/shared-runtime-type-provider.ts @@ -198,6 +198,51 @@ export function makeSharedRuntimeTypeProvider({ }, }, }; + } else if (moduleName === 'ReactCompilerKnownIncompatibleTest') { + /** + * Fake module used for testing validation of known incompatible + * API validation + */ + return { + kind: 'object', + properties: { + useKnownIncompatible: { + kind: 'hook', + positionalParams: [], + restParam: EffectEnum.Read, + returnType: {kind: 'type', name: 'Any'}, + knownIncompatible: `useKnownIncompatible is known to be incompatible`, + }, + useKnownIncompatibleIndirect: { + kind: 'hook', + positionalParams: [], + restParam: EffectEnum.Read, + returnType: { + kind: 'object', + properties: { + incompatible: { + kind: 'function', + positionalParams: [], + restParam: EffectEnum.Read, + calleeEffect: EffectEnum.Read, + returnType: {kind: 'type', name: 'Any'}, + returnValueKind: ValueKindEnum.Mutable, + knownIncompatible: `useKnownIncompatibleIndirect returns an incompatible() function that is known incompatible`, + }, + }, + }, + }, + knownIncompatible: { + kind: 'function', + positionalParams: [], + restParam: EffectEnum.Read, + calleeEffect: EffectEnum.Read, + returnType: {kind: 'type', name: 'Any'}, + returnValueKind: ValueKindEnum.Mutable, + knownIncompatible: `useKnownIncompatible is known to be incompatible`, + }, + }, + }; } else if (moduleName === 'ReactCompilerTest') { /** * Fake module used for testing validation that type providers return hook