UNPKG

@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
//#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 };