perf(web): pause dashboard refresh when inactive
This commit is contained in:
parent
b0b09eb8ad
commit
74d9aaf2b4
3 changed files with 257 additions and 31 deletions
|
|
@ -7,6 +7,11 @@ import type {
|
||||||
TelemetryCard,
|
TelemetryCard,
|
||||||
} from "@dimensionlab/dashboard-model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
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 { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
||||||
import {
|
import {
|
||||||
DashboardFrame,
|
DashboardFrame,
|
||||||
|
|
@ -151,12 +156,38 @@ export default function App() {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let refreshTimer: number | undefined;
|
let refreshTimer: number | undefined;
|
||||||
let hydrationRun = 0;
|
let hydrationRun = 0;
|
||||||
|
const requestAborter = createDashboardRequestAborter();
|
||||||
|
|
||||||
|
function refreshPaused() {
|
||||||
|
return shouldPauseDashboardRefresh({
|
||||||
|
visibilityState: document.visibilityState,
|
||||||
|
online: navigator.onLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRefreshTimer() {
|
||||||
|
if (refreshTimer) {
|
||||||
|
window.clearInterval(refreshTimer);
|
||||||
|
refreshTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseRefreshes() {
|
||||||
|
clearRefreshTimer();
|
||||||
|
requestAborter.abortActiveRequests();
|
||||||
|
setHydratingItemIds(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
const response = await fetch("/api/dashboard");
|
if (cancelled || refreshPaused()) return;
|
||||||
|
|
||||||
|
const shellSignal = requestAborter.beginShellRun();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/dashboard", { signal: shellSignal });
|
||||||
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
||||||
setDashboard(nextDashboard);
|
setDashboard(nextDashboard);
|
||||||
|
|
||||||
const currentRun = ++hydrationRun;
|
const currentRun = ++hydrationRun;
|
||||||
|
|
@ -165,51 +196,57 @@ export default function App() {
|
||||||
nextDashboard.liveDatasourceHydration?.enabled !== false
|
nextDashboard.liveDatasourceHydration?.enabled !== false
|
||||||
) {
|
) {
|
||||||
const tiles = dashboardHydrationTiles(nextDashboard.document);
|
const tiles = dashboardHydrationTiles(nextDashboard.document);
|
||||||
|
const tileSignal = requestAborter.beginTileRun();
|
||||||
setHydratingItemIds(new Set(tiles.map(dashboardTileKey)));
|
setHydratingItemIds(new Set(tiles.map(dashboardTileKey)));
|
||||||
hydrateDashboardTiles(tiles, currentRun);
|
hydrateDashboardTiles(tiles, currentRun, tileSignal);
|
||||||
} else {
|
} else {
|
||||||
setHydratingItemIds(new Set());
|
setHydratingItemIds(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refreshTimer) {
|
clearRefreshTimer();
|
||||||
window.clearInterval(refreshTimer);
|
|
||||||
refreshTimer = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshIntervalSeconds =
|
const refreshIntervalSeconds =
|
||||||
nextDashboard.state === "ready"
|
nextDashboard.state === "ready"
|
||||||
? nextDashboard.document.metadata.refreshIntervalSeconds
|
? nextDashboard.document.metadata.refreshIntervalSeconds
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (refreshIntervalSeconds) {
|
if (refreshIntervalSeconds && !refreshPaused()) {
|
||||||
refreshTimer = window.setInterval(
|
refreshTimer = window.setInterval(
|
||||||
() => void loadDashboard(),
|
() => void loadDashboard(),
|
||||||
refreshIntervalSeconds * 1000,
|
refreshIntervalSeconds * 1000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAbortError(error) && !cancelled) {
|
||||||
|
console.error("Dashboard refresh failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateDashboardTiles(
|
function hydrateDashboardTiles(
|
||||||
tiles: DashboardTileReference[],
|
tiles: DashboardTileReference[],
|
||||||
run: number,
|
run: number,
|
||||||
|
signal: AbortSignal,
|
||||||
) {
|
) {
|
||||||
tiles.forEach((tile) => {
|
tiles.forEach((tile) => {
|
||||||
void hydrateDashboardTile(tile, run);
|
void hydrateDashboardTile(tile, run, signal);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateDashboardTile(
|
async function hydrateDashboardTile(
|
||||||
tile: DashboardTileReference,
|
tile: DashboardTileReference,
|
||||||
run: number,
|
run: number,
|
||||||
|
signal: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const key = dashboardTileKey(tile);
|
const key = dashboardTileKey(tile);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(dashboardTileUrl(tile));
|
const response = await fetch(dashboardTileUrl(tile), { signal });
|
||||||
const tileResponse = (await response.json()) as DashboardTileResponse;
|
const tileResponse = (await response.json()) as DashboardTileResponse;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
|
signal.aborted ||
|
||||||
run !== hydrationRun ||
|
run !== hydrationRun ||
|
||||||
!response.ok ||
|
!response.ok ||
|
||||||
tileResponse.state !== "ready"
|
tileResponse.state !== "ready"
|
||||||
|
|
@ -226,8 +263,12 @@ export default function App() {
|
||||||
}
|
}
|
||||||
: current,
|
: current,
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAbortError(error) && !cancelled) {
|
||||||
|
console.error("Dashboard tile hydration failed", error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled && run === hydrationRun) {
|
if (!cancelled && !signal.aborted && run === hydrationRun) {
|
||||||
setHydratingItemIds((current) => {
|
setHydratingItemIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
next.delete(key);
|
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();
|
void loadDashboard();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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(
|
function markHydratingItems(
|
||||||
dashboard: UiDashboardPreview,
|
dashboard: UiDashboardPreview,
|
||||||
hydratingItemIds?: ReadonlySet<string>,
|
hydratingItemIds?: ReadonlySet<string>,
|
||||||
|
|
|
||||||
76
apps/web/src/lib/client/dashboard-refresh.test.ts
Normal file
76
apps/web/src/lib/client/dashboard-refresh.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
apps/web/src/lib/client/dashboard-refresh.ts
Normal file
90
apps/web/src/lib/client/dashboard-refresh.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue