diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 206b74f..c886391 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,11 @@ import type { TelemetryCard, } from "@dimensionlab/dashboard-model"; import type { DashboardRuntimeState } from "$lib/server/dashboard"; +import { + attachDashboardRefreshLifecycle, + createDashboardRequestAborter, + shouldPauseDashboardRefresh, +} from "$lib/client/dashboard-refresh"; import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer"; import { DashboardFrame, @@ -151,65 +156,97 @@ export default function App() { let cancelled = false; let refreshTimer: number | undefined; let hydrationRun = 0; + const requestAborter = createDashboardRequestAborter(); - async function loadDashboard() { - const response = await fetch("/api/dashboard"); - const nextDashboard = (await response.json()) as DashboardRuntimeState; - - if (cancelled) return; - setDashboard(nextDashboard); - - const currentRun = ++hydrationRun; - if ( - nextDashboard.state === "ready" && - nextDashboard.liveDatasourceHydration?.enabled !== false - ) { - const tiles = dashboardHydrationTiles(nextDashboard.document); - setHydratingItemIds(new Set(tiles.map(dashboardTileKey))); - hydrateDashboardTiles(tiles, currentRun); - } else { - setHydratingItemIds(new Set()); - } + function refreshPaused() { + return shouldPauseDashboardRefresh({ + visibilityState: document.visibilityState, + online: navigator.onLine, + }); + } + function clearRefreshTimer() { if (refreshTimer) { window.clearInterval(refreshTimer); refreshTimer = undefined; } + } - const refreshIntervalSeconds = - nextDashboard.state === "ready" - ? nextDashboard.document.metadata.refreshIntervalSeconds - : undefined; + function pauseRefreshes() { + clearRefreshTimer(); + requestAborter.abortActiveRequests(); + setHydratingItemIds(new Set()); + } - if (refreshIntervalSeconds) { - refreshTimer = window.setInterval( - () => void loadDashboard(), - refreshIntervalSeconds * 1000, - ); + async function loadDashboard() { + if (cancelled || refreshPaused()) return; + + const shellSignal = requestAborter.beginShellRun(); + + try { + const response = await fetch("/api/dashboard", { signal: shellSignal }); + const nextDashboard = (await response.json()) as DashboardRuntimeState; + + if (cancelled || shellSignal.aborted || refreshPaused()) return; + setDashboard(nextDashboard); + + const currentRun = ++hydrationRun; + if ( + nextDashboard.state === "ready" && + nextDashboard.liveDatasourceHydration?.enabled !== false + ) { + const tiles = dashboardHydrationTiles(nextDashboard.document); + const tileSignal = requestAborter.beginTileRun(); + setHydratingItemIds(new Set(tiles.map(dashboardTileKey))); + hydrateDashboardTiles(tiles, currentRun, tileSignal); + } else { + setHydratingItemIds(new Set()); + } + + clearRefreshTimer(); + + const refreshIntervalSeconds = + nextDashboard.state === "ready" + ? nextDashboard.document.metadata.refreshIntervalSeconds + : undefined; + + if (refreshIntervalSeconds && !refreshPaused()) { + refreshTimer = window.setInterval( + () => void loadDashboard(), + refreshIntervalSeconds * 1000, + ); + } + } catch (error) { + if (!isAbortError(error) && !cancelled) { + console.error("Dashboard refresh failed", error); + } } } function hydrateDashboardTiles( tiles: DashboardTileReference[], run: number, + signal: AbortSignal, ) { tiles.forEach((tile) => { - void hydrateDashboardTile(tile, run); + void hydrateDashboardTile(tile, run, signal); }); } async function hydrateDashboardTile( tile: DashboardTileReference, run: number, + signal: AbortSignal, ) { const key = dashboardTileKey(tile); try { - const response = await fetch(dashboardTileUrl(tile)); + const response = await fetch(dashboardTileUrl(tile), { signal }); const tileResponse = (await response.json()) as DashboardTileResponse; if ( cancelled || + signal.aborted || run !== hydrationRun || !response.ok || tileResponse.state !== "ready" @@ -226,8 +263,12 @@ export default function App() { } : current, ); + } catch (error) { + if (!isAbortError(error) && !cancelled) { + console.error("Dashboard tile hydration failed", error); + } } finally { - if (!cancelled && run === hydrationRun) { + if (!cancelled && !signal.aborted && run === hydrationRun) { setHydratingItemIds((current) => { const next = new Set(current); next.delete(key); @@ -237,11 +278,21 @@ export default function App() { } } + const detachRefreshLifecycle = attachDashboardRefreshLifecycle({ + documentTarget: document, + windowTarget: window, + loadDashboard, + pauseRefreshes, + refreshPaused: () => cancelled || refreshPaused(), + }); + void loadDashboard(); return () => { cancelled = true; - if (refreshTimer) window.clearInterval(refreshTimer); + clearRefreshTimer(); + requestAborter.abortActiveRequests(); + detachRefreshLifecycle(); }; }, []); @@ -275,6 +326,15 @@ function getThemeStorage(): Storage | undefined { } } +function isAbortError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError" + ); +} + function markHydratingItems( dashboard: UiDashboardPreview, hydratingItemIds?: ReadonlySet, diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts new file mode 100644 index 0000000..75305d5 --- /dev/null +++ b/apps/web/src/lib/client/dashboard-refresh.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; +import { + attachDashboardRefreshLifecycle, + createDashboardRequestAborter, + shouldPauseDashboardRefresh, +} from "./dashboard-refresh"; + +describe("dashboard refresh lifecycle", () => { + test("pauses refreshes when the document is hidden or the browser is offline", () => { + expect( + shouldPauseDashboardRefresh({ visibilityState: "visible", online: true }), + ).toBe(false); + expect( + shouldPauseDashboardRefresh({ visibilityState: "hidden", online: true }), + ).toBe(true); + expect( + shouldPauseDashboardRefresh({ visibilityState: "visible", online: false }), + ).toBe(true); + }); + + test("aborts shell and tile requests when a new shell run starts", () => { + const aborter = createDashboardRequestAborter(); + const shellSignal = aborter.beginShellRun(); + const tileSignal = aborter.beginTileRun(); + + const nextShellSignal = aborter.beginShellRun(); + + expect(shellSignal.aborted).toBe(true); + expect(tileSignal.aborted).toBe(true); + expect(nextShellSignal.aborted).toBe(false); + }); + + test("aborts active requests when refreshes are paused", () => { + const aborter = createDashboardRequestAborter(); + const shellSignal = aborter.beginShellRun(); + const tileSignal = aborter.beginTileRun(); + + aborter.abortActiveRequests(); + + expect(shellSignal.aborted).toBe(true); + expect(tileSignal.aborted).toBe(true); + }); + + test("pauses on hidden, offline, and pagehide events then resumes immediately when visible", () => { + const documentTarget = new EventTarget(); + const windowTarget = new EventTarget(); + const calls: string[] = []; + let paused = true; + + const detach = attachDashboardRefreshLifecycle({ + documentTarget, + windowTarget, + loadDashboard: () => { + calls.push("load"); + }, + pauseRefreshes: () => { + calls.push("pause"); + }, + refreshPaused: () => paused, + }); + + documentTarget.dispatchEvent(new Event("visibilitychange")); + windowTarget.dispatchEvent(new Event("offline")); + windowTarget.dispatchEvent(new Event("pagehide")); + + paused = false; + documentTarget.dispatchEvent(new Event("visibilitychange")); + windowTarget.dispatchEvent(new Event("online")); + + detach(); + documentTarget.dispatchEvent(new Event("visibilitychange")); + windowTarget.dispatchEvent(new Event("online")); + + expect(calls).toEqual(["pause", "pause", "pause", "load", "load"]); + }); +}); diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts new file mode 100644 index 0000000..1124b27 --- /dev/null +++ b/apps/web/src/lib/client/dashboard-refresh.ts @@ -0,0 +1,90 @@ +export interface DashboardRefreshPauseState { + visibilityState: DocumentVisibilityState; + online: boolean; +} + +export function shouldPauseDashboardRefresh( + state: DashboardRefreshPauseState, +): boolean { + return state.visibilityState !== "visible" || !state.online; +} + +export function createDashboardRequestAborter() { + let shellController: AbortController | undefined; + let tileController: AbortController | undefined; + + function abortController(controller: AbortController | undefined) { + if (controller && !controller.signal.aborted) { + controller.abort(); + } + } + + return { + beginShellRun(): AbortSignal { + abortController(shellController); + abortController(tileController); + shellController = new AbortController(); + tileController = undefined; + return shellController.signal; + }, + beginTileRun(): AbortSignal { + abortController(tileController); + tileController = new AbortController(); + return tileController.signal; + }, + abortActiveRequests(): void { + abortController(shellController); + abortController(tileController); + shellController = undefined; + tileController = undefined; + }, + }; +} + +type DashboardRefreshEventTarget = Pick< + EventTarget, + "addEventListener" | "removeEventListener" +>; + +export interface DashboardRefreshLifecycleOptions { + documentTarget: DashboardRefreshEventTarget; + windowTarget: DashboardRefreshEventTarget; + loadDashboard: () => void | Promise; + pauseRefreshes: () => void; + refreshPaused: () => boolean; +} + +export function attachDashboardRefreshLifecycle( + options: DashboardRefreshLifecycleOptions, +): () => void { + function handleRefreshLifecycleChange() { + if (options.refreshPaused()) { + options.pauseRefreshes(); + return; + } + + void options.loadDashboard(); + } + + function handlePageHide() { + options.pauseRefreshes(); + } + + options.documentTarget.addEventListener( + "visibilitychange", + handleRefreshLifecycleChange, + ); + options.windowTarget.addEventListener("online", handleRefreshLifecycleChange); + options.windowTarget.addEventListener("offline", handleRefreshLifecycleChange); + options.windowTarget.addEventListener("pagehide", handlePageHide); + + return () => { + options.documentTarget.removeEventListener( + "visibilitychange", + handleRefreshLifecycleChange, + ); + options.windowTarget.removeEventListener("online", handleRefreshLifecycleChange); + options.windowTarget.removeEventListener("offline", handleRefreshLifecycleChange); + options.windowTarget.removeEventListener("pagehide", handlePageHide); + }; +}