Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spring-ref-events-strictmode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@react-spring/core': patch
---

fix: events not firing when SpringRef attached manually under StrictMode
114 changes: 114 additions & 0 deletions packages/core/src/hooks/useSpring.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpringValue>
Expand Down Expand Up @@ -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(<Component />)

// 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(
<React.StrictMode>
<Component />
</React.StrictMode>
)
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(
<React.StrictMode>
<Component />
</React.StrictMode>
)
await advanceUntilIdle()

expect(capturedRef).toBeDefined()
expect(onStart).toHaveBeenCalledTimes(1)
expect(onRest).toHaveBeenCalledTimes(1)
})
})
})

interface TestContext extends ISpringContext {
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/hooks/useSprings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@

// Create a local ref if a props function or deps array is ever passed.
const ref = useMemo(
() => (propsFn || arguments.length == 3 ? SpringRef() : void 0),

Check warning on line 84 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo has a missing dependency: 'propsFn'
[]
)

Expand Down Expand Up @@ -120,7 +120,7 @@
state.queue.push(() => {
resolve(flushUpdateQueue(ctrl, updates))
})
forceUpdate()

Check warning on line 123 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo has a missing dependency: 'forceUpdate'
})
},
}),
Expand All @@ -141,7 +141,7 @@
// the affected controllers when "length" decreases.
useMemo(() => {
// Clean up any unused controllers
each(ctrls.current.slice(length, prevLength), ctrl => {

Check warning on line 144 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo has missing dependencies: 'prevLength', 'ref', and 'declareUpdates'
detachRefs(ctrl, ref)
ctrl.stop(true)
})
Expand All @@ -154,7 +154,7 @@
useMemo(() => {
declareUpdates(0, Math.min(prevLength, length))
// @ts-expect-error – we want to allow passing undefined to useMemo
}, deps)

Check warning on line 157 in packages/core/src/hooks/useSprings.ts

View workflow job for this annotation

GitHub Actions / Style Checks

react-hooks(exhaustive-deps)

React Hook useMemo was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies.

/** Fill the `updates` array with declarative updates for the given index range. */
function declareUpdates(startIndex: number, endIndex: number) {
Expand Down Expand Up @@ -220,7 +220,15 @@
// 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)
}
Expand Down
Loading