From 7a5d534f5befbca0f16fac94a652f878952666d6 Mon Sep 17 00:00:00 2001 From: Ho Lim <166576253+HOYALIM@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:13:09 -0700 Subject: [PATCH] fix(desktop): gate idle renderer loops Signed-off-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com> --- apps/desktop/electron/main.ts | 6 + .../terminal/persistent.test.tsx | 214 ++++++++++++++++++ .../app/right-sidebar/terminal/persistent.tsx | 71 +++++- .../src/components/pet/pet-sprite.test.tsx | 190 ++++++++++++++++ .../desktop/src/components/pet/pet-sprite.tsx | 104 ++++++++- .../src/components/pet/use-pet-roam.test.tsx | 142 ++++++++++++ .../src/components/pet/use-pet-roam.ts | 81 ++++++- apps/desktop/src/global.d.ts | 2 + apps/desktop/src/lib/renderer-loop-pause.ts | 30 +++ 9 files changed, 822 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx create mode 100644 apps/desktop/src/components/pet/pet-sprite.test.tsx create mode 100644 apps/desktop/src/components/pet/use-pet-roam.test.tsx create mode 100644 apps/desktop/src/lib/renderer-loop-pause.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index cf38981f1824..d6fcb69d7f2d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4939,6 +4939,8 @@ function getNativeOverlayWidth() { function getWindowState(win = mainWindow) { return { isFullscreen: Boolean(win?.isFullScreen?.()), + isMinimized: Boolean(win?.isMinimized?.()), + isVisible: Boolean(win?.isVisible?.()), nativeOverlayWidth: getNativeOverlayWidth(), windowButtonPosition: getWindowButtonPosition() } @@ -8968,6 +8970,10 @@ function createWindow() { mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) + mainWindow.on('minimize', () => sendWindowStateChanged()) + mainWindow.on('restore', () => sendWindowStateChanged()) + mainWindow.on('hide', () => sendWindowStateChanged()) + mainWindow.on('show', () => sendWindowStateChanged()) // Reopen where the user left off. resized/moved settle once per drag; close is // the cross-platform backstop, flushed synchronously before the window is gone. diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx new file mode 100644 index 000000000000..04fb3b9cfa79 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx @@ -0,0 +1,214 @@ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { PersistentTerminal, TerminalSlot } from './persistent' + +vi.mock('./terminals', () => ({ + ensureTerminal: vi.fn() +})) + +vi.mock('./workspace', () => ({ + TerminalWorkspace: () =>
+})) + +let resizeObserverCallback: ResizeObserverCallback | null = null +let root: Root | null = null +let container: HTMLDivElement | null = null +let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null + +function render(ui: ReactNode) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + + act(() => { + root!.render(ui) + }) +} + +function cleanup() { + if (root) { + act(() => { + root!.unmount() + }) + } + + container?.remove() + root = null + container = null +} + +function setVisibility(hidden: boolean) { + Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' }) +} + +function installWindowStateBridge() { + windowStateCallback = null + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => { + windowStateCallback = callback + + return () => { + if (windowStateCallback === callback) { + windowStateCallback = null + } + } + }) + } + }) +} + +function rect(top: number, left: number, width: number, height: number): DOMRect { + return { + bottom: top + height, + height, + left, + right: left + width, + top, + width, + x: left, + y: top, + toJSON: () => ({}) + } as DOMRect +} + +function installRaf() { + let nextId = 1 + 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 { + cancel, + pending: () => frames.size, + request, + runNext: () => { + const next = frames.entries().next().value + + if (!next) { + throw new Error('No pending RAF') + } + + const [id, callback] = next + frames.delete(id) + callback(0) + } + } +} + +function Harness() { + return ( + <> + + undefined} /> + + ) +} + +describe('PersistentTerminal rect tracking', () => { + beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + setVisibility(false) + installWindowStateBridge() + resizeObserverCallback = null + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resizeObserverCallback = callback + } + + disconnect = vi.fn() + observe = vi.fn() + unobserve = vi.fn() + } as unknown as typeof ResizeObserver + ) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.restoreAllMocks() + setVisibility(false) + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + it('settles after rect changes instead of polling forever', () => { + const raf = installRaf() + let currentRect = rect(10, 20, 200, 100) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect) + + render() + + expect(raf.request).toHaveBeenCalledTimes(1) + + act(() => { + raf.runNext() + }) + + expect(raf.request).toHaveBeenCalledTimes(1) + expect(raf.pending()).toBe(0) + + currentRect = rect(12, 24, 220, 120) + act(() => { + resizeObserverCallback?.([], {} as ResizeObserver) + }) + + expect(raf.request).toHaveBeenCalledTimes(2) + + act(() => { + raf.runNext() + }) + + expect(raf.request).toHaveBeenCalledTimes(3) + + act(() => { + raf.runNext() + }) + + expect(raf.request).toHaveBeenCalledTimes(3) + expect(raf.pending()).toBe(0) + }) + + it('does not schedule rect RAFs while the Electron window is paused, then resumes when visible', () => { + const raf = installRaf() + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) + + render() + + expect(raf.request).toHaveBeenCalledTimes(1) + + act(() => { + windowStateCallback?.({ isMinimized: true, isVisible: false }) + }) + + expect(raf.cancel).toHaveBeenCalledTimes(1) + expect(raf.pending()).toBe(0) + + act(() => { + resizeObserverCallback?.([], {} as ResizeObserver) + }) + + expect(raf.request).toHaveBeenCalledTimes(1) + + act(() => { + windowStateCallback?.({ isMinimized: false, isVisible: true }) + }) + + expect(raf.request).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx index e0ef66cedd85..4d1421f0be28 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx @@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react' import { atom } from 'nanostores' import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause' + import { $terminalTakeover } from '../store' import { ensureTerminal } from './terminals' @@ -83,8 +85,23 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP let prev: Rect | null = null let frame = 0 + let stopped = false + let pauseController: ReturnType | null = null + + const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden' + + const cancelFrame = () => { + if (frame !== 0) { + window.cancelAnimationFrame(frame) + frame = 0 + } + } + + const measure = (): boolean => { + if (rendererPaused()) { + return false + } - const tick = () => { const r = slot.getBoundingClientRect() // floor top/left + ceil right/bottom: overlay always covers the slot's // full pixel footprint, so half-pixel rects can't leak page bg through. @@ -99,14 +116,60 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP if (next.width > 0 && next.height > 0) { setReady(true) } + + return true } - frame = requestAnimationFrame(tick) + return false } - tick() + const scheduleMeasure = () => { + if (stopped || rendererPaused() || frame !== 0) { + return + } - return () => cancelAnimationFrame(frame) + frame = window.requestAnimationFrame(() => { + frame = 0 + + if (measure()) { + scheduleMeasure() + } + }) + } + + const handleVisibilityChange = () => { + if (rendererPaused()) { + cancelFrame() + + return + } + + scheduleMeasure() + } + + const observer = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => { + scheduleMeasure() + }) + + if (measure()) { + scheduleMeasure() + } + observer?.observe(slot) + window.addEventListener('resize', scheduleMeasure) + window.addEventListener('scroll', scheduleMeasure, true) + pauseController = createRendererLoopPauseController(handleVisibilityChange) + + return () => { + stopped = true + cancelFrame() + observer?.disconnect() + window.removeEventListener('resize', scheduleMeasure) + window.removeEventListener('scroll', scheduleMeasure, true) + pauseController?.dispose() + } }, [slot]) const visible = Boolean(rect && rect.width > 0 && rect.height > 0) diff --git a/apps/desktop/src/components/pet/pet-sprite.test.tsx b/apps/desktop/src/components/pet/pet-sprite.test.tsx new file mode 100644 index 000000000000..bee16e2a9ba2 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-sprite.test.tsx @@ -0,0 +1,190 @@ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/store/pet', () => { + const listeners = new Set<(state: string) => void>() + + return { + $petState: { + get: () => 'idle', + listen: (callback: (state: string) => void) => { + listeners.add(callback) + + return () => { + listeners.delete(callback) + } + } + } + } +}) + +import { PetSprite } from './pet-sprite' + +const INFO = { + enabled: true, + frameH: 16, + frameW: 16, + framesPerState: 2, + loopMs: 120, + scale: 1, + spritesheetBase64: 'stub', + stateRows: ['idle'] +} + +let root: Root | null = null +let container: HTMLDivElement | null = null +let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null + +function render(ui: ReactNode) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + + act(() => { + root!.render(ui) + }) +} + +function cleanup() { + if (root) { + act(() => { + root!.unmount() + }) + } + + container?.remove() + root = null + container = null +} + +function setVisibility(hidden: boolean) { + Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' }) +} + +function installWindowStateBridge() { + windowStateCallback = null + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => { + windowStateCallback = callback + + return () => { + if (windowStateCallback === callback) { + windowStateCallback = null + } + } + }) + } + }) +} + +function installRaf() { + let nextId = 1 + 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 { + cancel, + 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('PetSprite RAF scheduling', () => { + beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + setVisibility(false) + installWindowStateBridge() + vi.stubGlobal( + 'Image', + class extends EventTarget { + complete = true + naturalWidth = 16 + src = '' + } as unknown as typeof Image + ) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + clearRect: vi.fn(), + drawImage: vi.fn(), + imageSmoothingEnabled: false + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + setVisibility(false) + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + it('sleeps between visible sprite frames instead of chaining RAFs', () => { + const raf = installRaf() + + render() + + expect(raf.request).toHaveBeenCalledTimes(1) + + act(() => { + raf.runNext(0) + }) + + expect(raf.request).toHaveBeenCalledTimes(1) + expect(raf.pending()).toBe(0) + expect(vi.getTimerCount()).toBe(1) + + act(() => { + vi.advanceTimersByTime(60) + }) + + expect(raf.request).toHaveBeenCalledTimes(2) + }) + + it('cancels pending RAF work while the Electron window is paused and resumes when visible', () => { + const raf = installRaf() + + render() + + expect(raf.request).toHaveBeenCalledTimes(1) + + act(() => { + windowStateCallback?.({ isMinimized: true, isVisible: false }) + }) + + expect(raf.cancel).toHaveBeenCalledTimes(1) + expect(raf.pending()).toBe(0) + + act(() => { + windowStateCallback?.({ isMinimized: false, isVisible: true }) + }) + + expect(raf.request).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx index a1e91f3ece10..bb00b54bf6d4 100644 --- a/apps/desktop/src/components/pet/pet-sprite.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -1,5 +1,6 @@ import { memo, useEffect, useMemo, useRef } from 'react' +import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause' import { $petState, type PetInfo, type PetState } from '@/store/pet' const DEFAULT_FRAME_W = 192 @@ -119,16 +120,19 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite const stateRef = useRef($petState.get()) const overrideRef = useRef(stateOverride) const rowOverrideRef = useRef(rowOverride) + const kickAnimationRef = useRef<() => void>(() => undefined) // Keep the override current without re-running the RAF setup effect. // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { overrideRef.current = stateOverride + kickAnimationRef.current() }, [stateOverride]) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { rowOverrideRef.current = rowOverride + kickAnimationRef.current() }, [rowOverride]) const frameW = info.frameW ?? DEFAULT_FRAME_W @@ -173,17 +177,75 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite // Track state via subscription, not a prop — no re-render on activity ticks. stateRef.current = $petState.get() - const unsubState = $petState.listen(next => { - stateRef.current = next - }) - let raf = 0 + let wakeTimer = 0 + let stopped = false let frame = 0 let lastStep = performance.now() let drawnFrame = -1 let drawnRow = -1 let activeRow = -1 let activeCount = -1 + let pauseController: ReturnType | null = null + + const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden' + + const cancelWakeTimer = () => { + if (wakeTimer !== 0) { + window.clearTimeout(wakeTimer) + wakeTimer = 0 + } + } + + const cancelRaf = () => { + if (raf !== 0) { + window.cancelAnimationFrame(raf) + raf = 0 + } + } + + const clearScheduled = () => { + cancelWakeTimer() + cancelRaf() + } + + const scheduleFrame = (delayMs = 0) => { + if (stopped || rendererPaused() || raf !== 0 || wakeTimer !== 0) { + return + } + + if (delayMs > 16) { + wakeTimer = window.setTimeout(() => { + wakeTimer = 0 + scheduleFrame() + }, delayMs) + + return + } + + raf = window.requestAnimationFrame(render) + } + + const kickAnimation = () => { + if (stopped || rendererPaused()) { + return + } + + cancelWakeTimer() + scheduleFrame() + } + + const handleVisibilityChange = () => { + clearScheduled() + + if (rendererPaused()) { + return + } + + lastStep = performance.now() + drawnFrame = -1 + kickAnimation() + } const rowIndexForState = (s: PetState): number => { for (const key of STATE_ALIASES[s] ?? [s]) { @@ -223,6 +285,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite } const render = (now: number) => { + raf = 0 + + if (stopped || rendererPaused()) { + return + } + const forcedRow = rowOverrideRef.current const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current) @@ -245,10 +313,13 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite frame %= count + if (!image.complete || image.naturalWidth <= 0) { + return + } + // Only touch the canvas when the visible cell actually changes. The RAF - // ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of - // the clear+draw work and keeps the main thread free. - if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) { + // wakes when a sprite cell is due, so the idle path avoids a 60Hz loop. + if (frame !== drawnFrame || row !== drawnRow) { const sx = frame * frameW const sy = row * frameH ctx.clearRect(0, 0, canvas.width, canvas.height) @@ -258,13 +329,26 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite drawnRow = row } - raf = requestAnimationFrame(render) + scheduleFrame(Math.max(0, stepMs - (now - lastStep))) } - raf = requestAnimationFrame(render) + kickAnimationRef.current = kickAnimation + + const unsubState = $petState.listen(next => { + stateRef.current = next + kickAnimation() + }) + + image.addEventListener('load', kickAnimation) + pauseController = createRendererLoopPauseController(handleVisibilityChange) + scheduleFrame() return () => { - cancelAnimationFrame(raf) + stopped = true + kickAnimationRef.current = () => undefined + clearScheduled() + image.removeEventListener('load', kickAnimation) + pauseController?.dispose() unsubState() } }, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows]) diff --git a/apps/desktop/src/components/pet/use-pet-roam.test.tsx b/apps/desktop/src/components/pet/use-pet-roam.test.tsx new file mode 100644 index 000000000000..007a1ba22c93 --- /dev/null +++ b/apps/desktop/src/components/pet/use-pet-roam.test.tsx @@ -0,0 +1,142 @@ +import { act, type ReactNode, type RefObject, useRef } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/store/pet', () => ({ + $petMotion: { set: () => undefined }, + $petRoamDir: { set: () => undefined } +})) + +import { usePetRoam } from './use-pet-roam' + +let root: Root | null = null +let container: HTMLDivElement | null = null +let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null + +function render(ui: ReactNode) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + + act(() => { + root!.render(ui) + }) +} + +function cleanup() { + if (root) { + act(() => { + root!.unmount() + }) + } + + container?.remove() + root = null + container = null +} + +function setVisibility(hidden: boolean) { + Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' }) +} + +function installWindowStateBridge() { + windowStateCallback = null + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => { + windowStateCallback = callback + + return () => { + if (windowStateCallback === callback) { + windowStateCallback = null + } + } + }) + } + }) +} + +function installRaf() { + const request = vi.fn((_callback: FrameRequestCallback) => 1) + const cancel = vi.fn() + + Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request }) + Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel }) + + return { cancel, request } +} + +function RoamHarness({ isInteracting = () => false }: { isInteracting?: () => boolean }) { + const ref = useRef(null) + + usePetRoam({ + commit: () => undefined, + containerRef: ref as RefObject, + enabled: true, + isInteracting, + loopMs: 1200, + overlayOpen: false, + petH: 64, + petW: 64 + }) + + return
+} + +describe('usePetRoam RAF scheduling', () => { + beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + setVisibility(false) + installWindowStateBridge() + vi.spyOn(Math, 'random').mockReturnValue(0) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + bottom: 164, + height: 64, + left: 100, + right: 164, + top: 100, + width: 64, + x: 100, + y: 100, + toJSON: () => ({}) + } as DOMRect) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + setVisibility(false) + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + it('uses a pause timer, not RAF, while dwelling at idle', () => { + const raf = installRaf() + + render() + + expect(raf.request).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + }) + + it('clears the pause wakeup while the Electron window is paused and restarts it when visible', () => { + const raf = installRaf() + + render() + expect(vi.getTimerCount()).toBe(1) + + windowStateCallback?.({ isMinimized: true, isVisible: false }) + + expect(raf.cancel).not.toHaveBeenCalled() + expect(raf.request).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + + windowStateCallback?.({ isMinimized: false, isVisible: true }) + + expect(raf.request).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + }) +}) diff --git a/apps/desktop/src/components/pet/use-pet-roam.ts b/apps/desktop/src/components/pet/use-pet-roam.ts index 84d7b5386afb..e4d1e9a801b1 100644 --- a/apps/desktop/src/components/pet/use-pet-roam.ts +++ b/apps/desktop/src/components/pet/use-pet-roam.ts @@ -1,5 +1,6 @@ import { type RefObject, useEffect } from 'react' +import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause' import { $petMotion, $petRoamDir, type PetState } from '@/store/pet' import { chooseMove, dwellMs, PAUSE_DWELL, pickStrollTarget } from './roam-behavior' @@ -33,6 +34,8 @@ const DROP_SETTLE_MS = 90 const ARRIVE_EPS = 1.5 // Cap dt so a backgrounded/throttled tab can't teleport the pet on resume. const MAX_DT_S = 0.05 +// While paused, wake rarely to notice drags/replans without burning a 60Hz RAF. +const PAUSE_POLL_MS = 250 type Phase = 'pause' | 'walk' | 'fall' | 'jump' @@ -114,6 +117,9 @@ export function usePetRoam({ let pauseUntil = performance.now() + rand(400, 1200) let last = performance.now() let raf = 0 + let pauseTimer = 0 + let stopped = false + let pauseController: ReturnType | null = null let walkTargetX = cur.x let curLedge: Ledge | null = null @@ -137,6 +143,61 @@ export function usePetRoam({ $petRoamDir.set(dir) } + const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden' + + const cancelRaf = () => { + if (raf !== 0) { + window.cancelAnimationFrame(raf) + raf = 0 + } + } + + const cancelPauseTimer = () => { + if (pauseTimer !== 0) { + window.clearTimeout(pauseTimer) + pauseTimer = 0 + } + } + + const clearScheduled = () => { + cancelRaf() + cancelPauseTimer() + } + + const schedule = (now = performance.now()) => { + if (stopped || rendererPaused() || raf !== 0 || pauseTimer !== 0) { + return + } + + if (phase === 'pause') { + const delay = Math.max(0, pauseUntil - now) + + if (delay > 0) { + pauseTimer = window.setTimeout(() => { + pauseTimer = 0 + step(performance.now()) + }, Math.min(delay, PAUSE_POLL_MS)) + + return + } + } + + raf = window.requestAnimationFrame(step) + } + + const handleVisibilityChange = () => { + clearScheduled() + last = performance.now() + + if (rendererPaused()) { + signal(null, 0) + + return + } + + schedule(last) + } + const beginPause = (now: number) => { phase = 'pause' pauseUntil = now + dwellMs(PAUSE_DWELL) @@ -209,6 +270,15 @@ export function usePetRoam({ } const step = (now: number) => { + raf = 0 + pauseTimer = 0 + + if (stopped || rendererPaused()) { + signal(null, 0) + + return + } + const dt = Math.min(MAX_DT_S, (now - last) / 1000) last = now @@ -223,7 +293,7 @@ export function usePetRoam({ // Short settle so the pet falls right after you drop it, not seconds later. pauseUntil = now + DROP_SETTLE_MS signal(null, 0) - raf = requestAnimationFrame(step) + schedule(now) return } @@ -300,13 +370,16 @@ export function usePetRoam({ } } - raf = requestAnimationFrame(step) + schedule(now) } - raf = requestAnimationFrame(step) + pauseController = createRendererLoopPauseController(handleVisibilityChange) + schedule() return () => { - cancelAnimationFrame(raf) + stopped = true + clearScheduled() + pauseController?.dispose() signal(null, 0) // Hand the final position back to React so its `style` matches the DOM once // the loop stops re-asserting it. diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c3405e404a2a..debae77f457b 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -465,6 +465,8 @@ export interface HermesTitleBarTheme { export interface HermesWindowState { isFullscreen: boolean + isMinimized?: boolean + isVisible?: boolean nativeOverlayWidth: number windowButtonPosition: { x: number; y: number } | null } diff --git a/apps/desktop/src/lib/renderer-loop-pause.ts b/apps/desktop/src/lib/renderer-loop-pause.ts new file mode 100644 index 000000000000..3175069216e2 --- /dev/null +++ b/apps/desktop/src/lib/renderer-loop-pause.ts @@ -0,0 +1,30 @@ +interface WindowStatePayload { + isMinimized?: boolean + isVisible?: boolean +} + +export function createRendererLoopPauseController(onChange: () => void) { + let windowPaused = false + + const onVisibilityChange = () => onChange() + const offWindowState = window.hermesDesktop?.onWindowStateChanged?.((payload: WindowStatePayload) => { + const next = payload?.isMinimized === true || payload?.isVisible === false + + if (windowPaused === next) { + return + } + + windowPaused = next + onChange() + }) + + document.addEventListener('visibilitychange', onVisibilityChange) + + return { + dispose: () => { + document.removeEventListener('visibilitychange', onVisibilityChange) + offWindowState?.() + }, + isPaused: () => document.visibilityState === 'hidden' || windowPaused + } +}