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; /** * Owns iframe document identities and expires retained, hidden documents. * * Entries retain object identity between snapshots so Solid's `` 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(); #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(); } }