diff --git a/.changeset/spring-ref-events-strictmode.md b/.changeset/spring-ref-events-strictmode.md new file mode 100644 index 0000000000..a3a88e0f9f --- /dev/null +++ b/.changeset/spring-ref-events-strictmode.md @@ -0,0 +1,5 @@ +--- +'@react-spring/core': patch +--- + +fix: events not firing when SpringRef attached manually under StrictMode diff --git a/packages/core/src/hooks/useSpring.test.tsx b/packages/core/src/hooks/useSpring.test.tsx index dbcccf8812..9d26f618e6 100644 --- a/packages/core/src/hooks/useSpring.test.tsx +++ b/packages/core/src/hooks/useSpring.test.tsx @@ -6,6 +6,7 @@ import { SpringContextProvider, type ISpringContext } from '../SpringContext' import { SpringValue } from '../SpringValue' import { SpringRef } from '../SpringRef' import { useSpring } from './useSpring' +import { useSpringRef } from './useSpringRef' describe('useSpring', () => { let springs: Lookup @@ -108,6 +109,119 @@ describe('useSpring', () => { testIsRef(ref) }) }) + + // Regression test for https://github.com/pmndrs/react-spring/issues/1991 + describe('when an external SpringRef is attached via the `ref` prop', () => { + it('fires events declared on render when `ref.start()` is called with no args', async () => { + const externalRef = SpringRef() + const onStart = vi.fn() + const onRest = vi.fn() + + function Component() { + useSpring({ + ref: externalRef, + from: { x: 0 }, + to: { x: 100 }, + onStart, + onRest, + }) + return null + } + + render() + + // Animation should not start until `ref.start()` is called. + expect(onStart).not.toHaveBeenCalled() + + externalRef.start() + await advanceUntilIdle() + + expect(onStart).toHaveBeenCalledTimes(1) + expect(onRest).toHaveBeenCalledTimes(1) + }) + + it('fires events under StrictMode (double-mount)', async () => { + const onStart = vi.fn() + const onRest = vi.fn() + let capturedRef: SpringRef | undefined + + function Component() { + const springRef = useSpringRef() + capturedRef = springRef + + useSpring({ + ref: springRef, + from: { x: 0 }, + to: { x: 100 }, + onStart, + onRest, + }) + + React.useEffect(() => { + springRef.start() + }, [springRef]) + + return null + } + + render( + + + + ) + await advanceUntilIdle() + + expect(capturedRef).toBeDefined() + expect(onStart).toHaveBeenCalledTimes(1) + expect(onRest).toHaveBeenCalledTimes(1) + }) + + // Exact reproduction from the issue body. + it('fires events when `springRef.start()` is called from a useEffect (issue #1991 repro)', async () => { + const onStart = vi.fn() + const onRest = vi.fn() + let capturedRef: SpringRef | undefined + + function Component() { + const springRef = useSpringRef() + capturedRef = springRef + + useSpring({ + ref: springRef, + config: { duration: 1000 }, + from: { + position: 'relative', + opacity: 1, + right: 0, + }, + to: { + position: 'relative', + opacity: 0, + right: -300, + }, + onStart, + onRest, + }) + + React.useEffect(() => { + springRef.start() + }, [springRef]) + + return null + } + + render( + + + + ) + await advanceUntilIdle() + + expect(capturedRef).toBeDefined() + expect(onStart).toHaveBeenCalledTimes(1) + expect(onRest).toHaveBeenCalledTimes(1) + }) + }) }) interface TestContext extends ISpringContext { diff --git a/packages/core/src/hooks/useSprings.ts b/packages/core/src/hooks/useSprings.ts index 5b90cced27..b2d8f91189 100644 --- a/packages/core/src/hooks/useSprings.ts +++ b/packages/core/src/hooks/useSprings.ts @@ -220,7 +220,15 @@ export function useSprings( // When an injected ref exists, the update is postponed // until the ref has its `start` method called. if (ctrl.ref) { - ctrl.queue.push(update) + // Push a shallow copy so `flushUpdate` mutations (e.g. wrapping event + // handlers for batching) do not leak back into `updates.current[i]`, + // which is reused across renders and StrictMode double-mounts. + ctrl.queue.push({ + ...update, + default: is.obj(update.default) + ? { ...update.default } + : update.default, + }) } else { ctrl.start(update) }