perf(web): adapt dashboard refresh hydration #49
3 changed files with 502 additions and 16 deletions
|
|
@ -9,11 +9,16 @@ import type {
|
||||||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
|
collectVisibleDashboardModelIds,
|
||||||
|
createDashboardRefreshDelay,
|
||||||
createDashboardTileSnapshotStore,
|
createDashboardTileSnapshotStore,
|
||||||
createDashboardTileBackoff,
|
createDashboardTileBackoff,
|
||||||
createDashboardRequestAborter,
|
createDashboardRequestAborter,
|
||||||
runDashboardHydrationQueue,
|
runViewportAwareDashboardHydrationQueue,
|
||||||
shouldPauseDashboardRefresh,
|
shouldPauseDashboardRefresh,
|
||||||
|
waitForDashboardHydrationIdle,
|
||||||
|
type DashboardIntersectionObserverFactory,
|
||||||
|
type DashboardTileReference,
|
||||||
type DashboardTileSnapshotStoreContext,
|
type DashboardTileSnapshotStoreContext,
|
||||||
type RestoredDashboardTileSnapshot,
|
type RestoredDashboardTileSnapshot,
|
||||||
} from "$lib/client/dashboard-refresh";
|
} from "$lib/client/dashboard-refresh";
|
||||||
|
|
@ -29,12 +34,6 @@ import {
|
||||||
type UiDashboardPreview,
|
type UiDashboardPreview,
|
||||||
} from "@dimensionlab/ui";
|
} 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 =
|
type DashboardTileResponse =
|
||||||
| {
|
| {
|
||||||
state: "ready";
|
state: "ready";
|
||||||
|
|
@ -55,6 +54,8 @@ type DashboardTileResponse =
|
||||||
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
|
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
|
||||||
|
|
||||||
const dashboardTileHydrationConcurrency = 6;
|
const dashboardTileHydrationConcurrency = 6;
|
||||||
|
const dashboardFallbackRefreshIntervalMs = 30_000;
|
||||||
|
const dashboardViewportObservationTimeoutMs = 80;
|
||||||
type DashboardTileSnapshotStore = ReturnType<typeof createDashboardTileSnapshotStore>;
|
type DashboardTileSnapshotStore = ReturnType<typeof createDashboardTileSnapshotStore>;
|
||||||
|
|
||||||
export function AppStateView({
|
export function AppStateView({
|
||||||
|
|
@ -170,8 +171,10 @@ export default function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let refreshTimer: number | undefined;
|
let refreshTimer: number | undefined;
|
||||||
|
let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs;
|
||||||
let hydrationRun = 0;
|
let hydrationRun = 0;
|
||||||
const requestAborter = createDashboardRequestAborter();
|
const requestAborter = createDashboardRequestAborter();
|
||||||
|
const refreshDelay = createDashboardRefreshDelay();
|
||||||
const tileBackoff = createDashboardTileBackoff();
|
const tileBackoff = createDashboardTileBackoff();
|
||||||
const tileSnapshotStore = createDashboardTileSnapshotStore(
|
const tileSnapshotStore = createDashboardTileSnapshotStore(
|
||||||
getTileSnapshotStorage(),
|
getTileSnapshotStorage(),
|
||||||
|
|
@ -185,12 +188,22 @@ export default function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRefreshTimer() {
|
function clearRefreshTimer() {
|
||||||
if (refreshTimer) {
|
if (refreshTimer !== undefined) {
|
||||||
window.clearInterval(refreshTimer);
|
window.clearTimeout(refreshTimer);
|
||||||
refreshTimer = undefined;
|
refreshTimer = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleNextRefresh(baseDelayMs: number) {
|
||||||
|
clearRefreshTimer();
|
||||||
|
if (cancelled || refreshPaused()) return;
|
||||||
|
|
||||||
|
refreshTimer = window.setTimeout(() => {
|
||||||
|
refreshTimer = undefined;
|
||||||
|
void loadDashboard();
|
||||||
|
}, refreshDelay.nextDelayMs(baseDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
function pauseRefreshes() {
|
function pauseRefreshes() {
|
||||||
clearRefreshTimer();
|
clearRefreshTimer();
|
||||||
requestAborter.abortActiveRequests();
|
requestAborter.abortActiveRequests();
|
||||||
|
|
@ -200,6 +213,7 @@ export default function App() {
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
if (cancelled || refreshPaused()) return;
|
if (cancelled || refreshPaused()) return;
|
||||||
|
|
||||||
|
clearRefreshTimer();
|
||||||
const shellSignal = requestAborter.beginShellRun();
|
const shellSignal = requestAborter.beginShellRun();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -207,6 +221,7 @@ export default function App() {
|
||||||
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
||||||
|
|
||||||
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
||||||
|
refreshDelay.recordSuccess();
|
||||||
const restored = restoreDashboardTileSnapshots(
|
const restored = restoreDashboardTileSnapshots(
|
||||||
nextDashboard,
|
nextDashboard,
|
||||||
nextDashboard.state === "ready"
|
nextDashboard.state === "ready"
|
||||||
|
|
@ -247,22 +262,20 @@ export default function App() {
|
||||||
setHydratingItemIds(new Set());
|
setHydratingItemIds(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
clearRefreshTimer();
|
|
||||||
|
|
||||||
const refreshIntervalSeconds =
|
const refreshIntervalSeconds =
|
||||||
nextDashboard.state === "ready"
|
nextDashboard.state === "ready"
|
||||||
? nextDashboard.document.metadata.refreshIntervalSeconds
|
? nextDashboard.document.metadata.refreshIntervalSeconds
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (refreshIntervalSeconds && !refreshPaused()) {
|
if (refreshIntervalSeconds && !refreshPaused()) {
|
||||||
refreshTimer = window.setInterval(
|
lastRefreshIntervalMs = refreshIntervalSeconds * 1000;
|
||||||
() => void loadDashboard(),
|
scheduleNextRefresh(lastRefreshIntervalMs);
|
||||||
refreshIntervalSeconds * 1000,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAbortError(error) && !cancelled) {
|
if (!isAbortError(error) && !cancelled) {
|
||||||
|
refreshDelay.recordFailure();
|
||||||
console.error("Dashboard refresh failed", error);
|
console.error("Dashboard refresh failed", error);
|
||||||
|
scheduleNextRefresh(lastRefreshIntervalMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -273,8 +286,23 @@ export default function App() {
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
snapshotContext: DashboardTileSnapshotStoreContext,
|
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,
|
concurrency: dashboardTileHydrationConcurrency,
|
||||||
|
getModelId: dashboardTileModelId,
|
||||||
hydrate: async (tile) => {
|
hydrate: async (tile) => {
|
||||||
const key = dashboardTileKey(tile);
|
const key = dashboardTileKey(tile);
|
||||||
const result = await hydrateDashboardTile(
|
const result = await hydrateDashboardTile(
|
||||||
|
|
@ -292,6 +320,13 @@ export default function App() {
|
||||||
},
|
},
|
||||||
items: tiles,
|
items: tiles,
|
||||||
signal,
|
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<void> {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
await new Promise<void>((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 {
|
function isAbortError(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
|
|
@ -539,6 +621,11 @@ function dashboardTileKey(tile: DashboardTileReference): string {
|
||||||
return `${tile.kind}:${tile.id}`;
|
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 {
|
function dashboardTileUrl(tile: DashboardTileReference): string {
|
||||||
const parts = tile.kind === "status"
|
const parts = tile.kind === "status"
|
||||||
? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id]
|
? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
|
collectVisibleDashboardModelIds,
|
||||||
|
createDashboardRefreshDelay,
|
||||||
createDashboardTileSnapshotStore,
|
createDashboardTileSnapshotStore,
|
||||||
createDashboardTileBackoff,
|
createDashboardTileBackoff,
|
||||||
createDashboardRequestAborter,
|
createDashboardRequestAborter,
|
||||||
runDashboardHydrationQueue,
|
runDashboardHydrationQueue,
|
||||||
|
runViewportAwareDashboardHydrationQueue,
|
||||||
shouldPauseDashboardRefresh,
|
shouldPauseDashboardRefresh,
|
||||||
|
splitDashboardHydrationItemsByVisibility,
|
||||||
|
waitForDashboardHydrationIdle,
|
||||||
|
type DashboardIntersectionEntry,
|
||||||
} from "./dashboard-refresh";
|
} from "./dashboard-refresh";
|
||||||
|
|
||||||
describe("dashboard refresh lifecycle", () => {
|
describe("dashboard refresh lifecycle", () => {
|
||||||
|
|
@ -113,6 +119,134 @@ describe("dashboard refresh lifecycle", () => {
|
||||||
expect(maxActive).toBe(2);
|
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<void>((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<typeof setTimeout>;
|
||||||
|
},
|
||||||
|
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", () => {
|
test("backs off failed tile keys and resets after success", () => {
|
||||||
const backoff = createDashboardTileBackoff();
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,263 @@ export async function runDashboardHydrationQueue<TItem>(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardViewportHydrationQueueOptions<TItem> {
|
||||||
|
collectVisibleModelIds: () => Promise<ReadonlySet<string>>;
|
||||||
|
concurrency: number;
|
||||||
|
getModelId: (item: TItem) => string;
|
||||||
|
hydrate: (item: TItem) => Promise<void> | void;
|
||||||
|
items: TItem[];
|
||||||
|
signal: AbortSignal;
|
||||||
|
waitForIdle: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
||||||
|
options: DashboardViewportHydrationQueueOptions<TItem>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<TItem>(options: {
|
||||||
|
getModelId: (item: TItem) => string;
|
||||||
|
items: TItem[];
|
||||||
|
visibleModelIds: ReadonlySet<string>;
|
||||||
|
}): {
|
||||||
|
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<DashboardModelElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof setTimeout>;
|
||||||
|
|
||||||
|
export interface DashboardVisibleModelIdCollectorOptions {
|
||||||
|
clearTimeout?: (handle: DashboardTimerHandle) => void;
|
||||||
|
createObserver?: DashboardIntersectionObserverFactory;
|
||||||
|
documentTarget: DashboardViewportElementSource;
|
||||||
|
modelIds: Iterable<string>;
|
||||||
|
setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle;
|
||||||
|
signal: AbortSignal;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectVisibleDashboardModelIds(
|
||||||
|
options: DashboardVisibleModelIdCollectorOptions,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
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<string>();
|
||||||
|
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<void> {
|
||||||
|
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<void>((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];
|
const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000];
|
||||||
|
|
||||||
export function createDashboardTileBackoff() {
|
export function createDashboardTileBackoff() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue