Files
fusion/packages/dashboard/app/hooks/useLiveTimeTicker.ts
gsxdsm f26cbedf4f fix(dashboard): close the code-review findings on the mobile tab-discard work
An 11-reviewer pass over f157bf7460..f5163d8351 found defects in the mobile
tab-discard change set itself. This fixes them.

Silent data loss (the recurring defect class):
- AgentDetailView reconnect refetched limit:100 and replaced wholesale, so 380
  displayed lines vanished with no "Load older" and no indicator; it now
  reconciles through the shared logStreamReconcile helper.
- useActivityLog.loadMore past the cap discarded the page it had just fetched
  while advancing the cursor and leaving hasMore true, so the feed silently
  stopped paginating behind a live-looking button.
- useAgentLogs: loadMore and resyncFromServer had no mutual exclusion, a
  no-overlap resync discarded explicitly paged-back history, a resync outliving
  the reconnect delay left an unmarked gap, and the live-tail trim could evict
  the gap marker itself.
- useLiveTranscript's resync overwrote live entries that raced the refetch.

The premise itself was not fully delivered:
- useProjects, useNodes, and useMeshState never called clearInterval, so they
  polled the whole time the tab was hidden. useProjects is mounted for the
  entire session, so the page never went idle -- the primary mechanism this
  work depends on. All three now use the shared visibility gate.
- sse-bus fired onReconnect twice per reconnect cycle and fanned out ~28
  subscribers in one tick, against a ~6-connection-per-origin cap on a waking
  radio. The successful open is now the single authority, and the fan-out uses
  the same exported stagger primitive as the polling path rather than a second
  copy of the slot formula.
- A channel first subscribed during the hidden window opened a live EventSource
  and keepalive; suspension is now a module-level condition openChannel
  consults, and a channel opened inside the grace window re-arms it.

