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 646b15d4bd..bee40e6a55 100644 --- a/packages/core/src/hooks/useSprings.test.tsx +++ b/packages/core/src/hooks/useSprings.test.tsx @@ -78,6 +78,22 @@ describe('useSprings', () => { expect(springs.length).toBe(4) expect(ref.current.length).toBe(4) }) + + it('imperative start is not overridden by stale declarative updates on re-render', () => { + // Regression for #2376 / #2377: declarative initial values via props fn + // are stored in a ref during render and applied in the layout effect. If + // the ref is not cleared after applying, every subsequent re-render's + // layout effect re-applies them, clobbering imperative `ref.start(...)` + // targets and resetting the goal back to the initial value. + update(1, () => ({ x: 0 })) + + ref.start({ x: 1 }) + expect(mapSprings(s => s.goal)).toEqual([{ x: 1 }]) + + // Re-render — props fn unchanged, no new declarative updates expected. + update(1, () => ({ x: 0 })) + expect(mapSprings(s => s.goal)).toEqual([{ x: 1 }]) + }) }) describe('when both a props function and a deps array are passed', () => { diff --git a/packages/core/src/hooks/useSprings.ts b/packages/core/src/hooks/useSprings.ts index b2d8f91189..71e08d1efa 100644 --- a/packages/core/src/hooks/useSprings.ts +++ b/packages/core/src/hooks/useSprings.ts @@ -134,6 +134,16 @@ export function useSprings( const ctrls = useRef([...state.ctrls]) const updates = useRef([]) + // Backup of the most recently applied updates. The layout effect drains + // `updates.current` after applying so a parent re-render with unchanged + // deps doesn't re-apply stale entries (see #2376). StrictMode mounts + // twice (mount → simulated unmount via `useOnce` cleanup → mount), and the + // second mount needs to restart the controllers after they've been stopped, + // so we keep this snapshot to fall back to. Reset each render so a snapshot + // from a previous commit can never bleed into a subsequent layout effect. + const strictModeRestartSnapshot = useRef([]) + strictModeRestartSnapshot.current = [] + // Cache old controllers to dispose in the commit phase. const prevLength = usePrev(length) || 0 @@ -201,6 +211,14 @@ export function useSprings( each(queue, cb => cb()) } + // Fall back to the restart snapshot when the primary array has been + // consumed — this lets StrictMode's second mount re-apply updates after + // the simulated cleanup stops the controllers. + const activeUpdates = + updates.current.length > 0 + ? updates.current + : strictModeRestartSnapshot.current + // Update existing controllers. each(ctrls.current, (ctrl, i) => { // Attach the controller to the local ref. @@ -212,7 +230,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) @@ -234,6 +252,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) { + strictModeRestartSnapshot.current = updates.current + } + updates.current = [] }) // Cancel the animations of all controllers on unmount.