Skip to content

Commit 498d81a

Browse files
patrickwehbePatrick Wehbe
andauthored
fix(core): pass an AnimationResult to SpringValue onChange (#2548)
Co-authored-by: Patrick Wehbe <patrick.wehbe.applications@gmail.com>
1 parent d4c86d6 commit 498d81a

3 files changed

Lines changed: 51 additions & 6 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): pass an AnimationResult to SpringValue `onChange`
6+
7+
The `SpringValue`-level `onChange` was called with the raw value instead of an `AnimationResult`, so `result.value` was `undefined` mid-animation even though the type advertises it as the value. `onChange` now receives `{ value, finished: false, cancelled: false }`, matching `onStart`/`onRest` and the `Controller`-level `onChange`. The internal `change` event the animated tree subscribes to still emits the raw value.

packages/core/src/SpringValue.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,42 @@ describe('SpringValue', () => {
7070
onChange,
7171
})
7272
await global.advanceUntilIdle()
73+
// `onChange` receives an AnimationResult (not the raw value); `result.value`
74+
// holds the array. See #2183.
7375
expect(onChange.mock.calls.slice(-1)[0]).toEqual([
74-
spring.animation.to,
76+
{ value: spring.animation.to, finished: false, cancelled: false },
7577
spring,
7678
])
7779
expect(global.getFrames(spring)).toMatchSnapshot()
7880
})
7981

82+
// Regression test for #2183: the SpringValue-level `onChange` was called with
83+
// the raw value, so `result.value` (the documented field) was `undefined`.
84+
it('passes an AnimationResult to onChange whose value is the current value', async () => {
85+
const seen: { value: number; live: number }[] = []
86+
const spring = new SpringValue(0)
87+
spring.start(1, {
88+
config: { duration: 10 * frameLength },
89+
onChange(result, source) {
90+
// `result` is an AnimationResult, never the bare number.
91+
expect(result).not.toBeTypeOf('number')
92+
expect(result).toMatchObject({ finished: false, cancelled: false })
93+
// `result.value` is defined and tracks the spring's live value.
94+
expect(typeof result.value).toBe('number')
95+
expect(source).toBe(spring)
96+
seen.push({ value: result.value, live: spring.get() })
97+
},
98+
})
99+
await global.advanceUntilIdle()
100+
101+
// The callback fired and every result.value matched the value at call time.
102+
expect(seen.length).toBeGreaterThan(0)
103+
seen.forEach(({ value, live }) => expect(value).toBe(live))
104+
// And it actually animated — at least one intermediate value is non-zero.
105+
expect(seen.some(({ value }) => value > 0)).toBe(true)
106+
expect(seen.slice(-1)[0].value).toBe(1)
107+
})
108+
80109
it('can have an animated string as its target', async () => {
81110
const target = new SpringValue('yellow')
82111
const spring = new SpringValue({
@@ -236,7 +265,8 @@ function describeFromProp() {
236265
await global.advance()
237266
// After the first frame the spring should have moved away from "from"
238267
// toward "to" — it should never have read its prior current value.
239-
expect(onChange.mock.calls[0][0]).toBeGreaterThan(5)
268+
// `onChange` receives an AnimationResult, so read `result.value`. See #2183.
269+
expect(onChange.mock.calls[0][0].value).toBeGreaterThan(5)
240270
await global.advanceUntilIdle()
241271
expect(spring.get()).toBe(10)
242272
})
@@ -816,8 +846,10 @@ function describeEvents() {
816846
spring.start('red')
817847
await global.advanceUntilIdle()
818848

819-
const [lastValue] = onChange.mock.calls.slice(-1)[0]
820-
expect(lastValue).toBe('red')
849+
// `onChange` receives an AnimationResult; the value is on `result.value`.
850+
// See #2183.
851+
const [lastResult] = onChange.mock.calls.slice(-1)[0]
852+
expect(lastResult.value).toBe('red')
821853
})
822854
it('is called by the "set" method', () => {
823855
const onChange = vi.fn()

packages/core/src/SpringValue.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -970,11 +970,17 @@ export class SpringValue<T = any> extends FrameValue<T> {
970970
}
971971

972972
protected _onChange(value: T, idle?: boolean) {
973+
// Hand `onChange` a real AnimationResult so `result.value` matches the
974+
// advertised type, mirroring how `onStart`/`onRest` dispatch their results.
975+
// See #2183.
976+
const result = getFinishedResult(value, false)
973977
if (!idle) {
974978
this._onStart()
975-
callProp(this.animation.onChange, value, this)
979+
callProp(this.animation.onChange, result, this)
976980
}
977-
callProp(this.defaultProps.onChange, value, this)
981+
callProp(this.defaultProps.onChange, result, this)
982+
// Keep the internal `change` event raw — fluid observers (the animated
983+
// tree, the Controller) read the value itself, not a result wrapper.
978984
super._onChange(value, idle)
979985
}
980986

0 commit comments

Comments
 (0)