refactor(console-web): centralize page lifecycle and UI composition
Declare navigation, page metadata, lazy loaders, and keep-alive policy in one registry. Retain primary iframe pages until a continuous inactive TTL expires and expose typed show, hide, and unload events through the Page SDK. Move business-aware components out of the UI primitive directory, migrate views and Host dialogs to variant primitives and local Tailwind classes, and remove the global component style layer. Keep native dialogs inside the overlay flex context so they remain centered. Cover iframe expiration, lifecycle dispatch, UI dependency direction, and global style ownership with focused tests.
This commit is contained in:
@@ -1,22 +1,27 @@
|
||||
import type { View } from "../lib/model";
|
||||
import type { View } from "../pages/registry";
|
||||
|
||||
export type FrameEntry = {
|
||||
view: View;
|
||||
instance: number;
|
||||
keepAlive: boolean;
|
||||
lastUsed: number;
|
||||
/**
|
||||
* 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 = {
|
||||
/**
|
||||
* 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.
|
||||
* How long a keep-alive document may remain continuously hidden.
|
||||
*/
|
||||
maxKeepAliveEntries: number;
|
||||
inactiveTtlMs: number;
|
||||
shouldKeepAlive: (view: View) => boolean;
|
||||
/** Injectable monotonic clock for deterministic tests. */
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export type FrameTransition = {
|
||||
@@ -25,24 +30,30 @@ export type FrameTransition = {
|
||||
activated: FrameEntry;
|
||||
};
|
||||
|
||||
export type FrameExpiration = Omit<FrameTransition, "activated">;
|
||||
|
||||
/**
|
||||
* Owns the bounded set of iframe document identities.
|
||||
* Owns iframe document identities and expires retained, hidden documents.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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<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");
|
||||
if (!Number.isFinite(options.inactiveTtlMs) || options.inactiveTtlMs <= 0) {
|
||||
throw new RangeError("inactiveTtlMs must be a positive finite number");
|
||||
}
|
||||
this.#options = options;
|
||||
this.#active = initialView;
|
||||
@@ -60,27 +71,29 @@ export class FrameCache {
|
||||
activate(view: View): FrameTransition {
|
||||
const removed: FrameEntry[] = [];
|
||||
const previous = this.#entries.get(this.#active);
|
||||
const now = this.#now();
|
||||
|
||||
if (previous && previous.view !== view && !previous.keepAlive) {
|
||||
this.#entries.delete(previous.view);
|
||||
removed.push(previous);
|
||||
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.lastUsed = ++this.#clock;
|
||||
activated.inactiveSince = null;
|
||||
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,
|
||||
@@ -88,29 +101,63 @@ export class FrameCache {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
lastUsed: ++this.#clock,
|
||||
inactiveSince: null,
|
||||
};
|
||||
}
|
||||
|
||||
#keepAliveCount(): number {
|
||||
let count = 0;
|
||||
#removeExpired(now: number): FrameEntry[] {
|
||||
const removed: FrameEntry[] = [];
|
||||
for (const entry of this.#entries.values()) {
|
||||
if (entry.keepAlive) count += 1;
|
||||
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 count;
|
||||
return removed;
|
||||
}
|
||||
|
||||
#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;
|
||||
#now(): number {
|
||||
return this.#options.now?.() ?? performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user