Skip to content

Commit 1fb03f4

Browse files
authored
fix(core): clear stale updates in useSprings layout effect (#2410)
1 parent 98d9f52 commit 1fb03f4

3 files changed

Lines changed: 47 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@react-spring/core': patch
3+
---
4+
5+
fix(core): clear stale updates in useSprings layout effect to prevent re-application on subsequent renders

packages/core/src/hooks/useSprings.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ describe('useSprings', () => {
7878
expect(springs.length).toBe(4)
7979
expect(ref.current.length).toBe(4)
8080
})
81+
82+
it('imperative start is not overridden by stale declarative updates on re-render', () => {
83+
// Regression for #2376 / #2377: declarative initial values via props fn
84+
// are stored in a ref during render and applied in the layout effect. If
85+
// the ref is not cleared after applying, every subsequent re-render's
86+
// layout effect re-applies them, clobbering imperative `ref.start(...)`
87+
// targets and resetting the goal back to the initial value.
88+
update(1, () => ({ x: 0 }))
89+
90+
ref.start({ x: 1 })
91+
expect(mapSprings(s => s.goal)).toEqual([{ x: 1 }])
92+
93+
// Re-render — props fn unchanged, no new declarative updates expected.
94+
update(1, () => ({ x: 0 }))
95+
expect(mapSprings(s => s.goal)).toEqual([{ x: 1 }])
96+
})
8197
})
8298

8399
describe('when both a props function and a deps array are passed', () => {

packages/core/src/hooks/useSprings.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,16 @@ export function useSprings(
134134
const ctrls = useRef([...state.ctrls])
135135
const updates = useRef<any[]>([])
136136

137+
// Backup of the most recently applied updates. The layout effect drains
138+
// `updates.current` after applying so a parent re-render with unchanged
139+
// deps doesn't re-apply stale entries (see #2376). StrictMode mounts
140+
// twice (mount → simulated unmount via `useOnce` cleanup → mount), and the
141+
// second mount needs to restart the controllers after they've been stopped,
142+
// so we keep this snapshot to fall back to. Reset each render so a snapshot
143+
// from a previous commit can never bleed into a subsequent layout effect.
144+
const strictModeRestartSnapshot = useRef<any[]>([])
145+
strictModeRestartSnapshot.current = []
146+
137147
// Cache old controllers to dispose in the commit phase.
138148
const prevLength = usePrev(length) || 0
139149

@@ -201,6 +211,14 @@ export function useSprings(
201211
each(queue, cb => cb())
202212
}
203213

214+
// Fall back to the restart snapshot when the primary array has been
215+
// consumed — this lets StrictMode's second mount re-apply updates after
216+
// the simulated cleanup stops the controllers.
217+
const activeUpdates =
218+
updates.current.length > 0
219+
? updates.current
220+
: strictModeRestartSnapshot.current
221+
204222
// Update existing controllers.
205223
each(ctrls.current, (ctrl, i) => {
206224
// Attach the controller to the local ref.
@@ -212,7 +230,7 @@ export function useSprings(
212230
}
213231

214232
// Apply updates created during render.
215-
const update = updates.current[i]
233+
const update = activeUpdates[i]
216234
if (update) {
217235
// Update the injected ref if needed.
218236
replaceRef(ctrl, update.ref)
@@ -234,6 +252,13 @@ export function useSprings(
234252
}
235253
}
236254
})
255+
256+
// Snapshot updates before clearing so StrictMode's second mount can
257+
// still access them (see activeUpdates above).
258+
if (updates.current.length > 0) {
259+
strictModeRestartSnapshot.current = updates.current
260+
}
261+
updates.current = []
237262
})
238263

239264
// Cancel the animations of all controllers on unmount.

0 commit comments

Comments
 (0)