diff --git a/apps/desktop/src/components/pet/pixel-egg-sprite.test.tsx b/apps/desktop/src/components/pet/pixel-egg-sprite.test.tsx
new file mode 100644
index 0000000000..aa25308775
--- /dev/null
+++ b/apps/desktop/src/components/pet/pixel-egg-sprite.test.tsx
@@ -0,0 +1,160 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { PixelEggSprite } from './pixel-egg-sprite'
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+function render(mode: 'bounce' | 'hatch', onDone?: () => void) {
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+
+ act(() => {
+ root!.render()
+ })
+}
+
+function cleanup() {
+ if (root) {
+ act(() => {
+ root!.unmount()
+ })
+ }
+
+ container?.remove()
+ root = null
+ container = null
+}
+
+function installRaf(startId = 1) {
+ let nextId = startId
+ const frames = new Map()
+
+ const request = vi.fn((callback: FrameRequestCallback) => {
+ const id = nextId++
+ frames.set(id, callback)
+
+ return id
+ })
+
+ const cancel = vi.fn((id: number) => {
+ frames.delete(id)
+ })
+
+ Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
+ Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
+
+ return {
+ pending: () => frames.size,
+ request,
+ runNext: (now: number) => {
+ const next = frames.entries().next().value
+
+ if (!next) {
+ throw new Error('No pending RAF')
+ }
+
+ const [id, callback] = next
+ frames.delete(id)
+ callback(now)
+ }
+ }
+}
+
+describe('PixelEggSprite scheduling', () => {
+ beforeEach(() => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.useFakeTimers()
+ vi.spyOn(Math, 'random').mockReturnValue(0)
+ Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 1 })
+ vi.stubGlobal(
+ 'Image',
+ class {
+ complete = true
+ onerror: (() => undefined) | null = null
+ onload: (() => undefined) | null = null
+
+ set src(_value: string) {
+ queueMicrotask(() => this.onload?.())
+ }
+ } as unknown as typeof Image
+ )
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
+ clearRect: vi.fn(),
+ drawImage: vi.fn(),
+ getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(32 * 32 * 4) })),
+ imageSmoothingEnabled: false,
+ putImageData: vi.fn()
+ } as unknown as CanvasRenderingContext2D)
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ })
+
+ it('sleeps during the long bounce rest instead of chaining RAFs', async () => {
+ const raf = installRaf()
+
+ render('bounce')
+ await act(async () => undefined)
+
+ act(() => {
+ raf.runNext(0)
+ })
+
+ expect(raf.pending()).toBe(0)
+ expect(vi.getTimerCount()).toBe(1)
+
+ act(() => {
+ vi.advanceTimersByTime(650)
+ })
+
+ expect(raf.pending()).toBe(1)
+
+ cleanup()
+ expect(raf.pending()).toBe(0)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('stops scheduling frames after the final hatch frame', async () => {
+ const raf = installRaf()
+ const onDone = vi.fn()
+
+ render('hatch', onDone)
+ await act(async () => undefined)
+
+ act(() => {
+ raf.runNext(0)
+ })
+
+ for (const now of [190, 380, 570, 760, 950]) {
+ act(() => {
+ vi.advanceTimersByTime(162)
+ })
+
+ act(() => {
+ raf.runNext(now)
+ })
+ }
+
+ expect(onDone).toHaveBeenCalledTimes(1)
+ expect(raf.pending()).toBe(0)
+ })
+
+ it('cancels a pending animation frame even when the browser assigns ID zero', async () => {
+ const raf = installRaf(0)
+
+ render('bounce')
+ await act(async () => undefined)
+
+ expect(raf.pending()).toBe(1)
+ cleanup()
+ expect(raf.pending()).toBe(0)
+ })
+})
diff --git a/apps/desktop/src/components/pet/pixel-egg-sprite.tsx b/apps/desktop/src/components/pet/pixel-egg-sprite.tsx
index 5c4253904d..0454996236 100644
--- a/apps/desktop/src/components/pet/pixel-egg-sprite.tsx
+++ b/apps/desktop/src/components/pet/pixel-egg-sprite.tsx
@@ -128,9 +128,6 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
const offCtx = off.getContext('2d', { willReadFrequently: true })
let sheet: HTMLImageElement | null = null
- void loadSheet().then(img => {
- sheet = img
- })
const render = (frame: number) => {
if (!sheet || !offCtx) {
@@ -161,7 +158,9 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
ctx.drawImage(off, 0, 0, FRAME, FRAME, 0, 0, dim, dim)
}
- let raf = 0
+ let raf: number | null = null
+ let wakeTimer: number | null = null
+ let stopped = false
let step = 0
let finished = false
// bounce: `nextAt` is when the next thing happens — the next bounce frame, or
@@ -169,40 +168,64 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
let resting = mode === 'bounce'
let nextAt = 0
let lastHatch = 0
+ let hatchStarted = false
+
+ const cancelWakeTimer = () => {
+ if (wakeTimer !== null) {
+ window.clearTimeout(wakeTimer)
+ wakeTimer = null
+ }
+ }
+
+ const cancelRaf = () => {
+ if (raf !== null) {
+ window.cancelAnimationFrame(raf)
+ raf = null
+ }
+ }
+
+ let scheduleFrame: (delayMs?: number) => void
const tick = (now: number) => {
- raf = requestAnimationFrame(tick)
+ raf = null
- if (!sheet) {
+ if (stopped || !sheet) {
return
}
if (mode === 'hatch') {
- if (!lastHatch) {
+ if (!hatchStarted) {
+ hatchStarted = true
lastHatch = now
- render(HATCH_START)
+ step = HATCH_START
+ render(step)
+ scheduleFrame(frameMs)
return
}
- if (now - lastHatch < frameMs) {
+ const remaining = frameMs - (now - lastHatch)
+
+ if (remaining > 0) {
+ scheduleFrame(remaining)
+
return
}
lastHatch = now
- const frame = Math.min(HATCH_START + step, lastFrame)
- render(frame)
+ step = Math.min(step + 1, lastFrame)
+ render(step)
- if (frame >= lastFrame) {
+ if (step >= lastFrame) {
if (!finished) {
finished = true
onDoneRef.current?.()
}
- return // hold the cracked-open last frame
+ return // hold the cracked-open last frame without further work
}
- step += 1
+ scheduleFrame(frameMs)
return
}
@@ -211,11 +234,14 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
if (!nextAt) {
render(0)
nextAt = now + firstDelay // staggered first bounce, per slot
+ scheduleFrame(firstDelay)
return
}
if (now < nextAt) {
+ scheduleFrame(nextAt - now)
+
return
}
@@ -224,6 +250,7 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
step = 0
render(0)
nextAt = now + frameMs
+ scheduleFrame(frameMs)
return
}
@@ -233,19 +260,44 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
if (step >= BOUNCE_FRAMES) {
resting = true
render(0)
- nextAt = now + restMs()
+ const delay = restMs()
+ nextAt = now + delay
+ scheduleFrame(delay)
return
}
render(step)
nextAt = now + frameMs
+ scheduleFrame(frameMs)
}
- raf = requestAnimationFrame(tick)
+ scheduleFrame = (delayMs = 0) => {
+ if (stopped || raf !== null || wakeTimer !== null) {
+ return
+ }
+
+ if (delayMs > 16) {
+ wakeTimer = window.setTimeout(() => {
+ wakeTimer = null
+ scheduleFrame()
+ }, delayMs)
+
+ return
+ }
+
+ raf = window.requestAnimationFrame(tick)
+ }
+
+ void loadSheet().then(img => {
+ sheet = img
+ scheduleFrame()
+ })
return () => {
- cancelAnimationFrame(raf)
+ stopped = true
+ cancelWakeTimer()
+ cancelRaf()
}
}, [mode, size, index])