From 0a44704e9115e41b59c49d619e88d849b7ed9feb Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 10 Jun 2026 09:37:06 +0100 Subject: [PATCH] fix(three): drive demand-mode frameloop so looping animations don't freeze Under `frameloop="demand"` a looping/sequenced animation froze after ~1 segment: rafz is driven from r3f's render loop, which sleeps unless frames are requested, and the loop-restart scheduled in the microtask gap was never rendered. rafz now signals pending frame work via `onDemand`; the three target turns it into a deferred `invalidate()` that restarts r3f's loop. Demand mode is preserved (the canvas sleeps once settled). Closes #2402. --- .changeset/demand-frameloop-wake.md | 7 ++ packages/core/src/FrameLoopDemand.test.ts | 110 ++++++++++++++++++++++ packages/rafz/src/index.ts | 16 ++++ packages/rafz/src/types.ts | 9 ++ packages/shared/src/globals.ts | 7 ++ targets/three/src/index.ts | 21 ++++- 6 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 .changeset/demand-frameloop-wake.md create mode 100644 packages/core/src/FrameLoopDemand.test.ts diff --git a/.changeset/demand-frameloop-wake.md b/.changeset/demand-frameloop-wake.md new file mode 100644 index 0000000000..e288f56e20 --- /dev/null +++ b/.changeset/demand-frameloop-wake.md @@ -0,0 +1,7 @@ +--- +'@react-spring/rafz': patch +'@react-spring/shared': patch +'@react-spring/three': patch +--- + +Fix looping/sequenced animations freezing under `@react-three/fiber`'s `frameloop="demand"`. The three target drives `rafz` from r3f's render loop, which only runs while frames are requested via `invalidate()`. When a `loop`/`to`-array/`delay` animation reached a segment boundary, the spring went idle for the microtask gap while the next segment was scheduled — r3f's loop stopped and nothing restarted it, so the animation froze (and event-driven `invalidate()` calls couldn't help, since they're cancelled by r3f's same-frame decrement). `rafz` now exposes an `onDemand` signal that fires while it has pending frame work; the three target turns it into an `invalidate()` deferred to after the current frame, cleanly restarting r3f's loop. Demand mode is preserved — once the animation settles, the canvas sleeps again. No more `invalidate()`-in-events or `useFrame` workarounds needed. Closes #2402. diff --git a/packages/core/src/FrameLoopDemand.test.ts b/packages/core/src/FrameLoopDemand.test.ts new file mode 100644 index 0000000000..da95668fa5 --- /dev/null +++ b/packages/core/src/FrameLoopDemand.test.ts @@ -0,0 +1,110 @@ +import { describe, it, beforeEach, afterEach, expect } from 'vitest' +import { raf } from '@react-spring/rafz' +import { flushMicroTasks } from 'flush-microtasks' + +import { Globals, Controller } from './index' + +/** + * Regression for #2402 — looping animations freeze under r3f's + * `frameloop="demand"`. + * + * Correct expectation (the bar this test sets): a *bare* demand host must + * animate a `loop: true` sequence with **no** user-side workarounds — no + * `invalidate()` in `onChange`/`onStart`/`onRest`, no `useFrame` `isAnimating` + * guard. react-spring must drive the host itself. + * + * The fix: rafz already knows whether it has a queue. It signals that through + * `Globals.onDemand`; `@react-spring/three` turns the signal into an + * `invalidate()` *deferred to after the current frame*. The deferral matters — + * `@react-spring/three` ticks rafz from r3f's `addEffect`, which runs in r3f's + * *before* phase, where `invalidate()` only SETS `frames = 1` and the same + * tick's `update()` decrements it back to `0` (cancelling the next frame). A + * deferred `invalidate()` instead lands once the loop has stopped, cleanly + * restarting it. + * + * `DemandHost` faithfully models r3f v9's loop plus that deferred-invalidate + * glue. It registers no `useFrame` subscriber, so the only thing that can keep + * the loop alive is react-spring driving it via `onDemand`. + */ +class DemandHost { + private frames = 0 + running = false + + /** r3f's invalidate(): request a frame and wake a sleeping loop. */ + invalidate = () => { + this.frames = Math.max(this.frames, 1) + this.running = true + } + + /** + * The three target's glue: on rafz's "pending work" signal, request a frame + * deferred past the current frame so r3f's same-frame decrement can't eat it. + */ + onDemand = () => { + Promise.resolve().then(this.invalidate) + } + + async drive({ cap, until }: { cap: number; until: () => boolean }) { + let ticks = 0 + while (this.running && ticks < cap) { + globalThis.mockRaf.step() // advance the clock so each frame has dt > 0 + let repeat = 0 + + // flushGlobalEffects('before') === the three target's addEffect; rafz + // update() runs here and fires onDemand while it has a queue. + raf.advance() + + if (this.frames > 0) { + // update(): no useFrame subscribers; render, then decrement. + this.frames = Math.max(0, this.frames - 1) + repeat += this.frames + } + + // Nothing kept the loop alive this frame → r3f stops it... + if (repeat === 0) this.running = false + + await flushMicroTasks() // ...the deferred invalidate (and loop restarts) land here + ticks++ + if (until()) break + } + return ticks + } +} + +describe('frameLoop "demand"', () => { + beforeEach(() => { + Globals.assign({ frameLoop: 'demand' }) + }) + afterEach(() => { + raf.frameLoop = 'always' + raf.onDemand = () => {} + }) + + it('drives a looping to-array with no user workarounds (regression for #2402)', async () => { + const ctrl = new Controller<{ pos: number }>({ pos: 0 }) + const host = new DemandHost() + + // The proposed seam: rafz signals pending demand-mode work, the target + // turns it into a deferred frame request. No-op today, so the loop freezes. + Globals.assign({ onDemand: host.onDemand }) + + // Measurement only — counts segment starts; never requests a frame. + let starts = 0 + ctrl.start({ + to: [{ pos: 1 }, { pos: 0 }], + loop: true, + config: { duration: 100 }, + onStart: () => starts++, + }) + + // r3f renders one frame on mount; after that react-spring is on its own. + host.invalidate() + + await host.drive({ cap: 1000, until: () => starts >= 3 }) + + // A `loop: true` animation must keep cycling through its segments. With the + // bug the loop dies once the host stops being told to render, so only the + // first segment ever starts. + expect(starts).toBeGreaterThanOrEqual(3) + }) +}) diff --git a/packages/rafz/src/index.ts b/packages/rafz/src/index.ts index 42032e007d..1601083023 100644 --- a/packages/rafz/src/index.ts +++ b/packages/rafz/src/index.ts @@ -98,6 +98,9 @@ raf.catch = console.error raf.frameLoop = 'always' +// eslint-disable-next-line @typescript-eslint/no-empty-function +raf.onDemand = () => {} + raf.advance = () => { if (raf.frameLoop !== 'demand') { console.warn( @@ -124,6 +127,11 @@ function schedule(fn: T, queue: Queue) { } else { queue.add(fn) start() + // New work was scheduled (animation start, loop/sequence restart, delay). + // In demand mode the host must be told, since it may have stopped driving. + if (raf.frameLoop === 'demand') { + raf.onDemand() + } } } @@ -169,6 +177,14 @@ function update() { onFrameQueue.flush() writeQueue.flush() onFinishQueue.flush() + + // Work remains for the next frame (e.g. an animation still in flight). + // In demand mode, ask the host to render it — flushing re-queues via the + // queue's internal `add`, which bypasses `schedule`, so this is the only + // signal for a continuing animation. + if (raf.frameLoop === 'demand' && pendingCount > 0) { + raf.onDemand() + } } interface Queue { diff --git a/packages/rafz/src/types.ts b/packages/rafz/src/types.ts index fb560a02ef..bfcd36628c 100644 --- a/packages/rafz/src/types.ts +++ b/packages/rafz/src/types.ts @@ -108,4 +108,13 @@ export interface Rafz { * only if `.frameLoop === 'demand'` */ advance: () => void + + /** + * Called while `.frameLoop === 'demand'` whenever there is frame work + * pending — when new work is scheduled and at the end of each `advance` + * while work remains. A demand-mode host (e.g. `@react-spring/three`) wires + * this to request the next frame, so animations don't stall between the + * frames the host happens to render. Does nothing by default. + */ + onDemand: () => void } diff --git a/packages/shared/src/globals.ts b/packages/shared/src/globals.ts index 85d07157ed..614092ac02 100644 --- a/packages/shared/src/globals.ts +++ b/packages/shared/src/globals.ts @@ -55,6 +55,12 @@ export interface AnimatedGlobals { willAdvance?: typeof willAdvance /** sets the global frameLoop setting for the global raf instance */ frameLoop?: Rafz['frameLoop'] + /** + * Called in demand mode when rafz has frame work pending. Targets that let a + * host drive the frameloop (e.g. `@react-spring/three`) wire this to request + * the next frame so animations don't stall between host-rendered frames. + */ + onDemand?: Rafz['onDemand'] } export const assign = (globals: AnimatedGlobals) => { @@ -68,4 +74,5 @@ export const assign = (globals: AnimatedGlobals) => { if (globals.batchedUpdates) raf.batchedUpdates = globals.batchedUpdates if (globals.willAdvance) willAdvance = globals.willAdvance if (globals.frameLoop) raf.frameLoop = globals.frameLoop + if (globals.onDemand) raf.onDemand = globals.onDemand } diff --git a/targets/three/src/index.ts b/targets/three/src/index.ts index 450a68c960..26937b7a52 100644 --- a/targets/three/src/index.ts +++ b/targets/three/src/index.ts @@ -1,4 +1,4 @@ -import { applyProps, addEffect } from '@react-three/fiber' +import { applyProps, addEffect, invalidate } from '@react-three/fiber' import { Globals } from '@react-spring/core' import { createStringInterpolator, colors, raf } from '@react-spring/shared' @@ -7,10 +7,29 @@ import { createHost } from '@react-spring/animated' import { primitives } from './primitives' import { WithAnimated } from './animated' +// With `frameloop="demand"`, r3f only runs its loop while frames are requested +// via `invalidate()`. rafz signals (via `onDemand`) whenever it has frame work +// pending — including the microtask gaps between looped/sequenced segments, +// where the spring is momentarily idle. We defer the `invalidate()` to a +// microtask: calling it from within `addEffect` (r3f's "before" phase) would +// only set the frame count to 1, which r3f's same-frame `update()` decrements +// back to 0. Deferring lets it land once the loop has stopped, cleanly +// restarting it. One request per frame is enough, so we dedupe. +let frameRequested = false +const requestFrame = () => { + if (frameRequested) return + frameRequested = true + Promise.resolve().then(() => { + frameRequested = false + invalidate() + }) +} + Globals.assign({ createStringInterpolator, colors, frameLoop: 'demand', + onDemand: requestFrame, }) // Let r3f drive the frameloop.