Skip to content

fix(core): clear stale updates in useSprings layout effect#2410

Open
gumob wants to merge 1 commit intopmndrs:nextfrom
gumob:fix/useSprings-stale-updates
Open

fix(core): clear stale updates in useSprings layout effect#2410
gumob wants to merge 1 commit intopmndrs:nextfrom
gumob:fix/useSprings-stale-updates

Conversation

@gumob
Copy link

@gumob gumob commented Mar 12, 2026

Summary

Fixes #2376

The updates ref in useSprings (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. useMemo is skipped because deps haven't changed), the stale entries persist in the ref and get re-applied, corrupting animation state.

The fix

Clear updates.current at the end of the layout effect so that consumed updates are not carried over to the next render cycle.

A committedUpdates snapshot is kept so that React StrictMode's simulated unmount/remount can still re-apply updates after controllers are stopped during cleanup.

How committedUpdates works

  1. Render phasecommittedUpdates.current is reset to []. This ensures stale snapshots from a previous render are discarded.
  2. First layout effectupdates.current has entries from declareUpdates. After processing, the array is copied into committedUpdates.current and then cleared.
  3. StrictMode cleanupuseOnce stops all controllers via ctrl.stop(true).
  4. Second layout effectupdates.current is empty, so activeUpdates falls back to committedUpdates.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 — clear updates.current after commit; maintain committedUpdates for StrictMode
  • packages/core/src/hooks/useSprings.test.tsx — regression test: re-render with unchanged deps must not re-apply stale updates

Testing

  • Added regression test (does not re-apply stale updates on re-render with unchanged deps)
  • All existing tests pass (including StrictMode coverage)
  • Verified in a production app using @react-spring/three with React 19 and React Three Fiber 9

…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
@vercel
Copy link

vercel bot commented Mar 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-spring Ready Ready Preview Mar 12, 2026 1:59pm

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Mar 12, 2026

🦋 Changeset detected

Latest commit: ce0834d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@react-spring/core Patch
@react-spring/animated Patch
@react-spring/parallax Patch
@react-spring/rafz Patch
@react-spring/shared Patch
@react-spring/types Patch
@react-spring/konva Patch
@react-spring/native Patch
@react-spring/three Patch
@react-spring/web Patch
@react-spring/zdog Patch

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

Copy link

@travisbreaks travisbreaks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Reset committedUpdates.current = [] each render (discard stale snapshots).
  2. Copy updates.current into committedUpdates.current before clearing.
  3. On StrictMode's second layout effect invocation, updates.current is empty (cleared by the first invocation), so activeUpdates falls back to committedUpdates.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:

  1. 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.

  2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug]: animation broken on v10

2 participants