Skip to content
Merged
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
16 changes: 16 additions & 0 deletions packages/core/src/hooks/useSprings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
27 changes: 26 additions & 1 deletion packages/core/src/hooks/useSprings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@

// Create a local ref if a props function or deps array is ever passed.
const ref = useMemo(
() => (propsFn || arguments.length == 3 ? SpringRef() : void 0),

Check warning on line 84 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo has a missing dependency: 'propsFn'
[]
)

Expand Down Expand Up @@ -120,7 +120,7 @@
state.queue.push(() => {
resolve(flushUpdateQueue(ctrl, updates))
})
forceUpdate()

Check warning on line 123 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo has a missing dependency: 'forceUpdate'
})
},
}),
Expand All @@ -134,6 +134,16 @@
const ctrls = useRef([...state.ctrls])
const updates = useRef<any[]>([])

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

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

Expand All @@ -147,14 +157,14 @@
})
ctrls.current.length = length

declareUpdates(prevLength, length)

Check warning on line 160 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo has missing dependencies: 'declareUpdates', 'ref', and 'prevLength'
}, [length])

// Update existing controllers when "deps" are changed.
useMemo(() => {
declareUpdates(0, Math.min(prevLength, length))
// @ts-expect-error – we want to allow passing undefined to useMemo
}, deps)

Check warning on line 167 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies.

/** Fill the `updates` array with declarative updates for the given index range. */
function declareUpdates(startIndex: number, endIndex: number) {
Expand Down Expand Up @@ -201,6 +211,14 @@
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.
Expand All @@ -212,7 +230,7 @@
}

// 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 @@ -234,6 +252,13 @@
}
}
})

// 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.
Expand Down
Loading