diff --git a/.changeset/skip-animation-async-script.md b/.changeset/skip-animation-async-script.md new file mode 100644 index 0000000000..8b7633ebb9 --- /dev/null +++ b/.changeset/skip-animation-async-script.md @@ -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) diff --git a/packages/core/src/Controller.test.ts b/packages/core/src/Controller.test.ts index 752daf052b..955d0ecb37 100644 --- a/packages/core/src/Controller.test.ts +++ b/packages/core/src/Controller.test.ts @@ -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 @@ -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) }) }) diff --git a/packages/core/src/runAsync.ts b/packages/core/src/runAsync.ts index ea91fc738b..389bcdd612 100644 --- a/packages/core/src/runAsync.ts +++ b/packages/core/src/runAsync.ts @@ -92,6 +92,12 @@ export function runAsync( } } + // 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. @@ -99,20 +105,6 @@ export function runAsync( 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 } @@ -124,6 +116,21 @@ export function runAsync( } }) + 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) @@ -139,15 +146,6 @@ export function runAsync( let result!: AnimationResult - 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