From 71ca97487aef0b751e500ce2b9a3405a93951bf0 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 15:06:38 +0100 Subject: [PATCH] fix(core): preserve velocity across retargets for decay animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decay animations integrate an initial velocity; the previous `_update` logic unconditionally reset `config.velocity` to `0` whenever `to` or `from` was provided, silently breaking gesture-driven decay flows that retarget mid-throw. Skip the reset when `config.decay` is set. Also clarifies the JSDoc and docs-site reference for `decay` to explain that it decelerates from an initial velocity and does not ease toward `to` — the root cause of the user-facing confusion in #1843. Closes #1843. --- .../decay-preserve-velocity-on-retarget.md | 5 ++++ docs/app/data/fixtures.tsx | 6 ++++- packages/core/src/AnimationConfig.ts | 14 ++++++++--- packages/core/src/SpringValue.test.ts | 23 +++++++++++++++++++ packages/core/src/SpringValue.ts | 5 +++- 5 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 .changeset/decay-preserve-velocity-on-retarget.md diff --git a/.changeset/decay-preserve-velocity-on-retarget.md b/.changeset/decay-preserve-velocity-on-retarget.md new file mode 100644 index 0000000000..fc9e4aa387 --- /dev/null +++ b/.changeset/decay-preserve-velocity-on-retarget.md @@ -0,0 +1,5 @@ +--- +'@react-spring/core': patch +--- + +Preserve `config.velocity` across `to`/`from` retargets when `config.decay` is set. Previously, `SpringValue._update` unconditionally reset `config.velocity` to `0` whenever a new goal was provided, which silently broke gesture-driven decay flows that retarget mid-throw (e.g. mouse-flick decay). Also clarifies the `decay` JSDoc and docs-site reference to explain that decay decelerates from an initial velocity and does not ease toward `to`. Closes #1843. diff --git a/docs/app/data/fixtures.tsx b/docs/app/data/fixtures.tsx index 52ce9fa970..b18eb5eb84 100644 --- a/docs/app/data/fixtures.tsx +++ b/docs/app/data/fixtures.tsx @@ -268,7 +268,11 @@ export const configData: CellData[][] = [ label: 'number | boolean', content: (

- If true, default value is 0.998. + Decelerates from an initial velocity. Requires a non-zero{' '} + config.velocity — the to value is not a goal + for decay animations. Typically paired with gesture velocity (see the + rocket-decay example). Pass true for the default + exponential factor (0.998).

), }, diff --git a/packages/core/src/AnimationConfig.ts b/packages/core/src/AnimationConfig.ts index aa21a49834..8ae45ac48a 100644 --- a/packages/core/src/AnimationConfig.ts +++ b/packages/core/src/AnimationConfig.ts @@ -129,13 +129,21 @@ export class AnimationConfig { bounce?: number /** - * "Decay animations" decelerate without an explicit goal value. - * Useful for scrolling animations. + * "Decay animations" decelerate from an initial velocity. They do **not** + * ease toward a `to` value — `to` is ignored by the decay integration. + * + * Requires a non-zero `config.velocity` (otherwise the animation produces + * no movement). Typically paired with gesture libraries that supply + * velocity, e.g. `@use-gesture/react`'s `useDrag`: + * + * ```ts + * api.start({ pos, config: { velocity: [vx, vy], decay: true } }) + * ``` * * Use `true` for the default exponential decay factor (`0.998`). * * When a `number` between `0` and `1` is given, a lower number makes the - * animation slow down faster. And setting to `1` would make an unending + * animation slow down faster. Setting to `1` would make an unending * animation. * * @default false diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index 46f0326cb6..b35297194f 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -533,6 +533,29 @@ function describeConfigProp() { spring.start({ to: 200 }) expect(config.velocity).toBe(0) }) + it('preserves velocity across "to" updates when decay is set', () => { + const spring = new SpringValue(0) + spring.start({ to: 100, config: { velocity: 10, decay: true } }) + + const { config } = spring.animation + expect(config.velocity).toBe(10) + + // Retargeting must NOT wipe velocity for decay animations. + spring.start({ to: 200, config: { decay: true } }) + expect(config.velocity).toBe(10) + }) + it('decay continues animating after retarget (#1843)', async () => { + const spring = new SpringValue(0) + spring.start(100, { config: { velocity: 10, decay: 0.48 } }) + await global.advanceUntilIdle() + const firstRest = spring.get() + expect(firstRest).toBeGreaterThan(1) + + // Simulate a mid-gesture retarget with a new velocity. + spring.start(200, { config: { velocity: 5, decay: 0.48 } }) + await global.advanceUntilIdle() + expect(spring.get()).toBeGreaterThan(firstRest) + }) describe('when "damping" is 1.0', () => { it('should prevent bouncing', async () => { const spring = new SpringValue(0) diff --git a/packages/core/src/SpringValue.ts b/packages/core/src/SpringValue.ts index 9cc335cb2b..281f052cd8 100644 --- a/packages/core/src/SpringValue.ts +++ b/packages/core/src/SpringValue.ts @@ -698,7 +698,10 @@ export class SpringValue extends FrameValue { const { decay, velocity } = config // Reset to default velocity when goal values are defined. - if (hasToProp || hasFromProp) { + // Skip the reset when `decay` is configured — decay animations are + // driven by velocity, so wiping it on every retarget breaks gesture-driven + // throws (e.g. mouse-flick decay). See #1843. + if ((hasToProp || hasFromProp) && !config.decay) { config.velocity = 0 }