From def96f3f0445798ffe3d44258fdb514b8c088ef5 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sun, 19 Jul 2026 13:13:07 +0300 Subject: [PATCH 1/3] fix(core): reject open() on system teardown via cancelAll Add LayerCancelledError and cancelAll so parent-dismiss drains, group dispose, and host disconnect reject callers instead of resolving undefined. dismissAll(response) still completes with R. --- .changeset/layer-cancelled-error.md | 14 +++++ apps/docs/content/adapters/core.mdx | 6 +-- apps/docs/content/concepts/blockers.mdx | 4 +- apps/docs/content/concepts/glossary.mdx | 5 +- .../content/guides/dismissal-blockers.mdx | 4 +- apps/docs/content/guides/error-handling.mdx | 22 ++++++++ apps/docs/content/guides/nested-overlays.mdx | 2 +- apps/docs/content/reference/core-api.mdx | 28 +++++++++- docs/architecture.md | 4 +- docs/glossary.md | 5 +- packages/alpine/src/index.ts | 2 +- packages/angular/src/index.ts | 2 +- packages/core/skills/layers/SKILL.md | 5 +- packages/core/src/errors.ts | 41 ++++++++++++++ packages/core/src/index.ts | 4 +- packages/core/src/layerClient.ts | 17 +++++- packages/core/src/layerGroup.test.ts | 17 ++++-- packages/core/src/layerStack.test.ts | 43 +++++++++++++++ packages/core/src/layerStack.ts | 53 +++++++++++++++++++ packages/core/src/types.ts | 1 + packages/devtools/src/live-actions.test.ts | 3 +- packages/devtools/src/live-actions.ts | 9 ++-- packages/lit/src/index.ts | 6 ++- packages/preact/src/index.ts | 2 +- packages/react/src/index.tsx | 2 +- packages/solid/src/index.ts | 2 +- packages/svelte/src/index.ts | 2 +- packages/svelte/src/store.ts | 2 +- packages/vue/src/index.ts | 2 +- 29 files changed, 272 insertions(+), 37 deletions(-) create mode 100644 .changeset/layer-cancelled-error.md diff --git a/.changeset/layer-cancelled-error.md b/.changeset/layer-cancelled-error.md new file mode 100644 index 0000000..5fd5a97 --- /dev/null +++ b/.changeset/layer-cancelled-error.md @@ -0,0 +1,14 @@ +--- +"@stainless-code/layers": patch +"@stainless-code/react-layers": patch +"@stainless-code/preact-layers": patch +"@stainless-code/vue-layers": patch +"@stainless-code/solid-layers": patch +"@stainless-code/angular-layers": patch +"@stainless-code/svelte-layers": patch +"@stainless-code/alpine-layers": patch +"@stainless-code/lit-layers": patch +"@stainless-code/layers-devtools": patch +--- + +Add `cancelAll` and `LayerCancelledError`: system teardown (parent-dismiss child drain, layer-group dispose, host disconnect) now **rejects** `open()` instead of resolving `undefined`. User `dismiss` / `dismissAll(response)` still complete with `R`. Narrow with `isLayerCancelledError`. diff --git a/apps/docs/content/adapters/core.mdx b/apps/docs/content/adapters/core.mdx index 4a52adf..1077bfb 100644 --- a/apps/docs/content/adapters/core.mdx +++ b/apps/docs/content/adapters/core.mdx @@ -19,8 +19,8 @@ npm i @stainless-code/layers | Export | Role | | ------ | ---- | -| `LayerClient` | App-wide orchestrator — `open()`, `getStack()`, `ensureStack()`, `subscribeStacks()`, `dismissAll()` | -| `LayerStack` | One named surface — `getSnapshot()`, `subscribe()`, `dismiss()`, `dismissAll()`, `addBlocker()`, `settle()`, `cancelQueued()` | +| `LayerClient` | App-wide orchestrator — `open()`, `getStack()`, `ensureStack()`, `subscribeStacks()`, `dismissAll()`, `cancelAll()` | +| `LayerStack` | One named surface — `getSnapshot()`, `subscribe()`, `dismiss()`, `dismissAll()`, `cancelAll()`, `addBlocker()`, `settle()`, `cancelQueued()` | | `layerOptions()` | Brand a reusable layer definition; `key` carries a `DataTag` for response inference | | `layerKey()` | Brand a key alone: `layerKey()(key)` | | `DataTag`, `InferDataTagResponse`, `ResponseOf`, `ErrorOf` | Compile-time key branding helpers | @@ -30,7 +30,7 @@ npm i @stainless-code/layers | `notifyManager` | `{ batch, batchCalls }` — coalesce notifications inside a tick | | `Register` | Module augmentation interface for app-wide `DefaultLayerError` | -The core also exports `Layer`, `Subscribable`, `ControlledPromise`, `PayloadValidationError`, `isPayloadValidationError`, `LayerKeyError`, `isLayerKeyError`, `assertLayerKey`, `hashKey`, `keySignature`, `childStackId`, and the full type surface (`LayerState`, `LayerPhase`, `LayerCallContext`, `StackOptions`, …). See [Core API](/reference/core-api). +The core also exports `Layer`, `Subscribable`, `ControlledPromise`, `PayloadValidationError`, `isPayloadValidationError`, `LayerKeyError`, `isLayerKeyError`, `LayerCancelledError`, `isLayerCancelledError`, `assertLayerKey`, `hashKey`, `keySignature`, `childStackId`, and the full type surface (`LayerState`, `LayerPhase`, `LayerCallContext`, `StackOptions`, …). See [Core API](/reference/core-api). ## Headless quick start diff --git a/apps/docs/content/concepts/blockers.mdx b/apps/docs/content/concepts/blockers.mdx index 957f24b..8529809 100644 --- a/apps/docs/content/concepts/blockers.mdx +++ b/apps/docs/content/concepts/blockers.mdx @@ -53,8 +53,8 @@ Default configurable via `StackOptions.dismissAllMode` / `LayerClientOptions.def | ---- | -------- | | `end` / `dismiss` (user intent) | Honor | | `cancelQueued` (serial, never mounted) | Skip | -| Component / group-hook unmount | Honor | -| Layer-group cascade (`onLayerDismiss` → drain child stacks) | Force children — guard the parent instead | +| `cancelAll` / group-hook unmount / host disconnect | Skip | +| Layer-group cascade (`onLayerDismiss` → `cancelAll` on children) | Skip — rejects child `open()` with `LayerCancelledError`; guard the parent instead | :::tip For adapter-level patterns — dirty-form guards, confirm dialogs, backdrop handling — see [Dismissal & blockers](/guides/dismissal-blockers). diff --git a/apps/docs/content/concepts/glossary.mdx b/apps/docs/content/concepts/glossary.mdx index 028ba61..983fab7 100644 --- a/apps/docs/content/concepts/glossary.mdx +++ b/apps/docs/content/concepts/glossary.mdx @@ -20,7 +20,7 @@ From `LayerClient` down to `keySignature` — every term in code, docs, and issu - **Key** — the **logical** identity of a layer (`find`/`upsert`/`gcTime` operate on `keySignature(key)`). Must be JSON-safe (`string` \| `boolean` \| `null` \| finite `number` \| plain objects/arrays of those); invalid segments throw `LayerKeyError`. Multiple live layers may share a key in a `parallel` stack. - **Instance id** (`LayerState.id`) — the **physical**, unique id of one `Layer` instance (`` `${hashKey(key)}#n` ``); used for rendering keys and instance lookup/removal (`getLayer`, `#remove`). - **Call context** (`LayerCallContext`) — imperative handle handed to a layer component: `end`/`dismiss` (async — resolve the caller's `await`; return `Promise`), `addBlocker`, `update` (patch payload live), `setRunning` (drives `actionStatus`), `settle` (drives `transition`), `ended`, `index`, `stackSize`, `root`, `stackId`, `layerId`. Built by `createCallContext`. - - **Layer group** — a child stack owned by a parent layer and tied to its lifetime: created via `createLayerGroup` / adapter `useLayerGroup`; when the parent dismisses, the group's stack auto-drains. Same `LayerClient`, no second client. + - **Layer group** — a child stack owned by a parent layer and tied to its lifetime: created via `createLayerGroup` / adapter `useLayerGroup`; when the parent dismisses, the group's stack is `cancelAll`'d (`LayerCancelledError`). Same `LayerClient`, no second client. - **Child stack** — the stack a layer group opens onto; id derived from the parent's `stackId` + `layerId` via `childStackId` (`` `${parentStackId}~${parentLayerId}~${name}` ``). @@ -32,8 +32,9 @@ From `LayerClient` down to `keySignature` — every term in code, docs, and issu - **enteringDelay** — ms the enter transition runs before settling; `0` (default) = instant. `call.settle()` finishes early. - **exitingDelay** — ms the exit transition runs before removal; `0` (default) = instant. `call.settle()` removes early. - **Action status** — independent axis (`idle` | `running`) for in-layer mutations, separate from `phase` and `transition`. - - **open** — push a layer onto a stack; returns `Promise` resolved by `dismiss`. The caller `await`s the user's decision. + - **open** — push a layer onto a stack; returns `Promise` resolved by `dismiss`, or rejected by load/validation/`cancelAll`. The caller `await`s the user's decision. - **dismiss** — async (`Promise`): consult blockers (unless `{ force: true }`), then resolve the layer's promise with a response, flip `ended`, set `phase: "dismissed"` + `transition: "exiting"`, then remove after `exitingDelay` or `call.settle()` (whichever first). Return `true` if dismissed, `false` if vetoed. + - **cancelAll** — force-clear a stack; reject every open/queued caller with `LayerCancelledError` (skips blockers). System teardown — distinct from `dismissAll(response)`. - **cancelQueued** — resolve and remove a serial `queued` layer without mounting; skips blockers. Omit `{ id }` → FIFO head for the key; pass `{ id }` → exact queued instance. - **dismissing** — `LayerState.dismissing`: `true` while a user-intent dismiss is consulting blockers; `false` on veto or removal. Use to disable close UI during async confirm. diff --git a/apps/docs/content/guides/dismissal-blockers.mdx b/apps/docs/content/guides/dismissal-blockers.mdx index d9156b5..a02d0c1 100644 --- a/apps/docs/content/guides/dismissal-blockers.mdx +++ b/apps/docs/content/guides/dismissal-blockers.mdx @@ -81,8 +81,8 @@ Default mode is configurable via `StackOptions.dismissAllMode` or `LayerClientOp | `call.end` / `call.dismiss` | Honored | | `stack.dismiss` / `stack.dismissAll` | Honored | | `cancelQueued` (serial, never mounted) | Skipped | -| Component/outlet unmount, route teardown | Skipped (force) | -| Layer-group cascade on parent dismiss | Skipped (force) — guard the parent | +| `cancelAll` / component unmount / host disconnect | Skipped — rejects `open()` with `LayerCancelledError` | +| Layer-group cascade on parent dismiss | Skipped — child `cancelAll({ reason: "parentDismiss" })`; guard the parent | :::danger Predicate throw/reject is treated as a veto (fail-closed). Confirm UI for async blockers is always your responsibility — core never opens UI on your behalf. diff --git a/apps/docs/content/guides/error-handling.mdx b/apps/docs/content/guides/error-handling.mdx index 1917aef..cf62865 100644 --- a/apps/docs/content/guides/error-handling.mdx +++ b/apps/docs/content/guides/error-handling.mdx @@ -17,6 +17,9 @@ The promise rejects when: 1. **`loadFn` throws** — typed at runtime as the layer's error type `E` 2. **`validate` fails** — `PayloadValidationError` +3. **System teardown** — `LayerCancelledError` from `cancelAll` (parent dismiss drain, layer-group dispose, host disconnect) + +User dismiss / `dismissAll(response)` still **resolves** with `R` (including `undefined` for `R = void`). Narrow cancel with `isLayerCancelledError` — do not treat it as the user choosing a falsy response. Dismissal during `pending` resolves with the dismissal response (the in-flight `loadFn` is aborted via `AbortController`); it does not reject. @@ -50,6 +53,25 @@ try { } ``` +## Narrow teardown cancel + +```ts lineNumbers +import { createLayer, isLayerCancelledError } from "@stainless-code/layers"; + +const c = createLayer(confirm, client); + +try { + const ok = await c.open(payload); + if (ok) deleteThing(); +} catch (err) { + if (isLayerCancelledError(err)) { // [!code highlight] + // parent/group/host tore the stack down — not user "No" + return; + } + throw err; +} +``` + ## App-wide error type Augment `Register` once to set `DefaultLayerError` for every layer's `E` default: diff --git a/apps/docs/content/guides/nested-overlays.mdx b/apps/docs/content/guides/nested-overlays.mdx index 2f808c2..8012ebf 100644 --- a/apps/docs/content/guides/nested-overlays.mdx +++ b/apps/docs/content/guides/nested-overlays.mdx @@ -5,7 +5,7 @@ search: tags: ["guide"] --- -A drawer that opens a sub-dialog needs its own stack — but tied to the parent layer's lifetime. Layer groups create a child stack on the same `LayerClient`; when the parent dismisses, children drain automatically. +A drawer that opens a sub-dialog needs its own stack — but tied to the parent layer's lifetime. Layer groups create a child stack on the same `LayerClient`; when the parent dismisses, children are cleared with `cancelAll` and awaiting child `open()` promises reject with `LayerCancelledError` (not a completion response). ## Create a child stack diff --git a/apps/docs/content/reference/core-api.mdx b/apps/docs/content/reference/core-api.mdx index de60f00..9aa6416 100644 --- a/apps/docs/content/reference/core-api.mdx +++ b/apps/docs/content/reference/core-api.mdx @@ -30,19 +30,24 @@ class LayerClient { response?: unknown, opts?: DismissAllOptions, ): Promise; + cancelAll( + stackId?: string, + opts?: { reason?: LayerCancelReason }, + ): Promise; } ``` | Method | Purpose | | ----------------- | ------------------------------------------------------------------------------------------------------ | -| `open` | Opens a layer on a stack; resolves with the dismissal response (or rejects on load/validation failure) | +| `open` | Opens a layer on a stack; resolves with the dismissal response (or rejects on load/validation/cancel) | | `getStack` | Returns a stack by id, creating the default stack on first access | | `ensureStack` | Returns a stack, applying `StackOptions` only when creating it | | `getStackIds` | Lists materialized stack ids | | `subscribeStacks` | Notifies when a stack is created for the first time | | `subscribeNotify` | Labeled stack transitions (`StackNotifyEvent`) for inspectors / Devtools | | `seedNotify` | Re-emits a `register` notify for one stack, or all materialized stacks when omitted | -| `dismissAll` | Async bulk dismiss on one stack (honors blockers unless `mode: "force"`) | +| `dismissAll` | Async bulk dismiss on one stack (honors blockers unless `mode: "force"`); completes with `response` | +| `cancelAll` | Force-clear; rejects every open/queued caller with `LayerCancelledError` (skips blockers) | ; dismissAll(response?, opts?: DismissAllOptions): Promise; + cancelAll(opts?: { reason?: LayerCancelReason }): Promise; addBlocker(fn: StackBlockerFn): () => void; cancelQueued(key: LayerKey, response, opts?: { id?: string }): boolean; settle(layer): void; @@ -187,6 +193,24 @@ function isLayerKeyError(value: unknown): value is LayerKeyError; function assertLayerKey(key: unknown): asserts key is LayerKey; ``` +## Layer cancel errors + +System teardown (`cancelAll`, parent-dismiss child drain, group dispose, host disconnect) rejects `open()` with `LayerCancelledError`. User `dismiss` / `dismissAll(response)` still resolve with `R`. See [Error handling](/guides/error-handling). + +```ts +type LayerCancelReason = + | "parentDismiss" + | "groupDispose" + | "cancelAll" + | "stackDisconnect"; + +class LayerCancelledError extends Error { + readonly reason: LayerCancelReason; +} + +function isLayerCancelledError(value: unknown): value is LayerCancelledError; +``` + ## createLayer Wire `layerOptions` + a `LayerClient` into a headless handle. Adapters wrap this in reactive `useLayer` / `injectLayer` / `createLayer`. diff --git a/docs/architecture.md b/docs/architecture.md index 014f7f3..2056b08 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -127,7 +127,7 @@ call.dismiss(response, opts?: { force?: boolean }): Promise; Return value = "did it dismiss?". `{ force: true }` bypasses blockers. Repeat `end`/`dismiss` while `dismissing` dedupes to the in-flight promise; `force` wins immediately. Predicate throw/reject = veto (fail-closed; dev warning). -**Paths** — honor blockers: `end`/`dismiss` (user intent). Skip: `cancelQueued` (serial, never mounted). Force: component/outlet unmount, route teardown; **layer-group cascade** (`onLayerDismiss` → `#drainChildStacks`) forces children — guard the parent instead. +**Paths** — honor blockers: `end`/`dismiss` (user intent). Skip: `cancelQueued` (serial, never mounted), `cancelAll` (system teardown). **Layer-group cascade** (`onLayerDismiss` → `#drainChildStacks` → `cancelAll`) rejects child `open()` with `LayerCancelledError` — guard the parent instead. **`dismissAll` modes** — `stack.dismissAll(response, opts?: { mode? })` is async: @@ -182,7 +182,7 @@ Narrow in a `catch` with the shipped guards (`isPayloadValidationError`) or the ## Layer groups -A parent layer owns a **child stack** on the same `LayerClient` via `createLayerGroup(client, call)` (each adapter wraps it as `useLayerGroup(call)`). The child stack id is derived collision-safely from the parent's `stackId` + instance `layerId` — `` `${parentStackId}~${parentLayerId}~${name}` `` — so sibling and nested groups never clash. Lifetime is **event-driven**: `LayerStack.onLayerDismiss` fires on dismiss → the client drains that layer's registered child stacks, recursively for nesting. No second client, no manual cleanup. Rejected alternatives: a second `LayerClient` (disconnected from provider/config) and a `call.group()` method (would couple the call-context module to the client). +A parent layer owns a **child stack** on the same `LayerClient` via `createLayerGroup(client, call)` (each adapter wraps it as `useLayerGroup(call)`). The child stack id is derived collision-safely from the parent's `stackId` + instance `layerId` — `` `${parentStackId}~${parentLayerId}~${name}` `` — so sibling and nested groups never clash. Lifetime is **event-driven**: `LayerStack.onLayerDismiss` fires on dismiss → the client `cancelAll`s that layer's registered child stacks (`reason: "parentDismiss"`), recursively for nesting. No second client, no manual cleanup. Rejected alternatives: a second `LayerClient` (disconnected from provider/config) and a `call.group()` method (would couple the call-context module to the client). ## Type defaults & inference diff --git a/docs/glossary.md b/docs/glossary.md index 026f6ec..291df53 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -20,8 +20,9 @@ Ubiquitous language for the `@stainless-code/layers` domain. Keep terms stable a - **exitingDelay** — ms the exit transition runs before removal; `0` (default) = instant. `call.settle()` removes early. - **Action status** — independent axis (`idle` | `running`) for in-layer mutations, separate from `phase` and `transition`. - **Call context** (`LayerCallContext`) — imperative handle handed to a layer component: `end`/`dismiss` (async — resolve the caller's `await`; return `Promise`), `addBlocker`, `update` (patch payload live), `setRunning` (drives `actionStatus`), `settle` (drives `transition`), `ended`, `index`, `stackSize`, `root`, `stackId`, `layerId`. Built by `createCallContext`. -- **open** — push a layer onto a stack; returns `Promise` resolved by `dismiss`. The caller `await`s the user's decision. +- **open** — push a layer onto a stack; returns `Promise` resolved by `dismiss`, or rejected by `loadFn` / validation / `cancelAll`. The caller `await`s the user's decision. - **dismiss** — async (`Promise`): consult blockers (unless `{ force: true }`), then resolve the layer's promise with a response, flip `ended`, set `phase: "dismissed"` + `transition: "exiting"`, then remove after `exitingDelay` or `call.settle()` (whichever first). Return `true` if dismissed, `false` if vetoed. +- **cancelAll** — force-clear a stack; reject every open/queued caller with `LayerCancelledError` (skips blockers). Used for parent-dismiss child drain, group dispose, and host disconnect — distinct from `dismissAll(response)` which completes with `R`. - **cancelQueued** — resolve and remove a serial `queued` layer without mounting; skips blockers. Omit `{ id }` → FIFO head for the key; pass `{ id }` → exact queued instance. - **blocker** — consumer predicate gating user-intent dismissal; `true` = allow, falsy = veto. Instance (`call.addBlocker`) or stack (`stack.addBlocker`) scope; multiple allowed; async-capable; throw/reject = veto (fail-closed). - **addBlocker** — register a blocker; returns a disposer. `call.addBlocker(fn)` — instance scope (`() => boolean | Promise`). `stack.addBlocker(fn)` — stack policy (`(layer) => boolean | Promise`). @@ -35,7 +36,7 @@ Ubiquitous language for the `@stainless-code/layers` domain. Keep terms stable a - **Options helper** (`layerOptions`) — identity function carrying `` generics so `LayerClient.open` infers the response type end-to-end. - **Outlet** — renders the active stack. React, Preact, and Solid ship `StackOutlet`; Angular exposes `renderStack(vcr)`; Vue ships `StackOutlet`; Lit ships ``; Alpine ships `x-layer-outlet` + `$layer`; Svelte renders from `useStack().current` + `callFor`. See [adapter ergonomics](./architecture.md#adapter-ergonomics). - **Stack handles** (`useStackHandles`) — headless `{ states, getCall }` for custom hosts (React/Preact/Solid/Angular/Vue/Lit; Alpine Rank-2 = subscribe + render yourself via `layerStack` + `callFor`; Svelte's `useStack()` already returns `.current` + `callFor`). `StackOutlet` is the registered-component convenience built on it where shipped. -- **Layer group** — a child stack owned by a parent layer and tied to its lifetime: created via `createLayerGroup` / adapter `useLayerGroup`; when the parent dismisses, the group's stack auto-drains. Same `LayerClient`, no second client. +- **Layer group** — a child stack owned by a parent layer and tied to its lifetime: created via `createLayerGroup` / adapter `useLayerGroup`; when the parent dismisses, the group's stack is `cancelAll`'d (`reason: "parentDismiss"`). Same `LayerClient`, no second client. - **Child stack** — the stack a layer group opens onto; id derived from the parent's `stackId` + `layerId` via `childStackId` (`` `${parentStackId}~${parentLayerId}~${name}` ``). - **Validator** (`validate`) — a Standard Schema or sync `(input) => output` fn that parses/validates a layer's `payload` at `open`; the layer stores the parsed output. Failure rejects `open` with a `PayloadValidationError`. - **createLayer** — core factory: `options` + `client` → headless `LayerHandle` / `ValidatedLayerHandle` (layer ops + `stack`/`client`/`options`/`current` escapes; no reactive field). diff --git a/packages/alpine/src/index.ts b/packages/alpine/src/index.ts index e30a279..7f98324 100644 --- a/packages/alpine/src/index.ts +++ b/packages/alpine/src/index.ts @@ -486,7 +486,7 @@ export function useLayerGroup( const states = useStack({ stack: stackId }, resolved); const dispose = () => { group.dispose(); - resolved.dismissAll(group.stackId); + resolved.cancelAll(group.stackId, { reason: "groupDispose" }); states.destroy(); }; const open = (< diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index 2d74b48..b6103a4 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -512,7 +512,7 @@ export function useLayerGroup( inject(DestroyRef).onDestroy(() => { group.dispose(); - client.dismissAll(group.stackId); + client.cancelAll(group.stackId, { reason: "groupDispose" }); }); const states = useStack({ stack: stackId }); diff --git a/packages/core/skills/layers/SKILL.md b/packages/core/skills/layers/SKILL.md index 545e793..d69d4a3 100644 --- a/packages/core/skills/layers/SKILL.md +++ b/packages/core/skills/layers/SKILL.md @@ -171,7 +171,7 @@ call.addBlocker(async () => ); ``` -`dismissing` is `true` while blockers run, and `{ force: true }` bypasses them. `stack.dismissAll(response, { mode })` supports `skipBlocked`, `stopAtBlocked`, and `force`; the default is `skipBlocked`, configurable per stack with `dismissAllMode` in `defaultStackOptions`. Teardown, unmount, and parent-child cascade force dismissal. +`dismissing` is `true` while blockers run, and `{ force: true }` bypasses them. `stack.dismissAll(response, { mode })` supports `skipBlocked`, `stopAtBlocked`, and `force`; the default is `skipBlocked`, configurable per stack with `dismissAllMode` in `defaultStackOptions`. System teardown uses `cancelAll` (parent-dismiss child drain, group dispose, host disconnect) and rejects `open()` with `LayerCancelledError` — narrow with `isLayerCancelledError`. Do not conflate that with user dismiss / `dismissAll(response)`, which still resolve with `R`. ## Payload validation @@ -260,7 +260,7 @@ group.dispose(); ## Multi-stack -Use `ensureStack(id, options?)` to materialize a configured surface, `getStackIds()` to enumerate materialized stacks, and `subscribeStacks(listener)` to observe newly created stacks. `LayerClient.dismissAll(stackId, response?, options?)` drains a named surface. +Use `ensureStack(id, options?)` to materialize a configured surface, `getStackIds()` to enumerate materialized stacks, and `subscribeStacks(listener)` to observe newly created stacks. `LayerClient.dismissAll(stackId, response?, options?)` completes open callers with a response; `LayerClient.cancelAll(stackId?, { reason? })` force-clears and rejects with `LayerCancelledError`. ## Public API @@ -272,6 +272,7 @@ The complete public API surface: | Infrastructure classes | `Subscribable`, `ControlledPromise` | | Validation class | `PayloadValidationError` | | Key errors | `LayerKeyError`, `isLayerKeyError`, `assertLayerKey` | +| Cancel errors | `LayerCancelledError`, `isLayerCancelledError`, `LayerCancelReason` | | Declaration and inference | `layerOptions`, `layerKey`, `DataTag`, `InferDataTagResponse`, `InferDataTagError`, `ResponseOf`, `ErrorOf` | | Wired handles | `createLayer`, `LayerHandle`, `ValidatedLayerHandle` | | Rendering seam | `createCallContext`, `LayerCallContext`, `LayerComponentProps`, `LayerComponent` | diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 61a998b..5ab2be5 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -66,3 +66,44 @@ export class LayerKeyError extends Error { export function isLayerKeyError(value: unknown): value is LayerKeyError { return value instanceof LayerKeyError; } + +/** Why {@link LayerCancelledError} rejected an `open()` promise. */ +export type LayerCancelReason = + | "parentDismiss" + | "groupDispose" + | "cancelAll" + | "stackDisconnect"; + +/** + * Rejects `open()` when a stack is torn down without a completion response + * (`cancelAll`, parent dismiss drain, group dispose, host disconnect). + */ +export class LayerCancelledError extends Error { + readonly reason: LayerCancelReason; + constructor(reason: LayerCancelReason = "cancelAll") { + super(`LayerCancelledError: ${reason}`); + this.name = "LayerCancelledError"; + this.reason = reason; + } +} + +/** + * Narrows an unknown rejection to {@link LayerCancelledError}. + * + * @example + * ```ts + * import { isLayerCancelledError } from "@stainless-code/layers"; + * + * try { + * await confirm.open(payload); + * } catch (error) { + * if (isLayerCancelledError(error)) return; + * throw error; + * } + * ``` + */ +export function isLayerCancelledError( + value: unknown, +): value is LayerCancelledError { + return value instanceof LayerCancelledError; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 640f485..7227f14 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,5 +38,7 @@ export { isPayloadValidationError, LayerKeyError, isLayerKeyError, + LayerCancelledError, + isLayerCancelledError, } from "./errors"; -export type { ValidationIssue } from "./errors"; +export type { ValidationIssue, LayerCancelReason } from "./errors"; diff --git a/packages/core/src/layerClient.ts b/packages/core/src/layerClient.ts index 3b75249..d250831 100644 --- a/packages/core/src/layerClient.ts +++ b/packages/core/src/layerClient.ts @@ -1,4 +1,5 @@ import type { DataTag } from "./dataTag"; +import type { LayerCancelReason } from "./errors"; import { LayerStack } from "./layerStack"; import type { DefaultLayerError, @@ -96,7 +97,7 @@ export class LayerClient { return; } for (const childId of childIds) { - void this.#stacks.get(childId)?.dismissAll(undefined, { mode: "force" }); + void this.#stacks.get(childId)?.cancelAll({ reason: "parentDismiss" }); } this.#childStacksByParent.delete(parentLayerId); } @@ -199,4 +200,18 @@ export class LayerClient { this.#stack(stackId) as LayerStack ).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. + */ + cancelAll( + stackId = "default", + opts?: { reason?: LayerCancelReason }, + ): Promise { + return ( + this.#stack(stackId) as LayerStack + ).cancelAll(opts); + } } diff --git a/packages/core/src/layerGroup.test.ts b/packages/core/src/layerGroup.test.ts index b15c188..ca77fa9 100644 --- a/packages/core/src/layerGroup.test.ts +++ b/packages/core/src/layerGroup.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; +import { isLayerCancelledError, LayerCancelledError } from "./errors"; import { LayerClient } from "./layerClient"; import { childStackId, createLayerGroup } from "./layerGroup"; import { LayerStack } from "./layerStack"; @@ -43,7 +44,17 @@ describe("createLayerGroup — cascade", () => { await drawer.dismiss(parentLayer, false); expect(childStack.getSnapshot()).toHaveLength(0); - expect(await childPending).toBe(undefined); + let childError: unknown; + try { + await childPending; + } catch (error) { + childError = error; + } + expect(childError).toBeInstanceOf(LayerCancelledError); + expect(isLayerCancelledError(childError)).toBe(true); + if (isLayerCancelledError(childError)) { + expect(childError.reason).toBe("parentDismiss"); + } void parentPending; }); }); @@ -84,8 +95,8 @@ describe("createLayerGroup — nesting", () => { await drawer.dismiss(parentLayer, false); expect(childStack.getSnapshot()).toHaveLength(0); expect(grandchildStack.getSnapshot()).toHaveLength(0); - expect(await childPending).toBe(undefined); - expect(await grandchildPending).toBe(undefined); + await expect(childPending).rejects.toBeInstanceOf(LayerCancelledError); + await expect(grandchildPending).rejects.toBeInstanceOf(LayerCancelledError); }); }); diff --git a/packages/core/src/layerStack.test.ts b/packages/core/src/layerStack.test.ts index 999dc50..fd3c2bb 100644 --- a/packages/core/src/layerStack.test.ts +++ b/packages/core/src/layerStack.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "bun:test"; import { + isLayerCancelledError, isLayerKeyError, isPayloadValidationError, + LayerCancelledError, LayerKeyError, PayloadValidationError, } from "./errors"; @@ -327,6 +329,47 @@ describe("LayerStack — scope serial", () => { expect(stack.getQueuedSnapshot()).toHaveLength(0); }); + it("cancelAll rejects active and queued open() promises", async () => { + const stack = new LayerStack<{ n: number }, boolean>("s", { + scope: { strategy: "serial" }, + }); + const a = stack.open({ key: ["a"], payload: { n: 1 } }); + const b = stack.open({ key: ["b"], payload: { n: 2 } }); + expect(stack.getQueuedSnapshot()).toHaveLength(1); + await stack.cancelAll({ reason: "cancelAll" }); + await expect(a.promise.promise).rejects.toBeInstanceOf(LayerCancelledError); + await expect(b.promise.promise).rejects.toBeInstanceOf(LayerCancelledError); + expect(stack.getSnapshot()).toHaveLength(0); + expect(stack.getQueuedSnapshot()).toHaveLength(0); + try { + await a.promise.promise; + } catch (error) { + expect(isLayerCancelledError(error)).toBe(true); + if (isLayerCancelledError(error)) { + expect(error.reason).toBe("cancelAll"); + } + } + }); + + it("cancelAll skips blockers", async () => { + const stack = new LayerStack<{ n: number }, boolean>("s"); + const layer = stack.open({ key: ["a"], payload: { n: 1 } }); + stack.addBlocker(() => false); + await stack.cancelAll(); + await expect(layer.promise.promise).rejects.toBeInstanceOf( + LayerCancelledError, + ); + expect(stack.getSnapshot()).toHaveLength(0); + }); + + it("dismissAll(undefined) for void R still resolves (not cancel)", async () => { + const stack = new LayerStack<{ n: number }, void>("s"); + const layer = stack.open({ key: ["a"], payload: { n: 1 } }); + await stack.dismissAll(undefined); + await expect(layer.promise.promise).resolves.toBe(undefined); + expect(stack.getSnapshot()).toHaveLength(0); + }); + it("onLoadError block (default): error occupies lane; queued waits; no leapfrog", async () => { let rejectLoad!: (error: Error) => void; const stack = new LayerStack<{ n: number }, boolean, Error>("s", { diff --git a/packages/core/src/layerStack.ts b/packages/core/src/layerStack.ts index 7a515e3..8c78481 100644 --- a/packages/core/src/layerStack.ts +++ b/packages/core/src/layerStack.ts @@ -1,3 +1,5 @@ +import { LayerCancelledError } from "./errors"; +import type { LayerCancelReason } from "./errors"; import { Layer } from "./layer"; import { createLayerGcCache } from "./layerGcCache"; import { notifyManager } from "./notifyManager"; @@ -415,6 +417,57 @@ export class LayerStack< } } + /** + * 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. + */ + async cancelAll(opts?: { reason?: LayerCancelReason }): Promise { + 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.onLayerDismiss?.(layer); + this.#gcCache.maybeStore(layer); + } + this.#flush(); + }); + } finally { + if (shouldEmit) { + this.#emitNotify("cancelAll"); + } + } + } + + /** Abort, reject open(), mark dismissed — no completion response. */ + #rejectCancel(layer: Layer, 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. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a319e9d..e24cd60 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -192,6 +192,7 @@ export type StackNotifyAction = | "dismiss" | "dismissVetoed" | "dismissAll" + | "cancelAll" | "cancelQueued" | "phase" | "remove"; diff --git a/packages/devtools/src/live-actions.test.ts b/packages/devtools/src/live-actions.test.ts index c4e3012..955176f 100644 --- a/packages/devtools/src/live-actions.test.ts +++ b/packages/devtools/src/live-actions.test.ts @@ -83,7 +83,8 @@ describe("live-actions", () => { await Promise.resolve(); await dismissAllWithMode(client, "default", "force"); - await Promise.all([a, b]); + await expect(a).rejects.toBeTruthy(); + await expect(b).rejects.toBeTruthy(); const stack = client.getStack("default"); expect(stack.getSnapshot()).toHaveLength(0); diff --git a/packages/devtools/src/live-actions.ts b/packages/devtools/src/live-actions.ts index 4dfb7af..df80ef3 100644 --- a/packages/devtools/src/live-actions.ts +++ b/packages/devtools/src/live-actions.ts @@ -66,14 +66,17 @@ export function forceDismissTop( return stack.dismiss(layer, undefined, { force: true }); } -/** Run {@link LayerClient#dismissAll} with an explicit mode. */ +/** + * Force-clears a stack via {@link LayerClient#cancelAll} (rejects open callers). + * `mode` is unused — kept for call-site compatibility with the mode picker UI. + */ export function dismissAllWithMode( client: LayerClient, stackId: string, - mode: DismissAllMode, + _mode: DismissAllMode, ): Promise { if (!client.getStackIds().includes(stackId)) { return Promise.resolve(); } - return client.dismissAll(stackId, undefined, { mode }); + return client.cancelAll(stackId, { reason: "cancelAll" }); } diff --git a/packages/lit/src/index.ts b/packages/lit/src/index.ts index b0aa0d2..2526994 100644 --- a/packages/lit/src/index.ts +++ b/packages/lit/src/index.ts @@ -930,7 +930,9 @@ export class LayerGroupController< this.#init(c); } else if (this.#client !== c) { this.#group.dispose(); - this.#client?.dismissAll(this.#stackId); + this.#client?.cancelAll(this.#stackId, { + reason: "stackDisconnect", + }); this.#init(c); } this.#states.bindClient(c); @@ -952,7 +954,7 @@ export class LayerGroupController< hostConnected(): void {} hostDisconnected(): void { this.#group?.dispose(); - this.#client?.dismissAll(this.#stackId); + this.#client?.cancelAll(this.#stackId, { reason: "stackDisconnect" }); } get stackId(): string { diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 7f1d4ca..19a1168 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -511,7 +511,7 @@ export function useLayerGroup( const group = createLayerGroup(client, call, options); return () => { group.dispose(); - client.dismissAll(group.stackId); + client.cancelAll(group.stackId, { reason: "groupDispose" }); }; }, [client, stackId]); diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx index b9c98fc..95a4334 100644 --- a/packages/react/src/index.tsx +++ b/packages/react/src/index.tsx @@ -537,7 +537,7 @@ export function useLayerGroup( const group = createLayerGroup(client, call, options); return () => { group.dispose(); - client.dismissAll(group.stackId); + client.cancelAll(group.stackId, { reason: "groupDispose" }); }; }, [client, stackId]); diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 7212124..c7e688e 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -525,7 +525,7 @@ export function useLayerGroup( onCleanup(() => { group.dispose(); - client.dismissAll(group.stackId); + client.cancelAll(group.stackId, { reason: "groupDispose" }); }); const states = useStack({ stack: stackId }); diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 5446755..eb47301 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -539,7 +539,7 @@ export function useLayerGroup( onDestroy(() => { group.dispose(); - client.dismissAll(group.stackId); + client.cancelAll(group.stackId, { reason: "groupDispose" }); }); const stack = useStack({ stack: stackId }); diff --git a/packages/svelte/src/store.ts b/packages/svelte/src/store.ts index d689122..ead87a6 100644 --- a/packages/svelte/src/store.ts +++ b/packages/svelte/src/store.ts @@ -486,7 +486,7 @@ export function useLayerGroup( onDestroy(() => { group.dispose(); - client.dismissAll(group.stackId); + client.cancelAll(group.stackId, { reason: "groupDispose" }); }); const stack = useStack({ stack: stackId }); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 9aa3009..a9511cb 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -534,7 +534,7 @@ export function useLayerGroup( onScopeDispose(() => { group.dispose(); - client.dismissAll(group.stackId); + client.cancelAll(group.stackId, { reason: "groupDispose" }); }); const states = useStack({ stack: stackId }); From 8e88bbd12810939e8e3fb7d9083c3cd254f5b81e Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sun, 19 Jul 2026 13:23:56 +0300 Subject: [PATCH 2/3] harden: split dismissAll vs cancelAll honesty across docs and devtools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore Dismiss all → dismissAll (resolve) and Force clear → cancelAll (reject); tighten JSDoc/skills/docs and cancelAll fail-safety coverage. --- .changeset/layer-cancelled-error.md | 2 +- .../content/concepts/identity-and-types.mdx | 2 +- apps/docs/content/examples/nested-confirm.mdx | 4 +- apps/docs/content/guides/awaiting-results.mdx | 1 + apps/docs/content/guides/devtools.mdx | 3 +- apps/docs/content/guides/nested-overlays.mdx | 2 +- .../content/integrations/base-ui/index.mdx | 2 +- .../content/integrations/react-aria/index.mdx | 2 +- apps/docs/recipes/nested-confirm/vue.vue | 2 +- docs/architecture.md | 2 +- .../angular/skills/angular-layers/SKILL.md | 2 +- packages/angular/src/index.ts | 3 +- packages/core/skills/layers/SKILL.md | 2 +- packages/core/src/errors.ts | 14 ++++- packages/core/src/layerClient.ts | 9 +++ packages/core/src/layerGroup.test.ts | 40 +++++++++++- packages/core/src/layerStack.test.ts | 62 +++++++++++++++++++ packages/core/src/layerStack.ts | 15 ++++- packages/core/src/types.ts | 14 ++++- .../devtools/src/components/StackActions.tsx | 24 ++++++- packages/devtools/src/index.ts | 1 + packages/devtools/src/live-actions.test.ts | 48 +++++++++++++- packages/devtools/src/live-actions.ts | 22 ++++++- packages/devtools/src/production.ts | 1 + packages/lit/src/index.test.ts | 25 ++++++++ packages/lit/src/index.ts | 6 +- packages/preact/skills/preact-layers/SKILL.md | 2 +- packages/preact/src/index.ts | 3 +- packages/react/skills/react-layers/SKILL.md | 2 +- packages/react/src/index.tsx | 3 +- packages/solid/skills/solid-layers/SKILL.md | 2 +- packages/solid/src/index.ts | 3 +- packages/svelte/skills/svelte-layers/SKILL.md | 2 +- packages/svelte/src/index.ts | 3 +- packages/svelte/src/store.ts | 3 +- packages/vue/skills/vue-layers/SKILL.md | 2 +- packages/vue/src/index.ts | 3 +- 37 files changed, 298 insertions(+), 40 deletions(-) diff --git a/.changeset/layer-cancelled-error.md b/.changeset/layer-cancelled-error.md index 5fd5a97..ae40c39 100644 --- a/.changeset/layer-cancelled-error.md +++ b/.changeset/layer-cancelled-error.md @@ -11,4 +11,4 @@ "@stainless-code/layers-devtools": patch --- -Add `cancelAll` and `LayerCancelledError`: system teardown (parent-dismiss child drain, layer-group dispose, host disconnect) now **rejects** `open()` instead of resolving `undefined`. User `dismiss` / `dismissAll(response)` still complete with `R`. Narrow with `isLayerCancelledError`. +Add `cancelAll` and `LayerCancelledError`: system teardown (parent-dismiss child drain, layer-group dispose, host disconnect) now **rejects** `open()` instead of resolving `undefined`. User `dismiss` / `dismissAll(response)` still complete with `R`. Narrow with `isLayerCancelledError`. Devtools: **Dismiss all** still calls `dismissAll` (resolves); **Force clear** calls `cancelAll` (rejects). diff --git a/apps/docs/content/concepts/identity-and-types.mdx b/apps/docs/content/concepts/identity-and-types.mdx index 1b208be..574ca29 100644 --- a/apps/docs/content/concepts/identity-and-types.mdx +++ b/apps/docs/content/concepts/identity-and-types.mdx @@ -114,7 +114,7 @@ declare module "@stainless-code/layers" { } ``` -`DefaultLayerError` defaults to `Error` unless augmented. `open` returns `Promise` — rejections are not typed on the promise (a TypeScript limitation); narrow at runtime with guards like `isPayloadValidationError`. +`DefaultLayerError` defaults to `Error` unless augmented. `open` returns `Promise` — rejections are not typed on the promise (a TypeScript limitation); narrow at runtime with guards like `isPayloadValidationError` and `isLayerCancelledError`. ## Payload validation diff --git a/apps/docs/content/examples/nested-confirm.mdx b/apps/docs/content/examples/nested-confirm.mdx index b62c1e2..c22f252 100644 --- a/apps/docs/content/examples/nested-confirm.mdx +++ b/apps/docs/content/examples/nested-confirm.mdx @@ -16,7 +16,7 @@ import nestedSvelteRunesSrc from "../../recipes/nested-confirm/svelte-runes.svel import nestedSvelteStoreSrc from "../../recipes/nested-confirm/svelte-store.svelte?raw"; import nestedAngularSrc from "../../recipes/nested-confirm/angular.ts?raw"; -Delete inside an open parent dialog. `useLayer(parent)` opens the parent; `group.open` scopes the confirm to that stack and auto-drains when the parent dismisses. +Delete inside an open parent dialog. `useLayer(parent)` opens the parent; `group.open` scopes the confirm to that stack. Parent dismiss runs `cancelAll` on the child stack (`LayerCancelledError`). :::note[Live] This demo runs the real `@stainless-code/react-layers` in your browser (React). @@ -61,5 +61,5 @@ One complete recipe per framework adapter (live demo above is React). ## Learn more -- [Nested overlays](/guides/nested-overlays) — child stacks, `Outlet`, and auto-drain +- [Nested overlays](/guides/nested-overlays) — child stacks, `Outlet`, and parent-dismiss `cancelAll` - [Confirm dialog](/examples/confirm-dialog) — typed `await confirm.open(...)` basics diff --git a/apps/docs/content/guides/awaiting-results.mdx b/apps/docs/content/guides/awaiting-results.mdx index 9a0842e..01924eb 100644 --- a/apps/docs/content/guides/awaiting-results.mdx +++ b/apps/docs/content/guides/awaiting-results.mdx @@ -225,4 +225,5 @@ Both payload and response are optional. A layer with `R = void` and no payload i - **`loadFn` errors** reject the promise — see [Error handling](/guides/error-handling). - **Validation failures** reject with `PayloadValidationError` before mount — see [Payload validation](/guides/payload-validation). +- **System teardown** (`cancelAll`, parent-dismiss child drain, group dispose, host disconnect) rejects with `LayerCancelledError` — narrow with `isLayerCancelledError`; see [Error handling](/guides/error-handling). - **Serial stacks** queue later opens; the promise resolves only when the layer activates and ends — see [Serial queues](/guides/serial-queues). diff --git a/apps/docs/content/guides/devtools.mdx b/apps/docs/content/guides/devtools.mdx index 98ad15f..3d96190 100644 --- a/apps/docs/content/guides/devtools.mdx +++ b/apps/docs/content/guides/devtools.mdx @@ -60,7 +60,8 @@ Buttons call the live `LayerClient` — not bus commands: | Soft dismiss | Top active layer — respects blockers | | Cancel queued | FIFO head of the serial queue | | Force dismiss | Top active layer with `{ force: true }` after confirm | -| Dismiss all | Modes: `skipBlocked` / `stopAtBlocked` / `force` | +| Dismiss all | Modes: `skipBlocked` / `stopAtBlocked` / `force` — open callers **resolve** with the dismiss response (void → `undefined`) | +| Force clear | `cancelAll` after confirm — open callers **reject** with `LayerCancelledError` | ## Subscribe without the panel diff --git a/apps/docs/content/guides/nested-overlays.mdx b/apps/docs/content/guides/nested-overlays.mdx index 8012ebf..a086cd0 100644 --- a/apps/docs/content/guides/nested-overlays.mdx +++ b/apps/docs/content/guides/nested-overlays.mdx @@ -273,7 +273,7 @@ Store entry renders via `$childStack` + standalone `callFor` — same `group.ope | `stackId` | Derived id — `` `${parentStackId}~${parentLayerId}~${name}` `` | :::warning[Heads up] -Child stacks cascade on parent dismiss — blockers on the parent are the right place to guard "close drawer with open sub-dialog?". Children are force-drained; see [Dismissal blockers](/guides/dismissal-blockers). +Child stacks cascade on parent dismiss — blockers on the parent are the right place to guard "close drawer with open sub-dialog?". Children are cleared via `cancelAll` (`LayerCancelledError`); see [Dismissal blockers](/guides/dismissal-blockers). ::: No second `LayerClient`, no manual cleanup. See [Lifecycle](/concepts/lifecycle) for how dismissal propagates. diff --git a/apps/docs/content/integrations/base-ui/index.mdx b/apps/docs/content/integrations/base-ui/index.mdx index 7d8cd44..e1aa277 100644 --- a/apps/docs/content/integrations/base-ui/index.mdx +++ b/apps/docs/content/integrations/base-ui/index.mdx @@ -33,7 +33,7 @@ Skip Base UI's trigger helpers — mount controlled `Dialog`, `Drawer`, and aler /> ({ const { call, payload } = defineProps>(); -// Owns a child stack scoped to this parent's lifetime; auto-drains on dismiss. +// Owns a child stack scoped to this parent's lifetime; parent dismiss cancelAlls it. const group = useLayerGroup(call); const childResult = ref(null); diff --git a/docs/architecture.md b/docs/architecture.md index 2056b08..a7f58db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,7 +21,7 @@ The seam is a **published package boundary** in a bun-workspaces monorepo: one z renders layers (StackOutlet, renderStack, or useStack().current — see § Adapter ergonomics) ``` -- **Core** (`packages/core` → `@stainless-code/layers`): `LayerClient` (`ensureStack`, `getStackIds`, `subscribeStacks`), `LayerStack` (`dismiss`/`dismissAll`/`addBlocker`/`settle`/`setRunning`/`cancelQueued`/`getQueuedSnapshot`), `Layer`, `Subscribable`, `notifyManager`, `ControlledPromise`, `createCallContext`, `layerOptions`, `layerKey`/`DataTag`, `createLayer`/`LayerHandle`/`ValidatedLayerHandle`, `createLayerGroup`/`childStackId`, `layerGcCache` (internal), `errors`, payload validation (`StandardSchemaV1`, `Validator`, `PayloadValidationError`, `isPayloadValidationError`), `Register`/`DefaultLayerError`, `utils`. Zero deps; `sideEffects: false`. Entry `.` → `src/index.ts`. +- **Core** (`packages/core` → `@stainless-code/layers`): `LayerClient` (`ensureStack`, `getStackIds`, `subscribeStacks`), `LayerStack` (`dismiss`/`dismissAll`/`cancelAll`/`addBlocker`/`settle`/`setRunning`/`cancelQueued`/`getQueuedSnapshot`), `Layer`, `Subscribable`, `notifyManager`, `ControlledPromise`, `createCallContext`, `layerOptions`, `layerKey`/`DataTag`, `createLayer`/`LayerHandle`/`ValidatedLayerHandle`, `createLayerGroup`/`childStackId`, `layerGcCache` (internal), `errors`, payload validation (`StandardSchemaV1`, `Validator`, `PayloadValidationError`, `isPayloadValidationError`), `Register`/`DefaultLayerError`, `utils`. Zero deps; `sideEffects: false`. Entry `.` → `src/index.ts`. - **Adapters** (`packages/` → `@stainless-code/-layers`): each declares the library or framework as a **required peer dependency** and `@stainless-code/layers` as a direct dependency, re-exports core (including `createLayer`), and exposes the wired/observe hooks (`useLayer` / `useLayerState` / `useQueuedStack` / `useLayerQueuedState` — names vary per § Adapter ergonomics), a `useStack`-shaped binding, an idiomatic client-context provider + `useLayerClient` (React/Preact `StackProvider`; Svelte `setLayerClient`; Vue/Lit `provideLayerClient`; Solid `LayerClientContext`; Angular `LAYER_CLIENT` + `provideLayerClient`; Alpine `getLayerClient` / `setLayerClient` in plugin closure), and a rendering surface (`StackOutlet`, or the imperative/primitive equivalent — see § Adapter ergonomics). Lit peers are `lit` + `@lit/context`; CEs register via explicit `defineStackElements()` (not on import). Alpine peers `alpinejs` (required) plus optional peer `@alpinejs/focus` (`peerDependenciesMeta.optional`); `./cdn` bootstrap mirrors ESM. **Svelte ships two entries in one package:** `.` (runes, `svelte/reactivity`, 5.7+) and `./store` (stores, `svelte/store`, 3.0+). All adapters have reached ergonomic parity; Angular, Alpine, and Svelte diverge by design (compiler-free / markup-in-template → primitive rendering rather than shipped host components or a `component` registry); Lit diverges with shadow provider + light-DOM outlet CEs + `LayerGroup.outlet()` (footnote ⁷). Cross-package type resolution in dev uses a `@stainless-code/source` export condition (each package's `exports` maps it to `src`) plus tsconfig `customConditions`, so typecheck resolves core's source without a build-order dependency; published consumers get `dist` via the standard `types`/`import` conditions. diff --git a/packages/angular/skills/angular-layers/SKILL.md b/packages/angular/skills/angular-layers/SKILL.md index 08fa51d..8d456a9 100644 --- a/packages/angular/skills/angular-layers/SKILL.md +++ b/packages/angular/skills/angular-layers/SKILL.md @@ -279,7 +279,7 @@ The package is compiler-free — no shipped Angular components. Call `renderStac ## Nested layers -Use `useLayerGroup(call, options?)` inside a layer component (injection context). The child stack is disposed and dismissed when the parent layer unmounts. +Use `useLayerGroup(call, options?)` inside a layer component (injection context). Parent dismiss / unmount `cancelAll`s the child stack (`LayerCancelledError`); `dispose()` unbinds the lifetime hook. ```ts import { diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index b6103a4..7752cb6 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -500,7 +500,8 @@ export interface LayerGroup { /** * Create a child stack scoped to the calling layer's lifetime. * - * The child stack is disposed and dismissed when its parent layer unmounts. + * The child stack is disposed and cleared via `cancelAll` when its parent + * layer unmounts (`LayerCancelledError`). */ export function useLayerGroup( call: LayerCallContext, diff --git a/packages/core/skills/layers/SKILL.md b/packages/core/skills/layers/SKILL.md index d69d4a3..1a4ffd7 100644 --- a/packages/core/skills/layers/SKILL.md +++ b/packages/core/skills/layers/SKILL.md @@ -242,7 +242,7 @@ Serial scope allows only one occupying layer (`pending`, `active`, or `error` wh ## Nested layers -`createLayerGroup(client, call, options?)` creates a child stack scoped to its parent layer. Parent dismissal force-drains the child stack; `dispose()` removes the lifetime binding when the owner unmounts. +`createLayerGroup(client, call, options?)` creates a child stack scoped to its parent layer. Parent dismissal `cancelAll`s the child stack (`LayerCancelledError`, `reason: "parentDismiss"`); `dispose()` removes the lifetime binding when the owner unmounts. ```ts import { createLayerGroup } from "@stainless-code/layers"; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 5ab2be5..21cc751 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -67,7 +67,14 @@ export function isLayerKeyError(value: unknown): value is LayerKeyError { return value instanceof LayerKeyError; } -/** Why {@link LayerCancelledError} rejected an `open()` promise. */ +/** + * Why {@link LayerCancelledError} rejected an `open()` promise. + * + * - `parentDismiss` — child stack `cancelAll`'d when its parent layer dismissed + * - `groupDispose` — adapter / host cleaned up a `useLayerGroup` owner + * - `cancelAll` — explicit {@link LayerStack#cancelAll} / {@link LayerClient#cancelAll} + * - `stackDisconnect` — host disconnect (e.g. Lit `hostDisconnected`) + */ export type LayerCancelReason = | "parentDismiss" | "groupDispose" @@ -76,9 +83,10 @@ export type LayerCancelReason = /** * Rejects `open()` when a stack is torn down without a completion response - * (`cancelAll`, parent dismiss drain, group dispose, host disconnect). + * (`cancelAll`, parent dismiss child clear, group dispose, `stackDisconnect`). */ export class LayerCancelledError extends Error { + /** {@link LayerCancelReason} that triggered this rejection. */ readonly reason: LayerCancelReason; constructor(reason: LayerCancelReason = "cancelAll") { super(`LayerCancelledError: ${reason}`); @@ -89,6 +97,8 @@ export class LayerCancelledError extends Error { /** * Narrows an unknown rejection to {@link LayerCancelledError}. + * Treat any cancel reason as normal teardown unless you branch on + * {@link LayerCancelledError#reason}. * * @example * ```ts diff --git a/packages/core/src/layerClient.ts b/packages/core/src/layerClient.ts index d250831..df7f56e 100644 --- a/packages/core/src/layerClient.ts +++ b/packages/core/src/layerClient.ts @@ -191,6 +191,12 @@ export class LayerClient { } } + /** + * 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?: unknown, @@ -205,6 +211,9 @@ export class LayerClient { * 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", diff --git a/packages/core/src/layerGroup.test.ts b/packages/core/src/layerGroup.test.ts index ca77fa9..a70a012 100644 --- a/packages/core/src/layerGroup.test.ts +++ b/packages/core/src/layerGroup.test.ts @@ -95,12 +95,48 @@ describe("createLayerGroup — nesting", () => { await drawer.dismiss(parentLayer, false); expect(childStack.getSnapshot()).toHaveLength(0); expect(grandchildStack.getSnapshot()).toHaveLength(0); - await expect(childPending).rejects.toBeInstanceOf(LayerCancelledError); - await expect(grandchildPending).rejects.toBeInstanceOf(LayerCancelledError); + await expect(childPending).rejects.toMatchObject({ + name: "LayerCancelledError", + reason: "parentDismiss", + }); + await expect(grandchildPending).rejects.toMatchObject({ + name: "LayerCancelledError", + reason: "parentDismiss", + }); }); }); describe("createLayerGroup — dispose", () => { + it("adapter-style dispose + cancelAll rejects with groupDispose", async () => { + const client = new LayerClient(); + client.open({ + key: ["parent"], + payload: { title: "Parent" }, + stack: "drawer", + }); + const drawer = client.getStack("drawer") as unknown as LayerStack< + { title: string }, + boolean + >; + const parentLayer = drawer.find(["parent"])!; + const parent = { stackId: drawer.id, layerId: parentLayer.id }; + + const group = createLayerGroup(client, parent); + const childPending = group.open({ + key: ["child"], + payload: { label: "Child" }, + }); + expect(client.getStack(group.stackId).getSnapshot()).toHaveLength(1); + + group.dispose(); + await client.cancelAll(group.stackId, { reason: "groupDispose" }); + await expect(childPending).rejects.toMatchObject({ + name: "LayerCancelledError", + reason: "groupDispose", + }); + expect(client.getStack(group.stackId).getSnapshot()).toHaveLength(0); + }); + it("does not drain the child stack after dispose unbinds the parent", async () => { const client = new LayerClient(); client.open({ diff --git a/packages/core/src/layerStack.test.ts b/packages/core/src/layerStack.test.ts index fd3c2bb..6c384f2 100644 --- a/packages/core/src/layerStack.test.ts +++ b/packages/core/src/layerStack.test.ts @@ -351,6 +351,55 @@ describe("LayerStack — scope serial", () => { } }); + it("cancelAll rejects every parallel active open()", async () => { + const stack = new LayerStack<{ n: number }, boolean>("s"); + const a = stack.open({ key: ["a"], payload: { n: 1 } }); + const b = stack.open({ key: ["b"], payload: { n: 2 } }); + expect(stack.getSnapshot()).toHaveLength(2); + await stack.cancelAll({ reason: "groupDispose" }); + await expect(a.promise.promise).rejects.toMatchObject({ + name: "LayerCancelledError", + reason: "groupDispose", + }); + await expect(b.promise.promise).rejects.toMatchObject({ + name: "LayerCancelledError", + reason: "groupDispose", + }); + }); + + it("cancelAll propagates stackDisconnect reason", async () => { + const stack = new LayerStack<{ n: number }, boolean>("s"); + const layer = stack.open({ key: ["a"], payload: { n: 1 } }); + await stack.cancelAll({ reason: "stackDisconnect" }); + try { + await layer.promise.promise; + expect.unreachable(); + } catch (error) { + expect(isLayerCancelledError(error)).toBe(true); + if (isLayerCancelledError(error)) { + expect(error.reason).toBe("stackDisconnect"); + } + } + }); + + it("cancelAll does not raise unhandledrejection for void open()", async () => { + const stack = new LayerStack<{ n: number }, boolean>("s"); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + try { + void stack.open({ key: ["a"], payload: { n: 1 } }).promise.promise; + await stack.cancelAll(); + await Promise.resolve(); + await Promise.resolve(); + expect(unhandled).toHaveLength(0); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + it("cancelAll skips blockers", async () => { const stack = new LayerStack<{ n: number }, boolean>("s"); const layer = stack.open({ key: ["a"], payload: { n: 1 } }); @@ -362,6 +411,19 @@ describe("LayerStack — scope serial", () => { expect(stack.getSnapshot()).toHaveLength(0); }); + it("cancelAll rejects every open() even if onLayerDismiss throws", async () => { + const stack = new LayerStack<{ n: number }, boolean>("s"); + stack.onLayerDismiss = () => { + throw new Error("hook boom"); + }; + const a = stack.open({ key: ["a"], payload: { n: 1 } }); + const b = stack.open({ key: ["b"], payload: { n: 2 } }); + await expect(stack.cancelAll()).rejects.toThrow("hook boom"); + await expect(a.promise.promise).rejects.toBeInstanceOf(LayerCancelledError); + await expect(b.promise.promise).rejects.toBeInstanceOf(LayerCancelledError); + expect(stack.getSnapshot()).toHaveLength(0); + }); + it("dismissAll(undefined) for void R still resolves (not cancel)", async () => { const stack = new LayerStack<{ n: number }, void>("s"); const layer = stack.open({ key: ["a"], payload: { n: 1 } }); diff --git a/packages/core/src/layerStack.ts b/packages/core/src/layerStack.ts index 8c78481..1c7c450 100644 --- a/packages/core/src/layerStack.ts +++ b/packages/core/src/layerStack.ts @@ -378,6 +378,12 @@ export class LayerStack< } } + /** + * 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 { const mode = opts?.mode ?? this.options.dismissAllMode ?? "skipBlocked"; // Final labeled snapshot: active-only stacks only emit per-layer `"dismiss"`. @@ -421,6 +427,9 @@ export class LayerStack< * 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 { const error = new LayerCancelledError(opts?.reason ?? "cancelAll"); @@ -433,12 +442,16 @@ export class LayerStack< 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); } - this.#flush(); }); } finally { if (shouldEmit) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e24cd60..473a05c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -29,6 +29,15 @@ export type BlockerFn = () => boolean | Promise; /** Stack-scoped predicate — `true` allows dismissal. */ export type StackBlockerFn = (layer: LayerState) => boolean | Promise; +/** + * How {@link LayerStack#dismissAll} treats blockers. All modes still + * **resolve** `open()` with the dismiss response — unlike {@link LayerStack#cancelAll}, + * which rejects with {@link LayerCancelledError}. + * + * - `skipBlocked` — soft-dismiss each layer; leave blocked ones + * - `stopAtBlocked` — soft-dismiss until the first veto, then stop + * - `force` — bypass blockers; still completes with the response + */ export type DismissAllMode = "skipBlocked" | "stopAtBlocked" | "force"; export interface DismissOptions { @@ -181,7 +190,10 @@ export interface LayerClientOptions { defaultStackOptions?: StackDefaults; } -/** Coarse mutation label for devtools / {@link LayerClient#subscribeNotify}. */ +/** + * Coarse mutation label for devtools / {@link LayerClient#subscribeNotify}. + * `dismissAll` = bulk completion; `cancelAll` = teardown that rejects `open()`. + */ export type StackNotifyAction = | "register" | "open" diff --git a/packages/devtools/src/components/StackActions.tsx b/packages/devtools/src/components/StackActions.tsx index 559afe6..f3babe1 100644 --- a/packages/devtools/src/components/StackActions.tsx +++ b/packages/devtools/src/components/StackActions.tsx @@ -5,6 +5,7 @@ import { createSignal } from "solid-js"; import { cancelQueuedHead, dismissAllWithMode, + forceClearStack, forceDismissTop, softDismissTop, } from "../live-actions"; @@ -84,7 +85,7 @@ export function StackActions(props: { Force dismiss