Compare commits
No commits in common. "b6de67f0812f5e6d82ca6a64da91971a3f4c00c0" and "30bff9da37330edb8010fce04bd84b9a2f8d06e8" have entirely different histories.
b6de67f081
...
30bff9da37
3 changed files with 16 additions and 502 deletions
|
|
@ -9,16 +9,11 @@ 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,
|
||||||
runViewportAwareDashboardHydrationQueue,
|
runDashboardHydrationQueue,
|
||||||
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";
|
||||||
|
|
@ -34,6 +29,12 @@ 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";
|
||||||
|
|
@ -54,8 +55,6 @@ 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({
|
||||||
|
|
@ -171,10 +170,8 @@ 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(),
|
||||||
|
|
@ -188,22 +185,12 @@ export default function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRefreshTimer() {
|
function clearRefreshTimer() {
|
||||||
if (refreshTimer !== undefined) {
|
if (refreshTimer) {
|
||||||
window.clearTimeout(refreshTimer);
|
window.clearInterval(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();
|
||||||
|
|
@ -213,7 +200,6 @@ 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 {
|
||||||
|
|
@ -221,7 +207,6 @@ 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"
|
||||||
|
|
@ -262,20 +247,22 @@ 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()) {
|
||||||
lastRefreshIntervalMs = refreshIntervalSeconds * 1000;
|
refreshTimer = window.setInterval(
|
||||||
scheduleNextRefresh(lastRefreshIntervalMs);
|
() => void loadDashboard(),
|
||||||
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -286,23 +273,8 @@ export default function App() {
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
snapshotContext: DashboardTileSnapshotStoreContext,
|
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||||
) {
|
) {
|
||||||
const modelIds = tiles.map(dashboardTileModelId);
|
void runDashboardHydrationQueue({
|
||||||
|
|
||||||
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(
|
||||||
|
|
@ -320,13 +292,6 @@ export default function App() {
|
||||||
},
|
},
|
||||||
items: tiles,
|
items: tiles,
|
||||||
signal,
|
signal,
|
||||||
waitForIdle: () =>
|
|
||||||
waitForDashboardHydrationIdle({
|
|
||||||
...getDashboardIdleCallbacks(),
|
|
||||||
clearTimeout: window.clearTimeout.bind(window),
|
|
||||||
setTimeout: window.setTimeout.bind(window),
|
|
||||||
signal,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -441,53 +406,6 @@ 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" &&
|
||||||
|
|
@ -621,11 +539,6 @@ 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,17 +1,11 @@
|
||||||
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", () => {
|
||||||
|
|
@ -119,134 +113,6 @@ 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();
|
||||||
|
|
||||||
|
|
@ -433,11 +299,3 @@ function createMemoryStorage(): Storage {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelElement(modelId: string) {
|
|
||||||
return {
|
|
||||||
getAttribute(name: string) {
|
|
||||||
return name === "data-model-id" ? modelId : null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -151,263 +151,6 @@ 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