Merge pull request 'perf(web): pause dashboard refresh when inactive' (#44)

Merge PR #44 from codex/dashboard-client-lifecycle
This commit is contained in:
vince 2026-06-20 13:54:12 +02:00
commit 83c2c0f4a4
3 changed files with 257 additions and 31 deletions

View file

@ -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<string>,

View file

@ -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"]);
});
});

View file

@ -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<void>;
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);
};
}