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/skip-animation-async-script.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@react-spring/core': patch
---

fix(core): run async script `to` to completion under `skipAnimation` so the spring lands at the script's final value rather than being skipped entirely (#1429)
48 changes: 41 additions & 7 deletions packages/core/src/Controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,26 +258,58 @@ describe('Controller', () => {
})

describe('when skipAnimations is true', () => {
it('should not run at all', async () => {
it('applies values from an async function `to` (regression for #1429)', async () => {
const ctrl = new Controller({ from: { opacity: 0 } })

global.setSkipAnimation(true)

await ctrl.start({
to: async next => {
await next({ opacity: 1 })
},
})

expect(ctrl.springs.opacity.get()).toEqual(1)
})

it('runs an async script to completion and lands on its final value', async () => {
const ctrl = new Controller({ from: { x: 0 } })

global.setSkipAnimation(true)

await ctrl.start({
to: async next => {
await next({ x: 1 })
await next({ x: 2 })
},
})

// End state matches the final `next(...)` call, as if all
// animations had run normally.
expect(ctrl.springs.x.get()).toEqual(2)
})

it('does not hang on an unterminating async script', async () => {
const ctrl = new Controller({ from: { x: 0 } })
let n = 0

global.setSkipAnimation(true)

ctrl.start({
await ctrl.start({
to: async next => {
while (true) {
n += 1
await next({ x: 1, reset: true })
await next({ x: n, reset: true })
}
},
})

await flushMicroTasks()
expect(n).toBe(0)
// The safety cap kicks in and the script is bailed.
expect(n).toBeGreaterThan(1)
expect(ctrl.springs.x.get()).toBeGreaterThan(0)
})

it('should stop running and push the animation to the finished state when called mid animation', async () => {
it('lets the script run to completion when set mid animation', async () => {
const ctrl = new Controller({ from: { x: 0 } })
let n = 0

Expand All @@ -298,7 +330,9 @@ describe('Controller', () => {
await global.advanceUntilIdle()

const { x } = ctrl.springs
expect(n).toBe(2)
// Remaining iterations apply immediately rather than animating,
// and the loop terminates naturally instead of being aborted.
expect(n).toBe(5)
expect(x.get()).toEqual(10)
})
})
Expand Down
44 changes: 21 additions & 23 deletions packages/core/src/runAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,27 +92,19 @@ export function runAsync<T extends AnimationTarget>(
}
}

// Safety cap for unterminating async scripts under `skipAnimation`.
// Without animation frames to pace it, `while (true) await next(...)`
// becomes a tight microtask loop that would hang the host.
let skipAnimationCallCount = 0
const SKIP_ANIMATION_CALL_LIMIT = 1024

const animate: any = (arg1: any, arg2?: any) => {
// Create the bail signal outside the returned promise,
// so the generated stack trace is relevant.
const bailSignal = new BailSignal()
const skipAnimationSignal = new SkipAnimationSignal()

return (async () => {
if (G.skipAnimation) {
/**
* We need to stop animations if `skipAnimation`
* is set in the Globals
*
*/
stopAsync(state)

// create the rejection error that's handled gracefully
skipAnimationSignal.result = getFinishedResult(target, false)
bail(skipAnimationSignal)
throw skipAnimationSignal
}

bailIfEnded(bailSignal)

const props: any = is.obj(arg1) ? { ...arg1 } : { ...arg2, to: arg1 }
Expand All @@ -124,6 +116,21 @@ export function runAsync<T extends AnimationTarget>(
}
})

if (G.skipAnimation) {
if (++skipAnimationCallCount > SKIP_ANIMATION_CALL_LIMIT) {
stopAsync(state)
skipAnimationSignal.result = getFinishedResult(target, false)
bail(skipAnimationSignal)
throw skipAnimationSignal
}

// Apply each step immediately so the script can run to completion
// and the spring lands on whatever value the final `next(...)` call
// would set under normal animation.
props.immediate = true
return await target.start(props)
}

const result = await target.start(props)
bailIfEnded(bailSignal)

Expand All @@ -139,15 +146,6 @@ export function runAsync<T extends AnimationTarget>(

let result!: AnimationResult<T>

if (G.skipAnimation) {
/**
* We need to stop animations if `skipAnimation`
* is set in the Globals
*/
stopAsync(state)
return getFinishedResult(target, false)
}

try {
let animating!: Promise<void>

Expand Down
Loading