fix(desktop): gate idle renderer loops

Signed-off-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
This commit is contained in:
Ho Lim 2026-07-08 12:13:09 -07:00 committed by Brooklyn Nicholson
parent f8a554bced
commit 7a5d534f5b
9 changed files with 822 additions and 18 deletions

View file

@ -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.

View file

@ -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: () => <div data-testid="terminal-workspace" />
}))
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<number, FrameRequestCallback>()
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 (
<>
<TerminalSlot className="slot" />
<PersistentTerminal onAddSelectionToChat={() => 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(<Harness />)
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(<Harness />)
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)
})
})

View file

@ -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<typeof createRendererLoopPauseController> | 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)

View file

@ -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<number, FrameRequestCallback>()
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(<PetSprite info={INFO} />)
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(<PetSprite info={INFO} />)
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)
})
})

View file

@ -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>($petState.get())
const overrideRef = useRef<PetState | undefined>(stateOverride)
const rowOverrideRef = useRef<string | undefined>(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<typeof createRendererLoopPauseController> | 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])

View file

@ -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<HTMLDivElement | null>(null)
usePetRoam({
commit: () => undefined,
containerRef: ref as RefObject<HTMLDivElement | null>,
enabled: true,
isInteracting,
loopMs: 1200,
overlayOpen: false,
petH: 64,
petW: 64
})
return <div ref={ref} />
}
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(<RoamHarness />)
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(<RoamHarness />)
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)
})
})

View file

@ -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<typeof createRendererLoopPauseController> | 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.

View file

@ -465,6 +465,8 @@ export interface HermesTitleBarTheme {
export interface HermesWindowState {
isFullscreen: boolean
isMinimized?: boolean
isVisible?: boolean
nativeOverlayWidth: number
windowButtonPosition: { x: number; y: number } | null
}

View file

@ -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
}
}