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.
),
},
diff --git a/packages/core/src/Interpolation.test.ts b/packages/core/src/Interpolation.test.ts
index 1c6e4d969d..db09a7bac5 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(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', () => {
diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts
index e5c7ffb6c6..7309a29b93 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 () => {
@@ -144,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')
})
@@ -196,7 +223,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)
+ })
})
}
@@ -216,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 })
+ })
})
}
@@ -372,9 +430,38 @@ function describeReverseProp() {
function describeImmediateProp() {
describe('when "immediate" prop is true', () => {
- it.todo('still resolves the "start" promise')
- it.todo('never calls the "onStart" prop')
- it.todo('never calls the "onRest" prop')
+ 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('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)
@@ -457,11 +544,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)
@@ -924,7 +1010,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 })
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)
+ })
})
})