import { LayerCancelledError } from "./errors"; import type { LayerCancelReason } from "./errors"; import { Layer } from "./layer"; import { createLayerGcCache } from "./layerGcCache"; import { notifyManager } from "./notifyManager"; import { Subscribable } from "./subscribable"; import type { CancelQueuedArgs, DefaultLayerError, DismissAllArgs, EndArgs, LayerKey, LayerNotifyView, LayerState, StackBlockerFn, StackNotifyAction, StackNotifyEvent, StackOptions, } from "./types"; import { keySignature } from "./utils"; import { validatePayload } from "./validators"; import type { Validator } from "./validators"; interface OpenOpts
{
key: LayerKey;
payload: P;
component?: unknown;
enteringDelay?: number;
exitingDelay?: number;
upsert?: boolean;
loadFn?: (ctx: { payload: P; signal: AbortSignal }) => Promise ;
}
interface QueuedEntry {
layer: Layer ;
commit: () => void;
}
/**
* Manages the ordered layers for one surface.
* Snapshots retain their references across batched notifications until their contents change.
*/
export class LayerStack<
P = unknown,
R = void,
E = DefaultLayerError,
D = unknown,
> extends Subscribable {
readonly id: string;
readonly options: StackOptions;
/** @internal Allows LayerClient to `cancelAll` child stacks on parent dismissal. */
onLayerDismiss?: (layer: Layer ) => void;
/** @internal Fan-out hook for {@link LayerClient#subscribeNotify}. */
onNotify?: (event: StackNotifyEvent) => void;
#layers: Layer [] = [];
#snapshot: LayerState [] = [];
#queuedSnapshot: LayerState [] = [];
#scopeQueue: QueuedEntry [] = [];
#notifySeq = 0;
#pendingNotifyAction?: StackNotifyAction;
#gcCache = createLayerGcCache ({
gcTime: () => this.options.gcTime ?? 0,
onBeforeStore: (layer) =>
layer.setPartial({ phase: "dismissed", transition: "settled" }),
});
#blockers = new Set [] => this.#snapshot;
/** Returns serially queued layers, which are excluded from `getSnapshot`. */
getQueuedSnapshot = (): LayerState [] => this.#queuedSnapshot;
/** @internal First materialization ping for devtools registry. */
emitRegisterNotify(): void {
this.#emitNotify("register");
}
getLayer(id: string): Layer | undefined {
return this.#layers.find((l) => l.id === id);
}
find(key: LayerKey): Layer | undefined {
const sig = keySignature(key);
return this.#layers.findLast((l) => keySignature(l.key) === sig);
}
addBlocker(fn: StackBlockerFn): () => void {
this.#blockers.add(fn);
return () => this.#blockers.delete(fn);
}
get #serial(): boolean {
return this.options.scope?.strategy === "serial";
}
/** Serial occupancy — pending, active, or mounted error (block policy). */
#hasActive(): boolean {
return this.#layers.some(
(l) =>
l.state.phase === "pending" ||
l.state.phase === "active" ||
l.state.phase === "error",
);
}
open(opts: OpenOpts ): Layer {
return notifyManager.batch(() => {
let payload = opts.payload;
if (opts.validate) {
try {
payload = validatePayload(opts.validate, opts.payload);
} catch (error) {
const layer = new Layer ({
key: opts.key,
payload: opts.payload,
index: this.#layers.length,
stackSize: this.#layers.length,
component: opts.component,
enteringDelay: opts.enteringDelay,
exitingDelay: opts.exitingDelay,
});
layer.reject(error as E);
return layer;
}
}
if (opts.upsert) {
const existing = this.find(opts.key);
if (existing) {
this.#dispatch("update", () => {
existing.setPartial({ payload });
this.#flush();
});
return existing;
}
}
// Cached data suppresses a second load when the same key reopens.
const cached = this.#gcCache.take(opts.key);
const loadFn = cached ? undefined : opts.loadFn;
const data = cached?.state.data;
const layer = new Layer ({
key: opts.key,
payload,
index: this.#layers.length,
stackSize: this.#layers.length + 1,
component: opts.component,
enteringDelay: opts.enteringDelay,
exitingDelay: opts.exitingDelay,
data,
});
const commit = () => this.#commit(layer, loadFn);
if (this.#serial && this.#hasActive()) {
this.#dispatch("queue", () => {
this.#scopeQueue.push({ layer, commit });
layer.setPartial({ phase: "queued" });
this.#flush();
});
return layer;
}
this.#dispatch("open", () => {
commit();
});
return layer;
});
}
#commit(layer: Layer , loadFn: OpenOpts ["loadFn"]): void {
this.#layers = [...this.#layers, layer];
this.#reindex();
const entering = layer.enteringDelay > 0;
layer.setPartial({
phase: loadFn ? "pending" : "active",
transition: entering ? "entering" : "settled",
});
this.#flush();
if (entering) {
layer.enterTimer = setTimeout(
() => this.#settleEnter(layer),
layer.enteringDelay,
);
}
if (loadFn) {
void this.#runLoad(layer, loadFn);
}
}
#settleEnter(layer: Layer ): void {
layer.enterTimer = undefined;
notifyManager.batch(() => {
this.#dispatch("settle", () => {
layer.setPartial({ transition: "settled" });
this.#flush();
});
});
}
async #runLoad(
layer: Layer ,
loadFn: NonNullable , ...args: EndArgs ,
response: R,
): Promise ): Promise , response: R): void {
notifyManager.batch(() => {
this.#dispatch("dismiss", () => {
layer.abort();
layer.resolve(response);
layer.setPartial({
phase: "dismissed",
transition: "exiting",
ended: true,
response,
dismissing: false,
});
this.#flush();
this.onLayerDismiss?.(layer);
});
});
if (layer.exitingDelay > 0) {
layer.exitTimer = setTimeout(
() => this.#remove(layer),
layer.exitingDelay,
);
} else {
this.#remove(layer);
}
}
settle(layer: Layer ): void {
const t = layer.state.transition;
if (t === "entering") {
if (layer.enterTimer) {
clearTimeout(layer.enterTimer);
layer.enterTimer = undefined;
}
notifyManager.batch(() => {
this.#dispatch("settle", () => {
layer.setPartial({ transition: "settled" });
this.#flush();
});
});
} else if (t === "exiting") {
if (layer.exitTimer) {
clearTimeout(layer.exitTimer);
layer.exitTimer = undefined;
}
this.#remove(layer);
}
}
/**
* Bulk-dismisses active and queued layers, completing every `open()` with
* `response` (including omitted/`undefined` when `undefined extends R` —
* {@link DismissAllArgs} / {@link EndArgs} gate).
* Honors {@link DismissAllMode}; does not reject — use {@link cancelAll} for
* teardown without a completion value.
*/
async dismissAll(...args: DismissAllArgs , error: LayerCancelledError): void {
if (layer.enterTimer) {
clearTimeout(layer.enterTimer);
layer.enterTimer = undefined;
}
if (layer.exitTimer) {
clearTimeout(layer.exitTimer);
layer.exitTimer = undefined;
}
layer.abort();
layer.reject(error as E);
// Prevent unhandledrejection when callers used void open().
void layer.promise.promise.catch(() => {});
layer.setPartial({
phase: "dismissed",
transition: "settled",
ended: true,
});
}
/**
* Resolves and removes a serially queued layer without mounting it (skips blockers).
* No `id` → FIFO head for the key; `{ id }` → exact queued match.
* Response may be omitted when `undefined extends R` ({@link CancelQueuedArgs} /
* {@link EndArgs} gate).
*/
cancelQueued(key: LayerKey, ...args: CancelQueuedArgs , patch: Partial ): void {
notifyManager.batch(() => {
this.#dispatch("update", () => {
layer.setPartial({ payload: { ...layer.state.payload, ...patch } });
this.#flush();
});
});
}
setRunning(layer: Layer , running: boolean): void {
notifyManager.batch(() => {
this.#dispatch("setRunning", () => {
layer.setRunning(running);
this.#flush();
});
});
}
#remove(layer: Layer ): void {
notifyManager.batch(() => {
this.#dispatch("remove", () => {
this.#layers = this.#layers.filter((l) => l.id !== layer.id);
this.#reindex();
this.#flush();
this.#gcCache.maybeStore(layer);
if (this.#serial && !this.#hasActive()) {
const next = this.#scopeQueue.shift();
if (next) {
this.#dispatch("open", () => {
next.commit();
});
}
}
});
});
}
#reindex(): void {
this.#layers.forEach((l, i) =>
l.setPartial({ index: i, stackSize: this.#layers.length }),
);
}
/** Preserves snapshot identity when batching produces no observable change. */
#flush(): void {
const next = this.#layers.map((l) => l.state);
const nextQueued = this.#scopeQueue.map((entry) => entry.layer.state);
const snapshotUnchanged =
next.length === this.#snapshot.length &&
next.every((s, i) => s === this.#snapshot[i]);
const queuedUnchanged =
nextQueued.length === this.#queuedSnapshot.length &&
nextQueued.every((s, i) => s === this.#queuedSnapshot[i]);
if (snapshotUnchanged && queuedUnchanged) {
return;
}
if (!snapshotUnchanged) {
this.#snapshot = next;
}
if (!queuedUnchanged) {
this.#queuedSnapshot = nextQueued;
}
this.notify();
const action = this.#pendingNotifyAction;
if (action !== undefined) {
this.#pendingNotifyAction = undefined;
this.#emitNotify(action);
}
}
#dispatch