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