Skip to content

Commit e09a310

Browse files
authored
fix(core): infer all animated keys when a partial from is provided (#2545)
1 parent 40222d2 commit e09a310

14 files changed

Lines changed: 374 additions & 156 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@react-spring/core': patch
3+
---
4+
5+
fix(core): infer all animated keys when a partial `from` is provided
6+
7+
`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.

.github/workflows/tests.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,15 @@ jobs:
165165
- name: Build
166166
run: pnpm build-ci
167167

168-
- name: Test
168+
- name: Check repo types
169169
run: |
170170
pnpm tsc --version
171-
pnpm test:ts
171+
pnpm check:types
172+
173+
- name: Test types
174+
run: pnpm test:types
175+
176+
172177

173178
test-e2e:
174179
needs: [build]

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"build-ci": "turbo run build --filter=!@react-spring/docs",
2727
"build": "turbo run build",
2828
"changeset": "changeset",
29+
"check:types": "tsc --noEmit",
2930
"clean": "turbo run clean",
3031
"dev": "turbo run dev --no-cache --parallel --continue",
3132
"docs:dev": "pnpm --filter @react-spring/docs dev",
@@ -36,12 +37,12 @@
3637
"lint": "oxlint packages/*/src targets/*/src docs/app docs/scripts demo/src",
3738
"package": "turbo run pack",
3839
"prepare": "husky",
39-
"test": "pnpm test:ts && pnpm test:unit && pnpm test:e2e",
40+
"test": "pnpm test:unit && pnpm test:types && pnpm test:e2e",
4041
"test:unit": "vitest run --project unit",
4142
"test:cov": "vitest run --project unit --coverage",
42-
"test:ts": "tsc --noEmit",
43+
"test:types": "vitest run --project types",
4344
"test:e2e": "vitest run --project e2e",
44-
"release": "pnpm clean && pnpm install && pnpm build && pnpm test:ts && pnpm test:unit && pnpm changeset publish --no-git-tag",
45+
"release": "pnpm clean && pnpm install && pnpm build && pnpm changeset publish --no-git-tag",
4546
"vers": "pnpm changeset version"
4647
},
4748
"commitlint": {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { it, expectTypeOf } from 'vitest'
2+
3+
import { SpringValue } from './SpringValue'
4+
5+
/**
6+
* Guard for #2183 — "Incorrect result type in SpringValue `onChange` handler".
7+
*
8+
* The reported symptom is a TYPE/RUNTIME mismatch: the type claims
9+
* `result.value` is `number`, but at runtime it logs `undefined` mid-animation.
10+
* This test pins the type-level contract the issue relies on (the param is a
11+
* fully-typed `AnimationResult`, `result.value` is `number`, not `any`).
12+
*
13+
* Scenario lives inside a never-invoked function so the constructor is
14+
* type-checked by tsc but never executed by Vitest's runtime collection pass.
15+
*/
16+
it('#2183: SpringValue onChange result is typed, result.value is number (not any)', () => {
17+
function scenario() {
18+
// eslint-disable-next-line no-new
19+
new SpringValue(0, {
20+
onChange(result) {
21+
expectTypeOf(result).not.toBeAny()
22+
expectTypeOf(result.value).toEqualTypeOf<number>()
23+
},
24+
})
25+
}
26+
expectTypeOf(scenario).toBeFunction()
27+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as React from 'react'
2+
import { it, expectTypeOf } from 'vitest'
3+
4+
import { SpringValue } from '../SpringValue'
5+
import { Spring } from './Spring'
6+
7+
/**
8+
* Guard for #2006 — "render prop within <Spring> is inferred to any".
9+
*
10+
* The render prop should be inferred from `from`/`to` as a `SpringValues`
11+
* object (e.g. `{ opacity: SpringValue<number>, color: SpringValue<string> }`),
12+
* not `any` (which breaks consumers under `noImplicitAny`).
13+
*/
14+
// `to`-based overload (state inferred from `to`).
15+
it('#2006: <Spring to> render prop is inferred as SpringValues, not any', () => {
16+
function scenario() {
17+
return (
18+
<Spring to={{ opacity: 1, color: 'blue' }}>
19+
{styles => {
20+
expectTypeOf(styles).not.toBeAny()
21+
expectTypeOf(styles.opacity).toEqualTypeOf<SpringValue<number>>()
22+
expectTypeOf(styles.color).toEqualTypeOf<SpringValue<string>>()
23+
return null
24+
}}
25+
</Spring>
26+
)
27+
}
28+
expectTypeOf(scenario).toBeFunction()
29+
})
30+
31+
// `from`-based overload (overload 1) — the exact shape from the issue's repo.
32+
it('#2006: <Spring from> render prop is inferred as SpringValues, not any', () => {
33+
function scenario() {
34+
return (
35+
<Spring from={{ opacity: 0, color: 'red' }}>
36+
{styles => {
37+
expectTypeOf(styles).not.toBeAny()
38+
expectTypeOf(styles.opacity).toEqualTypeOf<SpringValue<number>>()
39+
expectTypeOf(styles.color).toEqualTypeOf<SpringValue<string>>()
40+
return null
41+
}}
42+
</Spring>
43+
)
44+
}
45+
expectTypeOf(scenario).toBeFunction()
46+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { it, expectTypeOf } from 'vitest'
2+
3+
import { useSpring } from './useSpring'
4+
5+
/**
6+
* Guard for a known `any` leak found while sweeping the public surface for
7+
* #2541.
8+
*
9+
* The per-key (object) form of an event handler — `onChange: { x: result => … }`
10+
* — should type `result.value` as the key's value type (here, `number`). It
11+
* does not: `result.value` is `any` at the inline call site.
12+
*
13+
* Root cause is a TypeScript limitation, NOT a wrong type definition: a callback
14+
* written inline in the same object literal that `useSpring`'s generic `Props`
15+
* is inferred from cannot be contextually typed from that (still-inferring)
16+
* generic, so the param degrades to `any`. With an explicit annotation
17+
* (`const p: ControllerProps<{ x: number }> = …`) the same handler types
18+
* `result.value` as `number`, which confirms the definitions are fine. A real
19+
* fix needs the hooks to separate state-inference from handler-typing.
20+
*
21+
* The assertion below is the type we WANT. The `@ts-expect-error` suppresses the
22+
* current mismatch; when the limitation is resolved the error disappears, the
23+
* directive becomes unused (a type error in its own right), and this test goes
24+
* red — at which point delete the directive.
25+
*/
26+
it('per-key onChange: result.value should match the key value type', () => {
27+
function scenario() {
28+
useSpring({
29+
x: 0,
30+
onChange: {
31+
// @ts-expect-error known limitation: result.value is `any`, not `number` (#2541)
32+
x: result => expectTypeOf(result.value).toEqualTypeOf<number>(),
33+
},
34+
})
35+
}
36+
expectTypeOf(scenario).toBeFunction()
37+
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { it, expectTypeOf } from 'vitest'
2+
3+
import { SpringValue } from '../SpringValue'
4+
import { useTransition } from './useTransition'
5+
6+
/**
7+
* Guard for #1114 — "Type definition for `onDestroyed` is wrong".
8+
*
9+
* Originally typed `(isDestroyed: boolean) => void`; the issue asked for the
10+
* transitioning item. The current source types it `(item: Item, key: Key)`,
11+
* so this guard LOCKS IN that fix (item inferred from the data) rather than
12+
* reproducing a regression.
13+
*/
14+
it('#1114: useTransition onDestroyed receives the typed item (not boolean/any)', () => {
15+
function scenario() {
16+
const items: number[] = [1, 2, 3]
17+
useTransition(items, {
18+
from: { opacity: 0 },
19+
enter: { opacity: 1 },
20+
leave: { opacity: 0 },
21+
onDestroyed(item) {
22+
expectTypeOf(item).not.toBeAny()
23+
expectTypeOf(item).toEqualTypeOf<number>()
24+
},
25+
})
26+
}
27+
expectTypeOf(scenario).toBeFunction()
28+
})
29+
30+
/**
31+
* Guard for #1483 — "Type inference fails when useTransition styles are set via
32+
* functions".
33+
*
34+
* When `from`/`enter`/`leave` are functions returning style objects, the
35+
* animated state should still be inferred (e.g. `{ sx, sy }`), so the render
36+
* prop's `styles` is `SpringValues<{ sx: number; sy: number }>` — not `{}`
37+
* (the reported regression) nor `any`.
38+
*/
39+
it('#1483: useTransition infers animated state from function-style props', () => {
40+
function scenario() {
41+
const items: number[] = [1, 2, 3]
42+
const transition = useTransition(items, {
43+
from: () => ({ sx: 0, sy: 0 }),
44+
enter: () => ({ sx: 1, sy: 1 }),
45+
leave: () => ({ sx: 0, sy: 0 }),
46+
})
47+
transition(styles => {
48+
expectTypeOf(styles).not.toBeAny()
49+
expectTypeOf(styles.sx).toEqualTypeOf<SpringValue<number>>()
50+
expectTypeOf(styles.sy).toEqualTypeOf<SpringValue<number>>()
51+
return null
52+
})
53+
}
54+
expectTypeOf(scenario).toBeFunction()
55+
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { it, expectTypeOf } from 'vitest'
2+
import { assert, _ } from 'spec.ts'
3+
4+
import { interpolate } from './interpolate'
5+
import { SpringValue } from './SpringValue'
6+
import { Interpolation } from './Interpolation'
7+
8+
// Revived from the previously-orphaned spec.ts suite in
9+
// `packages/core/src/types/__tests__/interpolate.ts`. Now co-located and run by
10+
// the `types` project. Expectations updated: `interpolate` returns an
11+
// `Interpolation`, not a `SpringValue`.
12+
//
13+
// `interpolate` is called inside never-invoked functions so the calls are
14+
// type-checked by tsc but never executed by Vitest's runtime collection pass.
15+
16+
/** Return the arguments as-is */
17+
const args = <T extends ReadonlyArray<any>>(...args: T) => args
18+
19+
it('with one SpringValue', () => {
20+
function scenario() {
21+
// Basic value (a single spring value spreads to a readonly array)
22+
const out1 = interpolate(_ as SpringValue<number>, args)
23+
assert(out1, _ as Interpolation<readonly number[]>)
24+
25+
// Array value
26+
const out2 = interpolate(_ as SpringValue<[number, number]>, args)
27+
assert(out2, _ as Interpolation<[number, number]>)
28+
}
29+
expectTypeOf(scenario).toBeFunction()
30+
})
31+
32+
it('with an array of SpringValues', () => {
33+
function scenario() {
34+
// 1 value
35+
const out1 = interpolate(_ as [SpringValue<number>], args)
36+
assert(out1, _ as Interpolation<[number]>)
37+
38+
// 2 values
39+
const out2 = interpolate(
40+
_ as [SpringValue<number>, SpringValue<string>],
41+
args
42+
)
43+
assert(out2, _ as Interpolation<[number, string]>)
44+
45+
// Infinite values
46+
const out3 = interpolate(_ as SpringValue<number>[], args)
47+
assert(out3, _ as Interpolation<number[]>)
48+
}
49+
expectTypeOf(scenario).toBeFunction()
50+
})

packages/core/src/types/__tests__/common.ts

Lines changed: 0 additions & 114 deletions
This file was deleted.

0 commit comments

Comments
 (0)