feat(console-web): add bounded iframe keep-alive

- opt service and settings pages into document retention
- preserve iframe identity and local Solid state across navigation
- cap retained documents with deterministic LRU eviction
- deliver activate, deactivate, and dispose lifecycle messages
- reject commands emitted by inactive page documents
- expose page activity as a Solid accessor
- test retention, recreation, eviction, and invalid limits
This commit is contained in:
Maofeng
2026-07-30 09:43:08 +08:00
parent ccad05fa5a
commit 1b2c50c951
9 changed files with 360 additions and 52 deletions
@@ -0,0 +1,116 @@
import type { View } from "../lib/model";
export type FrameEntry = {
view: View;
instance: number;
keepAlive: boolean;
lastUsed: number;
};
export type FrameCacheOptions = {
/**
* Maximum number of keep-alive documents, including the active document.
*
* A non-keep-alive active document may temporarily exist in addition to this
* limit. The active document is never evicted; inactive entries are removed
* from least to most recently used.
*/
maxKeepAliveEntries: number;
shouldKeepAlive: (view: View) => boolean;
};
export type FrameTransition = {
entries: FrameEntry[];
removed: FrameEntry[];
activated: FrameEntry;
};
/**
* Owns the bounded set of iframe document identities.
*
* 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.
*/
export class FrameCache {
readonly #options: FrameCacheOptions;
readonly #entries = new Map<View, FrameEntry>();
#active: View;
#nextInstance = 1;
#clock = 0;
constructor(initialView: View, options: FrameCacheOptions) {
if (!Number.isInteger(options.maxKeepAliveEntries) || options.maxKeepAliveEntries < 0) {
throw new RangeError("maxKeepAliveEntries must be a non-negative integer");
}
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);
if (previous && previous.view !== view && !previous.keepAlive) {
this.#entries.delete(previous.view);
removed.push(previous);
}
let activated = this.#entries.get(view);
if (!activated) {
activated = this.#create(view);
this.#entries.set(view, activated);
}
activated.lastUsed = ++this.#clock;
this.#active = view;
while (this.#keepAliveCount() > this.#options.maxKeepAliveEntries) {
const candidate = this.#oldestInactiveKeepAlive();
if (!candidate) break;
this.#entries.delete(candidate.view);
removed.push(candidate);
}
return {
entries: this.snapshot(),
removed,
activated,
};
}
#create(view: View): FrameEntry {
return {
view,
instance: this.#nextInstance++,
keepAlive: this.#options.shouldKeepAlive(view),
lastUsed: ++this.#clock,
};
}
#keepAliveCount(): number {
let count = 0;
for (const entry of this.#entries.values()) {
if (entry.keepAlive) count += 1;
}
return count;
}
#oldestInactiveKeepAlive(): FrameEntry | undefined {
let oldest: FrameEntry | undefined;
for (const entry of this.#entries.values()) {
if (!entry.keepAlive || entry.view === this.#active) continue;
if (!oldest || entry.lastUsed < oldest.lastUsed) oldest = entry;
}
return oldest;
}
}