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
7 changes: 7 additions & 0 deletions .changeset/demand-frameloop-wake.md
Original file line number Diff line number Diff line change
@@ -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.
110 changes: 110 additions & 0 deletions packages/core/src/FrameLoopDemand.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
16 changes: 16 additions & 0 deletions packages/rafz/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -124,6 +127,11 @@ function schedule<T extends Function>(fn: T, queue: Queue<T>) {
} 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()
}
}
}

Expand Down Expand Up @@ -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<T extends Function = any> {
Expand Down
9 changes: 9 additions & 0 deletions packages/rafz/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
7 changes: 7 additions & 0 deletions packages/shared/src/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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
}
21 changes: 20 additions & 1 deletion targets/three/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.
Expand Down
Loading