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>
149 lines
5.8 KiB
TypeScript
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;
|
|
}
|