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 bb19eab93cf..a32823cb5f8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -677,6 +677,15 @@ export const EnvironmentConfigSchema = z.object({ * from refs need to be stored in state during mount. */ enableAllowSetStateFromRefsInEffects: z.boolean().default(true), + + /** + * Enables inference of event handler types for JSX props on built-in DOM elements. + * When enabled, functions passed to event handler props (props starting with "on") + * on primitive JSX tags are inferred to have the BuiltinEventHandlerId type, which + * allows ref access within those functions since DOM event handlers are guaranteed + * by React to only execute in response to events, not during render. + */ + enableInferEventHandlers: z.boolean().default(false), }); export type EnvironmentConfig = z.infer; 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 561bdab6982..f42d6fc5bd9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts @@ -29,7 +29,7 @@ import { BuiltInUseTransitionId, BuiltInWeakMapId, BuiltInWeakSetId, - BuiltinEffectEventId, + BuiltInEffectEventId, ReanimatedSharedValueId, ShapeRegistry, addFunction, @@ -863,7 +863,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [ returnType: { kind: 'Function', return: {kind: 'Poly'}, - shapeId: BuiltinEffectEventId, + shapeId: BuiltInEffectEventId, isConstructor: false, }, calleeEffect: Effect.Read, 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 beaff321e26..eb771615619 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -403,8 +403,9 @@ export const BuiltInStartTransitionId = 'BuiltInStartTransition'; export const BuiltInFireId = 'BuiltInFire'; export const BuiltInFireFunctionId = 'BuiltInFireFunction'; export const BuiltInUseEffectEventId = 'BuiltInUseEffectEvent'; -export const BuiltinEffectEventId = 'BuiltInEffectEventFunction'; +export const BuiltInEffectEventId = 'BuiltInEffectEventFunction'; export const BuiltInAutodepsId = 'BuiltInAutoDepsId'; +export const BuiltInEventHandlerId = 'BuiltInEventHandlerId'; // See getReanimatedModuleType() in Globals.ts — this is part of supporting Reanimated's ref-like types export const ReanimatedSharedValueId = 'ReanimatedSharedValueId'; @@ -1243,7 +1244,20 @@ addFunction( calleeEffect: Effect.ConditionallyMutate, returnValueKind: ValueKind.Mutable, }, - BuiltinEffectEventId, + BuiltInEffectEventId, +); + +addFunction( + BUILTIN_SHAPES, + [], + { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: {kind: 'Poly'}, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Mutable, + }, + BuiltInEventHandlerId, ); /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts index 55974db14ce..b6ec11fdb4f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts @@ -25,6 +25,7 @@ import { } from '../HIR/HIR'; import { BuiltInArrayId, + BuiltInEventHandlerId, BuiltInFunctionId, BuiltInJsxId, BuiltInMixedReadonlyId, @@ -471,6 +472,41 @@ function* generateInstructionTypes( } } } + if (env.config.enableInferEventHandlers) { + if ( + value.kind === 'JsxExpression' && + value.tag.kind === 'BuiltinTag' && + !value.tag.name.includes('-') + ) { + /* + * Infer event handler types for built-in DOM elements. + * Props starting with "on" (e.g., onClick, onSubmit) on primitive tags + * are inferred as event handlers. This allows functions with ref access + * to be passed to these props, since DOM event handlers are guaranteed + * by React to only execute in response to events, never during render. + * + * We exclude tags with hyphens to avoid web components (custom elements), + * which are required by the HTML spec to contain a hyphen. Web components + * may call event handler props during their lifecycle methods (e.g., + * connectedCallback), which would be unsafe for ref access. + */ + for (const prop of value.props) { + if ( + prop.kind === 'JsxAttribute' && + prop.name.startsWith('on') && + prop.name.length > 2 && + prop.name[2] === prop.name[2].toUpperCase() + ) { + yield equation(prop.place.identifier.type, { + kind: 'Function', + shapeId: BuiltInEventHandlerId, + return: makeType(), + isConstructor: false, + }); + } + } + } + } yield equation(left, {kind: 'Object', shapeId: BuiltInJsxId}); break; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts index abbb7d84769..232e9f55bbc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts @@ -14,12 +14,14 @@ import { BlockId, HIRFunction, IdentifierId, + Identifier, Place, SourceLocation, getHookKindForType, isRefValueType, isUseRefType, } from '../HIR'; +import {BuiltInEventHandlerId} from '../HIR/ObjectShape'; import { eachInstructionOperand, eachInstructionValueOperand, @@ -183,6 +185,11 @@ function refTypeOfType(place: Place): RefAccessType { } } +function isEventHandlerType(identifier: Identifier): boolean { + const type = identifier.type; + return type.kind === 'Function' && type.shapeId === BuiltInEventHandlerId; +} + function tyEqual(a: RefAccessType, b: RefAccessType): boolean { if (a.kind !== b.kind) { return false; @@ -519,6 +526,9 @@ function validateNoRefAccessInRenderImpl( */ if (!didError) { const isRefLValue = isUseRefType(instr.lvalue.identifier); + const isEventHandlerLValue = isEventHandlerType( + instr.lvalue.identifier, + ); for (const operand of eachInstructionValueOperand(instr.value)) { /** * By default we check that function call operands are not refs, @@ -526,29 +536,16 @@ function validateNoRefAccessInRenderImpl( */ if ( isRefLValue || + isEventHandlerLValue || (hookKind != null && hookKind !== 'useState' && hookKind !== 'useReducer') ) { /** - * Special cases: - * - * 1. the lvalue is a ref - * In general passing a ref to a function may access that ref - * value during render, so we disallow it. - * - * The main exception is the "mergeRefs" pattern, ie a function - * that accepts multiple refs as arguments (or an array of refs) - * and returns a new, aggregated ref. If the lvalue is a ref, - * we assume that the user is doing this pattern and allow passing - * refs. - * - * Eg `const mergedRef = mergeRefs(ref1, ref2)` - * - * 2. calling hooks - * - * Hooks are independently checked to ensure they don't access refs - * during render. + * Allow passing refs or ref-accessing functions when: + * 1. lvalue is a ref (mergeRefs pattern: `mergeRefs(ref1, ref2)`) + * 2. lvalue is an event handler (DOM events execute outside render) + * 3. calling hooks (independently validated for ref safety) */ validateNoDirectRefValueAccess(errors, operand, env); } else if (interpolatedAsJsx.has(instr.lvalue.identifier.id)) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-async-event-handler-wrapper.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-async-event-handler-wrapper.expect.md new file mode 100644 index 00000000000..5cee9a68ceb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-async-event-handler-wrapper.expect.md @@ -0,0 +1,148 @@ + +## Input + +```javascript +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates react-hook-form's handleSubmit +function handleSubmit(callback: (data: T) => void | Promise) { + return (event: any) => { + event.preventDefault(); + callback({} as T); + }; +} + +// Simulates an upload function +async function upload(file: any): Promise<{blob: {url: string}}> { + return {blob: {url: 'https://example.com/file.jpg'}}; +} + +interface SignatureRef { + toFile(): any; +} + +function Component() { + const ref = useRef(null); + + const onSubmit = async (value: any) => { + // This should be allowed: accessing ref.current in an async event handler + // that's wrapped and passed to onSubmit prop + let sigUrl: string; + if (value.hasSignature) { + const {blob} = await upload(ref.current?.toFile()); + sigUrl = blob?.url || ''; + } else { + sigUrl = value.signature; + } + console.log('Signature URL:', sigUrl); + }; + + return ( +
+ + +
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableInferEventHandlers +import { useRef } from "react"; + +// Simulates react-hook-form's handleSubmit +function handleSubmit(callback) { + const $ = _c(2); + let t0; + if ($[0] !== callback) { + t0 = (event) => { + event.preventDefault(); + callback({} as T); + }; + $[0] = callback; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +// Simulates an upload function +async function upload(file) { + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = { blob: { url: "https://example.com/file.jpg" } }; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +interface SignatureRef { + toFile(): any; +} + +function Component() { + const $ = _c(4); + const ref = useRef(null); + + const onSubmit = async (value) => { + let sigUrl; + if (value.hasSignature) { + const { blob } = await upload(ref.current?.toFile()); + sigUrl = blob?.url || ""; + } else { + sigUrl = value.signature; + } + + console.log("Signature URL:", sigUrl); + }; + + const t0 = handleSubmit(onSubmit); + let t1; + let t2; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t1 = ; + t2 = ; + $[0] = t1; + $[1] = t2; + } else { + t1 = $[0]; + t2 = $[1]; + } + let t3; + if ($[2] !== t0) { + t3 = ( +
+ {t1} + {t2} +
+ ); + $[2] = t0; + $[3] = t3; + } else { + t3 = $[3]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-async-event-handler-wrapper.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-async-event-handler-wrapper.tsx new file mode 100644 index 00000000000..be6f6656e18 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-async-event-handler-wrapper.tsx @@ -0,0 +1,48 @@ +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates react-hook-form's handleSubmit +function handleSubmit(callback: (data: T) => void | Promise) { + return (event: any) => { + event.preventDefault(); + callback({} as T); + }; +} + +// Simulates an upload function +async function upload(file: any): Promise<{blob: {url: string}}> { + return {blob: {url: 'https://example.com/file.jpg'}}; +} + +interface SignatureRef { + toFile(): any; +} + +function Component() { + const ref = useRef(null); + + const onSubmit = async (value: any) => { + // This should be allowed: accessing ref.current in an async event handler + // that's wrapped and passed to onSubmit prop + let sigUrl: string; + if (value.hasSignature) { + const {blob} = await upload(ref.current?.toFile()); + sigUrl = blob?.url || ''; + } else { + sigUrl = value.signature; + } + console.log('Signature URL:', sigUrl); + }; + + return ( +
+ + +
+ ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-event-handler-wrapper.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-event-handler-wrapper.expect.md new file mode 100644 index 00000000000..d40a1e080ad --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-event-handler-wrapper.expect.md @@ -0,0 +1,101 @@ + +## Input + +```javascript +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates react-hook-form's handleSubmit or similar event handler wrappers +function handleSubmit(callback: (data: T) => void) { + return (event: any) => { + event.preventDefault(); + callback({} as T); + }; +} + +function Component() { + const ref = useRef(null); + + const onSubmit = (data: any) => { + // This should be allowed: accessing ref.current in an event handler + // that's wrapped by handleSubmit and passed to onSubmit prop + if (ref.current !== null) { + console.log(ref.current.value); + } + }; + + return ( + <> + +
+ +
+ + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableInferEventHandlers +import { useRef } from "react"; + +// Simulates react-hook-form's handleSubmit or similar event handler wrappers +function handleSubmit(callback) { + const $ = _c(2); + let t0; + if ($[0] !== callback) { + t0 = (event) => { + event.preventDefault(); + callback({} as T); + }; + $[0] = callback; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +function Component() { + const $ = _c(1); + const ref = useRef(null); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const onSubmit = (data) => { + if (ref.current !== null) { + console.log(ref.current.value); + } + }; + + t0 = ( + <> + +
+ +
+ + ); + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-event-handler-wrapper.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-event-handler-wrapper.tsx new file mode 100644 index 00000000000..f305a1f9ac6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/allow-ref-access-in-event-handler-wrapper.tsx @@ -0,0 +1,36 @@ +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates react-hook-form's handleSubmit or similar event handler wrappers +function handleSubmit(callback: (data: T) => void) { + return (event: any) => { + event.preventDefault(); + callback({} as T); + }; +} + +function Component() { + const ref = useRef(null); + + const onSubmit = (data: any) => { + // This should be allowed: accessing ref.current in an event handler + // that's wrapped by handleSubmit and passed to onSubmit prop + if (ref.current !== null) { + console.log(ref.current.value); + } + }; + + return ( + <> + +
+ +
+ + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-custom-component-event-handler-wrapper.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-custom-component-event-handler-wrapper.expect.md new file mode 100644 index 00000000000..2a3657eda34 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-custom-component-event-handler-wrapper.expect.md @@ -0,0 +1,69 @@ + +## Input + +```javascript +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates a custom component wrapper +function CustomForm({onSubmit, children}: any) { + return
{children}
; +} + +// Simulates react-hook-form's handleSubmit +function handleSubmit(callback: (data: T) => void) { + return (event: any) => { + event.preventDefault(); + callback({} as T); + }; +} + +function Component() { + const ref = useRef(null); + + const onSubmit = (data: any) => { + // This should error: passing function with ref access to custom component + // event handler, even though it would be safe on a native
+ if (ref.current !== null) { + console.log(ref.current.value); + } + }; + + return ( + <> + + + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Cannot access refs during render + +React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). + +error.ref-value-in-custom-component-event-handler-wrapper.ts:31:41 + 29 | <> + 30 | +> 31 | + | ^^^^^^^^ Passing a ref to a function may read its value during render + 32 | + 33 | + 34 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-custom-component-event-handler-wrapper.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-custom-component-event-handler-wrapper.tsx new file mode 100644 index 00000000000..b90a1217165 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-custom-component-event-handler-wrapper.tsx @@ -0,0 +1,41 @@ +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates a custom component wrapper +function CustomForm({onSubmit, children}: any) { + return {children}
; +} + +// Simulates react-hook-form's handleSubmit +function handleSubmit(callback: (data: T) => void) { + return (event: any) => { + event.preventDefault(); + callback({} as T); + }; +} + +function Component() { + const ref = useRef(null); + + const onSubmit = (data: any) => { + // This should error: passing function with ref access to custom component + // event handler, even though it would be safe on a native
+ if (ref.current !== null) { + console.log(ref.current.value); + } + }; + + return ( + <> + + + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.expect.md new file mode 100644 index 00000000000..718e2c81419 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.expect.md @@ -0,0 +1,55 @@ + +## Input + +```javascript +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates a handler wrapper +function handleClick(value: any) { + return () => { + console.log(value); + }; +} + +function Component() { + const ref = useRef(null); + + // This should still error: passing ref.current directly to a wrapper + // The ref value is accessed during render, not in the event handler + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Cannot access refs during render + +React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). + +error.ref-value-in-event-handler-wrapper.ts:19:35 + 17 | <> + 18 | +> 19 | + | ^^^^^^^^^^^ Cannot access ref value during render + 20 | + 21 | ); + 22 | } +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.tsx new file mode 100644 index 00000000000..58313e560ce --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.ref-value-in-event-handler-wrapper.tsx @@ -0,0 +1,27 @@ +// @enableInferEventHandlers +import {useRef} from 'react'; + +// Simulates a handler wrapper +function handleClick(value: any) { + return () => { + console.log(value); + }; +} + +function Component() { + const ref = useRef(null); + + // This should still error: passing ref.current directly to a wrapper + // The ref value is accessed during render, not in the event handler + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{}], +};