Skip to content

chore(test): run hook tests under React.StrictMode#2431

Merged
joshuaellis merged 3 commits into
nextfrom
chore/strictmode-hook-tests
May 21, 2026
Merged

chore(test): run hook tests under React.StrictMode#2431
joshuaellis merged 3 commits into
nextfrom
chore/strictmode-hook-tests

Conversation

@joshuaellis
Copy link
Copy Markdown
Member

@joshuaellis joshuaellis commented May 21, 2026

Run every hook test under React.StrictMode by default. The premise: if a test passes under StrictMode it passes without, and the inverse hides real React-correctness bugs. The two fixes in this PR are exactly the kind of bug the inverse was hiding — both fall out the moment StrictMode is on.

Bugs StrictMode caught

useTrail chained in the wrong direction. reverse and passedRef were accumulated as side effects inside the useSprings wrapper, relying on the wrapper firing at least once per render. StrictMode's second render pass skips it — useMemo([length]) deps are unchanged so the cache holds, and the [deps] useMemo invokes declareUpdates(0, min(prevLength=0, length=2)) which iterates zero times on the first render. reverse stayed at its initial true, flipping the trail direction. Fixed by deriving reverse/passedRef from propsArg directly for the object form. The function form has the same latent issue and is noted as follow-up.

SpringValue.stop() established a phantom goal. stop() unconditionally called _focus(this.get()) to snap animation.to to the current value — correct for an active animation, wrong for a paused or never-started spring whose underlying value was seeded by _prepareNode via from. StrictMode's simulated unmount fires useSprings's cleanup (ctrl.stop(true)) on every controller, so a freshly-mounted paused spring ended up with animation.to = 0 instead of undefined. Fixed by gating the _focus call on animation.to already being defined.

Stale assertion

SpringContext > only merges when changed needed an updated first assertion — not a library bug, an unavoidable consequence of StrictMode double-firing effects on initial mount. The user update fires twice, and the default-context update fires on the remount because defaultProps.onProps was set by the first pass. Subsequent rerenders are unaffected.

Flip `configure({ reactStrictMode: true })` globally in
`packages/core/test/setup.ts` so every `render` and `renderHook` from
`vitest-browser-react` is automatically wrapped in `<React.StrictMode>`.
If a test passes under StrictMode it passes without; the inverse hides
real bugs (see #1991).

Simplifies `useSprings.test.tsx`: the local `isStrictMode` flag and
`<React.StrictMode>` wrapper in `createUpdater` are now redundant, so
both are dropped. The `strictModeFunctionCallMultiplier` is preserved
(still 2) because props functions are still invoked twice per render.

Refs #1991.
The `reverse` and `passedRef` flags were accumulated as side effects
inside the `useSprings` wrapper, expecting it to fire at least once per
render. Under React.StrictMode's second render pass `useSprings` skips
the wrapper — its `useMemo([length])` deps are unchanged and the
`[deps]` `useMemo` calls `declareUpdates(0, min(prevLength, length))`
which is `(0, 0)` on a first render — so the accumulators stay at
their initial values. `reverse` defaults to `true`, which inverts the
chaining direction in the layout effect.

For object-form props every spring receives the same props, so derive
`reverse` and `passedRef` directly from `propsArg` instead of mutating
them via the wrapper. The function-form path still uses the
accumulator pattern; same latent issue, tracked as follow-up.

Fixes #1991.
…sed springs

`SpringValue.stop()` unconditionally called `_focus(this.get())` to
snap `animation.to` to the current value. That is the right move when
freezing an active animation, but it incorrectly establishes a goal
where none existed on a paused or never-started spring (`_prepareNode`
seeds the underlying value via `from`, so `this.get()` returns that
value).

The bug became observable under React.StrictMode, whose simulated
unmount fires `useSprings`'s cleanup (`ctrl.stop(true)`) on every
controller — including a freshly-mounted spring whose initial update
was paused by `SpringContext`. The cleanup wrote `from` into
`animation.to`, leaving `t.goal` equal to `0` instead of `undefined`
on the subsequent remount.

Gate the `_focus` call on `animation.to` already being defined. The
"only merges when changed" test gets an updated first assertion that
acknowledges the StrictMode mount → simulated unmount → remount cycle
double-fires the layout effect: the user update fires twice, and the
default-context update fires on the remount because `defaultProps`
now has `onProps` set.

Fixes #1991.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

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

Project Deployment Actions Updated (UTC)
react-spring Ready Ready Preview May 21, 2026 6:23pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 21, 2026

🦋 Changeset detected

Latest commit: 8a43db7

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

This PR includes changesets to release 12 packages
Name Type
@react-spring/core Patch
@react-spring/animated Patch
@react-spring/mock-raf 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

@joshuaellis joshuaellis merged commit 98d9f52 into next May 21, 2026
15 checks passed
@joshuaellis joshuaellis deleted the chore/strictmode-hook-tests branch May 21, 2026 18:26
This was referenced May 21, 2026
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.

1 participant