117 lines
3.1 KiB
TypeScript
117 lines
3.1 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|