2026-07-30 15:16:02 +08:00
|
|
|
import type { View } from "../pages/registry";
|
2026-07-30 09:43:08 +08:00
|
|
|
|
|
|
|
|
export type FrameEntry = {
|
|
|
|
|
view: View;
|
|
|
|
|
instance: number;
|
|
|
|
|
keepAlive: boolean;
|
2026-07-30 15:16:02 +08:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-07-30 09:43:08 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type FrameCacheOptions = {
|
|
|
|
|
/**
|
2026-07-30 15:16:02 +08:00
|
|
|
* How long a keep-alive document may remain continuously hidden.
|
2026-07-30 09:43:08 +08:00
|
|
|
*/
|
2026-07-30 15:16:02 +08:00
|
|
|
inactiveTtlMs: number;
|
2026-07-30 09:43:08 +08:00
|
|
|
shouldKeepAlive: (view: View) => boolean;
|
2026-07-30 15:16:02 +08:00
|
|
|
/** Injectable monotonic clock for deterministic tests. */
|
|
|
|
|
now?: () => number;
|
2026-07-30 09:43:08 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type FrameTransition = {
|
|
|
|
|
entries: FrameEntry[];
|
|
|
|
|
removed: FrameEntry[];
|
|
|
|
|
activated: FrameEntry;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-30 15:16:02 +08:00
|
|
|
export type FrameExpiration = Omit<FrameTransition, "activated">;
|
|
|
|
|
|
2026-07-30 09:43:08 +08:00
|
|
|
/**
|
2026-07-30 15:16:02 +08:00
|
|
|
* Owns iframe document identities and expires retained, hidden documents.
|
2026-07-30 09:43:08 +08:00
|
|
|
*
|
|
|
|
|
* 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.
|
2026-07-30 15:16:02 +08:00
|
|
|
*
|
|
|
|
|
* 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.
|
2026-07-30 09:43:08 +08:00
|
|
|
*/
|
|
|
|
|
export class FrameCache {
|
|
|
|
|
readonly #options: FrameCacheOptions;
|
|
|
|
|
readonly #entries = new Map<View, FrameEntry>();
|
|
|
|
|
#active: View;
|
|
|
|
|
#nextInstance = 1;
|
|
|
|
|
|
|
|
|
|
constructor(initialView: View, options: FrameCacheOptions) {
|
2026-07-30 15:16:02 +08:00
|
|
|
if (!Number.isFinite(options.inactiveTtlMs) || options.inactiveTtlMs <= 0) {
|
|
|
|
|
throw new RangeError("inactiveTtlMs must be a positive finite number");
|
2026-07-30 09:43:08 +08:00
|
|
|
}
|
|
|
|
|
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);
|
2026-07-30 15:16:02 +08:00
|
|
|
const now = this.#now();
|
2026-07-30 09:43:08 +08:00
|
|
|
|
2026-07-30 15:16:02 +08:00
|
|
|
if (previous && previous.view !== view) {
|
|
|
|
|
if (previous.keepAlive) {
|
|
|
|
|
previous.inactiveSince = now;
|
|
|
|
|
} else {
|
|
|
|
|
this.#entries.delete(previous.view);
|
|
|
|
|
removed.push(previous);
|
|
|
|
|
}
|
2026-07-30 09:43:08 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-30 15:16:02 +08:00
|
|
|
// Prune before activation so revisiting an already-expired page creates a
|
|
|
|
|
// fresh iframe instead of reviving stale document state.
|
|
|
|
|
removed.push(...this.#removeExpired(now));
|
|
|
|
|
|
2026-07-30 09:43:08 +08:00
|
|
|
let activated = this.#entries.get(view);
|
|
|
|
|
if (!activated) {
|
|
|
|
|
activated = this.#create(view);
|
|
|
|
|
this.#entries.set(view, activated);
|
|
|
|
|
}
|
2026-07-30 15:16:02 +08:00
|
|
|
activated.inactiveSince = null;
|
2026-07-30 09:43:08 +08:00
|
|
|
this.#active = view;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
entries: this.snapshot(),
|
|
|
|
|
removed,
|
|
|
|
|
activated,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 15:16:02 +08:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 09:43:08 +08:00
|
|
|
#create(view: View): FrameEntry {
|
|
|
|
|
return {
|
|
|
|
|
view,
|
|
|
|
|
instance: this.#nextInstance++,
|
|
|
|
|
keepAlive: this.#options.shouldKeepAlive(view),
|
2026-07-30 15:16:02 +08:00
|
|
|
inactiveSince: null,
|
2026-07-30 09:43:08 +08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 15:16:02 +08:00
|
|
|
#removeExpired(now: number): FrameEntry[] {
|
|
|
|
|
const removed: FrameEntry[] = [];
|
2026-07-30 09:43:08 +08:00
|
|
|
for (const entry of this.#entries.values()) {
|
2026-07-30 15:16:02 +08:00
|
|
|
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);
|
2026-07-30 09:43:08 +08:00
|
|
|
}
|
2026-07-30 15:16:02 +08:00
|
|
|
return removed;
|
2026-07-30 09:43:08 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-30 15:16:02 +08:00
|
|
|
#now(): number {
|
|
|
|
|
return this.#options.now?.() ?? performance.now();
|
2026-07-30 09:43:08 +08:00
|
|
|
}
|
|
|
|
|
}
|