@stainless-code/layers
Version:
Headless, UI-agnostic layer/stack manager for modal/dialog/drawer/popover/toast UI. Call a layer like an async function and await its response.
653 lines (609 loc) • 18.9 kB
text/typescript
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 {
DefaultLayerError,
DismissAllOptions,
DismissOptions,
LayerKey,
LayerNotifyView,
LayerState,
StackBlockerFn,
StackNotifyAction,
StackNotifyEvent,
StackOptions,
} from "./types";
import { keySignature } from "./utils";
import { validatePayload } from "./validators";
import type { Validator } from "./validators";
interface OpenOpts<P, D> {
key: LayerKey;
payload: P;
component?: unknown;
enteringDelay?: number;
exitingDelay?: number;
upsert?: boolean;
loadFn?: (ctx: { payload: P; signal: AbortSignal }) => Promise<D> | D;
validate?: Validator<P>;
}
interface QueuedEntry<P, R, E, D> {
layer: Layer<P, R, E, D>;
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<P, R, E, D>) => void;
/** @internal Fan-out hook for {@link LayerClient#subscribeNotify}. */
onNotify?: (event: StackNotifyEvent) => void;
#layers: Layer<P, R, E, D>[] = [];
#snapshot: LayerState<P, R, E, D>[] = [];
#queuedSnapshot: LayerState<P, R, E, D>[] = [];
#scopeQueue: QueuedEntry<P, R, E, D>[] = [];
#notifySeq = 0;
#pendingNotifyAction?: StackNotifyAction;
#gcCache = createLayerGcCache<P, R, E, D>({
gcTime: () => this.options.gcTime ?? 0,
onBeforeStore: (layer) =>
layer.setPartial({ phase: "dismissed", transition: "settled" }),
});
#blockers = new Set<StackBlockerFn>();
constructor(id: string, options: StackOptions = {}) {
super();
this.id = id;
this.options = options;
}
/** Returns a referentially stable snapshot between mutations. */
getSnapshot = (): LayerState<P, R, E, D>[] => this.#snapshot;
/** Returns serially queued layers, which are excluded from `getSnapshot`. */
getQueuedSnapshot = (): LayerState<P, R, E, D>[] => this.#queuedSnapshot;
/** @internal First materialization ping for devtools registry. */
emitRegisterNotify(): void {
this.#emitNotify("register");
}
getLayer(id: string): Layer<P, R, E, D> | undefined {
return this.#layers.find((l) => l.id === id);
}
find(key: LayerKey): Layer<P, R, E, D> | 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<P, D>): Layer<P, R, E, D> {
return notifyManager.batch(() => {
let payload = opts.payload;
if (opts.validate) {
try {
payload = validatePayload(opts.validate, opts.payload);
} catch (error) {
const layer = new Layer<P, R, E, D>({
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<P, R, E, D>({
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<P, R, E, D>, loadFn: OpenOpts<P, D>["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<P, R, E, D>): void {
layer.enterTimer = undefined;
notifyManager.batch(() => {
this.#dispatch("settle", () => {
layer.setPartial({ transition: "settled" });
this.#flush();
});
});
}
async #runLoad(
layer: Layer<P, R, E, D>,
loadFn: NonNullable<OpenOpts<P, D>["loadFn"]>,
): Promise<void> {
try {
const data = await loadFn({
payload: layer.state.payload,
signal: layer.abortController.signal,
});
if (layer.aborted) {
return;
}
layer.setPartial({ data, phase: "active" });
this.#dispatch("phase", () => {
this.#flush();
});
} catch (error) {
if (layer.aborted) {
return;
}
const isAdvance =
this.#serial &&
(this.options.scope?.onLoadError ?? "block") === "advance";
if (isAdvance) {
// Drop the failed occupant and drain — no lasting error UI.
// Mirror #commitDismiss: mark dismissed, then onLayerDismiss, then remove.
layer.reject(error as E);
layer.setPartial({
error: error as E,
phase: "dismissed",
transition: "settled",
ended: true,
});
this.onLayerDismiss?.(layer);
this.#remove(layer);
return;
}
layer.setPartial({ error: error as E, phase: "error" });
layer.reject(error as E);
this.#dispatch("phase", () => {
this.#flush();
});
}
}
/**
* Resolves the caller and aborts in-flight loading.
* Exiting layers remain mounted until their transition settles.
*/
dismiss(
layer: Layer<P, R, E, D>,
response: R,
opts?: DismissOptions,
): Promise<boolean> {
if (opts?.force) {
this.#commitDismiss(layer, response);
return Promise.resolve(true);
}
if (layer.dismissPending) {
return layer.dismissPending;
}
layer.dismissPending = this.#guardedDismiss(layer, response).finally(() => {
layer.dismissPending = undefined;
});
return layer.dismissPending;
}
async #guardedDismiss(
layer: Layer<P, R, E, D>,
response: R,
): Promise<boolean> {
// Fresh read — concurrent force may dismiss while we await blockers.
const alreadyDismissed = () => layer.state.phase === "dismissed";
// Already exiting / dismissed — do not flip `dismissing` (Soft dismiss during exit).
if (alreadyDismissed()) {
return true;
}
notifyManager.batch(() => {
this.#dispatch("phase", () => {
layer.setPartial({ dismissing: true });
this.#flush();
});
});
const vetoed = await this.#vetoed(layer);
if (alreadyDismissed()) {
return true;
}
if (vetoed) {
notifyManager.batch(() => {
this.#dispatch("dismissVetoed", () => {
layer.setPartial({ dismissing: false });
this.#flush();
});
});
return false;
}
this.#commitDismiss(layer, response);
return true;
}
async #vetoed(layer: Layer<P, R, E, D>): Promise<boolean> {
const checks: Array<() => boolean | Promise<boolean>> = [
...[...layer.blockers].map((fn) => () => fn()),
...[...this.#blockers].map((fn) => () => fn(layer.state as LayerState)),
];
for (const check of checks) {
let allowed: boolean;
try {
allowed = await check();
} catch (error) {
// Fail-closed: an errored predicate vetoes (protect user data).
console.warn("[layers] blocker threw; treating as veto", error);
allowed = false;
}
if (!allowed) {
return true;
}
}
return false;
}
#commitDismiss(layer: Layer<P, R, E, D>, 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<P, R, E, D>): 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 `undefined` when `R` is `void`). Honors
* {@link DismissAllMode}; does not reject — use {@link cancelAll} for
* teardown without a completion value.
*/
async dismissAll(response: R, opts?: DismissAllOptions): Promise<void> {
const mode = opts?.mode ?? this.options.dismissAllMode ?? "skipBlocked";
// Final labeled snapshot: active-only stacks only emit per-layer `"dismiss"`.
const shouldEmit = this.#scopeQueue.length > 0 || this.#layers.length > 0;
try {
notifyManager.batch(() => {
// Drain the queue FIRST so a dismissed active layer's #remove does
// not activate a queued one. Queued callers get their response
// without ever mounting.
for (const entry of this.#scopeQueue) {
entry.layer.abort();
entry.layer.resolve(response);
entry.layer.setPartial({
phase: "dismissed",
transition: "settled",
ended: true,
response,
});
}
this.#scopeQueue = [];
this.#flush();
});
for (const l of this.#layers) {
if (mode === "force") {
await this.dismiss(l, response, { force: true });
continue;
}
const ok = await this.dismiss(l, response);
if (!ok && mode === "stopAtBlocked") {
return;
}
}
} finally {
if (shouldEmit) {
this.#emitNotify("dismissAll");
}
}
}
/**
* Force-clears the stack and rejects every open/queued caller with
* {@link LayerCancelledError}. Skips blockers. System teardown path —
* use {@link dismissAll} when completing with a response.
*
* @param opts.reason - Propagated on each rejection.
* @default opts.reason `"cancelAll"`
*/
async cancelAll(opts?: { reason?: LayerCancelReason }): Promise<void> {
const error = new LayerCancelledError(opts?.reason ?? "cancelAll");
const shouldEmit = this.#scopeQueue.length > 0 || this.#layers.length > 0;
try {
notifyManager.batch(() => {
for (const entry of this.#scopeQueue) {
this.#rejectCancel(entry.layer, error);
}
this.#scopeQueue = [];
const mounted = [...this.#layers];
this.#layers = [];
// Reject + flush before hooks so a throwing onLayerDismiss cannot
// leave later open() pending or a stale non-empty snapshot.
for (const layer of mounted) {
this.#rejectCancel(layer, error);
}
this.#flush();
for (const layer of mounted) {
this.onLayerDismiss?.(layer);
this.#gcCache.maybeStore(layer);
}
});
} finally {
if (shouldEmit) {
this.#emitNotify("cancelAll");
}
}
}
/** Abort, reject open(), mark dismissed — no completion response. */
#rejectCancel(layer: Layer<P, R, E, D>, 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.
*/
cancelQueued(key: LayerKey, response: R, opts?: { id?: string }): boolean {
return notifyManager.batch(() => {
return this.#dispatch("cancelQueued", () => {
const sig = keySignature(key);
const idx = this.#scopeQueue.findIndex((entry) => {
if (keySignature(entry.layer.key) !== sig) return false;
if (opts?.id !== undefined) return entry.layer.id === opts.id;
return true;
});
if (idx === -1) {
return false;
}
const entry = this.#scopeQueue[idx]!;
entry.layer.abort();
entry.layer.resolve(response);
entry.layer.setPartial({
phase: "dismissed",
transition: "settled",
ended: true,
response,
});
this.#scopeQueue.splice(idx, 1);
this.#flush();
return true;
});
});
}
update(layer: Layer<P, R, E, D>, patch: Partial<P>): void {
notifyManager.batch(() => {
this.#dispatch("update", () => {
layer.setPartial({ payload: { ...layer.state.payload, ...patch } });
this.#flush();
});
});
}
setRunning(layer: Layer<P, R, E, D>, running: boolean): void {
notifyManager.batch(() => {
this.#dispatch("setRunning", () => {
layer.setRunning(running);
this.#flush();
});
});
}
#remove(layer: Layer<P, R, E, D>): 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<T>(action: StackNotifyAction, run: () => T): T {
this.#pendingNotifyAction = action;
try {
return run();
} finally {
this.#pendingNotifyAction = undefined;
}
}
#emitNotify(action: StackNotifyAction): void {
if (!this.onNotify) {
return;
}
this.#notifySeq += 1;
this.onNotify({
stackId: this.id,
seq: this.#notifySeq,
ts: Date.now(),
action,
active: this.#snapshot.map((state) => projectLayerNotifyView(state)),
queued: this.#queuedSnapshot.map((state) =>
projectLayerNotifyView(state),
),
});
}
}
function projectLayerNotifyView(
state: LayerState<unknown, unknown, unknown, unknown>,
): LayerNotifyView {
const { payload, payloadTruncated } = projectJsonSafePayload(state.payload);
const view: LayerNotifyView = {
id: state.id,
key: keySignature(state.key),
phase: state.phase,
transition: state.transition,
actionStatus: state.actionStatus,
dismissing: state.dismissing,
ended: state.ended,
index: state.index,
stackSize: state.stackSize,
};
if (payload !== undefined) {
view.payload = payload;
}
if (payloadTruncated) {
view.payloadTruncated = true;
}
return view;
}
function projectJsonSafePayload(raw: unknown): {
payload?: unknown;
payloadTruncated?: boolean;
} {
if (raw === undefined) {
return {};
}
if (typeof raw === "number" && !Number.isFinite(raw)) {
return { payloadTruncated: true };
}
try {
return { payload: JSON.parse(JSON.stringify(raw)) as unknown };
} catch {
return { payloadTruncated: true };
}
}