Credentials and correctness:
- The service worker persisted every GET /api/* to durable Cache Storage,
  including /api/settings with daemonToken, githubAuthToken, gitlabAuthToken
  and ntfyAccessToken in plaintext, with no exclusion and no purge path --
  "Clear all cached data" only walked localStorage. Now gated, bounded, and
  genuinely purgeable.
- useTasks cleared its own snapshot when the mount revalidation failed on a
  waking radio, so the board blanked and the next restore was empty too.
  Suspension-class failures no longer destroy the cache.
- A single-row SSE update reset lastFetchTimeMs to now while an hours-old
  hydrated snapshot was on screen, re-marking every in-progress card stuck.
- ListView's "Select all visible tasks" acted on the full filtered set while
  only 50 rows rendered, so a bulk delete reached rows the operator could not
  see. Column's search window reset keyed on a boolean, so refining a query
  kept the expanded window.

Tests that could not fail:
- App.test.tsx mocked TerminalModal as isOpen ? <div/> : null, making the
  unmount-on-close invariant unobservable; MockEventSource kept its listeners
  after close(), so cases passed with their onReconnect handlers deleted.
- The SSE resync ratchet scanned only hooks/, exempting ~13 component call
  sites -- the exact regression it exists to prevent.
- MissionControlPanel's bespoke poll and the xterm scrollback constants and
  WebGL disposal had no coverage at all.

Verified: tsc -p tsconfig.app.json clean, pnpm lint clean, pnpm
check:changesets clean, 877 tests passing across 36 scoped files.
Known unrelated red: MailboxView.test.tsx's FN-8407 CSS guard fails at HEAD
too -- this diff adds no @media rule and no .mailbox-view--mobile selector,
the only two things that assertion inspects. Left alone deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:17:52 -07:00

149 lines
5.8 KiB
TypeScript

import { useEffect, useState } from "react";
import { createVisibilityGatedTimer } from "./visibilitySuspension";
/*
FNXC:BoardPerformance 2026-07-26-09:40:
Mobile browsers (iOS Safari tabs, iOS installed PWAs, Chrome Android) discard a backgrounded page
when it keeps doing work or holds a large resident set; the user then gets a full white-splash reload
on return. A page that never goes idle in the background is one of the strongest discard signals the
OS reads.
Before this module every rendered TaskCard owned its own `window.setInterval` for the live
elapsed-time indicator, so a 60-card board woke the tab 60 times every 30s — including while hidden.
This module collapses that into ONE process-wide ticker with three requirements:
1. Exactly one interval regardless of subscriber count; zero subscribers means NO timer at all.
2. The timer only runs while `document.visibilityState === "visible"`. Going hidden stops it, so a
backgrounded tab is genuinely idle.
3. Becoming visible ticks IMMEDIATELY, so elapsed-time indicators are never stale on return —
losing the background ticks must not cost correctness, only background work.
The ticker is a module singleton rather than a React context on purpose: TaskCard is rendered from
Board/Column, DockTaskList, WorktreeGroup, useRightDockController, and dashboard/MainContent. A
singleton covers every one of those surfaces without a provider mount, so no future TaskCard host can
silently fall back to a per-card timer.
*/
/** Cadence of the live elapsed-time indicators on task cards. */
export const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
type TickListener = () => void;
const listeners = new Set<TickListener>();
let intervalId: number | null = null;
let visibilityListenerBound = false;
function isDocumentVisible(): boolean {
// Non-DOM environments (SSR, plain-node unit tests) are treated as visible so the
// ticker still behaves normally rather than silently never firing.
return typeof document === "undefined" || document.visibilityState === "visible";
}
function notifyListeners(): void {
for (const listener of [...listeners]) {
listener();
}
}
function startInterval(): void {
if (intervalId !== null || typeof window === "undefined") {
return;
}
if (listeners.size === 0 || !isDocumentVisible()) {
return;
}
intervalId = window.setInterval(notifyListeners, LIVE_TIME_INDICATOR_POLL_MS);
}
function stopInterval(): void {
if (intervalId === null || typeof window === "undefined") {
return;
}
window.clearInterval(intervalId);
intervalId = null;
}
/*
FNXC:BoardPerformance 2026-07-26-16:05:
The stop/resume bookkeeping is delegated to `createVisibilityGatedTimer` in `visibilitySuspension.ts`, which
that module documents as the single visibility gate. This ticker previously hand-rolled a second copy of the
same shape. It still cannot call `useVisibilityAwarePoll` — the ticker is a module singleton fanning out to a
subscriber set, not a per-consumer hook — so the non-hook gate is what the two share. Requirement 3 (catch up
on return before re-arming) is the gate's `onResume`; `resumeDelayMs` is left at 0 because this timer issues
no network request, so it is not part of the visible-edge connection stampede the stagger exists to bound.
Behavior note: the gate is a no-op when the interval is already armed, so a redundant `visibilitychange`
fired while the tab is already visible no longer produces an extra tick. The previous code notified on every
such event; that was wasted renders, not a correctness requirement.
*/
const visibilityGate = createVisibilityGatedTimer({
arm: startInterval,
disarm: stopInterval,
isArmed: () => intervalId !== null,
onResume: notifyListeners,
});
function handleVisibilityChange(): void {
visibilityGate.handleVisibilityChange();
}
/**
* Subscribes to the shared live-time ticker. Returns an unsubscribe function.
* The underlying interval and `visibilitychange` listener exist only while at
* least one subscriber is registered.
*/
export function subscribeLiveTimeTicker(listener: TickListener): () => void {
listeners.add(listener);
if (listeners.size === 1 && typeof document !== "undefined" && !visibilityListenerBound) {
document.addEventListener("visibilitychange", handleVisibilityChange);
visibilityListenerBound = true;
}
startInterval();
return () => {
listeners.delete(listener);
if (listeners.size > 0) {
return;
}
// Via the gate so any pending resume is cancelled too, not just the interval.
visibilityGate.stop();
if (visibilityListenerBound && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", handleVisibilityChange);
visibilityListenerBound = false;
}
};
}
/** Test-only introspection: whether the shared interval is currently armed. */
export function isLiveTimeTickerRunning(): boolean {
return intervalId !== null;
}
/** Test-only introspection: current subscriber count. */
export function liveTimeTickerSubscriberCount(): number {
return listeners.size;
}
/**
* Returns a "now" timestamp that advances once per {@link LIVE_TIME_INDICATOR_POLL_MS}
* while the tab is visible.
*
* When `enabled` is false the caller does NOT subscribe (no timer work is attributed to it) and the
* returned value stays frozen at mount time — matching the pre-shared-ticker behavior of cards whose
* column/status made them ineligible for a live indicator.
*/
export function useLiveTimeTicker(enabled: boolean): number {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (!enabled) {
return;
}
// Freshen on (re-)subscribe: the shared ticker's cadence is global, so a card
// mounting mid-interval must not wait up to a full period for its first value.
setNowMs(Date.now());
return subscribeLiveTimeTicker(() => setNowMs(Date.now()));
}, [enabled]);
return nowMs;
}