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:
@@ -7,7 +7,7 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run build && node --test tests/build-output.test.mjs",
|
||||
"test": "npm run build && node --experimental-strip-types --test tests/*.test.mjs",
|
||||
"lint": "oxlint . --deny-warnings",
|
||||
"format": "oxfmt .",
|
||||
"format:check": "oxfmt --check ."
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
} from "../lib/api";
|
||||
import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model";
|
||||
import { servicesFrom } from "../lib/view-state";
|
||||
import { disposePage, pageUrl, sendState } from "../sdk/host";
|
||||
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
|
||||
import {
|
||||
isPageMessage,
|
||||
type HostSharedState,
|
||||
@@ -44,26 +44,34 @@ import {
|
||||
type RuntimeAction,
|
||||
} from "../sdk/protocol";
|
||||
import { DialogLayer, type DialogState } from "./dialogs";
|
||||
import { FrameCache, type FrameEntry } from "./frame-cache";
|
||||
|
||||
const NAVIGATION: Array<{
|
||||
id: View;
|
||||
label: string;
|
||||
icon: Component<LucideProps>;
|
||||
keepAlive?: boolean;
|
||||
}> = [
|
||||
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
|
||||
{ id: "services", label: "服务版本", icon: Package },
|
||||
{ id: "services", label: "服务版本", icon: Package, keepAlive: true },
|
||||
{ id: "instances", label: "运行实例", icon: Server },
|
||||
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
|
||||
{ id: "activity", label: "调用记录", icon: History },
|
||||
{ id: "settings", label: "运行设置", icon: Settings },
|
||||
{ id: "settings", label: "运行设置", icon: Settings, keepAlive: true },
|
||||
];
|
||||
const MAX_KEEP_ALIVE_IFRAMES = 3;
|
||||
|
||||
export default function HostApp() {
|
||||
// All backend ownership stays in this persistent document. Page iframes are
|
||||
// disposable renderers and can only request typed commands through the SDK.
|
||||
const [view, setView] = createSignal(initialView());
|
||||
const [frameInstance, setFrameInstance] = createSignal(1);
|
||||
const [frameReady, setFrameReady] = createSignal(false);
|
||||
const initial = initialView();
|
||||
const frameCache = new FrameCache(initial, {
|
||||
maxKeepAliveEntries: MAX_KEEP_ALIVE_IFRAMES,
|
||||
shouldKeepAlive: keepsFrameAlive,
|
||||
});
|
||||
const [view, setView] = createSignal(initial);
|
||||
const [frames, setFrames] = createSignal(frameCache.snapshot());
|
||||
const [readyViews, setReadyViews] = createSignal<ReadonlySet<View>>(new Set<View>());
|
||||
const [snapshot, setSnapshot] = createSignal<BackendSnapshot | null>(null);
|
||||
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
|
||||
const [apiBase, setApiBase] = createSignal(getApiBase());
|
||||
@@ -74,7 +82,7 @@ export default function HostApp() {
|
||||
const [dialog, setDialog] = createSignal<DialogState | null>(null);
|
||||
const [dialogBusy, setDialogBusy] = createSignal(false);
|
||||
const [toast, setToast] = createSignal<string | null>(null);
|
||||
let iframe: HTMLIFrameElement | undefined;
|
||||
const frameElements = new Map<View, HTMLIFrameElement>();
|
||||
let toastTimer: number | undefined;
|
||||
let refreshGeneration = 0;
|
||||
|
||||
@@ -91,11 +99,15 @@ export default function HostApp() {
|
||||
const activeServices = createMemo(
|
||||
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
||||
);
|
||||
const frameSource = createMemo(() => pageUrl(view(), frameInstance()));
|
||||
const frameReady = createMemo(() => readyViews().has(view()));
|
||||
const title = createMemo(
|
||||
() => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览",
|
||||
);
|
||||
|
||||
function stateFor(targetView: View): HostSharedState {
|
||||
return { ...state(), view: targetView };
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const generation = ++refreshGeneration;
|
||||
try {
|
||||
@@ -118,14 +130,29 @@ export default function HostApp() {
|
||||
|
||||
function navigate(next: View, pushHistory = true) {
|
||||
if (next === view()) return;
|
||||
// Give page-owned libraries a synchronous cleanup hook, then change the
|
||||
// keyed URL so Solid removes the old iframe realm in the same transition.
|
||||
const target = iframe?.contentWindow;
|
||||
if (target) disposePage(target);
|
||||
setFrameReady(false);
|
||||
|
||||
const previous = view();
|
||||
const transition = frameCache.activate(next);
|
||||
const removedViews = new Set(transition.removed.map((entry) => entry.view));
|
||||
|
||||
// Retained frames are paused before being hidden. Transient and LRU-evicted
|
||||
// frames receive dispose before Solid removes their document from the DOM.
|
||||
if (!removedViews.has(previous)) sendFrameLifecycle(previous, "deactivate");
|
||||
for (const entry of transition.removed) sendFrameLifecycle(entry.view, "dispose");
|
||||
|
||||
setReadyViews((current) => {
|
||||
const remaining = new Set(current);
|
||||
for (const removed of removedViews) remaining.delete(removed);
|
||||
return remaining;
|
||||
});
|
||||
setFrames(transition.entries);
|
||||
setView(next);
|
||||
setFrameInstance((instance) => instance + 1);
|
||||
setQuery("");
|
||||
|
||||
if (readyViews().has(next)) {
|
||||
sendFrameState(next);
|
||||
sendFrameLifecycle(next, "activate");
|
||||
}
|
||||
if (pushHistory) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("view", next);
|
||||
@@ -222,22 +249,39 @@ export default function HostApp() {
|
||||
}
|
||||
|
||||
const receive = (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== iframe?.contentWindow ||
|
||||
!isPageMessage(event.data) ||
|
||||
event.data.view !== view()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.origin !== window.location.origin || !isPageMessage(event.data)) return;
|
||||
const sourceView = viewForSource(event.source);
|
||||
if (!sourceView || event.data.view !== sourceView) return;
|
||||
|
||||
if (event.data.type === "ready") {
|
||||
setFrameReady(true);
|
||||
if (iframe?.contentWindow) sendState(iframe.contentWindow, state());
|
||||
setReadyViews((current) => new Set(current).add(sourceView));
|
||||
sendFrameState(sourceView);
|
||||
sendFrameLifecycle(sourceView, sourceView === view() ? "activate" : "deactivate");
|
||||
return;
|
||||
}
|
||||
// Hidden documents cannot initiate management operations. This matters for
|
||||
// page-owned timers or libraries that may finish work after deactivation.
|
||||
if (sourceView !== view()) return;
|
||||
void handleCommand(event.data.command);
|
||||
};
|
||||
|
||||
function viewForSource(source: MessageEventSource | null): View | null {
|
||||
for (const [frameView, element] of frameElements) {
|
||||
if (source === element.contentWindow) return frameView;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sendFrameState(targetView: View) {
|
||||
const target = frameElements.get(targetView)?.contentWindow;
|
||||
if (target) sendState(target, stateFor(targetView));
|
||||
}
|
||||
|
||||
function sendFrameLifecycle(targetView: View, phase: "activate" | "deactivate" | "dispose") {
|
||||
const target = frameElements.get(targetView)?.contentWindow;
|
||||
if (target) sendLifecycle(target, phase);
|
||||
}
|
||||
|
||||
const popState = () => {
|
||||
const requested = new URLSearchParams(window.location.search).get("view");
|
||||
navigate(isView(requested) ? requested : "overview", false);
|
||||
@@ -255,11 +299,15 @@ export default function HostApp() {
|
||||
window.removeEventListener("message", receive);
|
||||
window.removeEventListener("popstate", popState);
|
||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const next = state();
|
||||
if (frameReady() && iframe?.contentWindow) sendState(iframe.contentWindow, next);
|
||||
state();
|
||||
const ready = readyViews();
|
||||
for (const frame of frames()) {
|
||||
if (ready.has(frame.view)) sendFrameState(frame.view);
|
||||
}
|
||||
});
|
||||
|
||||
async function register(file: File) {
|
||||
@@ -377,18 +425,19 @@ export default function HostApp() {
|
||||
</header>
|
||||
|
||||
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
|
||||
<Show keyed when={frameSource()}>
|
||||
{(source) => (
|
||||
<iframe
|
||||
ref={(element) => {
|
||||
iframe = element;
|
||||
<For each={frames()}>
|
||||
{(frame) => (
|
||||
<PageFrame
|
||||
frame={frame}
|
||||
active={view() === frame.view}
|
||||
title={titleFor(frame.view)}
|
||||
register={(frameView, element) => frameElements.set(frameView, element)}
|
||||
unregister={(frameView, element) => {
|
||||
if (frameElements.get(frameView) === element) frameElements.delete(frameView);
|
||||
}}
|
||||
class="size-full border-0 bg-canvas"
|
||||
src={source}
|
||||
title={title()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</For>
|
||||
<Show when={!frameReady()}>
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-canvas">
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
@@ -442,3 +491,40 @@ function initialView(): View {
|
||||
const requested = new URLSearchParams(window.location.search).get("view");
|
||||
return isView(requested) ? requested : "overview";
|
||||
}
|
||||
|
||||
function keepsFrameAlive(view: View): boolean {
|
||||
return NAVIGATION.some((item) => item.id === view && item.keepAlive === true);
|
||||
}
|
||||
|
||||
function titleFor(view: View): string {
|
||||
return NAVIGATION.find((item) => item.id === view)?.label ?? view;
|
||||
}
|
||||
|
||||
function PageFrame(props: {
|
||||
frame: FrameEntry;
|
||||
active: boolean;
|
||||
title: string;
|
||||
register: (view: View, element: HTMLIFrameElement) => void;
|
||||
unregister: (view: View, element: HTMLIFrameElement) => void;
|
||||
}) {
|
||||
let element: HTMLIFrameElement | undefined;
|
||||
|
||||
onCleanup(() => {
|
||||
if (element) props.unregister(props.frame.view, element);
|
||||
});
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={(next) => {
|
||||
element = next;
|
||||
props.register(props.frame.view, next);
|
||||
}}
|
||||
class="size-full border-0 bg-canvas"
|
||||
classList={{ hidden: !props.active }}
|
||||
src={pageUrl(props.frame.view, props.frame.instance)}
|
||||
title={props.title}
|
||||
aria-hidden={!props.active}
|
||||
tabIndex={props.active ? 0 : -1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ function FramePage() {
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<Page state={bridge.state} command={bridge.command} />
|
||||
<Page state={bridge.state} active={bridge.active} command={bridge.command} />
|
||||
</Suspense>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -3,5 +3,10 @@ import type { HostSharedState, PageCommand } from "../sdk/protocol";
|
||||
|
||||
export type PageProps = {
|
||||
state: Accessor<HostSharedState | null>;
|
||||
/**
|
||||
* False while a keep-alive iframe is hidden. Page-owned polling, media or
|
||||
* render loops should pause until this accessor becomes true again.
|
||||
*/
|
||||
active: Accessor<boolean>;
|
||||
command: (command: PageCommand) => void;
|
||||
};
|
||||
|
||||
@@ -6,12 +6,13 @@ import { notifyReady, sendCommand } from "../sdk/page";
|
||||
/**
|
||||
* Connects one iframe document to the persistent Host Shell.
|
||||
*
|
||||
* The listener and state belong to the iframe's Solid owner. Navigating or
|
||||
* removing the iframe runs `onCleanup`, so the old document cannot continue to
|
||||
* receive Host snapshots.
|
||||
* A keep-alive document remains mounted while inactive and exposes that state
|
||||
* through `active`. Removing the iframe still runs `onCleanup`, so an evicted
|
||||
* document cannot continue to receive Host snapshots.
|
||||
*/
|
||||
export function createFrameBridge(view: View) {
|
||||
const [state, setState] = createSignal<HostSharedState | null>(null);
|
||||
const [active, setActive] = createSignal(false);
|
||||
|
||||
const receive = (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||
@@ -19,8 +20,15 @@ export function createFrameBridge(view: View) {
|
||||
if (event.data.type === "state" && event.data.state.view === view) {
|
||||
setState(event.data.state);
|
||||
}
|
||||
if (event.data.type === "dispose") {
|
||||
window.dispatchEvent(new Event("wasmeld:dispose"));
|
||||
if (event.data.type === "lifecycle") {
|
||||
const phase = event.data.phase;
|
||||
setActive(phase === "activate");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("wasmeld:lifecycle", {
|
||||
detail: { phase },
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(new Event(`wasmeld:${phase}`));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,6 +41,7 @@ export function createFrameBridge(view: View) {
|
||||
|
||||
return {
|
||||
state,
|
||||
active,
|
||||
command(command: PageCommand) {
|
||||
sendCommand(view, command);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { View } from "../lib/model";
|
||||
import { SDK_CHANNEL, SDK_VERSION, type HostMessage, type HostSharedState } from "./protocol";
|
||||
import {
|
||||
SDK_CHANNEL,
|
||||
SDK_VERSION,
|
||||
type HostMessage,
|
||||
type HostSharedState,
|
||||
type PageLifecyclePhase,
|
||||
} from "./protocol";
|
||||
|
||||
export function sendState(target: Window, state: HostSharedState): void {
|
||||
send(target, {
|
||||
@@ -11,12 +17,13 @@ export function sendState(target: Window, state: HostSharedState): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function disposePage(target: Window): void {
|
||||
export function sendLifecycle(target: Window, phase: PageLifecyclePhase): void {
|
||||
send(target, {
|
||||
channel: SDK_CHANNEL,
|
||||
version: SDK_VERSION,
|
||||
source: "host",
|
||||
type: "dispose",
|
||||
type: "lifecycle",
|
||||
phase,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const SDK_VERSION = 1;
|
||||
|
||||
export type RuntimeAction = "start" | "stop" | "restart";
|
||||
export type ServiceAction = "start" | "stop" | "restart";
|
||||
export type PageLifecyclePhase = "activate" | "deactivate" | "dispose";
|
||||
|
||||
export type HostSharedState = {
|
||||
// This is a serializable snapshot, never a Solid signal crossing realms.
|
||||
@@ -67,7 +68,8 @@ export type HostMessage =
|
||||
channel: typeof SDK_CHANNEL;
|
||||
version: typeof SDK_VERSION;
|
||||
source: "host";
|
||||
type: "dispose";
|
||||
type: "lifecycle";
|
||||
phase: PageLifecyclePhase;
|
||||
};
|
||||
|
||||
export function isPageMessage(value: unknown): value is PageMessage {
|
||||
@@ -82,12 +84,15 @@ export function isPageMessage(value: unknown): value is PageMessage {
|
||||
|
||||
export function isHostMessage(value: unknown): value is HostMessage {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value.channel === SDK_CHANNEL &&
|
||||
value.version === SDK_VERSION &&
|
||||
value.source === "host" &&
|
||||
(value.type === "state" || value.type === "dispose")
|
||||
);
|
||||
if (value.channel !== SDK_CHANNEL || value.version !== SDK_VERSION || value.source !== "host") {
|
||||
return false;
|
||||
}
|
||||
if (value.type === "state") return isRecord(value.state);
|
||||
return value.type === "lifecycle" && isPageLifecyclePhase(value.phase);
|
||||
}
|
||||
|
||||
function isPageLifecyclePhase(value: unknown): value is PageLifecyclePhase {
|
||||
return value === "activate" || value === "deactivate" || value === "dispose";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { FrameCache } from "../src/host/frame-cache.ts";
|
||||
|
||||
test("retains opted-in iframe identities across navigation", () => {
|
||||
const cache = new FrameCache("services", {
|
||||
maxKeepAliveEntries: 2,
|
||||
shouldKeepAlive: (view) => view === "services" || view === "settings",
|
||||
});
|
||||
const services = cache.snapshot()[0];
|
||||
|
||||
const away = cache.activate("overview");
|
||||
assert.deepEqual(
|
||||
away.entries.map((entry) => entry.view),
|
||||
["services", "overview"],
|
||||
);
|
||||
|
||||
const back = cache.activate("services");
|
||||
assert.equal(back.activated, services);
|
||||
assert.equal(back.activated.instance, services.instance);
|
||||
assert.deepEqual(
|
||||
back.removed.map((entry) => entry.view),
|
||||
["overview"],
|
||||
);
|
||||
});
|
||||
|
||||
test("recreates non-keep-alive documents after leaving them", () => {
|
||||
const cache = new FrameCache("overview", {
|
||||
maxKeepAliveEntries: 2,
|
||||
shouldKeepAlive: () => false,
|
||||
});
|
||||
const firstInstance = cache.snapshot()[0].instance;
|
||||
|
||||
cache.activate("activity");
|
||||
const transition = cache.activate("overview");
|
||||
|
||||
assert.notEqual(transition.activated.instance, firstInstance);
|
||||
assert.deepEqual(
|
||||
transition.removed.map((entry) => entry.view),
|
||||
["activity"],
|
||||
);
|
||||
});
|
||||
|
||||
test("evicts inactive keep-alive documents in LRU order", () => {
|
||||
const cache = new FrameCache("services", {
|
||||
maxKeepAliveEntries: 2,
|
||||
shouldKeepAlive: () => true,
|
||||
});
|
||||
const servicesInstance = cache.snapshot()[0].instance;
|
||||
|
||||
cache.activate("settings");
|
||||
const overflow = cache.activate("instances");
|
||||
|
||||
assert.deepEqual(
|
||||
overflow.removed.map((entry) => entry.view),
|
||||
["services"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
overflow.entries.map((entry) => entry.view),
|
||||
["settings", "instances"],
|
||||
);
|
||||
|
||||
const recreated = cache.activate("services");
|
||||
assert.notEqual(recreated.activated.instance, servicesInstance);
|
||||
assert.deepEqual(
|
||||
recreated.removed.map((entry) => entry.view),
|
||||
["settings"],
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid keep-alive limits", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
new FrameCache("overview", {
|
||||
maxKeepAliveEntries: -1,
|
||||
shouldKeepAlive: () => true,
|
||||
}),
|
||||
RangeError,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user