perf(renderer): stop six timers from ticking behind a hidden window (#18134)

* perf(renderer): stop six timers from ticking behind a hidden window

IntensiveWakeUpThrottling is disabled in this app, so a renderer interval
really does fire at full rate with the window hidden. Six of them had
nothing to observe them:

- NativeChatWorkingStatus ran a 1s interval + setState per in-flight turn
  purely to advance an elapsed-seconds counter. Deleted the effect and
  derived elapsed during render from the shared, visibility-gated
  useNow(1_000) clock, so N turns collapse onto one tick.
- The chromium-error fallback poll (250ms) kept probing a stuck-loading
  guest to write a loadError nobody could see.
- The contextual-tour full-pass interval (500ms) woke twice a second to
  queue a rAF a hidden window never paints.
- Three feature-wall animation timers (3600/2400/2400ms) kept committing
  React renders for animations nobody was watching.
- The landing preflight poll (30s) kept forcing IPC refreshes.

All five gated timers reuse installWindowVisibilityInterval. Each either
resumes where it left off (animations) or re-derives from durable state on
the becoming-visible run, so hiding and re-showing is observationally
identical to never hiding.

* test(git): stop two empty commits in the divergence fixture from hashing alike

`counts drift in both directions` builds 100 empty commits, resets to the fork
point, then adds one more — expecting 100 ahead + 1 behind to clear the cap of
100. An empty commit's hash covers only parent, tree, message and a
one-second-granularity timestamp, and every commit in the fixture reuses
`commit ${index}` starting from 0. On a runner fast enough to finish the whole
build inside one wall-clock second (CI: 1059ms for the case, ~7ms per commit),
the post-reset `commit 0` hashed identically to the first `commit 0` of the
chain, so Git handed back that same object and left the branch 99/0 apart
instead of 100/1 — `within`, not `exceeded`.

Numbering the empty commits across calls makes the fixture build the 101
distinct commits it already claimed to. Reproduced deterministically by pinning
GIT_AUTHOR_DATE/GIT_COMMITTER_DATE, which forces the timestamp collision the
fast runner hits by chance: fails with the exact CI assertion before, passes
after.
This commit is contained in:
Neil
2026-09-02 14:35:48 -07:00
committed by GitHub
parent 6d8dc0c97a
commit 02a417c04b
13 changed files with 534 additions and 49 deletions
@@ -32,9 +32,17 @@ async function createRepo(): Promise<string> {
return repoPath
}
// Why unique across calls: an empty commit's hash covers only parent, tree, message and a
// one-second-granularity timestamp. On a fast runner the whole 100-commit build finishes inside
// one second, so a post-reset `commit 0` off the same fork point hashed identically to the first
// `commit 0` of the chain and Git handed back that same object — leaving the branch 99/0 apart
// instead of 100/1.
let emptyCommitSequence = 0
function commitEmpty(repoPath: string, count: number): void {
for (let index = 0; index < count; index += 1) {
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`])
emptyCommitSequence += 1
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${emptyCommitSequence}`])
}
}
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserTabPageState } from '../describe-page/browser-page-types'
import { useBrowserPageWebviewUrlSync } from './use-browser-page-webview-url-sync'
const CHROMIUM_ERROR_URL = 'chrome-error://chromewebdata/'
function setDocumentVisibility(state: 'visible' | 'hidden'): void {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
}
function renderUrlSync(guestUrl: () => string): {
updates: [string, BrowserTabPageState][]
unmount: () => void
} {
const updates: [string, BrowserTabPageState][] = []
const webview = { getURL: () => guestUrl(), src: '' } as unknown as Electron.WebviewTag
const view = renderHook(() =>
useBrowserPageWebviewUrlSync({
browserTabId: 'tab-1',
browserTabUrl: 'https://example.test/slow',
browserTabLoading: true,
isActive: true,
isPaintable: true,
slotViewport: null,
webviewRef: { current: webview },
chromeHeaderRef: { current: null },
lastKnownWebviewUrlRef: { current: 'https://example.test/slow' },
trackNextLoadingEventRef: { current: false },
keepAddressBarFocusRef: { current: false },
addressBarInputRef: { current: null },
browserTabUrlRef: { current: 'https://example.test/slow' },
addressBarValueRef: { current: 'https://example.test/slow' },
onUpdatePageStateRef: {
current: (tabId, patch) => {
updates.push([tabId, patch])
}
},
focusWebviewNow: () => false
})
)
return { updates, unmount: view.unmount }
}
beforeEach(() => {
vi.useFakeTimers()
setDocumentVisibility('visible')
})
afterEach(() => {
cleanup()
setDocumentVisibility('visible')
vi.useRealTimers()
})
describe('chromium error page poll visibility gate', () => {
it('stops the 250ms poll while hidden and re-detects the error page on return', () => {
let guestUrl = 'https://example.test/slow'
const { updates, unmount } = renderUrlSync(() => guestUrl)
// Visible and still loading: the fallback poll is armed.
expect(vi.getTimerCount()).toBe(1)
act(() => vi.advanceTimersByTime(1_000))
expect(updates).toHaveLength(0)
setDocumentVisibility('hidden')
expect(vi.getTimerCount()).toBe(0)
// The guest lands on a chrome-error page while nobody can see the surface.
guestUrl = CHROMIUM_ERROR_URL
act(() => vi.advanceTimersByTime(10_000))
expect(updates).toHaveLength(0)
// Returning re-reads the durable guest URL, so the loadError is not lost.
setDocumentVisibility('visible')
expect(updates).toHaveLength(1)
expect(updates[0]?.[1].loadError?.validatedUrl).toBe('https://example.test/slow')
expect(updates[0]?.[1].loading).toBe(false)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
it('polls unchanged while the window stays visible', () => {
let guestUrl = 'https://example.test/slow'
const { updates } = renderUrlSync(() => guestUrl)
guestUrl = CHROMIUM_ERROR_URL
act(() => vi.advanceTimersByTime(250))
expect(updates).toHaveLength(1)
})
})
@@ -9,6 +9,7 @@ import {
applyBrowserPageViewportLayout,
syncBrowserPageChromeInset
} from '../host-guest/browser-page-viewport'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { shouldPollChromiumErrorPage } from './chromium-error-page-polling'
import { isChromiumErrorPage } from '../describe-page/browser-page-url-display'
import type { BrowserTabPageState } from '../describe-page/browser-page-types'
@@ -152,9 +153,11 @@ export function useBrowserPageWebviewUrlSync({
}
// Why: some Electron builds paint chrome-error pages without a did-fail-load event; poll only while the active tab loads as a fallback.
detectChromiumErrorPage()
const intervalId = window.setInterval(detectChromiumErrorPage, 250)
return () => window.clearInterval(intervalId)
// Why gated: a page stuck loading would otherwise poll 4x/sec forever behind a hidden window. The guest URL is durable state, so the becoming-visible run re-derives anything a hidden window skipped.
return installWindowVisibilityInterval({
run: detectChromiumErrorPage,
intervalMs: 250
})
}, [
addressBarValueRef,
browserTabId,
@@ -24,6 +24,7 @@ import {
handleContextualTourOverlayKeyDown,
type ActiveTourRenderState
} from './ContextualTourOverlaySurface'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split'
import { performContextualTourStepAction } from './contextual-tour-step-actions'
import { openWorkspaceCreationComposerWithTourHandoff } from './workspace-creation-tour-handoff'
@@ -228,14 +229,20 @@ export function ContextualTourOverlay(): JSX.Element | null {
const scheduleFullMeasure = (): void => scheduleMeasure(true)
window.addEventListener('resize', scheduleFullMeasure)
window.addEventListener('scroll', scheduleTargetMeasure, true)
const interval = window.setInterval(scheduleFullMeasure, 500)
// Why gated: a hidden window paints no frames, so the queued rAF never runs
// and the pass is pure wakeup. The becoming-visible run re-queues it, and
// the layout effect measures on every render, so nothing is missed.
const stopFullPassInterval = installWindowVisibilityInterval({
run: scheduleFullMeasure,
intervalMs: 500
})
return () => {
if (frame !== null) {
window.cancelAnimationFrame(frame)
}
window.removeEventListener('resize', scheduleFullMeasure)
window.removeEventListener('scroll', scheduleTargetMeasure, true)
window.clearInterval(interval)
stopFullPassInterval()
}
}, [activeTourId, measureTourOverlay])
@@ -0,0 +1,106 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ContextualTourOverlay } from './ContextualTourOverlay'
import { useAppStore } from '@/store'
let container: HTMLDivElement
let root: Root
function tourTarget(name: string, top: number): { moveTo: (top: number) => void } {
let currentTop = top
const element = document.createElement('div')
element.setAttribute('data-contextual-tour-target', name)
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => ({
left: 100,
right: 220,
top: currentTop,
bottom: currentTop + 40,
width: 120,
height: 40,
x: 100,
y: currentTop
})
})
document.body.appendChild(element)
return {
moveTo: (next) => {
currentTop = next
}
}
}
async function settle(ms: number): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, ms))
})
}
async function setDocumentVisibility(state: 'visible' | 'hidden'): Promise<void> {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'))
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
function ringsTop(): string | undefined {
return container.querySelector<HTMLElement>('[data-contextual-tour-target-rings]')?.style.top
}
beforeEach(async () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
;(window as unknown as { api: unknown }).api = { ui: { set: () => Promise.resolve() } }
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 960 })
await setDocumentVisibility('visible')
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
act(() => root.unmount())
container.remove()
document.querySelectorAll('[data-contextual-tour-target]').forEach((node) => node.remove())
await setDocumentVisibility('visible')
useAppStore.setState({ activeContextualTourId: null, activeContextualTourStepIndex: 0 })
})
describe('ContextualTourOverlay full-pass interval visibility gate', () => {
it('pauses the 500ms full pass while hidden and re-measures on return', async () => {
const target = tourTarget('workspace-create-control', 300)
useAppStore.setState({
activeContextualTourId: 'workspace-agent-sessions',
activeContextualTourStepIndex: 1,
activeModal: 'none',
contextualToursOnboardingVisible: false,
contextualToursBlockingSurfaceVisible: false,
activeContextualTourSuppressed: false
})
await act(async () => {
root.render(<ContextualTourOverlay />)
await new Promise((resolve) => setTimeout(resolve, 50))
})
expect(ringsTop()).toBe('300px')
// Baseline: while visible, the periodic full pass follows a silent move.
target.moveTo(640)
await settle(700)
expect(ringsTop()).toBe('640px')
await setDocumentVisibility('hidden')
target.moveTo(900)
await settle(1_500)
expect(ringsTop()).toBe('640px')
// Returning runs the pass immediately, so the overlay is never left stale.
await setDocumentVisibility('visible')
await settle(50)
expect(ringsTop()).toBe('900px')
})
})
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import type { JSX } from 'react'
import { AgentStateDot } from '@/components/AgentStateDot'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { ClaudeIcon, OpenCodeGoIcon } from '../status-bar/icons'
type AgentKind = 'claude' | 'codex' | 'opencode'
@@ -87,18 +88,23 @@ export function WorkspacesAnimatedVisual(props: { reducedMotion: boolean }): JSX
if (reducedMotion) {
return
}
const id = window.setInterval(() => {
setVisualState((current) => {
const next = current.order.slice()
const finishing = next.pop()
if (!finishing) {
return current
}
next.unshift(finishing)
return { order: next, promotedWorkspaceId: finishing.id }
})
}, STEP_MS)
return () => window.clearInterval(id)
// Why: nobody watches an animation in a hidden window. `runOnVisible` is a
// no-op so revealing the window resumes the cycle instead of skipping a card.
return installWindowVisibilityInterval({
run: () => {
setVisualState((current) => {
const next = current.order.slice()
const finishing = next.pop()
if (!finishing) {
return current
}
next.unshift(finishing)
return { order: next, promotedWorkspaceId: finishing.id }
})
},
runOnVisible: () => {},
intervalMs: STEP_MS
})
}, [reducedMotion])
// Why: reduced-motion mode should display the static stack without a
// post-render repair; only the animated interval needs promoted z-order.
@@ -5,6 +5,7 @@ import { Wrench } from 'lucide-react'
import { AgentStateDot } from '@/components/AgentStateDot'
import { getAgentCatalog, AgentIcon, type AgentCatalogEntry } from '@/lib/agent-catalog'
import { ClaudeIcon, OpenAIIcon } from '../../status-bar/icons'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -48,18 +49,25 @@ export function StatusesPage(props: { active: boolean; reducedMotion: boolean })
schedule(() => setRevealed((r) => ({ ...r, codex: true })), 1900)
let idx = 0
const cycleId = window.setInterval(() => {
setClaudeFading(true)
const swap = window.setTimeout(() => {
idx = (idx + 1) % CLAUDE_ACTIVITIES.length
setClaudeIdx(idx)
setClaudeFading(false)
}, 280)
timeouts.push(swap)
}, 2400)
// Why: nobody watches an animation in a hidden window. `runOnVisible` is a
// no-op so revealing the window resumes the cycle instead of skipping an
// activity; `idx` lives outside the timer, so the reveal picks up where it left off.
const stopCycle = installWindowVisibilityInterval({
run: () => {
setClaudeFading(true)
const swap = window.setTimeout(() => {
idx = (idx + 1) % CLAUDE_ACTIVITIES.length
setClaudeIdx(idx)
setClaudeFading(false)
}, 280)
timeouts.push(swap)
},
runOnVisible: () => {},
intervalMs: 2400
})
return () => {
timeouts.forEach((id) => window.clearTimeout(id))
window.clearInterval(cycleId)
stopCycle()
}
}, [active, reducedMotion])
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { StatusesPage } from './agents-orchestration/StatusesPage'
import { useWorkbenchTerminalStoryboard } from './use-workbench-terminal-storyboard'
import { WorkspacesAnimatedVisual } from './WorkspacesAnimatedVisual'
let container: HTMLDivElement
let root: Root
function setDocumentVisibility(state: 'visible' | 'hidden'): void {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
}
function cardOrder(): string[] {
return Array.from(container.querySelectorAll<HTMLElement>('[data-ws-id]'))
.map((node) => ({
id: node.dataset.wsId ?? '',
top: Number.parseFloat(node.style.transform.replace(/[^\d.-]/g, '')) || 0
}))
.sort((left, right) => left.top - right.top)
.map((card) => card.id)
}
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
setDocumentVisibility('visible')
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
setDocumentVisibility('visible')
vi.useRealTimers()
})
describe('feature wall animation timers', () => {
it('pauses the workspaces card rotation while hidden and resumes without skipping', () => {
act(() =>
root.render(
<TooltipProvider>
<WorkspacesAnimatedVisual reducedMotion={false} />
</TooltipProvider>
)
)
const initialOrder = cardOrder()
expect(vi.getTimerCount()).toBe(1)
act(() => vi.advanceTimersByTime(3_600))
const afterOneStep = cardOrder()
expect(afterOneStep).not.toEqual(initialOrder)
setDocumentVisibility('hidden')
expect(vi.getTimerCount()).toBe(0)
act(() => vi.advanceTimersByTime(3_600 * 10))
expect(cardOrder()).toEqual(afterOneStep)
// Revealing resumes the cycle rather than jumping a card forward.
setDocumentVisibility('visible')
expect(cardOrder()).toEqual(afterOneStep)
expect(vi.getTimerCount()).toBe(1)
act(() => vi.advanceTimersByTime(3_600))
expect(cardOrder()).not.toEqual(afterOneStep)
})
it('pauses the workbench run queue while hidden and resumes from the same entry', () => {
const view = renderHook(() => useWorkbenchTerminalStoryboard('tour', false))
const first = view.result.current.running
act(() => vi.advanceTimersByTime(2_400))
const second = view.result.current.running
expect(second).not.toBe(first)
setDocumentVisibility('hidden')
act(() => vi.advanceTimersByTime(2_400 * 10))
expect(view.result.current.running).toBe(second)
setDocumentVisibility('visible')
expect(view.result.current.running).toBe(second)
act(() => vi.advanceTimersByTime(2_400))
expect(view.result.current.running).not.toBe(second)
view.unmount()
})
it('pauses the agent-status activity cycle while hidden and resumes in place', () => {
act(() =>
root.render(
<TooltipProvider>
<StatusesPage active reducedMotion={false} />
</TooltipProvider>
)
)
act(() => vi.advanceTimersByTime(2_400 + 280))
const afterOneCycle = container.textContent ?? ''
setDocumentVisibility('hidden')
act(() => vi.advanceTimersByTime((2_400 + 280) * 10))
expect(container.textContent).toBe(afterOneCycle)
setDocumentVisibility('visible')
expect(container.textContent).toBe(afterOneCycle)
act(() => vi.advanceTimersByTime(2_400 + 280))
expect(container.textContent).not.toBe(afterOneCycle)
})
})
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import {
WORKBENCH_RUN_QUEUE,
WORKBENCH_RUN_TICK_MS,
@@ -38,10 +39,13 @@ export function useWorkbenchTerminalStoryboard(
if (reducedMotion || isTwoAgentsChecklist) {
return
}
const id = window.setInterval(() => {
setRunIdx((index) => (index + 1) % WORKBENCH_RUN_QUEUE.length)
}, WORKBENCH_RUN_TICK_MS)
return () => window.clearInterval(id)
// Why: nobody watches an animation in a hidden window. `runOnVisible` is a
// no-op so revealing the window resumes the queue instead of skipping an entry.
return installWindowVisibilityInterval({
run: () => setRunIdx((index) => (index + 1) % WORKBENCH_RUN_QUEUE.length),
runOnVisible: () => {},
intervalMs: WORKBENCH_RUN_TICK_MS
})
}, [isTwoAgentsChecklist, reducedMotion])
useEffect(() => {
@@ -15,6 +15,11 @@ const status = (overrides: Partial<PreflightStatus> = {}): PreflightStatus => ({
...overrides
})
function setDocumentVisibility(state: 'visible' | 'hidden'): void {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
document.dispatchEvent(new Event('visibilitychange'))
}
const githubRepo: Repo = {
id: 'github',
path: '/repos/github',
@@ -27,6 +32,7 @@ const githubRepo: Repo = {
beforeEach(() => {
vi.useFakeTimers()
setDocumentVisibility('visible')
refresh.mockClear()
invalidate.mockClear()
useAppStore.setState(useAppStore.getInitialState(), true)
@@ -35,6 +41,7 @@ beforeEach(() => {
afterEach(() => {
cleanup()
setDocumentVisibility('visible')
vi.useRealTimers()
useAppStore.setState(useAppStore.getInitialState(), true)
})
@@ -108,6 +115,29 @@ describe('landing preflight runtime boundary', () => {
view.unmount()
})
it('stops the 30s preflight poll while hidden and lets the reveal refresh instead', () => {
useAppStore.setState({ repos: [githubRepo], preflightStatus: status() })
const view = renderHook(() => useLandingPreflightRuntime())
refresh.mockClear()
expect(vi.getTimerCount()).toBe(1)
act(() => setDocumentVisibility('hidden'))
expect(vi.getTimerCount()).toBe(0)
// Five poll windows pass behind a hidden window with no IPC at all.
act(() => vi.advanceTimersByTime(150_000))
expect(refresh).not.toHaveBeenCalled()
// The sibling visibilitychange handler force-refreshes on reveal, so the
// banner is current the moment it can be seen; the poll re-arms behind it.
act(() => setDocumentVisibility('visible'))
expect(refresh).toHaveBeenCalledTimes(1)
expect(refresh).toHaveBeenCalledWith({ force: true })
expect(vi.getTimerCount()).toBe(1)
view.unmount()
})
it('keeps one active interval and removes listeners and polling on cleanup', () => {
useAppStore.setState({ repos: [githubRepo], preflightStatus: status() })
const addEventListener = vi.spyOn(document, 'addEventListener')
@@ -1,5 +1,6 @@
import { useEffect, useMemo } from 'react'
import { useAppStore } from '../store'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import {
getLandingPreflightIssues,
hasGitHubBackedProject,
@@ -60,10 +61,16 @@ export function useLandingPreflightRuntime(): { preflightIssues: PreflightIssue[
if (preflightIssues.length === 0) {
return
}
const intervalId = window.setInterval(() => {
void refreshPreflightStatus({ force: true })
}, 30000)
return () => window.clearInterval(intervalId)
// Why gated: the effect above already force-refreshes on visibilitychange
// and focus, so a revealed window has fresh data without this poll firing
// while hidden — hence the no-op `runOnVisible`.
return installWindowVisibilityInterval({
run: () => {
void refreshPreflightStatus({ force: true })
},
runOnVisible: () => {},
intervalMs: 30000
})
}, [preflightIssues.length, refreshPreflightStatus])
return { preflightIssues }
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { useNow } from '@/hooks/use-now'
export function NativeChatWorkingStatus({
startedAt,
@@ -15,18 +16,17 @@ export function NativeChatWorkingStatus({
expanded?: boolean
onToggleExpanded?: () => void
}): React.JSX.Element {
const [elapsedSeconds, setElapsedSeconds] = useState(0)
useEffect(() => {
if (thinking || workedSeconds != null) {
return
}
const epoch = startedAt ?? Date.now()
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - epoch) / 1000)))
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - epoch) / 1000)))
const timer = window.setInterval(update, 1000)
return () => window.clearInterval(timer)
}, [startedAt, thinking, workedSeconds])
// Why: elapsed seconds is ordinary render dataflow, not an external system.
// The shared 1s clock is visibility-gated and collapses every in-flight turn
// onto one tick, instead of one interval plus one commit per turn.
const counting = !thinking && workedSeconds == null
const now = useNow(1_000, counting)
// Why: preserves the old effect's `startedAt ?? Date.now()` epoch for the
// single frame before the turn's startedAt lands.
const [mountedAt] = useState(() => Date.now())
const elapsedSeconds = counting
? Math.max(0, Math.floor((now - (startedAt ?? mountedAt)) / 1000))
: 0
const label =
workedSeconds != null
@@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NativeChatWorkingStatus } from './NativeChatWorkingStatus'
let container: HTMLDivElement
let root: Root
function setDocumentVisibility(state: 'visible' | 'hidden'): void {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
}
function renderTurns(count: number, startedAt: number): void {
act(() => {
root.render(
<>
{Array.from({ length: count }, (_, index) => (
<NativeChatWorkingStatus key={index} startedAt={startedAt} thinking={false} />
))}
</>
)
})
}
function elapsedLabels(): string[] {
return Array.from(container.querySelectorAll('[aria-label]'), (node) => node.textContent ?? '')
}
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
setDocumentVisibility('visible')
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
setDocumentVisibility('visible')
vi.useRealTimers()
})
describe('native chat working status elapsed clock', () => {
it('collapses every in-flight turn onto one shared visibility-gated timer', () => {
renderTurns(3, 1_000_000)
// One shared 1s clock for all three turns, not one interval per turn.
expect(vi.getTimerCount()).toBe(1)
act(() => vi.advanceTimersByTime(3_000))
expect(elapsedLabels()).toEqual([
'Working for 3 seconds',
'Working for 3 seconds',
'Working for 3 seconds'
])
})
it('stops ticking while hidden and re-syncs the elapsed value on return', () => {
renderTurns(1, 1_000_000)
act(() => vi.advanceTimersByTime(3_000))
expect(elapsedLabels()).toEqual(['Working for 3 seconds'])
setDocumentVisibility('hidden')
expect(vi.getTimerCount()).toBe(0)
// A minute of hidden wall-clock: no callbacks, no commits, label frozen.
act(() => vi.advanceTimersByTime(60_000))
expect(elapsedLabels()).toEqual(['Working for 3 seconds'])
// Returning re-derives elapsed from startedAt, so nothing was lost.
setDocumentVisibility('visible')
expect(elapsedLabels()).toEqual(['Working for 63 seconds'])
expect(vi.getTimerCount()).toBe(1)
})
it('holds no timer for a thinking turn or a completed turn', () => {
act(() => {
root.render(
<>
<NativeChatWorkingStatus startedAt={1_000_000} thinking />
<NativeChatWorkingStatus startedAt={1_000_000} thinking={false} workedSeconds={12} />
</>
)
})
expect(vi.getTimerCount()).toBe(0)
})
})