@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.
1,154 lines (1,153 loc) • 33.5 kB
JavaScript
//#region src/errors.ts
/** Normalizes payload failures across supported validator styles. */
var PayloadValidationError = class extends Error {
issues;
constructor(issues, options) {
super(issues[0]?.message ?? "Payload validation failed", options);
this.name = "PayloadValidationError";
this.issues = issues;
}
};
/**
* Narrows an unknown rejection to {@link PayloadValidationError}.
*
* @example
* ```ts
* import { isPayloadValidationError } from "@stainless-code/layers";
*
* function validationMessages(error: unknown) {
* return isPayloadValidationError(error)
* ? error.issues.map((issue) => issue.message)
* : [];
* }
* ```
*/
function isPayloadValidationError(value) {
return value instanceof PayloadValidationError;
}
/** Thrown when a layer key is not JSON-safe (from `assertLayerKey` / `hashKey`). */
var LayerKeyError = class extends Error {
path;
constructor(message, path = []) {
super(message);
this.name = "LayerKeyError";
this.path = path;
}
};
/**
* Narrows an unknown synchronous throw to {@link LayerKeyError}.
*
* @example
* ```ts
* import { isLayerKeyError } from "@stainless-code/layers";
*
* try {
* client.open({ key: [maybeId], payload });
* } catch (error) {
* if (isLayerKeyError(error)) {
* console.error(error.path, error.message);
* }
* }
* ```
*/
function isLayerKeyError(value) {
return value instanceof LayerKeyError;
}
/**
* Rejects `open()` when a stack is torn down without a completion response
* (`cancelAll`, parent dismiss child clear, group dispose, `stackDisconnect`).
*/
var LayerCancelledError = class extends Error {
/** {@link LayerCancelReason} that triggered this rejection. */
reason;
constructor(reason = "cancelAll") {
super(`LayerCancelledError: ${reason}`);
this.name = "LayerCancelledError";
this.reason = reason;
}
};
/**
* Narrows an unknown rejection to {@link LayerCancelledError}.
* Treat any cancel reason as normal teardown unless you branch on
* {@link LayerCancelledError#reason}.
*
* @example
* ```ts
* import { isLayerCancelledError } from "@stainless-code/layers";
*
* try {
* await confirm.open(payload);
* } catch (error) {
* if (isLayerCancelledError(error)) return;
* throw error;
* }
* ```
*/
function isLayerCancelledError(value) {
return value instanceof LayerCancelledError;
}
//#endregion
//#region src/utils.ts
function isPlainObject(value) {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function sortedObject(value) {
const keys = Object.keys(value).sort();
const out = Object.create(null);
for (const k of keys) out[k] = value[k];
return out;
}
function formatKeyPath(path) {
if (path.length === 0) return "key";
let out = "key";
for (const segment of path) if (typeof segment === "number") out += `[${segment}]`;
else out += `.${String(segment)}`;
return out;
}
function walkLayerKey(value, path, seen) {
if (value === void 0) throw new LayerKeyError(`${formatKeyPath(path)}: undefined is not JSON-safe`, path);
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new LayerKeyError(`${formatKeyPath(path)}: non-finite number is not JSON-safe`, path);
return;
}
if (typeof value === "string" || typeof value === "boolean" || value === null) return;
if (typeof value === "bigint") throw new LayerKeyError(`${formatKeyPath(path)}: bigint is not JSON-safe`, path);
if (typeof value === "symbol" || typeof value === "function") throw new LayerKeyError(`${formatKeyPath(path)}: ${typeof value} is not JSON-safe`, path);
if (typeof value !== "object") throw new LayerKeyError(`${formatKeyPath(path)}: unsupported key segment`, path);
if (seen.has(value)) throw new LayerKeyError(`${formatKeyPath(path)}: cyclic structure is not JSON-safe`, path);
seen.add(value);
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) walkLayerKey(value[i], [...path, i], seen);
seen.delete(value);
return;
}
if (!isPlainObject(value)) throw new LayerKeyError(`${formatKeyPath(path)}: only plain objects are JSON-safe`, path);
for (const k of Object.keys(value)) walkLayerKey(value[k], [...path, k], seen);
seen.delete(value);
}
/**
* Ensures a layer key is JSON-safe for {@link hashKey}.
* Allowed: `string` | `boolean` | `null` | finite `number` | plain objects | arrays of those.
* Throws {@link LayerKeyError} when a segment is outside that domain.
*
* @example
* ```ts
* import { assertLayerKey } from "@stainless-code/layers";
*
* assertLayerKey(["confirm", filterId ?? "none"]);
* ```
*/
function assertLayerKey(key) {
if (!Array.isArray(key)) throw new LayerKeyError("key: must be an array", []);
walkLayerKey(key, [], /* @__PURE__ */ new WeakSet());
}
/**
* Serializes a key deterministically by sorting object properties recursively.
* Throws {@link LayerKeyError} when the key is not JSON-safe.
*/
function hashKey(key) {
assertLayerKey(key);
return JSON.stringify(key, (_unused, val) => isPlainObject(val) ? sortedObject(val) : val);
}
/**
* Produces the canonical identity used to compare layer keys.
* Throws {@link LayerKeyError} when the key is not JSON-safe (via {@link hashKey}).
*/
function keySignature(key) {
return hashKey(key);
}
/**
* Element-wise `Object.is` for arrays.
* Keeps key-filtered snapshot selections stable when `filter()` reallocates
* but the matched element refs are unchanged.
*/
function shallowArrayEqual(a, b) {
if (Object.is(a, b)) return true;
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false;
return true;
}
//#endregion
//#region src/notifyManager.ts
let isBatching = false;
const scheduled = /* @__PURE__ */ new Set();
/**
* Coalesces listener calls by identity within a synchronous batch.
* Nested batches flush only when the outer batch completes; wrapped listeners
* called repeatedly during that interval run once.
*/
const notifyManager = {
batch(fn) {
if (isBatching) return fn();
isBatching = true;
try {
return fn();
} finally {
isBatching = false;
for (const listener of scheduled) listener();
scheduled.clear();
}
},
batchCalls(listener) {
return () => {
if (isBatching) scheduled.add(listener);
else listener();
};
}
};
//#endregion
//#region src/subscribable.ts
/**
* Base for stores whose listeners participate in {@link notifyManager} batching.
* Wrapping occurs once at subscription, so repeated notifications in one batch
* coalesce for framework and bare subscribers alike.
*/
var Subscribable = class {
listeners = /* @__PURE__ */ new Map();
subscribe(listener) {
const wrapped = notifyManager.batchCalls(listener);
this.listeners.set(listener, wrapped);
this.onSubscribe?.();
return () => {
this.listeners.delete(listener);
this.onUnsubscribe?.();
};
}
notify() {
for (const wrapped of this.listeners.values()) wrapped();
}
get size() {
return this.listeners.size;
}
};
//#endregion
//#region src/controlledPromise.ts
/** Lets lifecycle code outside the executor settle a promise exactly once. */
var ControlledPromise = class {
promise;
settled = false;
resolve;
reject;
constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = (value) => {
if (this.settled) return;
this.settled = true;
resolve(value);
};
this.reject = (reason) => {
if (this.settled) return;
this.settled = true;
reject(reason);
};
});
}
};
//#endregion
//#region src/layer.ts
/** Distinguishes concurrent instances that share a key. */
let instanceCounter = 0;
/** Runtime state and cancellation resources for one stack entry. */
var Layer = class {
id;
key;
promise;
abortController;
component;
enteringDelay;
exitingDelay;
enterTimer;
exitTimer;
aborted = false;
dismissPending;
#blockers = /* @__PURE__ */ new Set();
#state;
constructor(opts) {
this.id = `${hashKey(opts.key)}#${++instanceCounter}`;
this.key = opts.key;
this.promise = new ControlledPromise();
this.abortController = new AbortController();
this.component = opts.component;
this.enteringDelay = opts.enteringDelay ?? 0;
this.exitingDelay = opts.exitingDelay ?? 0;
this.#state = {
id: this.id,
key: opts.key,
payload: opts.payload,
data: opts.data,
phase: "pending",
transition: "settled",
dismissing: false,
actionStatus: "idle",
ended: false,
index: opts.index,
stackSize: opts.stackSize
};
}
get state() {
return this.#state;
}
get blockers() {
return this.#blockers;
}
addBlocker(fn) {
this.#blockers.add(fn);
return () => this.#blockers.delete(fn);
}
/** Returns true if the snapshot changed. */
setPartial(patch) {
for (const key of Object.keys(patch)) if (!Object.is(patch[key], this.#state[key])) {
this.#state = {
...this.#state,
...patch
};
return true;
}
return false;
}
setRunning(running) {
this.setPartial({ actionStatus: running ? "running" : "idle" });
}
/** Cancel any in-flight `loadFn`; subsequent resolutions are ignored. */
abort() {
this.aborted = true;
this.abortController.abort();
}
resolve(response) {
this.promise.resolve(response);
}
reject(error) {
this.promise.reject(error);
}
};
//#endregion
//#region src/layerGcCache.ts
/**
* Retains dismissed layer data for same-key restoration without reloading.
* Only the last dismissed layer for each key is retained.
*/
function createLayerGcCache(opts) {
const entries = /* @__PURE__ */ new Map();
const evict = (sig) => {
const entry = entries.get(sig);
if (!entry) return;
clearTimeout(entry.timer);
entries.delete(sig);
opts.onEvict?.(entry.layer);
};
return {
maybeStore(layer) {
const gcTime = opts.gcTime();
if (gcTime <= 0 || layer.state.data === void 0) return;
opts.onBeforeStore?.(layer);
const sig = keySignature(layer.key);
evict(sig);
const timer = setTimeout(() => evict(sig), gcTime);
entries.set(sig, {
layer,
timer
});
},
take(key) {
const sig = keySignature(key);
const entry = entries.get(sig);
if (!entry) return;
clearTimeout(entry.timer);
entries.delete(sig);
return entry.layer;
}
};
}
//#endregion
//#region src/validators.ts
function isStandardSchema(v) {
return typeof v === "object" && v !== null && "~standard" in v;
}
/**
* Runs synchronous validation and returns output that may differ from the input.
* Async Standard Schema validators are unsupported.
* Invalid input is reported as {@link PayloadValidationError}.
*
* @internal
*/
function validatePayload(validate, input) {
if (isStandardSchema(validate)) {
const result = validate["~standard"].validate(input);
if (result instanceof Promise) throw new Error("[layers] Async payload validation is not supported — use a synchronous schema.");
if (result.issues) throw new PayloadValidationError(result.issues.map((i) => ({
message: i.message,
path: i.path?.map((p) => typeof p === "object" ? p.key : p)
})));
return result.value;
}
try {
return validate(input);
} catch (cause) {
if (cause instanceof PayloadValidationError) throw cause;
throw new PayloadValidationError([{ message: cause instanceof Error ? cause.message : String(cause) }], { cause });
}
}
//#endregion
//#region src/layerStack.ts
/**
* Manages the ordered layers for one surface.
* Snapshots retain their references across batched notifications until their contents change.
*/
var LayerStack = class extends Subscribable {
id;
options;
/** @internal Allows LayerClient to `cancelAll` child stacks on parent dismissal. */
onLayerDismiss;
/** @internal Fan-out hook for {@link LayerClient#subscribeNotify}. */
onNotify;
#layers = [];
#snapshot = [];
#queuedSnapshot = [];
#scopeQueue = [];
#notifySeq = 0;
#pendingNotifyAction;
#gcCache = createLayerGcCache({
gcTime: () => this.options.gcTime ?? 0,
onBeforeStore: (layer) => layer.setPartial({
phase: "dismissed",
transition: "settled"
})
});
#blockers = /* @__PURE__ */ new Set();
constructor(id, options = {}) {
super();
this.id = id;
this.options = options;
}
/** Returns a referentially stable snapshot between mutations. */
getSnapshot = () => this.#snapshot;
/** Returns serially queued layers, which are excluded from `getSnapshot`. */
getQueuedSnapshot = () => this.#queuedSnapshot;
/** @internal First materialization ping for devtools registry. */
emitRegisterNotify() {
this.#emitNotify("register");
}
getLayer(id) {
return this.#layers.find((l) => l.id === id);
}
find(key) {
const sig = keySignature(key);
return this.#layers.findLast((l) => keySignature(l.key) === sig);
}
addBlocker(fn) {
this.#blockers.add(fn);
return () => this.#blockers.delete(fn);
}
get #serial() {
return this.options.scope?.strategy === "serial";
}
/** Serial occupancy — pending, active, or mounted error (block policy). */
#hasActive() {
return this.#layers.some((l) => l.state.phase === "pending" || l.state.phase === "active" || l.state.phase === "error");
}
open(opts) {
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);
return layer;
}
if (opts.upsert) {
const existing = this.find(opts.key);
if (existing) {
this.#dispatch("update", () => {
existing.setPartial({ payload });
this.#flush();
});
return existing;
}
}
const cached = this.#gcCache.take(opts.key);
const loadFn = cached ? void 0 : 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, loadFn) {
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) this.#runLoad(layer, loadFn);
}
#settleEnter(layer) {
layer.enterTimer = void 0;
notifyManager.batch(() => {
this.#dispatch("settle", () => {
layer.setPartial({ transition: "settled" });
this.#flush();
});
});
}
async #runLoad(layer, loadFn) {
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;
if (this.#serial && (this.options.scope?.onLoadError ?? "block") === "advance") {
layer.reject(error);
layer.setPartial({
error,
phase: "dismissed",
transition: "settled",
ended: true
});
this.onLayerDismiss?.(layer);
this.#remove(layer);
return;
}
layer.setPartial({
error,
phase: "error"
});
layer.reject(error);
this.#dispatch("phase", () => {
this.#flush();
});
}
}
/**
* Resolves the caller and aborts in-flight loading.
* Exiting layers remain mounted until their transition settles.
*/
dismiss(layer, response, opts) {
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 = void 0;
});
return layer.dismissPending;
}
async #guardedDismiss(layer, response) {
const alreadyDismissed = () => layer.state.phase === "dismissed";
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) {
const checks = [...[...layer.blockers].map((fn) => () => fn()), ...[...this.#blockers].map((fn) => () => fn(layer.state))];
for (const check of checks) {
let allowed;
try {
allowed = await check();
} catch (error) {
console.warn("[layers] blocker threw; treating as veto", error);
allowed = false;
}
if (!allowed) return true;
}
return false;
}
#commitDismiss(layer, response) {
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) {
const t = layer.state.transition;
if (t === "entering") {
if (layer.enterTimer) {
clearTimeout(layer.enterTimer);
layer.enterTimer = void 0;
}
notifyManager.batch(() => {
this.#dispatch("settle", () => {
layer.setPartial({ transition: "settled" });
this.#flush();
});
});
} else if (t === "exiting") {
if (layer.exitTimer) {
clearTimeout(layer.exitTimer);
layer.exitTimer = void 0;
}
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, opts) {
const mode = opts?.mode ?? this.options.dismissAllMode ?? "skipBlocked";
const shouldEmit = this.#scopeQueue.length > 0 || this.#layers.length > 0;
try {
notifyManager.batch(() => {
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;
}
if (!await this.dismiss(l, response) && 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) {
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 = [];
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, error) {
if (layer.enterTimer) {
clearTimeout(layer.enterTimer);
layer.enterTimer = void 0;
}
if (layer.exitTimer) {
clearTimeout(layer.exitTimer);
layer.exitTimer = void 0;
}
layer.abort();
layer.reject(error);
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, response, opts) {
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 !== void 0) 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, patch) {
notifyManager.batch(() => {
this.#dispatch("update", () => {
layer.setPartial({ payload: {
...layer.state.payload,
...patch
} });
this.#flush();
});
});
}
setRunning(layer, running) {
notifyManager.batch(() => {
this.#dispatch("setRunning", () => {
layer.setRunning(running);
this.#flush();
});
});
}
#remove(layer) {
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() {
this.#layers.forEach((l, i) => l.setPartial({
index: i,
stackSize: this.#layers.length
}));
}
/** Preserves snapshot identity when batching produces no observable change. */
#flush() {
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 !== void 0) {
this.#pendingNotifyAction = void 0;
this.#emitNotify(action);
}
}
#dispatch(action, run) {
this.#pendingNotifyAction = action;
try {
return run();
} finally {
this.#pendingNotifyAction = void 0;
}
}
#emitNotify(action) {
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) {
const { payload, payloadTruncated } = projectJsonSafePayload(state.payload);
const view = {
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 !== void 0) view.payload = payload;
if (payloadTruncated) view.payloadTruncated = true;
return view;
}
function projectJsonSafePayload(raw) {
if (raw === void 0) return {};
if (typeof raw === "number" && !Number.isFinite(raw)) return { payloadTruncated: true };
try {
return { payload: JSON.parse(JSON.stringify(raw)) };
} catch {
return { payloadTruncated: true };
}
}
//#endregion
//#region src/layerClient.ts
/** Coordinates named layer stacks. */
var LayerClient = class {
#stacks = /* @__PURE__ */ new Map();
#childStacksByParent = /* @__PURE__ */ new Map();
#stackListeners = /* @__PURE__ */ new Set();
#notifyListeners = /* @__PURE__ */ new Set();
#defaultStackOptions;
constructor(opts = {}) {
this.#defaultStackOptions = opts.defaultStackOptions ?? {};
}
/** Returns a stack, applying options only when creating it. */
ensureStack(id, options) {
let stack = this.#stacks.get(id);
if (!stack) {
stack = new LayerStack(id, {
...this.#defaultStackOptions[id],
...options
});
stack.onLayerDismiss = (layer) => this.#drainChildStacks(layer.id);
stack.onNotify = (event) => {
for (const listener of this.#notifyListeners) try {
listener(event);
} catch {}
};
this.#stacks.set(id, stack);
stack.emitRegisterNotify();
this.#stackListeners.forEach((l) => l(id));
}
return stack;
}
#stack(id) {
return this.ensureStack(id);
}
bindChildStack(parentLayerId, childStackId) {
let set = this.#childStacksByParent.get(parentLayerId);
if (!set) {
set = /* @__PURE__ */ new Set();
this.#childStacksByParent.set(parentLayerId, set);
}
set.add(childStackId);
return () => {
const childSet = this.#childStacksByParent.get(parentLayerId);
if (!childSet) return;
childSet.delete(childStackId);
if (childSet.size === 0) this.#childStacksByParent.delete(parentLayerId);
};
}
#drainChildStacks(parentLayerId) {
const childIds = this.#childStacksByParent.get(parentLayerId);
if (!childIds) return;
for (const childId of childIds) this.#stacks.get(childId)?.cancelAll({ reason: "parentDismiss" }).catch(() => {});
this.#childStacksByParent.delete(parentLayerId);
}
open(options) {
const stackId = options.stack ?? "default";
const stack = this.#stack(stackId);
const openOptions = options;
return stack.open({
key: openOptions.key,
payload: openOptions.payload,
component: openOptions.component,
exitingDelay: openOptions.exitingDelay,
enteringDelay: openOptions.enteringDelay,
upsert: openOptions.upsert,
loadFn: openOptions.loadFn,
validate: openOptions.validate
}).promise.promise;
}
getStack(id = "default") {
return this.#stack(id);
}
getStackIds() {
return [...this.#stacks.keys()];
}
/** Subscribes to first-time stack materialization. */
subscribeStacks(listener) {
this.#stackListeners.add(listener);
return () => this.#stackListeners.delete(listener);
}
/** Subscribes to labeled stack snapshot transitions (devtools). */
subscribeNotify(listener) {
this.#notifyListeners.add(listener);
return () => this.#notifyListeners.delete(listener);
}
/**
* Re-emits the current snapshot as a `register` notify for one stack, or all
* materialized stacks when `stackId` is omitted (devtools seed).
*/
seedNotify(stackId) {
if (stackId !== void 0) {
this.#stacks.get(stackId)?.emitRegisterNotify();
return;
}
for (const stack of this.#stacks.values()) stack.emitRegisterNotify();
}
/**
* Bulk-dismisses a stack, completing every `open()` with `response`
* (including omitted/`undefined` for void layers). Honors
* {@link DismissAllMode}; does not reject — prefer {@link cancelAll} for
* teardown without a completion value.
*/
dismissAll(stackId = "default", response, opts) {
return this.#stack(stackId).dismissAll(response, opts);
}
/**
* Force-clears a stack and rejects every open/queued caller with
* {@link LayerCancelledError}. System teardown — prefer {@link dismissAll}
* when completing with a response.
*
* @param opts.reason - Propagated on each rejection.
* @default opts.reason `"cancelAll"`
*/
cancelAll(stackId = "default", opts) {
return this.#stack(stackId).cancelAll(opts);
}
};
//#endregion
//#region src/layerOptions.ts
/**
* Preserves payload, response, error, and data inference for reusable layer options.
* Returns the same object at runtime; its {@link DataTag}-branded key lets
* `LayerClient.open` infer the response without an explicit generic.
*
* @example
* ```ts
* import { LayerClient, layerOptions } from "@stainless-code/layers";
*
* const client = new LayerClient();
* const confirm = layerOptions<{ title: string }, boolean>({
* key: ["confirm", "remove"],
* });
* const ok = await client.open({ ...confirm, payload: { title: "Remove?" } });
* // ^? boolean
* ```
*/
function layerOptions(options) {
return options;
}
//#endregion
//#region src/createLayer.ts
function createLayer(options, client) {
const stackId = options.stack ?? "default";
const stack = client.getStack(stackId);
const opts = options;
const sig = keySignature(opts.key);
let mine;
const toOpenOpts = (payload) => ({
key: opts.key,
payload,
component: opts.component,
enteringDelay: opts.enteringDelay,
exitingDelay: opts.exitingDelay,
upsert: opts.upsert,
loadFn: opts.loadFn,
validate: opts.validate
});
const target = (id) => {
if (id) {
const l = stack.getLayer(id);
return l && keySignature(l.key) === sig ? l : void 0;
}
return stack.find(opts.key);
};
return {
open: (payload) => {
mine = stack.open(toOpenOpts(payload));
return mine.promise.promise;
},
upsert: (payload) => {
mine = stack.open({
...toOpenOpts(payload),
upsert: true
});
return mine.promise.promise;
},
dismiss: (response, o) => {
const l = target(o?.id);
return l ? stack.dismiss(l, response, { force: o?.force }) : Promise.resolve(false);
},
update: (patch, o) => {
const l = target(o?.id);
if (l) stack.update(l, patch);
},
cancelQueued: (response, o) => stack.cancelQueued(opts.key, response ?? void 0, o),
client,
stack,
options: opts,
get current() {
return mine ? stack.getLayer(mine.id) ?? null : null;
}
};
}
//#endregion
//#region src/dataTag.ts
/**
* Adds response and error inference to a key without changing it at runtime.
*
* @example
* ```ts
* import { LayerClient, layerKey } from "@stainless-code/layers";
*
* const client = new LayerClient();
* const removeKey = layerKey<boolean>()(["confirm", "remove"]);
* const ok = await client.open({ key: removeKey, payload: { title: "Remove?" } });
* // ^? boolean
* ```
*/
function layerKey() {
return (key) => key;
}
//#endregion
//#region src/callContext.ts
/**
* Creates the framework-neutral imperative context passed to a layer component.
* Adapters provide rendering; stack and layer instances retain lifecycle control.
*/
function createCallContext(stack, layer, state, rootProps) {
return {
end: (response, opts) => stack.dismiss(layer, response, opts),
dismiss: (response, opts) => stack.dismiss(layer, response, opts),
addBlocker: (fn) => layer.addBlocker(fn),
update: (patch) => stack.update(layer, patch),
setRunning: (running) => stack.setRunning(layer, running),
settle: () => stack.settle(layer),
ended: state.ended,
index: state.index,
stackSize: state.stackSize,
root: rootProps,
stackId: stack.id,
layerId: layer.id
};
}
//#endregion
//#region src/layerGroup.ts
/** Derives a collision-free path id: `${parentStackId}~${parentLayerId}~${name}`. */
function childStackId(parent, name = "group") {
return `${parent.stackId}~${parent.layerId}~${name}`;
}
/**
* Creates a child stack that is `cancelAll`'d (`LayerCancelledError`, reason
* `parentDismiss`) when its parent dismisses. Pass the {@link LayerClient} that
* owns `parent`; another client cannot observe the parent's lifetime.
*
* @example
* ```ts
* import {
* createLayerGroup,
* type LayerCallContext,
* type LayerClient,
* } from "@stainless-code/layers";
*
* declare const client: LayerClient;
* declare const parent: LayerCallContext<unknown, unknown>;
*
* const group = createLayerGroup(client, parent, { name: "nested" });
* group.dismissAll();
* group.dispose();
* ```
*/
function createLayerGroup(client, parent, options = {}) {
const stackId = childStackId(parent, options.name);
client.ensureStack(stackId, {
scope: options.scope,
gcTime: options.gcTime
});
return {
stackId,
open: (opts) => client.open({
...opts,
stack: stackId
}),
dismissAll: (response) => client.dismissAll(stackId, response),
dispose: client.bindChildStack(parent.layerId, stackId)
};
}
//#endregion
export { ControlledPromise, Layer, LayerCancelledError, LayerClient, LayerKeyError, LayerStack, PayloadValidationError, Subscribable, assertLayerKey, childStackId, createCallContext, createLayer, createLayerGroup, hashKey, isLayerCancelledError, isLayerKeyError, isPayloadValidationError, keySignature, layerKey, layerOptions, notifyManager, shallowArrayEqual };