Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-stale-useSprings-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@react-spring/core': patch
---

fix(core): clear stale updates in useSprings layout effect to prevent re-application on subsequent renders
26 changes: 26 additions & 0 deletions packages/core/src/hooks/useSprings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/hooks/useSprings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ export function useSprings(
const ctrls = useRef([...state.ctrls])
const updates = useRef<any[]>([])

// 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<any[]>([])
committedUpdates.current = []

// Cache old controllers to dispose in the commit phase.
const prevLength = usePrev(length) || 0

Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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.
Expand Down