Files
wasmeld/crates/wasmeld-console/web/src/host/frame-cache.ts
T
Maofeng 9ff3223c44 refactor(console-web): centralize page lifecycle and UI composition
Declare navigation, page metadata, lazy loaders, and keep-alive policy in one registry. Retain primary iframe pages until a continuous inactive TTL expires and expose typed show, hide, and unload events through the Page SDK.

Move business-aware components out of the UI primitive directory, migrate views and Host dialogs to variant primitives and local Tailwind classes, and remove the global component style layer. Keep native dialogs inside the overlay flex context so they remain centered.

Cover iframe expiration, lifecycle dispatch, UI dependency direction, and global style ownership with focused tests.
2026-07-30 15:16:02 +08:00

164 lines
4.7 KiB
TypeScript

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();
}
}