Skip to content

Commit 01810a4

Browse files
authored
fix(core): run async script to to completion under skipAnimation (#2438)
1 parent 46f97db commit 01810a4

3 files changed

Lines changed: 67 additions & 30 deletions

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(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)

packages/core/src/Controller.test.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -258,26 +258,58 @@ describe('Controller', () => {
258258
})
259259

260260
describe('when skipAnimations is true', () => {
261-
it('should not run at all', async () => {
261+
it('applies values from an async function `to` (regression for #1429)', async () => {
262+
const ctrl = new Controller({ from: { opacity: 0 } })
263+
264+
global.setSkipAnimation(true)
265+
266+
await ctrl.start({
267+
to: async next => {
268+
await next({ opacity: 1 })
269+
},
270+
})
271+
272+
expect(ctrl.springs.opacity.get()).toEqual(1)
273+
})
274+
275+
it('runs an async script to completion and lands on its final value', async () => {
276+
const ctrl = new Controller({ from: { x: 0 } })
277+
278+
global.setSkipAnimation(true)
279+
280+
await ctrl.start({
281+
to: async next => {
282+
await next({ x: 1 })
283+
await next({ x: 2 })
284+
},
285+
})
286+
287+
// End state matches the final `next(...)` call, as if all
288+
// animations had run normally.
289+
expect(ctrl.springs.x.get()).toEqual(2)
290+
})
291+
292+
it('does not hang on an unterminating async script', async () => {
262293
const ctrl = new Controller({ from: { x: 0 } })
263294
let n = 0
264295

265296
global.setSkipAnimation(true)
266297

267-
ctrl.start({
298+
await ctrl.start({
268299
to: async next => {
269300
while (true) {
270301
n += 1
271-
await next({ x: 1, reset: true })
302+
await next({ x: n, reset: true })
272303
}
273304
},
274305
})
275306

276-
await flushMicroTasks()
277-
expect(n).toBe(0)
307+
// The safety cap kicks in and the script is bailed.
308+
expect(n).toBeGreaterThan(1)
309+
expect(ctrl.springs.x.get()).toBeGreaterThan(0)
278310
})
279311

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

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

300332
const { x } = ctrl.springs
301-
expect(n).toBe(2)
333+
// Remaining iterations apply immediately rather than animating,
334+
// and the loop terminates naturally instead of being aborted.
335+
expect(n).toBe(5)
302336
expect(x.get()).toEqual(10)
303337
})
304338
})

packages/core/src/runAsync.ts

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -92,27 +92,19 @@ export function runAsync<T extends AnimationTarget>(
9292
}
9393
}
9494

95+
// Safety cap for unterminating async scripts under `skipAnimation`.
96+
// Without animation frames to pace it, `while (true) await next(...)`
97+
// becomes a tight microtask loop that would hang the host.
98+
let skipAnimationCallCount = 0
99+
const SKIP_ANIMATION_CALL_LIMIT = 1024
100+
95101
const animate: any = (arg1: any, arg2?: any) => {
96102
// Create the bail signal outside the returned promise,
97103
// so the generated stack trace is relevant.
98104
const bailSignal = new BailSignal()
99105
const skipAnimationSignal = new SkipAnimationSignal()
100106

101107
return (async () => {
102-
if (G.skipAnimation) {
103-
/**
104-
* We need to stop animations if `skipAnimation`
105-
* is set in the Globals
106-
*
107-
*/
108-
stopAsync(state)
109-
110-
// create the rejection error that's handled gracefully
111-
skipAnimationSignal.result = getFinishedResult(target, false)
112-
bail(skipAnimationSignal)
113-
throw skipAnimationSignal
114-
}
115-
116108
bailIfEnded(bailSignal)
117109

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

119+
if (G.skipAnimation) {
120+
if (++skipAnimationCallCount > SKIP_ANIMATION_CALL_LIMIT) {
121+
stopAsync(state)
122+
skipAnimationSignal.result = getFinishedResult(target, false)
123+
bail(skipAnimationSignal)
124+
throw skipAnimationSignal
125+
}
126+
127+
// Apply each step immediately so the script can run to completion
128+
// and the spring lands on whatever value the final `next(...)` call
129+
// would set under normal animation.
130+
props.immediate = true
131+
return await target.start(props)
132+
}
133+
127134
const result = await target.start(props)
128135
bailIfEnded(bailSignal)
129136

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

140147
let result!: AnimationResult<T>
141148

142-
if (G.skipAnimation) {
143-
/**
144-
* We need to stop animations if `skipAnimation`
145-
* is set in the Globals
146-
*/
147-
stopAsync(state)
148-
return getFinishedResult(target, false)
149-
}
150-
151149
try {
152150
let animating!: Promise<void>
153151

0 commit comments

Comments
 (0)