diff --git a/.changeset/type-per-key-handler-args.md b/.changeset/type-per-key-handler-args.md new file mode 100644 index 0000000000..a7741ae273 --- /dev/null +++ b/.changeset/type-per-key-handler-args.md @@ -0,0 +1,7 @@ +--- +'@react-spring/core': major +--- + +fix(core)!: type per-key event-handler arguments for `useSpring`/`useTransition` (#2541) + +Per-key handlers like `onChange: { x: result => result.value }` now infer `result.value` from the animated value instead of `any`. Props are stricter as a result, so loosely-typed props may need annotating. diff --git a/packages/core/src/hooks/useSpring.test-d.ts b/packages/core/src/hooks/useSpring.test-d.ts index b0e317179d..d7d4ee0881 100644 --- a/packages/core/src/hooks/useSpring.test-d.ts +++ b/packages/core/src/hooks/useSpring.test-d.ts @@ -3,34 +3,25 @@ import { it, expectTypeOf } from 'vitest' import { useSpring } from './useSpring' /** - * Guard for a known `any` leak found while sweeping the public surface for - * #2541. + * Guard for the per-key event-handler `any` leak fixed under #2541. * * The per-key (object) form of an event handler — `onChange: { x: result => … }` - * — should type `result.value` as the key's value type (here, `number`). It - * does not: `result.value` is `any` at the inline call site. - * - * Root cause is a TypeScript limitation, NOT a wrong type definition: a callback - * written inline in the same object literal that `useSpring`'s generic `Props` - * is inferred from cannot be contextually typed from that (still-inferring) - * generic, so the param degrades to `any`. With an explicit annotation - * (`const p: ControllerProps<{ x: number }> = …`) the same handler types - * `result.value` as `number`, which confirms the definitions are fine. A real - * fix needs the hooks to separate state-inference from handler-typing. - * - * The assertion below is the type we WANT. The `@ts-expect-error` suppresses the - * current mismatch; when the limitation is resolved the error disappears, the - * directive becomes unused (a type error in its own right), and this test goes - * red — at which point delete the directive. + * — types `result.value` as the key's value type (here, `number`). It used to + * leak `any`: a callback inline in the same object literal that `useSpring`'s + * `Props` generic is inferred from could not be contextually typed from the + * still-inferring generic, so the param degraded to `any`. `EventfulProps` + * (see `types/common.ts`) sources the handler keys from the resolved props via + * `NoInfer`, so the state resolves first and per-key handlers get the key type. */ -it('per-key onChange: result.value should match the key value type', () => { +it('per-key event handlers: result.value matches the key value type', () => { function scenario() { useSpring({ x: 0, - onChange: { - // @ts-expect-error known limitation: result.value is `any`, not `number` (#2541) - x: result => expectTypeOf(result.value).toEqualTypeOf(), - }, + onStart: { x: r => expectTypeOf(r.value).toEqualTypeOf() }, + onChange: { x: r => expectTypeOf(r.value).toEqualTypeOf() }, + onRest: { x: r => expectTypeOf(r.value).toEqualTypeOf() }, + onPause: { x: r => expectTypeOf(r.value).toEqualTypeOf() }, + onResume: { x: r => expectTypeOf(r.value).toEqualTypeOf() }, }) } expectTypeOf(scenario).toBeFunction() diff --git a/packages/core/src/hooks/useSpring.test.tsx b/packages/core/src/hooks/useSpring.test.tsx index 9716819ba8..f19cb774e6 100644 --- a/packages/core/src/hooks/useSpring.test.tsx +++ b/packages/core/src/hooks/useSpring.test.tsx @@ -452,8 +452,10 @@ function createUpdater(Component: React.ComponentType<{ args: [any, any?] }>) { return result } - type Args = Parameters - const update = (...args: [Args[0], Args[1]?]) => + // `useSpring` is an overloaded generic; deriving a reusable arg type via + // `Parameters` freezes `Props` to `object` and rejects plain + // forward props. The runtime shape is exercised below, so keep this loose. + const update = (...args: [any, any?]) => renderWithContext((prevElem = )) return [update, context] as const diff --git a/packages/core/src/hooks/useSpring.ts b/packages/core/src/hooks/useSpring.ts index bbfaf5f2be..090222df4a 100644 --- a/packages/core/src/hooks/useSpring.ts +++ b/packages/core/src/hooks/useSpring.ts @@ -2,7 +2,7 @@ import { Lookup, Remap } from '@react-spring/types' import { is } from '@react-spring/shared' import { ControllerUpdate, PickAnimated, SpringValues } from '../types' -import { Valid } from '../types/common' +import { EventfulProps } from '../types/common' import { SpringRef } from '../SpringRef' import { useSprings } from './useSprings' @@ -32,7 +32,11 @@ export type UseSpringProps = unknown & export function useSpring( props: | Function - | (() => (Props & Valid>) | UseSpringProps), + | (() => EventfulProps< + Props, + UseSpringProps>, + UseSpringProps + >), deps?: readonly any[] | undefined ): PickAnimated extends infer State ? State extends Lookup @@ -44,14 +48,14 @@ export function useSpring( * Updated on every render, with state inferred from forward props. */ export function useSpring( - props: (Props & Valid>) | UseSpringProps + props: EventfulProps>, UseSpringProps> ): SpringValues> /** * Updated only when `deps` change, with state inferred from forward props. */ export function useSpring( - props: (Props & Valid>) | UseSpringProps, + props: EventfulProps>, UseSpringProps>, deps: readonly any[] | undefined ): PickAnimated extends infer State ? State extends Lookup diff --git a/packages/core/src/hooks/useSprings.test-d.ts b/packages/core/src/hooks/useSprings.test-d.ts new file mode 100644 index 0000000000..bd05bb8b37 --- /dev/null +++ b/packages/core/src/hooks/useSprings.test-d.ts @@ -0,0 +1,34 @@ +import { it, expectTypeOf } from 'vitest' + +import { useSprings } from './useSprings' + +/** + * KNOWN LIMITATION (#2541). + * + * The per-key event-handler `any` leak is fixed for `useSpring` and + * `useTransition`, but NOT for `useSprings`. `useSprings` infers a single `Props` + * from its props ARRAY via best-common-type element inference, which is + * fundamentally incompatible with the mapped-type + `NoInfer` phasing that fixes + * the leak (`EventfulProps`): wrapping the array element makes per-key handlers + * resolve from only the FIRST element and turns valid multi-element, + * heterogeneous arrays into hard errors. Its function form leaks for the same + * reason `useSpring`'s does — a callback inside a function *return* can't be + * contextually typed from the still-inferring generic. + * + * So a `useSprings` per-key handler callback isn't typed at all (its param is + * implicit `any`). This guard locks that in: if a future TypeScript lets the + * phasing compose with array inference, the `@ts-expect-error` goes unused and + * this should be revisited. + */ +it('#2541: useSprings per-key handler is untyped (array-inference limitation)', () => { + function scenario() { + useSprings(1, [ + { + x: 0, + // @ts-expect-error per-key handler param leaks to implicit `any` (#2541) + onChange: { x: result => void result }, + }, + ]) + } + expectTypeOf(scenario).toBeFunction() +}) diff --git a/packages/core/src/hooks/useSprings.ts b/packages/core/src/hooks/useSprings.ts index 71e08d1efa..db308960f0 100644 --- a/packages/core/src/hooks/useSprings.ts +++ b/packages/core/src/hooks/useSprings.ts @@ -51,6 +51,13 @@ export function useSprings( /** * Animations are updated on re-render. + * + * @remarks + * Unlike `useSpring` and `useTransition`, inline event-handler arguments are + * not inferred in the array form: `onChange: { x: result => result.value }` + * leaves `result` untyped (a TypeScript array-inference limitation — see #2541). + * Annotate the argument explicitly, e.g. + * `onChange: { x: (result: { value: number }) => result.value }`. */ export function useSprings( length: number, diff --git a/packages/core/src/hooks/useTransition.test-d.ts b/packages/core/src/hooks/useTransition.test-d.ts index 1f66d8269f..925e2a9dbb 100644 --- a/packages/core/src/hooks/useTransition.test-d.ts +++ b/packages/core/src/hooks/useTransition.test-d.ts @@ -27,6 +27,29 @@ it('#1114: useTransition onDestroyed receives the typed item (not boolean/any)', expectTypeOf(scenario).toBeFunction() }) +/** + * Guard for the per-key event-handler leak fixed under #2541. The per-key + * (object) form types `result.value` as the key's value type (here `number`). + * It used to leak `unknown` for `useTransition` (its `UseTransitionProps` + * handlers were based on `UnknownProps`); `TransitionUpdate` now sources the + * handler keys from a state-resolved `ControllerProps`. + */ +it('#2541: useTransition per-key handler resolves result.value to the key type', () => { + function scenario() { + const items: number[] = [1, 2, 3] + useTransition(items, { + from: { x: 0 }, + onRest: { + x: result => expectTypeOf(result.value).toEqualTypeOf(), + }, + onChange: { + x: result => expectTypeOf(result.value).toEqualTypeOf(), + }, + }) + } + expectTypeOf(scenario).toBeFunction() +}) + /** * Guard for #1483 — "Type inference fails when useTransition styles are set via * functions". diff --git a/packages/core/src/hooks/useTransition.tsx b/packages/core/src/hooks/useTransition.tsx index 5cc892f206..12b89c0b9b 100644 --- a/packages/core/src/hooks/useTransition.tsx +++ b/packages/core/src/hooks/useTransition.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useContext, useRef, useMemo } from 'react' -import { Lookup, OneOrMore, UnknownProps } from '@react-spring/types' +import { Lookup, Merge, OneOrMore, UnknownProps } from '@react-spring/types' import { is, toArray, @@ -13,6 +13,7 @@ import { import { Change, + ControllerProps, ControllerUpdate, ItemKeys, PickAnimated, @@ -21,7 +22,8 @@ import { TransitionTo, UseTransitionProps, } from '../types' -import { Valid } from '../types/common' +import { EventfulProps } from '../types/common' +import type { EventKey } from '../types/internal' import { callProp, detachRefs, @@ -39,11 +41,26 @@ import { TransitionPhase } from '../TransitionPhase' declare function setTimeout(handler: Function, timeout?: number): number declare function clearTimeout(timeoutId: number): void +/** + * The props `useTransition` accepts, with per-key event handlers typed from the + * inferred transition state instead of leaking `any`/`unknown` (see #2541 and + * `EventfulProps`). `UseTransitionProps` bases its handlers on `UnknownProps`, so + * the event keys are overridden from a state-resolved, `Item`-aware + * `ControllerProps` while the transition-specific props (and their typo checks) + * are kept from `UseTransitionProps`. + */ +type TransitionUpdate = EventfulProps< + Props, + Merge< + UseTransitionProps, + Pick>, Item>, EventKey> + >, + UseTransitionProps +> + export function useTransition( data: OneOrMore, - props: () => - | UseTransitionProps - | (Props & Valid>), + props: () => TransitionUpdate, deps?: any[] ): PickAnimated extends infer State ? State extends Lookup @@ -53,16 +70,12 @@ export function useTransition( export function useTransition( data: OneOrMore, - props: - | UseTransitionProps - | (Props & Valid>) + props: TransitionUpdate ): TransitionFn> export function useTransition( data: OneOrMore, - props: - | UseTransitionProps - | (Props & Valid>), + props: TransitionUpdate, deps: any[] | undefined ): PickAnimated extends infer State ? State extends Lookup diff --git a/packages/core/src/types/common.ts b/packages/core/src/types/common.ts index 6f44c3a196..d2a815f6be 100644 --- a/packages/core/src/types/common.ts +++ b/packages/core/src/types/common.ts @@ -1,9 +1,43 @@ import { Remap, Any } from '@react-spring/types' import { FluidValue } from '@react-spring/shared' +import type { EventKey } from './internal' + /** Replace the type of each `T` property with `never` (unless compatible with `U`) */ export type Valid = NeverProps> +/** + * Constrain a props object `T` against its expected shape `U`, with event-handler + * keys typed from `U` rather than inferred from `T`. + * + * `U` is the already-resolved props type for the inferred animated state (e.g. + * `UseSpringProps>`). Event handlers (`onChange` and friends) live + * inside the same object literal that the hook's `Props` generic is inferred from, + * so TypeScript cannot contextually type their callbacks from the still-inferring + * generic and degrades the argument to `any`. Sourcing those keys from `U` — which + * the caller wraps in `NoInfer` so they no longer drive inference — lets the state + * resolve first, so per-key handlers get the key's value type. See #2541. + * + * Non-event keys keep their inferred types and are still typo-checked via `Valid`, + * applied as a `NoInfer` layer so it stays inference-neutral. + * + * The `Omit` arm preserves the loose escape hatch the hooks + * previously got from a bare `| UseSpringProps`-style union member: a + * `Props`-independent shape (pass the hook's non-generic props type as `Loose`) + * that still accepts pre-typed/untyped values such as a `ref` from `SpringRef()`, + * and keeps `Parameters` extraction usable. It omits the event keys + * so it never supplies a competing — and leaky — contextual type for the callbacks. + */ +export type EventfulProps< + T extends object, + U extends object, + Loose extends object = U, +> = + | ({ [K in keyof T]: K extends EventKey & keyof U ? U[K] : T[K] } & NoInfer< + Valid, U> + >) + | Omit + /** Replace the type of each `P` property with `never` */ type NeverProps = Remap< Pick> & { [K in P]: never }