From ce0834d2937707e82f9252f301b950a089cf4bf5 Mon Sep 17 00:00:00 2001 From: Kojiro Futamura Date: Thu, 12 Mar 2026 22:07:23 +0900 Subject: [PATCH] fix(core): clear updates ref after processing in useSprings layout effect The updates ref introduced in #2368 accumulates declarative updates during render but was never cleared after the layout effect processed them. This caused stale updates to persist and be re-applied on subsequent renders, breaking animations. A backup of the committed updates is kept so that StrictMode's simulated unmount/remount cycle can re-apply updates after controllers are stopped during cleanup. Fixes #2376 --- .changeset/fix-stale-useSprings-updates.md | 5 ++++ packages/core/src/hooks/useSprings.test.tsx | 26 +++++++++++++++++++++ packages/core/src/hooks/useSprings.ts | 22 ++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-stale-useSprings-updates.md diff --git a/.changeset/fix-stale-useSprings-updates.md b/.changeset/fix-stale-useSprings-updates.md new file mode 100644 index 0000000000..51fd96b372 --- /dev/null +++ b/.changeset/fix-stale-useSprings-updates.md @@ -0,0 +1,5 @@ +--- +'@react-spring/core': patch +--- + +fix(core): clear stale updates in useSprings layout effect to prevent re-application on subsequent renders diff --git a/packages/core/src/hooks/useSprings.test.tsx b/packages/core/src/hooks/useSprings.test.tsx index 6e2add47c1..039706464b 100644 --- a/packages/core/src/hooks/useSprings.test.tsx +++ b/packages/core/src/hooks/useSprings.test.tsx @@ -98,6 +98,32 @@ describe('useSprings', () => { 4 * strictModeFunctionCallMultiplier ) }) + + it('does not re-apply stale updates on re-render with unchanged deps', () => { + update( + 1, + () => ({ + from: { x: 0 }, + to: { x: 1 }, + }), + [1] + ) + + const goalAfterFirst = mapSprings(s => s.goal) + + // Re-render with same deps — no new updates should be generated. + update( + 1, + () => ({ + from: { x: 0 }, + to: { x: 2 }, + }), + [1] + ) + + // Goal should remain unchanged because deps didn't change. + expect(mapSprings(s => s.goal)).toEqual(goalAfterFirst) + }) }) describe('when only a props array is passed', () => { diff --git a/packages/core/src/hooks/useSprings.ts b/packages/core/src/hooks/useSprings.ts index 5b90cced27..2e9c806ce7 100644 --- a/packages/core/src/hooks/useSprings.ts +++ b/packages/core/src/hooks/useSprings.ts @@ -134,6 +134,13 @@ export function useSprings( const ctrls = useRef([...state.ctrls]) const updates = useRef([]) + // A snapshot of updates from the most recent layout effect, used to + // restore controller state after StrictMode's simulated unmount/remount. + // Reset each render so stale snapshots from a previous render cycle + // are never carried over. + const committedUpdates = useRef([]) + committedUpdates.current = [] + // Cache old controllers to dispose in the commit phase. const prevLength = usePrev(length) || 0 @@ -201,6 +208,12 @@ export function useSprings( each(queue, cb => cb()) } + // Fall back to the committed snapshot when the primary array has + // been consumed — this lets StrictMode's second mount re-apply + // updates after the simulated cleanup stops controllers. + const activeUpdates = + updates.current.length > 0 ? updates.current : committedUpdates.current + // Update existing controllers. each(ctrls.current, (ctrl, i) => { // Attach the controller to the local ref. @@ -212,7 +225,7 @@ export function useSprings( } // Apply updates created during render. - const update = updates.current[i] + const update = activeUpdates[i] if (update) { // Update the injected ref if needed. replaceRef(ctrl, update.ref) @@ -226,6 +239,13 @@ export function useSprings( } } }) + + // Snapshot updates before clearing so StrictMode's second mount + // can still access them (see activeUpdates above). + if (updates.current.length > 0) { + committedUpdates.current = updates.current + } + updates.current = [] }) // Cancel the animations of all controllers on unmount.