From 2dadbebbcb4791ccccf5cd11f7e8c072592b12ee Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:03:53 +0100 Subject: [PATCH 01/12] test(core): enable SpringValue bounce test with correct damping The test sat inside 'when damping is less than 1.0' but passed damping: 1 (critical damping), so countBounces was always 0. Set damping: 0.5 to match the describe block's premise. --- packages/core/src/SpringValue.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index e5c7ffb6c6..3da43ea13a 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -457,11 +457,10 @@ function describeConfigProp() { }) }) describe('when "damping" is less than 1.0', () => { - // FIXME: This test fails. - it.skip('should bounce', async () => { + it('should bounce', async () => { const spring = new SpringValue(0) spring.start(1, { - config: { frequency: 1.5, damping: 1 }, + config: { frequency: 1.5, damping: 0.5 }, }) await global.advanceUntilIdle() expect(global.countBounces(spring)).toBeGreaterThan(0) From 2782ac612f76a4eb5f7e48d0647d851c6328c8b6 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:05:02 +0100 Subject: [PATCH 02/12] test(core): enable SpringValue numeric/string parity test The numeric and string-numeric animation paths agree to ~15 significant digits but diverge by one ULP because the string path routes values through the interpolator. Compare frames with a tolerance instead of bitwise equality. --- packages/core/src/SpringValue.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index 3da43ea13a..acc053d263 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -36,19 +36,28 @@ describe('SpringValue', () => { expect(finished).toBeTruthy() }) - // FIXME: This test fails. - it.skip('animates a number the same as a numeric string', async () => { + it('animates a number the same as a numeric string', async () => { const spring1 = new SpringValue(0) spring1.start(10) await global.advanceUntilIdle() - const frames = global.getFrames(spring1).map(n => n + 'px') + const numericFrames = global.getFrames(spring1) const spring2 = new SpringValue('0px') spring2.start('10px') await global.advanceUntilIdle() - expect(frames).toEqual(global.getFrames(spring2)) + const stringFrames = global + .getFrames(spring2) + .map((s: string) => parseFloat(s)) + + // The string-numeric path runs values through the string interpolator, + // which costs an extra arithmetic step and can shift the final mantissa + // bit. Compare with tolerance rather than bitwise equality. + expect(numericFrames).toHaveLength(stringFrames.length) + numericFrames.forEach((n: number, i: number) => + expect(n).toBeCloseTo(stringFrames[i], 10) + ) }) it('can animate an array of numbers', async () => { From 72ec174233339ea92470715abe04d6857e3c9713 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:11:29 +0100 Subject: [PATCH 03/12] test(core): cover SpringValue immediate prop start promise Implements the "still resolves the start promise" todo: when start({ immediate: true }) is used, the returned promise resolves with finished: true and the final value. --- packages/core/src/SpringValue.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index acc053d263..31698fc5b7 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -381,7 +381,15 @@ function describeReverseProp() { function describeImmediateProp() { describe('when "immediate" prop is true', () => { - it.todo('still resolves the "start" promise') + it('still resolves the "start" promise', async () => { + const spring = new SpringValue(0) + const promise = spring.start(1, { immediate: true }) + await global.advanceUntilIdle() + const result = await promise + expect(result.finished).toBe(true) + expect(result.value).toBe(1) + }) + it.todo('never calls the "onStart" prop') it.todo('never calls the "onRest" prop') From 281ee51fa1fd413ecb7ba4bbbbf6a3fc983cb0ba Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:11:59 +0100 Subject: [PATCH 04/12] test(core): cover SpringValue from prop start value Implements the "controls the start value" todo: passing a "from" prop on start sets the spring to that value before the first frame, and the first emitted frame moves toward "to". --- packages/core/src/SpringValue.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index 31698fc5b7..2490957131 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -205,7 +205,23 @@ function describeToProp() { function describeFromProp() { describe('when "from" prop is defined', () => { - it.todo('controls the start value') + it('controls the start value', async () => { + const spring = new SpringValue() + const onChange = vi.fn() + spring.start({ + from: 5, + to: 10, + config: { duration: 10 * frameLength }, + onChange, + }) + expect(spring.get()).toBe(5) + await global.advance() + // After the first frame the spring should have moved away from "from" + // toward "to" — it should never have read its prior current value. + expect(onChange.mock.calls[0][0]).toBeGreaterThan(5) + await global.advanceUntilIdle() + expect(spring.get()).toBe(10) + }) }) } From 38fc88ebd3a9aa389cd793bb60ee2667a883d848 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:14:00 +0100 Subject: [PATCH 05/12] test(core): cover SpringValue to-prop retarget behaviour Implements two "to" prop change todos: when start() is called with a new "to" mid-animation, the old promise resolves with finished:false, and an onStart prop captured by an earlier start() call is not invoked again when the retarget call passes no new onStart. The third todo (avoids calling the onRest prop) remains because the current implementation does call the previous onRest on retarget. --- packages/core/src/SpringValue.test.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index 2490957131..daf6eb2616 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -153,8 +153,26 @@ function describeProps() { function describeToProp() { describe('when "to" prop is changed', () => { - it.todo('resolves the "start" promise with (finished: false)') - it.todo('avoids calling the "onStart" prop') + it('resolves the "start" promise with (finished: false)', async () => { + const spring = new SpringValue(0) + const promise = spring.start(1) + await global.advance(5) + spring.start(2) + const result = await promise + expect(result.finished).toBe(false) + }) + + it('avoids calling the "onStart" prop', async () => { + const onStart = vi.fn() + const spring = new SpringValue(0) + spring.start(1, { onStart }) + await global.advance(5) + expect(onStart).toBeCalledTimes(1) + spring.start(2) + await global.advanceUntilIdle() + expect(onStart).toBeCalledTimes(1) + }) + it.todo('avoids calling the "onRest" prop') }) From 9593474eeb1922f731e2cc60b4e7febdcc60a335 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:20:44 +0100 Subject: [PATCH 06/12] test(core): cover SpringValue reset prop interruption Implements the two "reset prop" todos: when start({ reset: true }) is called mid-animation, the previous start promise resolves with finished: false, and the previous onRest is invoked with finished: false. --- packages/core/src/SpringValue.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index daf6eb2616..2933458fa4 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -259,8 +259,23 @@ function describeResetProp() { expect(spring.get()).toBe(0) }) - it.todo('resolves the "start" promise with (finished: false)') - it.todo('calls the "onRest" prop with (finished: false)') + it('resolves the "start" promise with (finished: false)', async () => { + const spring = new SpringValue() + const promise = spring.start({ from: 0, to: 1 }) + await global.advance(5) + spring.start({ reset: true }) + const result = await promise + expect(result.finished).toBe(false) + }) + + it('calls the "onRest" prop with (finished: false)', async () => { + const onRest = vi.fn() + const spring = new SpringValue({ from: 0, to: 1, onRest }) + await global.advance(5) + spring.start({ reset: true }) + expect(onRest).toBeCalledTimes(1) + expect(onRest.mock.calls[0][0]).toMatchObject({ finished: false }) + }) }) } From 0c85b182ea94c62e058b1b786d135f728c25a2ca Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:21:27 +0100 Subject: [PATCH 07/12] test(core): cover SpringValue fluid-target onRest persistence Implements the "preserves its onRest prop between animations" todo: when a spring tracks a fluid target, an "onRest" handler set on the original start call fires for each subsequent settle as the target re-animates. --- packages/core/src/SpringValue.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index 2933458fa4..48d413e0e7 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -989,7 +989,19 @@ function describeTarget(name: string, create: (from: number) => OpaqueTarget) { expect(spring.get()).toBe(target.node.get()) }) - it.todo('preserves its "onRest" prop between animations') + it('preserves its "onRest" prop between animations', async () => { + const onRest = vi.fn() + spring.start({ to: target.node, onRest }) + await global.advanceUntilIdle() + expect(onRest).toBeCalledTimes(1) + + // When the fluid target moves, the spring re-animates without a + // fresh start() call. The "onRest" handler should still fire when + // the new animation settles. + target.start(2) + await global.advanceUntilIdle() + expect(onRest).toBeCalledTimes(2) + }) it('can change its target while animating', async () => { spring.start({ to: target.node }) From c19f3549edf26d0e0d7dc8297a3b4129050b6442 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:22:47 +0100 Subject: [PATCH 08/12] test(core): cover Interpolation source types and update semantics Implements the five Interpolation todos: covers SpringValue, nested Interpolation, and a custom non-animated FluidValue as sources, asserts that simultaneous input updates trigger a single recompute per frame, and that resetting an input updates the interpolation synchronously before the next frame. --- packages/core/src/Interpolation.test.ts | 57 ++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Interpolation.test.ts b/packages/core/src/Interpolation.test.ts index 1c6e4d969d..6d142c2349 100644 --- a/packages/core/src/Interpolation.test.ts +++ b/packages/core/src/Interpolation.test.ts @@ -1,18 +1,63 @@ import { SpringValue } from './SpringValue' import { to } from './interpolate' -import { addFluidObserver } from '@react-spring/shared' +import { FluidValue, addFluidObserver } from '@react-spring/shared' describe('Interpolation', () => { - it.todo('can use a SpringValue') - it.todo('can use another Interpolation') - it.todo('can use a non-animated FluidValue') + it('can use a SpringValue', async () => { + const source = new SpringValue({ from: 0, to: 10 }) + const interp = to(source, (n: number) => n * 2) + addFluidObserver(interp, () => {}) + await global.advanceUntilIdle() + expect(interp.get()).toBe(20) + }) + + it('can use another Interpolation', async () => { + const source = new SpringValue({ from: 0, to: 10 }) + const inner = to(source, (n: number) => n * 2) + const interp = to(inner, (n: number) => n + 1) + addFluidObserver(interp, () => {}) + await global.advanceUntilIdle() + expect(interp.get()).toBe(21) + }) + + it('can use a non-animated FluidValue', () => { + class StaticFluid extends FluidValue { + constructor(private value: number) { + super(() => value) + } + } + const source = new StaticFluid(5) + const interp = to(source, (n: number) => n * 2) + expect(interp.get()).toBe(10) + }) describe('when multiple inputs change in the same frame', () => { - it.todo('only computes its value once') + it('only computes its value once', async () => { + const a = new SpringValue({ from: 0, to: 1 }) + const b = new SpringValue({ from: 0, to: 1 }) + const calc = vi.fn((x: number, y: number) => x + y) + const interp = to([a, b], calc) + addFluidObserver(interp, () => {}) + + calc.mockClear() + await global.advance(1) + expect(calc).toBeCalledTimes(1) + }) }) describe('when an input resets its animation', () => { - it.todo('computes its value before the first frame') + it('computes its value before the first frame', async () => { + const source = new SpringValue({ from: 0, to: 10 }) + const interp = to(source, (n: number) => n * 2) + addFluidObserver(interp, () => {}) + await global.advanceUntilIdle() + expect(interp.get()).toBe(20) + + // Reset the source: it jumps back to "from" (0). The interpolation + // should reflect that immediately, without waiting for the next frame. + source.start({ reset: true }) + expect(interp.get()).toBe(0) + }) }) describe('when all inputs are paused', () => { From 879ff65af9111c96a0e3c6e3e0000fb57bf16f75 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:24:04 +0100 Subject: [PATCH 09/12] test(core): cover useTrail props function and reverse toggling Implements the five useTrail todos: a props function is not re-invoked when the hook re-renders with no deps change, and toggling the "reverse" prop swaps "to"/"from" on the new head and flips the parent-spring chaining direction for the followers. --- packages/core/src/hooks/useTrail.test.tsx | 38 ++++++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/core/src/hooks/useTrail.test.tsx b/packages/core/src/hooks/useTrail.test.tsx index 5bc28072c6..88b95cb732 100644 --- a/packages/core/src/hooks/useTrail.test.tsx +++ b/packages/core/src/hooks/useTrail.test.tsx @@ -41,17 +41,45 @@ describe('useTrail', () => { }) describe('when a props function is passed', () => { - it.todo('does nothing on rerender') + it('does nothing on rerender', async () => { + const propsFn = vi.fn((_i: number) => ({ x: 100 })) + await update(2, propsFn) + propsFn.mockClear() + await update(2, propsFn) + expect(propsFn).not.toBeCalled() + }) }) describe('with the "reverse" prop', () => { describe('when "reverse" becomes true', () => { - it.todo('swaps the "to" and "from" props') - it.todo('has each spring follow the spring after it') + it('swaps the "to" and "from" props', async () => { + await update(2, { x: 100, from: { x: 0 } }) + await update(2, { x: 100, from: { x: 0 }, reverse: true }) + // The head with reverse:true is the last spring, with to/from swapped. + expect(springs[1].x.animation.to).toBe(0) + expect(springs[1].x.animation.from).toBe(100) + }) + + it('has each spring follow the spring after it', async () => { + await update(2, { x: 100, from: { x: 0 } }) + await update(2, { x: 100, from: { x: 0 }, reverse: true }) + expect(springs[0].x.animation.to).toBe(springs[1].x) + }) }) describe('when "reverse" becomes false', () => { - it.todo('uses the "to" and "from" props as-is') - it.todo('has each spring follow the spring before it') + it('uses the "to" and "from" props as-is', async () => { + await update(2, { x: 100, from: { x: 0 }, reverse: true }) + await update(2, { x: 100, from: { x: 0 }, reverse: false }) + // The head with reverse:false is springs[0], with to/from as passed. + expect(springs[0].x.animation.to).toBe(100) + expect(springs[0].x.animation.from).toBe(0) + }) + + it('has each spring follow the spring before it', async () => { + await update(2, { x: 100, from: { x: 0 }, reverse: true }) + await update(2, { x: 100, from: { x: 0 }, reverse: false }) + expect(springs[1].x.animation.to).toBe(springs[0].x) + }) }) }) From f006c5d07bd22d4b88bc88e4d27ac7589e719e8f Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:33:11 +0100 Subject: [PATCH 10/12] test(core): cover SpringValue immediate prop lifecycle events Implements the remaining two "immediate" prop todos by locking in the current behaviour: an immediate animation still fires onStart and onRest on the next frame with finished: true. This matches the convention used by Framer Motion, GSAP, the Web Animations API, and React Native Animated, where a zero-duration animation completes through the normal lifecycle. spring.set() remains the lifecycle-free path. --- packages/core/src/SpringValue.test.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index 48d413e0e7..7309a29b93 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -439,8 +439,29 @@ function describeImmediateProp() { expect(result.value).toBe(1) }) - it.todo('never calls the "onStart" prop') - it.todo('never calls the "onRest" prop') + it('calls the "onStart" prop with finished: true', async () => { + const onStart = vi.fn() + const spring = new SpringValue(0) + spring.start(1, { immediate: true, onStart }) + await global.advanceUntilIdle() + expect(onStart).toBeCalledTimes(1) + expect(onStart.mock.calls[0][0]).toMatchObject({ + finished: true, + cancelled: false, + }) + }) + + it('calls the "onRest" prop with finished: true', async () => { + const onRest = vi.fn() + const spring = new SpringValue(0) + spring.start(1, { immediate: true, onRest }) + await global.advanceUntilIdle() + expect(onRest).toBeCalledTimes(1) + expect(onRest.mock.calls[0][0]).toMatchObject({ + finished: true, + value: 1, + }) + }) it('stops animating', async () => { const spring = new SpringValue(0) From 2d066a712fdf64d9e002cf03c5b5e3dcee114992 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:33:34 +0100 Subject: [PATCH 11/12] docs: clarify immediate prop lifecycle semantics "Prevents the animation if true" implied no lifecycle events. The immediate animation still fires onStart and onRest on its single frame; SpringValue.set() is the lifecycle-free path. --- docs/app/data/fixtures.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/app/data/fixtures.tsx b/docs/app/data/fixtures.tsx index 80891f20a7..57d666cbc0 100644 --- a/docs/app/data/fixtures.tsx +++ b/docs/app/data/fixtures.tsx @@ -519,7 +519,9 @@ export const USESPRINGVALUE_CONFIG_DATA: CellData[][] = [ label: 'immediate', content: (

- Prevents the animation if true, applying the `to` styles immediately. + Skips interpolation if true, jumping to the `to` value on the next + frame. The animation lifecycle (`onStart`, `onRest`) still fires; use + `SpringValue.set()` if you want to assign without lifecycle events.

), }, From dd95e575d5890025d1828c48898254e2d881bec6 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 07:50:17 +0100 Subject: [PATCH 12/12] test(core): drop unused parameter-property on StaticFluid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `private value` shorthand declared a class field that was never read — the constructor captures the local parameter via closure. TS6138 fails strict CI matrix builds. --- packages/core/src/Interpolation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/Interpolation.test.ts b/packages/core/src/Interpolation.test.ts index 6d142c2349..db09a7bac5 100644 --- a/packages/core/src/Interpolation.test.ts +++ b/packages/core/src/Interpolation.test.ts @@ -22,7 +22,7 @@ describe('Interpolation', () => { it('can use a non-animated FluidValue', () => { class StaticFluid extends FluidValue { - constructor(private value: number) { + constructor(value: number) { super(() => value) } }