diff --git a/.changeset/fix-pick-animated-from-keys.md b/.changeset/fix-pick-animated-from-keys.md new file mode 100644 index 0000000000..a6ac7978fd --- /dev/null +++ b/.changeset/fix-pick-animated-from-keys.md @@ -0,0 +1,7 @@ +--- +'@react-spring/core': patch +--- + +fix(core): infer all animated keys when a partial `from` is provided + +`PickAnimated` returned only the `from` shape whenever a `from` prop was present, dropping forward props, `to` keys, and the other transition phases. `useSpring({ width: 100, height: 100, from: { width: 0 } })` typed its result as `{ width }`, so `styles.height` was a compile error even though `height` animates at runtime. It now merges `from` with the `to`, forward, and transition-phase values. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d7261fe8ff..c08b77e795 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -165,10 +165,15 @@ jobs: - name: Build run: pnpm build-ci - - name: Test + - name: Check repo types run: | pnpm tsc --version - pnpm test:ts + pnpm check:types + + - name: Test types + run: pnpm test:types + + test-e2e: needs: [build] diff --git a/package.json b/package.json index 96f089bda4..0182b8da89 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "build-ci": "turbo run build --filter=!@react-spring/docs", "build": "turbo run build", "changeset": "changeset", + "check:types": "tsc --noEmit", "clean": "turbo run clean", "dev": "turbo run dev --no-cache --parallel --continue", "docs:dev": "pnpm --filter @react-spring/docs dev", @@ -36,12 +37,12 @@ "lint": "oxlint packages/*/src targets/*/src docs/app docs/scripts demo/src", "package": "turbo run pack", "prepare": "husky", - "test": "pnpm test:ts && pnpm test:unit && pnpm test:e2e", + "test": "pnpm test:unit && pnpm test:types && pnpm test:e2e", "test:unit": "vitest run --project unit", "test:cov": "vitest run --project unit --coverage", - "test:ts": "tsc --noEmit", + "test:types": "vitest run --project types", "test:e2e": "vitest run --project e2e", - "release": "pnpm clean && pnpm install && pnpm build && pnpm test:ts && pnpm test:unit && pnpm changeset publish --no-git-tag", + "release": "pnpm clean && pnpm install && pnpm build && pnpm changeset publish --no-git-tag", "vers": "pnpm changeset version" }, "commitlint": { diff --git a/packages/core/src/SpringValue.test-d.ts b/packages/core/src/SpringValue.test-d.ts new file mode 100644 index 0000000000..f9f80d25ae --- /dev/null +++ b/packages/core/src/SpringValue.test-d.ts @@ -0,0 +1,27 @@ +import { it, expectTypeOf } from 'vitest' + +import { SpringValue } from './SpringValue' + +/** + * Guard for #2183 — "Incorrect result type in SpringValue `onChange` handler". + * + * The reported symptom is a TYPE/RUNTIME mismatch: the type claims + * `result.value` is `number`, but at runtime it logs `undefined` mid-animation. + * This test pins the type-level contract the issue relies on (the param is a + * fully-typed `AnimationResult`, `result.value` is `number`, not `any`). + * + * Scenario lives inside a never-invoked function so the constructor is + * type-checked by tsc but never executed by Vitest's runtime collection pass. + */ +it('#2183: SpringValue onChange result is typed, result.value is number (not any)', () => { + function scenario() { + // eslint-disable-next-line no-new + new SpringValue(0, { + onChange(result) { + expectTypeOf(result).not.toBeAny() + expectTypeOf(result.value).toEqualTypeOf() + }, + }) + } + expectTypeOf(scenario).toBeFunction() +}) diff --git a/packages/core/src/components/Spring.test-d.tsx b/packages/core/src/components/Spring.test-d.tsx new file mode 100644 index 0000000000..e961a880b0 --- /dev/null +++ b/packages/core/src/components/Spring.test-d.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { it, expectTypeOf } from 'vitest' + +import { SpringValue } from '../SpringValue' +import { Spring } from './Spring' + +/** + * Guard for #2006 — "render prop within is inferred to any". + * + * The render prop should be inferred from `from`/`to` as a `SpringValues` + * object (e.g. `{ opacity: SpringValue, color: SpringValue }`), + * not `any` (which breaks consumers under `noImplicitAny`). + */ +// `to`-based overload (state inferred from `to`). +it('#2006: render prop is inferred as SpringValues, not any', () => { + function scenario() { + return ( + + {styles => { + expectTypeOf(styles).not.toBeAny() + expectTypeOf(styles.opacity).toEqualTypeOf>() + expectTypeOf(styles.color).toEqualTypeOf>() + return null + }} + + ) + } + expectTypeOf(scenario).toBeFunction() +}) + +// `from`-based overload (overload 1) — the exact shape from the issue's repo. +it('#2006: render prop is inferred as SpringValues, not any', () => { + function scenario() { + return ( + + {styles => { + expectTypeOf(styles).not.toBeAny() + expectTypeOf(styles.opacity).toEqualTypeOf>() + expectTypeOf(styles.color).toEqualTypeOf>() + return null + }} + + ) + } + expectTypeOf(scenario).toBeFunction() +}) diff --git a/packages/core/src/hooks/useSpring.test-d.ts b/packages/core/src/hooks/useSpring.test-d.ts new file mode 100644 index 0000000000..b0e317179d --- /dev/null +++ b/packages/core/src/hooks/useSpring.test-d.ts @@ -0,0 +1,37 @@ +import { it, expectTypeOf } from 'vitest' + +import { useSpring } from './useSpring' + +/** + * Guard for a known `any` leak found while sweeping the public surface for + * #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. + */ +it('per-key onChange: result.value should match 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(), + }, + }) + } + expectTypeOf(scenario).toBeFunction() +}) diff --git a/packages/core/src/hooks/useTransition.test-d.ts b/packages/core/src/hooks/useTransition.test-d.ts new file mode 100644 index 0000000000..1f66d8269f --- /dev/null +++ b/packages/core/src/hooks/useTransition.test-d.ts @@ -0,0 +1,55 @@ +import { it, expectTypeOf } from 'vitest' + +import { SpringValue } from '../SpringValue' +import { useTransition } from './useTransition' + +/** + * Guard for #1114 — "Type definition for `onDestroyed` is wrong". + * + * Originally typed `(isDestroyed: boolean) => void`; the issue asked for the + * transitioning item. The current source types it `(item: Item, key: Key)`, + * so this guard LOCKS IN that fix (item inferred from the data) rather than + * reproducing a regression. + */ +it('#1114: useTransition onDestroyed receives the typed item (not boolean/any)', () => { + function scenario() { + const items: number[] = [1, 2, 3] + useTransition(items, { + from: { opacity: 0 }, + enter: { opacity: 1 }, + leave: { opacity: 0 }, + onDestroyed(item) { + expectTypeOf(item).not.toBeAny() + expectTypeOf(item).toEqualTypeOf() + }, + }) + } + expectTypeOf(scenario).toBeFunction() +}) + +/** + * Guard for #1483 — "Type inference fails when useTransition styles are set via + * functions". + * + * When `from`/`enter`/`leave` are functions returning style objects, the + * animated state should still be inferred (e.g. `{ sx, sy }`), so the render + * prop's `styles` is `SpringValues<{ sx: number; sy: number }>` — not `{}` + * (the reported regression) nor `any`. + */ +it('#1483: useTransition infers animated state from function-style props', () => { + function scenario() { + const items: number[] = [1, 2, 3] + const transition = useTransition(items, { + from: () => ({ sx: 0, sy: 0 }), + enter: () => ({ sx: 1, sy: 1 }), + leave: () => ({ sx: 0, sy: 0 }), + }) + transition(styles => { + expectTypeOf(styles).not.toBeAny() + expectTypeOf(styles.sx).toEqualTypeOf>() + expectTypeOf(styles.sy).toEqualTypeOf>() + return null + }) + } + expectTypeOf(scenario).toBeFunction() +}) diff --git a/packages/core/src/interpolate.test-d.ts b/packages/core/src/interpolate.test-d.ts new file mode 100644 index 0000000000..7562c01960 --- /dev/null +++ b/packages/core/src/interpolate.test-d.ts @@ -0,0 +1,50 @@ +import { it, expectTypeOf } from 'vitest' +import { assert, _ } from 'spec.ts' + +import { interpolate } from './interpolate' +import { SpringValue } from './SpringValue' +import { Interpolation } from './Interpolation' + +// Revived from the previously-orphaned spec.ts suite in +// `packages/core/src/types/__tests__/interpolate.ts`. Now co-located and run by +// the `types` project. Expectations updated: `interpolate` returns an +// `Interpolation`, not a `SpringValue`. +// +// `interpolate` is called inside never-invoked functions so the calls are +// type-checked by tsc but never executed by Vitest's runtime collection pass. + +/** Return the arguments as-is */ +const args = >(...args: T) => args + +it('with one SpringValue', () => { + function scenario() { + // Basic value (a single spring value spreads to a readonly array) + const out1 = interpolate(_ as SpringValue, args) + assert(out1, _ as Interpolation) + + // Array value + const out2 = interpolate(_ as SpringValue<[number, number]>, args) + assert(out2, _ as Interpolation<[number, number]>) + } + expectTypeOf(scenario).toBeFunction() +}) + +it('with an array of SpringValues', () => { + function scenario() { + // 1 value + const out1 = interpolate(_ as [SpringValue], args) + assert(out1, _ as Interpolation<[number]>) + + // 2 values + const out2 = interpolate( + _ as [SpringValue, SpringValue], + args + ) + assert(out2, _ as Interpolation<[number, string]>) + + // Infinite values + const out3 = interpolate(_ as SpringValue[], args) + assert(out3, _ as Interpolation) + } + expectTypeOf(scenario).toBeFunction() +}) diff --git a/packages/core/src/types/__tests__/common.ts b/packages/core/src/types/__tests__/common.ts deleted file mode 100644 index 15a2713f94..0000000000 --- a/packages/core/src/types/__tests__/common.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { assert, _, test } from 'spec.ts' -import { Remap } from '@react-spring/types' -import { SpringUpdateFn } from '../functions' -import { PickAnimated, ForwardProps, ReservedProps } from '../props' - -type SystemProps = { - [P in keyof ReservedProps]-?: P extends 'from' | 'to' ? {} : 1 -} - -type UserProps = { - foo: 1 - bar: 1 -} - -test('ForwardProps', () => { - // With reserved props, no forward props - type P1 = ForwardProps - assert(_ as P1, _ as {}) - - // With reserved and forward props - type P2 = ForwardProps - assert(_ as P2, _ as UserProps) - - // With forward props, no reserved props - type P3 = ForwardProps - assert(_ as P3, _ as UserProps) - - // No reserved or forward props - type P4 = ForwardProps<{}> - assert(_ as P4, _ as {}) -}) - -test('PickAnimated', () => { - // No props - type A1 = PickAnimated<{}> - assert(_ as A1, _ as {}) - - // Forward props only - type A3 = PickAnimated - assert(_ as A3, _ as UserProps) - - // Forward props and "from" prop - type A4 = PickAnimated<{ - foo: 1 - width: 1 - from: { bar: 1; width: 2 } - }> - assert(_ as A4, _ as Remap) - - // "to" and "from" props - type A5 = PickAnimated<{ - to: { foo: 1; width: 1 } - from: { bar: 1; width: 2 } - }> - assert(_ as A5, _ as Remap) - - // "useTransition" props - type A6 = PickAnimated<{ - from: { a: 1 } - initial: { b: 1 } - enter: { c: 1 } - update: { d: 1 } - leave: { e: 1 } - }> - assert( - _ as A6, - _ as { - a: 1 - b: 1 - c: 1 - d: 1 - e: 1 - } - ) - - // Same keys in each phase - type A7 = PickAnimated<{ - from: { a: 1 } - enter: { a: 2 } - leave: { a: 3 } - update: { a: 4 } - initial: { a: 5 } - }> - assert( - _ as A7, - _ as { - a: 1 | 2 | 3 | 4 | 5 - } - ) - - // Async "to" chain - type A8 = PickAnimated<{ - from: { a: 1 } - to: [{ a: 2 }, { a: 3 }] - }> - assert( - _ as A8, - _ as { - a: 1 - } - ) - - // Async "to" script - type A9 = PickAnimated<{ - from: { a: 1 } - to: (next: SpringUpdateFn) => void - }> - assert( - _ as A9, - _ as { - a: 1 - } - ) -}) diff --git a/packages/core/src/types/__tests__/interpolate.ts b/packages/core/src/types/__tests__/interpolate.ts deleted file mode 100644 index 7dd92d8cfd..0000000000 --- a/packages/core/src/types/__tests__/interpolate.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { test, assert, _ } from 'spec.ts' -import { interpolate, SpringValue } from '../..' - -/** Return the arguments as-is */ -const args = >(...args: T) => args - -test('with one SpringValue', () => { - // Basic value - const out1 = interpolate(_ as SpringValue, args) - assert(out1, _ as SpringValue<[number]>) - - // Array value - const out2 = interpolate(_ as SpringValue<[number, number]>, args) - assert(out2, _ as SpringValue<[number, number]>) -}) - -test('with an array of SpringValues', () => { - // 1 value - const out1 = interpolate(_ as [SpringValue], args) - assert(out1, _ as SpringValue<[number]>) - - // 2 values - const out2 = interpolate( - _ as [SpringValue, SpringValue], - args - ) - assert(out2, _ as SpringValue<[number, string]>) - - // Infinite values - const out3 = interpolate(_ as SpringValue[], args) - assert(out3, _ as SpringValue) -}) diff --git a/packages/core/src/types/props.test-d.ts b/packages/core/src/types/props.test-d.ts new file mode 100644 index 0000000000..1258e25ff7 --- /dev/null +++ b/packages/core/src/types/props.test-d.ts @@ -0,0 +1,119 @@ +import { it, expectTypeOf } from 'vitest' +import { assert, _ } from 'spec.ts' + +import { Lookup } from '@react-spring/types' +import { useSpring } from '../hooks/useSpring' +import { SpringValue } from '../SpringValue' +import { SpringUpdateFn } from './functions' +import { PickAnimated, ForwardProps, ReservedProps } from './props' + +// Revived from the previously-orphaned spec.ts suite in +// `packages/core/src/types/__tests__/common.ts` (which the root tsconfig +// excluded, so no project ran it). Now co-located and checked by both `test:ts` +// and the `types` project. + +type SystemProps = { + [P in keyof ReservedProps]-?: P extends 'from' | 'to' ? {} : 1 +} + +type UserProps = { + foo: 1 + bar: 1 +} + +it('ForwardProps', () => { + // With reserved props, no forward props + assert(_ as ForwardProps, _ as {}) + // With reserved and forward props + assert(_ as ForwardProps, _ as UserProps) + // With forward props, no reserved props + assert(_ as ForwardProps, _ as UserProps) + // No reserved or forward props + assert(_ as ForwardProps<{}>, _ as {}) +}) + +it('PickAnimated', () => { + // No props — an empty props object animates an open-ended set of keys + assert(_ as PickAnimated<{}>, _ as Lookup) + + // Forward props only + assert(_ as PickAnimated, _ as UserProps) + + // Distinct keys across every transition phase (plus `from`) + assert( + _ as PickAnimated<{ + from: { a: 1 } + initial: { b: 1 } + enter: { c: 1 } + update: { d: 1 } + leave: { e: 1 } + }>, + _ as { a: 1; b: 1; c: 1; d: 1; e: 1 } + ) + + // Async "to" chain — `from` defines the animated keys + assert( + _ as PickAnimated<{ from: { a: 1 }; to: [{ a: 2 }, { a: 3 }] }>, + _ as { + a: 1 + } + ) + + // Async "to" script + assert( + _ as PickAnimated<{ + from: { a: 1 } + to: (next: SpringUpdateFn) => void + }>, + _ as { a: 1 } + ) + + // NOTE: cases where a *shared* key draws values from multiple sources + // (`from` + forward/`to`, or the same key across phases) are asserted with + // widened (`number`) types. With literal types the per-source values are + // merged through `ObjectFromUnion`, which degenerates for literal primitives + // — a pre-existing machinery quirk that never surfaces in real usage, where + // props widen to `number`. The merge itself is what these lock in. + + // Forward props merged with a partial `from` (shared `width`) + assert( + _ as PickAnimated<{ + foo: number + width: number + from: { bar: number; width: number } + }>, + _ as { foo: number; bar: number; width: number } + ) + + // `to` merged with `from` (shared `width`) + assert( + _ as PickAnimated<{ + to: { foo: number; width: number } + from: { bar: number; width: number } + }>, + _ as { foo: number; bar: number; width: number } + ) + + // The same key set in every phase + assert( + _ as PickAnimated<{ + from: { a: number } + enter: { a: number } + leave: { a: number } + update: { a: number } + initial: { a: number } + }>, + _ as { a: number } + ) +}) + +// Regression for the `from`-drops-forward-keys bug: a partial `from` must not +// strip the other animated keys from the inferred state. +it('a partial `from` keeps forward-prop keys', () => { + function scenario() { + const styles = useSpring({ x: 0, y: 0, from: { x: -1 } }) + expectTypeOf(styles.x).toEqualTypeOf>() + expectTypeOf(styles.y).toEqualTypeOf>() + } + expectTypeOf(scenario).toBeFunction() +}) diff --git a/packages/core/src/types/props.ts b/packages/core/src/types/props.ts index a49a32c43f..79c403fc3e 100644 --- a/packages/core/src/types/props.ts +++ b/packages/core/src/types/props.ts @@ -351,10 +351,16 @@ export type PickAnimated = unknown & : [object] extends [Props] ? Lookup : ObjectFromUnion< - Props extends { from: infer From } // extract prop from the `from` prop if it exists - ? From extends () => any - ? ReturnType - : ObjectType + // The `from` prop contributes keys, but it must be *merged* with the + // `to`/forward/transition values rather than replacing them — otherwise + // a partial `from` drops the other animated keys (e.g. + // `useSpring({ x, y, from: { x } })` would lose `y`). + Props extends { from: infer From } + ? + | (From extends () => any ? ReturnType : ObjectType) + | (TransitionKey & keyof Props extends never + ? ToValues, Fwd> + : TransitionValues>) : TransitionKey & keyof Props extends never ? ToValues : TransitionValues diff --git a/tsconfig.json b/tsconfig.json index 818a4c5719..789e711526 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,6 @@ "vitest.config.ts" ], "exclude": [ - "packages/*/src/types/**/*", "targets/*/src/types/**/*", "docs/**/*" ], diff --git a/vitest.config.ts b/vitest.config.ts index 2fc93523eb..9b7c644f2c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -111,6 +111,18 @@ export default defineConfig({ }, }, }, + { + extends: true, + test: { + name: 'types', + include: ['packages/**/src/**/*.test-d.{ts,tsx}'], + typecheck: { + enabled: true, + tsconfig: './tsconfig.json', + include: ['packages/**/src/**/*.test-d.{ts,tsx}'], + }, + }, + }, ], }, })