fix(core): clear stale updates in useSprings layout effect#2410
fix(core): clear stale updates in useSprings layout effect#2410gumob wants to merge 1 commit intopmndrs:nextfrom
Conversation
…fect The updates ref introduced in pmndrs#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 pmndrs#2376
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: ce0834d The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
travisbreaks
left a comment
There was a problem hiding this comment.
Review: fix(core): clear stale updates in useSprings layout effect
Good catch. The bug is real and the root cause is well-identified: updates.current accumulates entries in declareUpdates during render but is never drained, so when useMemo skips on stable deps, stale updates get re-applied on every layout effect. This is a direct regression from #2368.
The fix
The approach of clearing updates.current at the end of the layout effect is the correct timing. Updates are produced during render, consumed during the layout effect, and should not survive past that point. This is clean.
StrictMode handling
The committedUpdates snapshot pattern is well thought through:
- Reset
committedUpdates.current = []each render (discard stale snapshots). - Copy
updates.currentintocommittedUpdates.currentbefore clearing. - On StrictMode's second layout effect invocation,
updates.currentis empty (cleared by the first invocation), soactiveUpdatesfalls back tocommittedUpdates.current.
This correctly handles the simulated unmount/remount cycle. One note: the committedUpdates.current = [] assignment during render is a side effect on a ref, which React docs discourage during render. In practice this is safe here because (a) it is idempotent and (b) StrictMode double-render will just reset it again to [] before the layout effect runs. But it is worth being aware that future React versions could theoretically tighten enforcement around ref mutations during render. If the react-spring codebase already follows this pattern elsewhere (and it does, with updates.current itself being populated during render via useMemo), this is consistent.
Edge cases to consider
Initial render: On first mount, declareUpdates populates updates.current during the useMemo calls, then the layout effect consumes and clears them. No issue here since committedUpdates is initialized to [] and the primary array has entries.
Updates pushed between layout effect and next render: If something externally pushes to updates.current after the layout effect clears it but before the next render, those entries would survive into the next render cycle's layout effect normally. The clearing only happens at the end of each layout effect, so mid-cycle mutations are not lost. This is fine.
Concurrent features: The render-phase ref mutation (committedUpdates.current = []) could be called multiple times if React discards a render. Since it is a simple reset to empty, this is safe (no information is lost that was not already consumed by a prior layout effect).
Test coverage
The test covers the exact regression scenario (re-render with unchanged deps should not re-apply stale updates). It is minimal but targeted. A couple of additions that would strengthen confidence:
-
A test that verifies StrictMode behavior explicitly: after the fix, does a fresh mount with StrictMode still correctly apply the initial update? The existing StrictMode test ("should reach final value in strict mode") covers this implicitly since the test suite runs in StrictMode, but it would be good to add a comment noting that.
-
A test for the sequence: render with deps=[1], then deps=[2] (triggering new updates), then deps=[2] again (should not re-apply). This would verify that the clearing does not interfere with legitimate dep-driven updates.
Neither of these is a blocker. The existing test suite running in StrictMode provides implicit coverage for case 1.
Verdict
The fix is correct, well-scoped, and handles the StrictMode edge case thoughtfully. The changeset is appropriate (patch). The PR description is excellent with clear documentation of the StrictMode flow.
One minor style note: the activeUpdates variable is computed before the each loop but could be inlined or computed per-index. Since updates.current does not change during the loop, the current approach is fine and arguably clearer.
Looks good to merge.
Summary
Fixes #2376
The
updatesref inuseSprings(introduced in #2368) accumulates declarative updates during the render phase. The layout effect processes these updates but never clears the ref afterward. On subsequent re-renders where no new updates are declared (i.e.useMemois skipped because deps haven't changed), the stale entries persist in the ref and get re-applied, corrupting animation state.The fix
Clear
updates.currentat the end of the layout effect so that consumed updates are not carried over to the next render cycle.A
committedUpdatessnapshot is kept so that React StrictMode's simulated unmount/remount can still re-apply updates after controllers are stopped during cleanup.How
committedUpdatesworkscommittedUpdates.currentis reset to[]. This ensures stale snapshots from a previous render are discarded.updates.currenthas entries fromdeclareUpdates. After processing, the array is copied intocommittedUpdates.currentand then cleared.useOncestops all controllers viactrl.stop(true).updates.currentis empty, soactiveUpdatesfalls back tocommittedUpdates.current, allowing controllers to be restarted.On the next real render, step 1 resets the snapshot, so no stale data survives across render boundaries.
Changes
packages/core/src/hooks/useSprings.ts— clearupdates.currentafter commit; maintaincommittedUpdatesfor StrictModepackages/core/src/hooks/useSprings.test.tsx— regression test: re-render with unchanged deps must not re-apply stale updatesTesting
does not re-apply stale updates on re-render with unchanged deps)@react-spring/threewith React 19 and React Three Fiber 9