Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/type-per-key-handler-args.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 13 additions & 22 deletions packages/core/src/hooks/useSpring.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(),
},
onStart: { x: r => expectTypeOf(r.value).toEqualTypeOf<number>() },
onChange: { x: r => expectTypeOf(r.value).toEqualTypeOf<number>() },
onRest: { x: r => expectTypeOf(r.value).toEqualTypeOf<number>() },
onPause: { x: r => expectTypeOf(r.value).toEqualTypeOf<number>() },
onResume: { x: r => expectTypeOf(r.value).toEqualTypeOf<number>() },
})
}
expectTypeOf(scenario).toBeFunction()
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/hooks/useSpring.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,10 @@ function createUpdater(Component: React.ComponentType<{ args: [any, any?] }>) {
return result
}

type Args = Parameters<typeof useSpring>
const update = (...args: [Args[0], Args[1]?]) =>
// `useSpring` is an overloaded generic; deriving a reusable arg type via
// `Parameters<typeof useSpring>` 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 = <Component args={args} />))

return [update, context] as const
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/hooks/useSpring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -32,7 +32,11 @@ export type UseSpringProps<Props extends object = any> = unknown &
export function useSpring<Props extends object>(
props:
| Function
| (() => (Props & Valid<Props, UseSpringProps<Props>>) | UseSpringProps),
| (() => EventfulProps<
Props,
UseSpringProps<NoInfer<Props>>,
UseSpringProps
>),
deps?: readonly any[] | undefined
): PickAnimated<Props> extends infer State
? State extends Lookup
Expand All @@ -44,14 +48,14 @@ export function useSpring<Props extends object>(
* Updated on every render, with state inferred from forward props.
*/
export function useSpring<Props extends object>(
props: (Props & Valid<Props, UseSpringProps<Props>>) | UseSpringProps
props: EventfulProps<Props, UseSpringProps<NoInfer<Props>>, UseSpringProps>
): SpringValues<PickAnimated<Props>>

/**
* Updated only when `deps` change, with state inferred from forward props.
*/
export function useSpring<Props extends object>(
props: (Props & Valid<Props, UseSpringProps<Props>>) | UseSpringProps,
props: EventfulProps<Props, UseSpringProps<NoInfer<Props>>, UseSpringProps>,
deps: readonly any[] | undefined
): PickAnimated<Props> extends infer State
? State extends Lookup
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/hooks/useSprings.test-d.ts
Original file line number Diff line number Diff line change
@@ -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()
})
7 changes: 7 additions & 0 deletions packages/core/src/hooks/useSprings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@

/**
* 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<Props extends UseSpringsProps>(
length: number,
Expand Down Expand Up @@ -81,7 +88,7 @@

// Create a local ref if a props function or deps array is ever passed.
const ref = useMemo(
() => (propsFn || arguments.length == 3 ? SpringRef() : void 0),

Check warning on line 91 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo has a missing dependency: 'propsFn'
[]
)

Expand Down Expand Up @@ -120,7 +127,7 @@
state.queue.push(() => {
resolve(flushUpdateQueue(ctrl, updates))
})
forceUpdate()

Check warning on line 130 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo has a missing dependency: 'forceUpdate'
})
},
}),
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/hooks/useTransition.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(),
},
onChange: {
x: result => expectTypeOf(result.value).toEqualTypeOf<number>(),
},
})
}
expectTypeOf(scenario).toBeFunction()
})

/**
* Guard for #1483 — "Type inference fails when useTransition styles are set via
* functions".
Expand Down
35 changes: 24 additions & 11 deletions packages/core/src/hooks/useTransition.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +13,7 @@

import {
Change,
ControllerProps,
ControllerUpdate,
ItemKeys,
PickAnimated,
Expand All @@ -21,7 +22,8 @@
TransitionTo,
UseTransitionProps,
} from '../types'
import { Valid } from '../types/common'
import { EventfulProps } from '../types/common'
import type { EventKey } from '../types/internal'
import {
callProp,
detachRefs,
Expand All @@ -39,11 +41,26 @@
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<Item, Props extends object> = EventfulProps<
Props,
Merge<
UseTransitionProps<Item>,
Pick<ControllerProps<PickAnimated<NoInfer<Props>>, Item>, EventKey>
>,
UseTransitionProps<Item>
>

export function useTransition<Item, Props extends object>(
data: OneOrMore<Item>,
props: () =>
| UseTransitionProps<Item>
| (Props & Valid<Props, UseTransitionProps<Item>>),
props: () => TransitionUpdate<Item, Props>,
deps?: any[]
): PickAnimated<Props> extends infer State
? State extends Lookup
Expand All @@ -53,16 +70,12 @@

export function useTransition<Item, Props extends object>(
data: OneOrMore<Item>,
props:
| UseTransitionProps<Item>
| (Props & Valid<Props, UseTransitionProps<Item>>)
props: TransitionUpdate<Item, Props>
): TransitionFn<Item, PickAnimated<Props>>

export function useTransition<Item, Props extends object>(
data: OneOrMore<Item>,
props:
| UseTransitionProps<Item>
| (Props & Valid<Props, UseTransitionProps<Item>>),
props: TransitionUpdate<Item, Props>,
deps: any[] | undefined
): PickAnimated<Props> extends infer State
? State extends Lookup
Expand Down Expand Up @@ -91,7 +104,7 @@

// Return a `SpringRef` if a deps array was passed.
const ref = useMemo(
() => (propsFn || arguments.length == 3 ? SpringRef() : void 0),

Check warning on line 107 in packages/core/src/hooks/useTransition.tsx

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo has a missing dependency: 'propsFn'
[]
)

Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/types/common.ts
Original file line number Diff line number Diff line change
@@ -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<T, U> = NeverProps<T, InvalidKeys<T, U>>

/**
* 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<NoInfer<Props>>`). 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<Loose, EventKey>` 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<typeof hook>` 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<Omit<T, EventKey>, U>
>)
| Omit<Loose, EventKey>

/** Replace the type of each `P` property with `never` */
type NeverProps<T, P extends keyof T> = Remap<
Pick<T, Exclude<keyof T, P>> & { [K in P]: never }
Expand Down
Loading