Files
wasmeld/crates/wasmeld-console/web/src/host/frame-cache.ts
T

164 lines
4.7 KiB
TypeScript
Raw Normal View History

import type { View } from "../pages/registry";
export type FrameEntry = {
view: View;
instance: number;
keepAlive: boolean;
/**
* Time at which this document became hidden, using the cache clock.
*
* Active and transient documents use `null`. Measuring from deactivation
* prevents a page that has been visible for a long time from expiring as
* soon as the user navigates away.
*/
inactiveSince: number | null;
};
export type FrameCacheOptions = {
/**
* How long a keep-alive document may remain continuously hidden.
*/
inactiveTtlMs: number;
shouldKeepAlive: (view: View) => boolean;
/** Injectable monotonic clock for deterministic tests. */
now?: () => number;
};
export type FrameTransition = {
entries: FrameEntry[];
removed: FrameEntry[];
activated: FrameEntry;
};
export type FrameExpiration = Omit<FrameTransition, "activated">;
/**
* Owns iframe document identities and expires retained, hidden documents.
*
* Entries retain object identity between snapshots so Solid's `<For>` keeps
* the corresponding iframe DOM node alive. `instance` is never reused, which
* makes a recreated document observable in its URL and prevents stale browser
* history or service-worker responses from masquerading as a retained frame.
*
* Primary pages may all opt into retention because their set is finite.
* Transient/detail pages opt out and are removed immediately after navigation.
* Retained pages are released only after a continuous inactive interval, not
* because another primary page happened to be opened.
*/
export class FrameCache {
readonly #options: FrameCacheOptions;
readonly #entries = new Map<View, FrameEntry>();
#active: View;
#nextInstance = 1;
constructor(initialView: View, options: FrameCacheOptions) {
if (!Number.isFinite(options.inactiveTtlMs) || options.inactiveTtlMs <= 0) {
throw new RangeError("inactiveTtlMs must be a positive finite number");
}
this.#options = options;
this.#active = initialView;
this.#entries.set(initialView, this.#create(initialView));
}
get active(): View {
return this.#active;
}
snapshot(): FrameEntry[] {
return [...this.#entries.values()];
}
activate(view: View): FrameTransition {
const removed: FrameEntry[] = [];
const previous = this.#entries.get(this.#active);
const now = this.#now();
if (previous && previous.view !== view) {
if (previous.keepAlive) {
previous.inactiveSince = now;
} else {
this.#entries.delete(previous.view);
removed.push(previous);
}
}
// Prune before activation so revisiting an already-expired page creates a
// fresh iframe instead of reviving stale document state.
removed.push(...this.#removeExpired(now));
let activated = this.#entries.get(view);
if (!activated) {
activated = this.#create(view);
this.#entries.set(view, activated);
}
activated.inactiveSince = null;
this.#active = view;
return {
entries: this.snapshot(),
removed,
activated,
};
}
/**
* Removes retained documents whose continuous hidden interval has elapsed.
*
* Host calls this from a timer and sends `dispose` before applying the
* returned snapshot to the DOM.
*/
expireInactive(): FrameExpiration {
return {
removed: this.#removeExpired(this.#now()),
entries: this.snapshot(),
};
}
/**
* Returns the delay until the next hidden document expires.
*
* `null` means no retained page is currently hidden. Recomputing the delay
* after every navigation and expiration avoids one interval timer per page.
*/
timeUntilExpiration(): number | null {
const now = this.#now();
let delay: number | null = null;
for (const entry of this.#entries.values()) {
if (!entry.keepAlive || entry.inactiveSince === null) continue;
const remaining = Math.max(0, entry.inactiveSince + this.#options.inactiveTtlMs - now);
if (delay === null || remaining < delay) delay = remaining;
}
return delay;
}
#create(view: View): FrameEntry {
return {
view,
instance: this.#nextInstance++,
keepAlive: this.#options.shouldKeepAlive(view),
inactiveSince: null,
};
}
#removeExpired(now: number): FrameEntry[] {
const removed: FrameEntry[] = [];
for (const entry of this.#entries.values()) {
if (
entry.view === this.#active ||
!entry.keepAlive ||
entry.inactiveSince === null ||
now - entry.inactiveSince < this.#options.inactiveTtlMs
) {
continue;
}
this.#entries.delete(entry.view);
removed.push(entry);
}
return removed;
}
#now(): number {
return this.#options.now?.() ?? performance.now();
}
}