Skip to content

Commit 4eed726

Browse files
authored
fix(core): auto-start useTransition when props is a function (#2287) (#2532)
1 parent 328f2bc commit 4eed726

3 files changed

Lines changed: 151 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@react-spring/core': patch
3+
---
4+
5+
Auto-start `useTransition` when `props` is a function. The function/`deps` form creates an internal `SpringRef`, which was assigned to every controller and treated like an injected ref, so the documented `useTransition(data, () => ({ ... }))` form never started its enter animation. `useTransition` now matches `useSpring`/`useSprings`: only an _injected_ ref (via `config.ref`) defers auto-start, so `useChain` and StrictMode remounts keep working. Note: if you previously worked around this by calling `api.start()` manually on the function form, the transition now also auto-starts. Closes #2287.

packages/core/src/hooks/useTransition.test.tsx

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { render } from 'vitest-browser-react'
33
import { toArray } from '@react-spring/shared'
44
import { TransitionFn, UseTransitionProps } from '../types'
55
import { useTransition } from './useTransition'
6+
import { useSpring } from './useSpring'
7+
import { useChain } from './useChain'
68
import { SpringRef } from '../SpringRef'
79

810
describe('useTransition', () => {
@@ -153,6 +155,142 @@ describe('useTransition', () => {
153155
testIsRef(transRef)
154156
})
155157

158+
// See https://github.com/pmndrs/react-spring/issues/2287
159+
it('auto-starts the enter animation when props is a function with deps', async () => {
160+
let enterSpring: any = null
161+
const update = createUpdater(({ args }) => {
162+
const transition = toArray(useTransition(...args))[0]
163+
transition(style => {
164+
enterSpring = style.n
165+
return null
166+
})
167+
return null
168+
})
169+
170+
await update(
171+
true,
172+
() => ({
173+
from: { n: 0 },
174+
enter: { n: 1 },
175+
leave: { n: 0 },
176+
}),
177+
[]
178+
)
179+
180+
await global.advanceUntilIdle()
181+
182+
expect(enterSpring.get()).toEqual(1)
183+
})
184+
185+
// Guard for the #2287 fix: only the *internal* ref should auto-start. An
186+
// injected ref must keep deferring so manual/imperative control still works.
187+
// This is the inverse of the test above and locks issue #1944.
188+
it('defers the enter animation when an injected ref is provided', async () => {
189+
const ref = SpringRef()
190+
let enterSpring: any = null
191+
const update = createUpdater(({ args }) => {
192+
const transition = toArray(useTransition(...args))[0]
193+
transition(style => {
194+
enterSpring = style.n
195+
return null
196+
})
197+
return null
198+
})
199+
200+
await update(true, {
201+
ref,
202+
from: { n: 0 },
203+
enter: { n: 1 },
204+
leave: { n: 0 },
205+
})
206+
207+
// No `ref.start()` yet — the injected ref defers the enter animation.
208+
await global.advanceUntilIdle()
209+
expect(enterSpring.get()).toEqual(0)
210+
211+
// ...and the ref still drives it imperatively.
212+
ref.start()
213+
await global.advanceUntilIdle()
214+
expect(enterSpring.get()).toEqual(1)
215+
})
216+
217+
// Guard for the #2287 fix: `useChain` works by draining each controller's
218+
// queue and replaying it in order, which only happens when injected refs
219+
// defer (populate the queue). There was previously zero coverage for useChain.
220+
it('sequences a transition behind a spring with useChain', async () => {
221+
const springRef = SpringRef()
222+
const transRef = SpringRef()
223+
let enterSpring: any = null
224+
225+
function Component() {
226+
useSpring({ ref: springRef, from: { x: 0 }, to: { x: 1 } })
227+
const transition = toArray(
228+
useTransition(true, {
229+
ref: transRef,
230+
from: { n: 0 },
231+
enter: { n: 1 },
232+
leave: { n: 0 },
233+
})
234+
)[0]
235+
transition(style => {
236+
enterSpring = style.n
237+
return null
238+
})
239+
useChain([springRef, transRef])
240+
return null
241+
}
242+
243+
await render(<Component />)
244+
245+
// While the spring is mid-flight, the chained transition stays at `from`.
246+
global.mockRaf.step()
247+
expect(enterSpring.get()).toEqual(0)
248+
249+
// Once the chain reaches it, the transition animates to `enter`.
250+
await global.advanceUntilIdle()
251+
expect(enterSpring.get()).toEqual(1)
252+
})
253+
254+
// Guard for the #2287 fix: injected-ref deferral must survive React
255+
// StrictMode's mount → unmount → remount cycle. This is the reattachment
256+
// behaviour `useTransition` adds on mount (#1890/#1944).
257+
it('keeps an injected ref deferred across a StrictMode double-mount', async () => {
258+
const ref = SpringRef()
259+
let enterSpring: any = null
260+
261+
function Component() {
262+
const transition = toArray(
263+
useTransition(true, {
264+
ref,
265+
from: { n: 0 },
266+
enter: { n: 1 },
267+
leave: { n: 0 },
268+
})
269+
)[0]
270+
transition(style => {
271+
enterSpring = style.n
272+
return null
273+
})
274+
return null
275+
}
276+
277+
await render(
278+
<React.StrictMode>
279+
<Component />
280+
</React.StrictMode>
281+
)
282+
283+
// Deferred even after the simulated unmount/remount.
284+
await global.advanceUntilIdle()
285+
expect(enterSpring.get()).toEqual(0)
286+
287+
// Controllers are still attached to the ref, which still drives them.
288+
expect(ref.current).toHaveLength(1)
289+
ref.start()
290+
await global.advanceUntilIdle()
291+
expect(enterSpring.get()).toEqual(1)
292+
})
293+
156294
it('passes immediate through to payload', async () => {
157295
const props = {
158296
from: { n: 0 },

packages/core/src/hooks/useTransition.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,18 @@ export function useTransition(
113113
* then reattach their refs on-mount, this was required
114114
* for react18 strict mode to work properly.
115115
*
116+
* StrictMode's simulated unmount detaches the controller from its ref but
117+
* leaves `ctrl.ref` set, so we reset it here to let the commit phase's
118+
* `replaceRef` reattach an *injected* ref. We must NOT assign the local
119+
* `ref` to `ctrl.ref` — that would make a function/deps-form transition
120+
* defer its enter animation as if a ref were injected (see #2287).
121+
*
116122
* See https://github.com/pmndrs/react-spring/issues/1890
117123
*/
118124

119125
each(transitions, t => {
120126
ref?.add(t.ctrl)
121-
t.ctrl.ref = ref
127+
t.ctrl.ref = undefined
122128
})
123129

124130
// Destroy all transitions on dismount.
@@ -428,7 +434,7 @@ export function useTransition(
428434
* Unless we have exitBeforeEnter in which case will skip
429435
* to enter the new animation straight away as if they "overlapped"
430436
*/
431-
if ((ctrl.ref || ref) && !forceChange.current) {
437+
if (ctrl.ref && !forceChange.current) {
432438
ctrl.update(payload)
433439
} else {
434440
ctrl.start(payload)

0 commit comments

Comments
 (0)