From 4613d03ff464f187f031b42516c7b1d2f374bf08 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 12:51:02 +0100 Subject: [PATCH 1/2] fix(core): fire onRest when an animation is cancelled before its first frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1802 `SpringValue._stop` gated `onRest` on `anim.changed`, which only flips to `true` once the first frame has rendered. When two `start()` calls happen within a single tick and the second returns the spring to its current value, the engine stops the animation before any frame fires, so `onRest` was silently dropped. The entry to the block already requires `isAnimating(this)` to be true, so dropping the `anim.changed` guard is safe — it just covers the gap where a spring transitioned active → idle without rendering a frame. --- packages/core/src/SpringValue.test.ts | 15 +++++++++++++++ packages/core/src/SpringValue.ts | 6 ++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts index 7309a29b93..46f0326cb6 100644 --- a/packages/core/src/SpringValue.test.ts +++ b/packages/core/src/SpringValue.test.ts @@ -956,6 +956,21 @@ function describeEvents() { global.mockRaf.step() expect(onRest).toBeCalledTimes(1) }) + // https://github.com/pmndrs/react-spring/issues/1802 + it('is called when two starts cancel each other before the first frame', async () => { + const onRest = vi.fn() + const spring = new SpringValue({ from: 0, onRest }) + + // Both starts happen synchronously, before any rAF tick. + // The second start returns to the current value, so the engine + // stops the animation before any frame has rendered. + spring.start(1) + spring.start(0) + + await global.advanceUntilIdle() + + expect(onRest).toBeCalled() + }) }) } diff --git a/packages/core/src/SpringValue.ts b/packages/core/src/SpringValue.ts index caa7a985bb..9cc335cb2b 100644 --- a/packages/core/src/SpringValue.ts +++ b/packages/core/src/SpringValue.ts @@ -1026,10 +1026,8 @@ export class SpringValue extends FrameValue { : getFinishedResult(this.get(), checkFinished(this, goal ?? anim.to)) flushCalls(this._pendingCalls, result) - if (anim.changed) { - anim.changed = false - sendEvent(this, 'onRest', result, this) - } + anim.changed = false + sendEvent(this, 'onRest', result, this) } } } From 0d564217530694a659ce7a3ee85af11262606413 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 May 2026 12:51:38 +0100 Subject: [PATCH 2/2] chore: add changeset for onRest fix --- .changeset/onrest-cancelled-before-first-frame.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/onrest-cancelled-before-first-frame.md diff --git a/.changeset/onrest-cancelled-before-first-frame.md b/.changeset/onrest-cancelled-before-first-frame.md new file mode 100644 index 0000000000..f9406207aa --- /dev/null +++ b/.changeset/onrest-cancelled-before-first-frame.md @@ -0,0 +1,5 @@ +--- +'@react-spring/core': patch +--- + +Fire `onRest` when an animation is cancelled before its first frame. `SpringValue._stop` gated `onRest` on `anim.changed`, which only becomes `true` once a frame has rendered. When two `start()` calls happened within a single tick and the second returned the spring to its current value, the engine stopped the animation before any frame fired, so `onRest` was silently dropped. The same gap affected `set()` and `stop()` called synchronously after `start()`. Closes #1802.