Skip to content

Commit ce0834d

Browse files
committed
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
1 parent e0c2004 commit ce0834d

File tree

3 files changed

+52
-1
lines changed

3 files changed

+52
-1
lines changed
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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,32 @@ describe('useSprings', () => {
9898
4 * strictModeFunctionCallMultiplier
9999
)
100100
})
101+
102+
it('does not re-apply stale updates on re-render with unchanged deps', () => {
103+
update(
104+
1,
105+
() => ({
106+
from: { x: 0 },
107+
to: { x: 1 },
108+
}),
109+
[1]
110+
)
111+
112+
const goalAfterFirst = mapSprings(s => s.goal)
113+
114+
// Re-render with same deps — no new updates should be generated.
115+
update(
116+
1,
117+
() => ({
118+
from: { x: 0 },
119+
to: { x: 2 },
120+
}),
121+
[1]
122+
)
123+
124+
// Goal should remain unchanged because deps didn't change.
125+
expect(mapSprings(s => s.goal)).toEqual(goalAfterFirst)
126+
})
101127
})
102128

103129
describe('when only a props array is passed', () => {

packages/core/src/hooks/useSprings.ts

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

137+
// A snapshot of updates from the most recent layout effect, used to
138+
// restore controller state after StrictMode's simulated unmount/remount.
139+
// Reset each render so stale snapshots from a previous render cycle
140+
// are never carried over.
141+
const committedUpdates = useRef<any[]>([])
142+
committedUpdates.current = []
143+
137144
// Cache old controllers to dispose in the commit phase.
138145
const prevLength = usePrev(length) || 0
139146

@@ -201,6 +208,12 @@ export function useSprings(
201208
each(queue, cb => cb())
202209
}
203210

211+
// Fall back to the committed snapshot when the primary array has
212+
// been consumed — this lets StrictMode's second mount re-apply
213+
// updates after the simulated cleanup stops controllers.
214+
const activeUpdates =
215+
updates.current.length > 0 ? updates.current : committedUpdates.current
216+
204217
// Update existing controllers.
205218
each(ctrls.current, (ctrl, i) => {
206219
// Attach the controller to the local ref.
@@ -212,7 +225,7 @@ export function useSprings(
212225
}
213226

214227
// Apply updates created during render.
215-
const update = updates.current[i]
228+
const update = activeUpdates[i]
216229
if (update) {
217230
// Update the injected ref if needed.
218231
replaceRef(ctrl, update.ref)
@@ -226,6 +239,13 @@ export function useSprings(
226239
}
227240
}
228241
})
242+
243+
// Snapshot updates before clearing so StrictMode's second mount
244+
// can still access them (see activeUpdates above).
245+
if (updates.current.length > 0) {
246+
committedUpdates.current = updates.current
247+
}
248+
updates.current = []
229249
})
230250

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

0 commit comments

Comments
 (0)