Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/harden-pr/LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ By-design or false-positive findings — do not re-raise.
- **[public-api]** `packages/lit/src/index.ts` `StackController` `queued`/`deferClient`/`bindClient` — by-design; rationale on the class/ctor JSDoc (factories are the supported path).
- **[correctness]** `packages/alpine/src/index.ts` multi-child `x-layer-outlet` template — by-design; Alpine `<template>` loops (`x-for`/`x-if`) require a single root element; outlet matches that contract (document in alpine.mdx).
- **[docs]** `apps/docs/content/adapters/index.mdx` Alpine footnote `⁷` vs `docs/architecture.md` `⁸` — by-design; each matrix numbers footnotes for its own footnote set (architecture also has Lit `⁷`).
- **[correctness]** `void group.open(...)` samples after Option C — false positive for unhandledrejection; `#rejectCancel` already `void layer.promise.promise.catch(() => {})`. Awaiters still need `isLayerCancelledError`.

## Deferred

Expand Down
14 changes: 14 additions & 0 deletions .changeset/layer-cancelled-error.md
Original file line number Diff line number Diff line change
@@ -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`. Devtools: **Dismiss all** still calls `dismissAll` (resolves); **Force clear** calls `cancelAll` (rejects).
6 changes: 3 additions & 3 deletions apps/docs/content/adapters/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ npm i @stainless-code/layers

| Export | Role |
| ------ | ---- |
| `<Tooltip tip="App-wide coordinator for named layer stacks.">LayerClient</Tooltip>` | App-wide orchestrator — `open()`, `getStack()`, `ensureStack()`, `subscribeStacks()`, `dismissAll()` |
| `LayerStack` | One named surface — `getSnapshot()`, `subscribe()`, `dismiss()`, `dismissAll()`, `addBlocker()`, `settle()`, `cancelQueued()` |
| `<Tooltip tip="App-wide coordinator for named layer stacks.">LayerClient</Tooltip>` | 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<R>()(key)` |
| `DataTag`, `InferDataTagResponse`, `ResponseOf`, `ErrorOf` | Compile-time key branding helpers |
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/concepts/blockers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 3 additions & 2 deletions apps/docs/content/concepts/glossary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>`), `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}` ``).
</AccordionItem>
<AccordionItem title="Lifecycle & transitions">
Expand All @@ -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<R>` resolved by `dismiss`. The caller `await`s the user's decision.
- **open** — push a layer onto a stack; returns `Promise<R>` resolved by `dismiss`, or rejected by load/validation/`cancelAll`. The caller `await`s the user's decision.
- **dismiss** — async (`Promise<boolean>`): 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.
</AccordionItem>
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/concepts/identity-and-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ declare module "@stainless-code/layers" {
}
```

`DefaultLayerError` defaults to `Error` unless augmented. `open` returns `Promise<R>` — 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<R>` — rejections are not typed on the promise (a TypeScript limitation); narrow at runtime with guards like `isPayloadValidationError` and `isLayerCancelledError`.

## Payload validation

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/examples/nested-confirm.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions apps/docs/content/guides/awaiting-results.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
3 changes: 2 additions & 1 deletion apps/docs/content/guides/devtools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/guides/dismissal-blockers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions apps/docs/content/guides/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/guides/nested-overlays.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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). Narrow with `isLayerCancelledError` — see [Error handling](/guides/error-handling). `void group.open(...)` is fine (core swallows cancel for fire-and-forget).

## Create a child stack

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion apps/docs/content/integrations/base-ui/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Skip Base UI's trigger helpers — mount controlled `Dialog`, `Drawer`, and aler
/>
<Card
title="Nested confirm inside a parent dialog"
description="Child stack via `useLayerGroup`; drains when the parent dismisses."
description="Child stack via `useLayerGroup`; cleared via `cancelAll` when the parent dismisses."
href="/integrations/base-ui/nested-confirm"
icon="layers"
arrow
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/integrations/react-aria/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Skip RAC's `DialogTrigger` — mount controlled `ModalOverlay`, `Dialog`, drawer
/>
<Card
title="Nested confirm inside a parent dialog"
description="Child stack via `useLayerGroup`; drains when the parent dismisses."
description="Child stack via `useLayerGroup`; cleared via `cancelAll` when the parent dismisses."
href="/integrations/react-aria/nested-confirm"
icon="layers"
arrow
Expand Down
28 changes: 26 additions & 2 deletions apps/docs/content/reference/core-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,24 @@ class LayerClient {
response?: unknown,
opts?: DismissAllOptions,
): Promise<void>;
cancelAll(
stackId?: string,
opts?: { reason?: LayerCancelReason },
): Promise<void>;
}
```

| 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) |

<AutoTypeTable
path="../../packages/core/src/types.ts"
Expand All @@ -66,6 +71,7 @@ class LayerStack {
getLayer(id: string): Layer | undefined;
dismiss(layer, response?, opts?: DismissOptions): Promise<boolean>;
dismissAll(response?, opts?: DismissAllOptions): Promise<void>;
cancelAll(opts?: { reason?: LayerCancelReason }): Promise<void>;
addBlocker(fn: StackBlockerFn): () => void;
cancelQueued(key: LayerKey, response, opts?: { id?: string }): boolean;
settle(layer): void;
Expand Down Expand Up @@ -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`.
Expand Down
21 changes: 13 additions & 8 deletions apps/docs/islands/BlockersForceExample.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const client = "visible";

import {
isLayerCancelledError,
layerOptions,
StackProvider,
StackOutlet,
Expand Down Expand Up @@ -146,14 +147,18 @@ function EditDialog({
call.end(false);
return;
}
const discard = await group.open({
...discardConfirm,
payload: {
title: "Discard changes?",
message: "You'll lose your unsaved edits.",
},
});
if (discard) call.end(false, { force: true });
try {
const discard = await group.open({
...discardConfirm,
payload: {
title: "Discard changes?",
message: "You'll lose your unsaved edits.",
},
});
if (discard) call.end(false, { force: true });
} catch (error) {
if (!isLayerCancelledError(error)) throw error;
}
};

return overlayPortal(
Expand Down
Loading
Loading