diff --git a/crates/wasmeld-console/web/package.json b/crates/wasmeld-console/web/package.json index e136432..7af48b0 100644 --- a/crates/wasmeld-console/web/package.json +++ b/crates/wasmeld-console/web/package.json @@ -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 ." diff --git a/crates/wasmeld-console/web/src/host/app.tsx b/crates/wasmeld-console/web/src/host/app.tsx index fbf20f7..748f275 100644 --- a/crates/wasmeld-console/web/src/host/app.tsx +++ b/crates/wasmeld-console/web/src/host/app.tsx @@ -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; + 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>(new Set()); const [snapshot, setSnapshot] = createSignal(null); const [connection, setConnection] = createSignal("connecting"); const [apiBase, setApiBase] = createSignal(getApiBase()); @@ -74,7 +82,7 @@ export default function HostApp() { const [dialog, setDialog] = createSignal(null); const [dialogBusy, setDialogBusy] = createSignal(false); const [toast, setToast] = createSignal(null); - let iframe: HTMLIFrameElement | undefined; + const frameElements = new Map(); 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) => { - 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() {
- - {(source) => ( -