Skip to content

Commit 8da5e50

Browse files
authored
fix: events not firing when SpringRef attached manually under StrictMode (#2430)
1 parent 6b61ebe commit 8da5e50

3 files changed

Lines changed: 128 additions & 1 deletion

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+
fix: events not firing when SpringRef attached manually under StrictMode

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SpringContextProvider, type ISpringContext } from '../SpringContext'
66
import { SpringValue } from '../SpringValue'
77
import { SpringRef } from '../SpringRef'
88
import { useSpring } from './useSpring'
9+
import { useSpringRef } from './useSpringRef'
910

1011
describe('useSpring', () => {
1112
let springs: Lookup<SpringValue>
@@ -108,6 +109,119 @@ describe('useSpring', () => {
108109
testIsRef(ref)
109110
})
110111
})
112+
113+
// Regression test for https://github.com/pmndrs/react-spring/issues/1991
114+
describe('when an external SpringRef is attached via the `ref` prop', () => {
115+
it('fires events declared on render when `ref.start()` is called with no args', async () => {
116+
const externalRef = SpringRef()
117+
const onStart = vi.fn()
118+
const onRest = vi.fn()
119+
120+
function Component() {
121+
useSpring({
122+
ref: externalRef,
123+
from: { x: 0 },
124+
to: { x: 100 },
125+
onStart,
126+
onRest,
127+
})
128+
return null
129+
}
130+
131+
render(<Component />)
132+
133+
// Animation should not start until `ref.start()` is called.
134+
expect(onStart).not.toHaveBeenCalled()
135+
136+
externalRef.start()
137+
await advanceUntilIdle()
138+
139+
expect(onStart).toHaveBeenCalledTimes(1)
140+
expect(onRest).toHaveBeenCalledTimes(1)
141+
})
142+
143+
it('fires events under StrictMode (double-mount)', async () => {
144+
const onStart = vi.fn()
145+
const onRest = vi.fn()
146+
let capturedRef: SpringRef | undefined
147+
148+
function Component() {
149+
const springRef = useSpringRef()
150+
capturedRef = springRef
151+
152+
useSpring({
153+
ref: springRef,
154+
from: { x: 0 },
155+
to: { x: 100 },
156+
onStart,
157+
onRest,
158+
})
159+
160+
React.useEffect(() => {
161+
springRef.start()
162+
}, [springRef])
163+
164+
return null
165+
}
166+
167+
render(
168+
<React.StrictMode>
169+
<Component />
170+
</React.StrictMode>
171+
)
172+
await advanceUntilIdle()
173+
174+
expect(capturedRef).toBeDefined()
175+
expect(onStart).toHaveBeenCalledTimes(1)
176+
expect(onRest).toHaveBeenCalledTimes(1)
177+
})
178+
179+
// Exact reproduction from the issue body.
180+
it('fires events when `springRef.start()` is called from a useEffect (issue #1991 repro)', async () => {
181+
const onStart = vi.fn()
182+
const onRest = vi.fn()
183+
let capturedRef: SpringRef | undefined
184+
185+
function Component() {
186+
const springRef = useSpringRef()
187+
capturedRef = springRef
188+
189+
useSpring({
190+
ref: springRef,
191+
config: { duration: 1000 },
192+
from: {
193+
position: 'relative',
194+
opacity: 1,
195+
right: 0,
196+
},
197+
to: {
198+
position: 'relative',
199+
opacity: 0,
200+
right: -300,
201+
},
202+
onStart,
203+
onRest,
204+
})
205+
206+
React.useEffect(() => {
207+
springRef.start()
208+
}, [springRef])
209+
210+
return null
211+
}
212+
213+
render(
214+
<React.StrictMode>
215+
<Component />
216+
</React.StrictMode>
217+
)
218+
await advanceUntilIdle()
219+
220+
expect(capturedRef).toBeDefined()
221+
expect(onStart).toHaveBeenCalledTimes(1)
222+
expect(onRest).toHaveBeenCalledTimes(1)
223+
})
224+
})
111225
})
112226

113227
interface TestContext extends ISpringContext {

packages/core/src/hooks/useSprings.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,15 @@ export function useSprings(
220220
// When an injected ref exists, the update is postponed
221221
// until the ref has its `start` method called.
222222
if (ctrl.ref) {
223-
ctrl.queue.push(update)
223+
// Push a shallow copy so `flushUpdate` mutations (e.g. wrapping event
224+
// handlers for batching) do not leak back into `updates.current[i]`,
225+
// which is reused across renders and StrictMode double-mounts.
226+
ctrl.queue.push({
227+
...update,
228+
default: is.obj(update.default)
229+
? { ...update.default }
230+
: update.default,
231+
})
224232
} else {
225233
ctrl.start(update)
226234
}

0 commit comments

Comments
 (0)