perf(web): adapt dashboard refresh hydration
This commit is contained in:
parent
30bff9da37
commit
a071df3194
3 changed files with 502 additions and 16 deletions
|
|
@ -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];
|
||||
|
||||
export function createDashboardTileBackoff() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue