From a071df31942a096957d33027aa455cf1e5a84197 Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 14:47:53 +0200 Subject: [PATCH] perf(web): adapt dashboard refresh hydration --- apps/web/src/App.tsx | 119 ++++++-- .../src/lib/client/dashboard-refresh.test.ts | 142 ++++++++++ apps/web/src/lib/client/dashboard-refresh.ts | 257 ++++++++++++++++++ 3 files changed, 502 insertions(+), 16 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f8a30e2..16e5c5e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,11 +9,16 @@ import type { import type { DashboardRuntimeState } from "$lib/server/dashboard"; import { attachDashboardRefreshLifecycle, + collectVisibleDashboardModelIds, + createDashboardRefreshDelay, createDashboardTileSnapshotStore, createDashboardTileBackoff, createDashboardRequestAborter, - runDashboardHydrationQueue, + runViewportAwareDashboardHydrationQueue, shouldPauseDashboardRefresh, + waitForDashboardHydrationIdle, + type DashboardIntersectionObserverFactory, + type DashboardTileReference, type DashboardTileSnapshotStoreContext, type RestoredDashboardTileSnapshot, } from "$lib/client/dashboard-refresh"; @@ -29,12 +34,6 @@ import { type UiDashboardPreview, } from "@dimensionlab/ui"; -type DashboardTileReference = - | { kind: "telemetry"; id: string } - | { kind: "service"; groupId: string; id: string } - | { kind: "module"; id: string } - | { kind: "status"; stripId: string; id: string }; - type DashboardTileResponse = | { state: "ready"; @@ -55,6 +54,8 @@ type DashboardTileResponse = type DashboardTileHydrationResult = "aborted" | "failed" | "ready"; const dashboardTileHydrationConcurrency = 6; +const dashboardFallbackRefreshIntervalMs = 30_000; +const dashboardViewportObservationTimeoutMs = 80; type DashboardTileSnapshotStore = ReturnType; export function AppStateView({ @@ -170,8 +171,10 @@ export default function App() { useEffect(() => { let cancelled = false; let refreshTimer: number | undefined; + let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs; let hydrationRun = 0; const requestAborter = createDashboardRequestAborter(); + const refreshDelay = createDashboardRefreshDelay(); const tileBackoff = createDashboardTileBackoff(); const tileSnapshotStore = createDashboardTileSnapshotStore( getTileSnapshotStorage(), @@ -185,12 +188,22 @@ export default function App() { } function clearRefreshTimer() { - if (refreshTimer) { - window.clearInterval(refreshTimer); + if (refreshTimer !== undefined) { + window.clearTimeout(refreshTimer); refreshTimer = undefined; } } + function scheduleNextRefresh(baseDelayMs: number) { + clearRefreshTimer(); + if (cancelled || refreshPaused()) return; + + refreshTimer = window.setTimeout(() => { + refreshTimer = undefined; + void loadDashboard(); + }, refreshDelay.nextDelayMs(baseDelayMs)); + } + function pauseRefreshes() { clearRefreshTimer(); requestAborter.abortActiveRequests(); @@ -200,6 +213,7 @@ export default function App() { async function loadDashboard() { if (cancelled || refreshPaused()) return; + clearRefreshTimer(); const shellSignal = requestAborter.beginShellRun(); try { @@ -207,6 +221,7 @@ export default function App() { const nextDashboard = (await response.json()) as DashboardRuntimeState; if (cancelled || shellSignal.aborted || refreshPaused()) return; + refreshDelay.recordSuccess(); const restored = restoreDashboardTileSnapshots( nextDashboard, nextDashboard.state === "ready" @@ -247,22 +262,20 @@ export default function App() { setHydratingItemIds(new Set()); } - clearRefreshTimer(); - const refreshIntervalSeconds = nextDashboard.state === "ready" ? nextDashboard.document.metadata.refreshIntervalSeconds : undefined; if (refreshIntervalSeconds && !refreshPaused()) { - refreshTimer = window.setInterval( - () => void loadDashboard(), - refreshIntervalSeconds * 1000, - ); + lastRefreshIntervalMs = refreshIntervalSeconds * 1000; + scheduleNextRefresh(lastRefreshIntervalMs); } } catch (error) { if (!isAbortError(error) && !cancelled) { + refreshDelay.recordFailure(); console.error("Dashboard refresh failed", error); + scheduleNextRefresh(lastRefreshIntervalMs); } } } @@ -273,8 +286,23 @@ export default function App() { signal: AbortSignal, snapshotContext: DashboardTileSnapshotStoreContext, ) { - void runDashboardHydrationQueue({ + const modelIds = tiles.map(dashboardTileModelId); + + void runViewportAwareDashboardHydrationQueue({ + collectVisibleModelIds: async () => { + await waitForDashboardRenderFrame(signal); + return collectVisibleDashboardModelIds({ + clearTimeout: window.clearTimeout.bind(window), + createObserver: getDashboardIntersectionObserverFactory(), + documentTarget: document, + modelIds, + setTimeout: window.setTimeout.bind(window), + signal, + timeoutMs: dashboardViewportObservationTimeoutMs, + }); + }, concurrency: dashboardTileHydrationConcurrency, + getModelId: dashboardTileModelId, hydrate: async (tile) => { const key = dashboardTileKey(tile); const result = await hydrateDashboardTile( @@ -292,6 +320,13 @@ export default function App() { }, items: tiles, signal, + waitForIdle: () => + waitForDashboardHydrationIdle({ + ...getDashboardIdleCallbacks(), + clearTimeout: window.clearTimeout.bind(window), + setTimeout: window.setTimeout.bind(window), + signal, + }), }); } @@ -406,6 +441,53 @@ function getTileSnapshotStorage(): Storage | undefined { } } +type DashboardIdleWindow = Window & { + cancelIdleCallback?: (handle: number) => void; + requestIdleCallback?: ( + callback: () => void, + options?: { timeout?: number }, + ) => number; +}; + +function getDashboardIdleCallbacks() { + const idleWindow = window as DashboardIdleWindow; + return { + cancelIdleCallback: idleWindow.cancelIdleCallback?.bind(idleWindow), + requestIdleCallback: idleWindow.requestIdleCallback?.bind(idleWindow), + }; +} + +function getDashboardIntersectionObserverFactory(): + | DashboardIntersectionObserverFactory + | undefined { + if (typeof window.IntersectionObserver === "undefined") return undefined; + + return (callback) => + new window.IntersectionObserver((entries) => { + callback(entries); + }); +} + +async function waitForDashboardRenderFrame(signal: AbortSignal): Promise { + if (signal.aborted) return; + + await new Promise((resolve) => { + let settled = false; + let frame: number | undefined; + + function finish() { + if (settled) return; + settled = true; + if (frame !== undefined) window.cancelAnimationFrame(frame); + signal.removeEventListener("abort", finish); + resolve(); + } + + signal.addEventListener("abort", finish, { once: true }); + frame = window.requestAnimationFrame(finish); + }); +} + function isAbortError(error: unknown): boolean { return ( typeof error === "object" && @@ -539,6 +621,11 @@ function dashboardTileKey(tile: DashboardTileReference): string { return `${tile.kind}:${tile.id}`; } +function dashboardTileModelId(tile: DashboardTileReference): string { + if (tile.kind === "status") return `${tile.stripId}:${tile.id}`; + return tile.id; +} + function dashboardTileUrl(tile: DashboardTileReference): string { const parts = tile.kind === "status" ? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id] diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts index a232560..d94b0c4 100644 --- a/apps/web/src/lib/client/dashboard-refresh.test.ts +++ b/apps/web/src/lib/client/dashboard-refresh.test.ts @@ -1,11 +1,17 @@ import { describe, expect, test } from "vitest"; import { attachDashboardRefreshLifecycle, + collectVisibleDashboardModelIds, + createDashboardRefreshDelay, createDashboardTileSnapshotStore, createDashboardTileBackoff, createDashboardRequestAborter, runDashboardHydrationQueue, + runViewportAwareDashboardHydrationQueue, shouldPauseDashboardRefresh, + splitDashboardHydrationItemsByVisibility, + waitForDashboardHydrationIdle, + type DashboardIntersectionEntry, } from "./dashboard-refresh"; describe("dashboard refresh lifecycle", () => { @@ -113,6 +119,134 @@ describe("dashboard refresh lifecycle", () => { expect(maxActive).toBe(2); }); + test("hydrates visible items before deferred items and waits for idle", async () => { + const calls: string[] = []; + const idleReleases: Array<() => void> = []; + + const hydration = runViewportAwareDashboardHydrationQueue({ + collectVisibleModelIds: async () => new Set(["b"]), + concurrency: 1, + getModelId: (item) => item, + hydrate: async (item) => { + calls.push(item); + }, + items: ["a", "b", "c"], + signal: new AbortController().signal, + waitForIdle: async () => { + calls.push("idle"); + await new Promise((resolve) => idleReleases.push(resolve)); + }, + }); + + await waitFor(() => calls.includes("idle")); + expect(calls).toEqual(["b", "idle"]); + + idleReleases.shift()?.(); + await hydration; + expect(calls).toEqual(["b", "idle", "a", "c"]); + }); + + test("splits visible hydration items while preserving document order", () => { + expect(splitDashboardHydrationItemsByVisibility({ + getModelId: (item) => item.id, + items: [{ id: "status" }, { id: "telemetry" }, { id: "service" }], + visibleModelIds: new Set(["service", "status"]), + })).toEqual({ + visible: [{ id: "status" }, { id: "service" }], + deferred: [{ id: "telemetry" }], + }); + }); + + test("collects visible data-model-id elements with IntersectionObserver", async () => { + const elements = [ + modelElement("status"), + modelElement("telemetry"), + modelElement("unrelated"), + ]; + let callback: + | ((entries: DashboardIntersectionEntry[]) => void) + | undefined; + let finishObservation: (() => void) | undefined; + let disconnected = false; + const observed: string[] = []; + + const visible = collectVisibleDashboardModelIds({ + createObserver: (observerCallback) => { + callback = observerCallback; + return { + disconnect() { + disconnected = true; + }, + observe(element) { + observed.push(element.getAttribute("data-model-id") || ""); + }, + }; + }, + documentTarget: { + querySelectorAll: () => elements, + }, + modelIds: ["status", "telemetry"], + setTimeout: (handler) => { + finishObservation = handler; + return 1 as unknown as ReturnType; + }, + clearTimeout: () => undefined, + signal: new AbortController().signal, + }); + + callback?.([ + { + isIntersecting: false, + intersectionRatio: 0, + target: elements[0], + }, + { + isIntersecting: true, + target: elements[1], + }, + ]); + finishObservation?.(); + + expect(await visible).toEqual(new Set(["telemetry"])); + expect(observed).toEqual(["status", "telemetry"]); + expect(disconnected).toBe(true); + }); + + test("waits for requestIdleCallback when available", async () => { + let idleCallback: (() => void) | undefined; + let cancelledIdle: number | undefined; + const wait = waitForDashboardHydrationIdle({ + cancelIdleCallback: (handle) => { + cancelledIdle = handle; + }, + requestIdleCallback: (callback) => { + idleCallback = callback; + return 7; + }, + signal: new AbortController().signal, + }); + + expect(cancelledIdle).toBeUndefined(); + idleCallback?.(); + await wait; + expect(cancelledIdle).toBe(7); + }); + + test("jitters refresh delays and slows repeated failures", () => { + const delay = createDashboardRefreshDelay({ + jitterRatio: 0.1, + random: () => 1, + }); + + expect(delay.nextDelayMs(1_000)).toBe(1_100); + delay.recordFailure(); + expect(delay.nextDelayMs(1_000)).toBe(2_200); + delay.recordFailure(); + expect(delay.nextDelayMs(1_000)).toBe(4_400); + delay.recordSuccess(); + expect(delay.nextDelayMs(1_000)).toBe(1_100); + }); + test("backs off failed tile keys and resets after success", () => { const backoff = createDashboardTileBackoff(); @@ -299,3 +433,11 @@ function createMemoryStorage(): Storage { }, }; } + +function modelElement(modelId: string) { + return { + getAttribute(name: string) { + return name === "data-model-id" ? modelId : null; + }, + }; +} diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts index aaf3cd3..a77fdd1 100644 --- a/apps/web/src/lib/client/dashboard-refresh.ts +++ b/apps/web/src/lib/client/dashboard-refresh.ts @@ -151,6 +151,263 @@ export async function runDashboardHydrationQueue( ); } +export interface DashboardViewportHydrationQueueOptions { + collectVisibleModelIds: () => Promise>; + concurrency: number; + getModelId: (item: TItem) => string; + hydrate: (item: TItem) => Promise | void; + items: TItem[]; + signal: AbortSignal; + waitForIdle: () => Promise; +} + +export async function runViewportAwareDashboardHydrationQueue( + options: DashboardViewportHydrationQueueOptions, +): Promise { + const visibleModelIds = await options.collectVisibleModelIds(); + if (options.signal.aborted) return; + + const { visible, deferred } = splitDashboardHydrationItemsByVisibility({ + getModelId: options.getModelId, + items: options.items, + visibleModelIds, + }); + + await runDashboardHydrationQueue({ + concurrency: options.concurrency, + hydrate: options.hydrate, + items: visible, + signal: options.signal, + }); + + if (!deferred.length || options.signal.aborted) return; + + await options.waitForIdle(); + if (options.signal.aborted) return; + + await runDashboardHydrationQueue({ + concurrency: options.concurrency, + hydrate: options.hydrate, + items: deferred, + signal: options.signal, + }); +} + +export function splitDashboardHydrationItemsByVisibility(options: { + getModelId: (item: TItem) => string; + items: TItem[]; + visibleModelIds: ReadonlySet; +}): { + deferred: TItem[]; + visible: TItem[]; +} { + const visible: TItem[] = []; + const deferred: TItem[] = []; + + for (const item of options.items) { + if (options.visibleModelIds.has(options.getModelId(item))) { + visible.push(item); + } else { + deferred.push(item); + } + } + + return { visible, deferred }; +} + +export interface DashboardModelElement { + getAttribute(name: string): string | null; +} + +export interface DashboardViewportElementSource { + querySelectorAll(selector: string): ArrayLike; +} + +export interface DashboardIntersectionEntry { + intersectionRatio?: number; + isIntersecting: boolean; + target: DashboardModelElement; +} + +export interface DashboardIntersectionObserver { + disconnect(): void; + observe(element: DashboardModelElement): void; +} + +export type DashboardIntersectionObserverFactory = ( + callback: (entries: DashboardIntersectionEntry[]) => void, +) => DashboardIntersectionObserver; + +type DashboardTimerHandle = ReturnType; + +export interface DashboardVisibleModelIdCollectorOptions { + clearTimeout?: (handle: DashboardTimerHandle) => void; + createObserver?: DashboardIntersectionObserverFactory; + documentTarget: DashboardViewportElementSource; + modelIds: Iterable; + setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle; + signal: AbortSignal; + timeoutMs?: number; +} + +export async function collectVisibleDashboardModelIds( + options: DashboardVisibleModelIdCollectorOptions, +): Promise> { + const targetIds = new Set(options.modelIds); + if (!targetIds.size || options.signal.aborted) return new Set(); + + const elements = Array.from( + options.documentTarget.querySelectorAll("[data-model-id]"), + ).filter((element) => { + const modelId = element.getAttribute("data-model-id"); + return modelId ? targetIds.has(modelId) : false; + }); + + if (!elements.length) return new Set(); + if (!options.createObserver) return targetIds; + const createObserver = options.createObserver; + + const setTimer = + options.setTimeout || + ((callback: () => void, timeoutMs: number) => + globalThis.setTimeout(callback, timeoutMs)); + const clearTimer = + options.clearTimeout || + ((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle)); + const timeoutMs = options.timeoutMs ?? 80; + + return new Promise((resolve) => { + const visibleModelIds = new Set(); + let settled = false; + let timeoutHandle: DashboardTimerHandle | undefined; + let observer: DashboardIntersectionObserver | undefined; + + function cleanup() { + if (timeoutHandle !== undefined) { + clearTimer(timeoutHandle); + } + observer?.disconnect(); + options.signal.removeEventListener("abort", finish); + } + + function finish() { + if (settled) return; + settled = true; + cleanup(); + resolve(visibleModelIds); + } + + observer = createObserver((entries) => { + for (const entry of entries) { + const modelId = entry.target.getAttribute("data-model-id"); + if ( + modelId && + targetIds.has(modelId) && + (entry.isIntersecting || (entry.intersectionRatio ?? 0) > 0) + ) { + visibleModelIds.add(modelId); + } + } + }); + + for (const element of elements) { + observer.observe(element); + } + + options.signal.addEventListener("abort", finish, { once: true }); + timeoutHandle = setTimer(finish, timeoutMs); + }); +} + +export interface DashboardHydrationIdleOptions { + cancelIdleCallback?: (handle: number) => void; + clearTimeout?: (handle: DashboardTimerHandle) => void; + requestIdleCallback?: ( + callback: () => void, + options?: { timeout?: number }, + ) => number; + setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle; + signal: AbortSignal; + timeoutMs?: number; +} + +export async function waitForDashboardHydrationIdle( + options: DashboardHydrationIdleOptions, +): Promise { + if (options.signal.aborted) return; + + const setTimer = + options.setTimeout || + ((callback: () => void, timeoutMs: number) => + globalThis.setTimeout(callback, timeoutMs)); + const clearTimer = + options.clearTimeout || + ((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle)); + + await new Promise((resolve) => { + let settled = false; + let idleHandle: number | undefined; + let timeoutHandle: DashboardTimerHandle | undefined; + + function cleanup() { + if (idleHandle !== undefined) { + options.cancelIdleCallback?.(idleHandle); + } + if (timeoutHandle !== undefined) { + clearTimer(timeoutHandle); + } + options.signal.removeEventListener("abort", finish); + } + + function finish() { + if (settled) return; + settled = true; + cleanup(); + resolve(); + } + + options.signal.addEventListener("abort", finish, { once: true }); + if (options.requestIdleCallback) { + idleHandle = options.requestIdleCallback(finish, { + timeout: options.timeoutMs ?? 1_000, + }); + } else { + timeoutHandle = setTimer(finish, 0); + } + }); +} + +export interface DashboardRefreshDelayOptions { + failureMultiplierLimit?: number; + jitterRatio?: number; + random?: () => number; +} + +export function createDashboardRefreshDelay( + options: DashboardRefreshDelayOptions = {}, +) { + const random = options.random || Math.random; + const jitterRatio = options.jitterRatio ?? 0.1; + const failureMultiplierLimit = options.failureMultiplierLimit ?? 8; + let consecutiveFailures = 0; + + return { + nextDelayMs(baseDelayMs: number): number { + const failureMultiplier = consecutiveFailures + ? Math.min(2 ** consecutiveFailures, failureMultiplierLimit) + : 1; + const jitterFactor = 1 + ((random() * 2) - 1) * jitterRatio; + return Math.max(0, Math.round(baseDelayMs * failureMultiplier * jitterFactor)); + }, + recordFailure(): void { + consecutiveFailures += 1; + }, + recordSuccess(): void { + consecutiveFailures = 0; + }, + }; +} + const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000]; export function createDashboardTileBackoff() {