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 @@ -19,6 +19,7 @@ By-design or false-positive findings — do not re-raise.
- **[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`.
- **[docs]** `packages/core/skills/layers/SKILL.md` omit listing `ResponseArgTuple`/`DismissAllArgs`/… in the public utility row — by-design; cite `EndArgs` only (lean advertise).

## Deferred

Expand Down
5 changes: 5 additions & 0 deletions .changeset/end-args-void-ergonomics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stainless-code/layers": patch
---

`EndArgs`: omit dismiss/end response when `undefined extends R` (twin of `PayloadArg`). Applies to `call.end`/`dismiss`, stack `dismiss`/`dismissAll`/`cancelQueued`, and handle `dismiss`/`cancelQueued`. **Type tighten:** handle dismiss/cancelQueued are no longer always-optional — bare omit errors when `R` does not admit `undefined`. Adapter skills note void omit.
8 changes: 4 additions & 4 deletions apps/docs/content/concepts/blockers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ stack.addBlocker(fn: (layer: LayerState) => boolean | Promise<boolean>): () => v
Predicates may be async; `dismiss` awaits them. A veto **rejects** the attempt (layer stays open; caller re-issues after confirming) rather than deferring the caller's `Promise<R>`. Confirm UI is the consumer's own layer — core never opens UI.

```ts
call.end(response, opts?: { force?: boolean }): Promise<boolean>; // return = "did it dismiss?"
call.dismiss(response, opts?: { force?: boolean }): Promise<boolean>;
call.end(...args: EndArgs<R>): Promise<boolean>; // return = "did it dismiss?"
call.dismiss(...args: EndArgs<R>): Promise<boolean>;
```

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).
Omit the response when `undefined extends R` (e.g. void toasts). Return value = "did it dismiss?". `{ force: true }` bypasses blockers — pass `end(response, { force: true })` or `end(undefined, { force: true })` when the response is optional. Repeat `end`/`dismiss` while `dismissing` dedupes to the in-flight promise; `force` wins immediately. Predicate throw/reject = veto (fail-closed; dev warning).

## dismissing flag

Expand All @@ -37,7 +37,7 @@ Gate runs **before** exit transition: allowed → resolve promise, `phase: "dism

## dismissAll modes

`stack.dismissAll(response, opts?: { mode? })` is async:
`stack.dismissAll(...args: DismissAllArgs<R>)` is async (omit response when `undefined extends R`):

| mode | behavior |
| ------------------------- | ------------------------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/concepts/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ LayerClient ──┬── LayerStack (named, ordered) ── Layer[]

1. **Declare** — `layerOptions<P, R>({ stack, key, ... })` brands the <Tooltip tip="The logical identity of a layer; find/upsert/gcTime operate on its signature.">key</Tooltip> with a `DataTag` so `open()` infers the response type.
2. **Observe** — subscribe to a stack snapshot; adapters bind `LayerStack.subscribe` / `getSnapshot` to UI reactivity.
3. **Call** — a wired handle's `open(payload)` (or bag-form `client.open({ ...options, payload })`); resolution happens when something calls `call.end(response)` or `call.dismiss(response)`.
3. **Call** — a wired handle's `open(payload)` (or bag-form `client.open({ ...options, payload })`); resolution happens when something calls `call.end` / `call.dismiss` (response optional when `undefined extends R`).

## Package boundary

Expand Down
6 changes: 4 additions & 2 deletions apps/docs/content/guides/dismissal-blockers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Both return a disposer. Predicates may be async; `dismiss` awaits them.
`call.end` and `call.dismiss` return `Promise<boolean>` — `true` if dismissed, `false` if vetoed:

```tsx
const didDismiss = await call.dismiss(undefined);
const didDismiss = await call.dismiss(); // void-R: omit the response
if (!didDismiss) {
// show confirm UI — your own layer, not opened by core
}
Expand All @@ -44,6 +44,7 @@ Bypass blockers with `{ force: true }`:
```ts
await call.end(response, { force: true });
await call.dismiss(response, { force: true });
// void-R: await call.end(undefined, { force: true });
```

While blockers evaluate, `dismissing` is `true` on the layer state — disable close buttons during async confirm:
Expand All @@ -60,7 +61,7 @@ function Editor({ call, dismissing }: LayerComponentProps<void, void>) {

## dismissAll modes

`stack.dismissAll(response, opts?)` is async:
`stack.dismissAll(...args)` is async (omit response for void-R):

| Mode | Behavior |
| ---- | -------- |
Expand All @@ -70,6 +71,7 @@ function Editor({ call, dismissing }: LayerComponentProps<void, void>) {

```ts
await stack.dismissAll(undefined, { mode: "stopAtBlocked" });
// void-R + default mode: await stack.dismissAll();
```

Default mode is configurable via `StackOptions.dismissAllMode` or `LayerClientOptions.defaultStackOptions`.
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/guides/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The promise rejects when:
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.
User `dismiss` / `dismissAll` complete `open()` with `R` (including `undefined` for void) — they resolve, they do not reject. Narrow cancel with `isLayerCancelledError`; do not treat cancel as a falsy user choice.

Dismissal during `pending` resolves with the dismissal response (the in-flight `loadFn` is aborted via `AbortController`); it does not reject.

Expand Down
20 changes: 11 additions & 9 deletions apps/docs/content/reference/core-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,11 @@ class LayerStack {

find(key: LayerKey): Layer | undefined; // topmost same-key (findLast)
getLayer(id: string): Layer | undefined;
dismiss(layer, response?, opts?: DismissOptions): Promise<boolean>;
dismissAll(response?, opts?: DismissAllOptions): Promise<void>;
dismiss(layer, ...args: EndArgs<R>): Promise<boolean>;
dismissAll(...args: DismissAllArgs<R>): Promise<void>;
cancelAll(opts?: { reason?: LayerCancelReason }): Promise<void>;
addBlocker(fn: StackBlockerFn): () => void;
cancelQueued(key: LayerKey, response, opts?: { id?: string }): boolean;
cancelQueued(key: LayerKey, ...args: CancelQueuedArgs<R>): boolean;
settle(layer): void;
setRunning(layer, running: boolean): void;
update(layer, patch: Partial<P>): void;
Expand All @@ -86,8 +86,8 @@ class LayerStack {
| `getQueuedSnapshot` | Serial-scope queue — layers waiting behind the occupying layer |
| `subscribe` | Register for snapshot changes (batched via `notifyManager`) |
| `find` | Topmost mounted layer for a logical key (key signature; `findLast`) |
| `dismiss` | Resolve one layer; returns whether dismissal succeeded (blockers may veto) |
| `cancelQueued` | Resolve a serial `queued` layer without mounting; omit `{ id }` → FIFO head for the key, `{ id }` → exact queued instance |
| `dismiss` | Resolve one layer; response optional when `undefined extends R` (`EndArgs`); blockers may veto |
| `cancelQueued` | Resolve a serial `queued` layer without mounting; same response gate; omit `{ id }` → FIFO, `{ id }` → exact match |
| `addBlocker` | Stack-scoped dismiss gate; returns a disposer |

## StackNotifyEvent
Expand Down Expand Up @@ -195,7 +195,7 @@ 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).
System teardown (`cancelAll`, parent-dismiss child drain, group dispose, host disconnect) rejects `open()` with `LayerCancelledError`. User `dismiss` / `dismissAll` complete `open()` with `R` — they do not reject. See [Error handling](/guides/error-handling).

```ts
type LayerCancelReason =
Expand Down Expand Up @@ -228,10 +228,10 @@ function createLayer<V extends Validator<unknown>, R, …>(
```

```ts
const confirm = createLayer(confirmOptions, client);
const confirm = createLayer(confirmOptions, client); // R = boolean
const ok = await confirm.open({ title: "Remove?" });
confirm.dismiss(false);
confirm.cancelQueued(undefined, { id: "queued-id" }); // optional exact match
confirm.cancelQueued(false, { id: "queued-id" }); // response required for boolean R
```

<AutoTypeTable
Expand All @@ -254,7 +254,9 @@ function createCallContext<P, R, RootProps = unknown>(
): LayerCallContext<P, R, RootProps>;
```

`LayerCallContext` exposes `end`, `dismiss`, `addBlocker`, `update`, `setRunning`, `settle`, plus read-only `ended`, `index`, `stackSize`, `root`, `stackId`, and `layerId`.
`LayerCallContext` exposes `end`, `dismiss`, `addBlocker`, `update`, `setRunning`, `settle`, plus read-only `ended`, `index`, `stackSize`, `root`, `stackId`, and `layerId`. Omit the response on `end`/`dismiss` when `undefined extends R` (`EndArgs` — same gate as `PayloadArg` / `.open()`).

<AutoTypeTable path="../../packages/core/src/types.ts" name="EndArgs" />

<AutoTypeTable
path="../../packages/core/src/types.ts"
Expand Down
14 changes: 14 additions & 0 deletions apps/docs/content/reference/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ On `LayerStack`: `cancelQueued(key, response, opts?: { id? })`. Omit `id` → fi
| Queued stack | `useQueuedStack` | `injectQueuedStack` | `useQueuedStack` | `createQueuedStack` |
| Queued per-key | `useLayerQueuedState` | `injectLayerQueuedState` | `createLayerQueuedState` | `createLayerQueuedState` |

## 0.x — omit dismiss response when void

Toasts and other void-result layers no longer need `call.end(undefined)`. Omit the response whenever `undefined extends R` — same rule as omitting `.open()`'s payload (`PayloadArg` / `EndArgs`). Applies to `call.end`/`dismiss`, stack `dismiss`/`dismissAll`/`cancelQueued`, and handle `dismiss`/`cancelQueued`.

<Diff
lang="ts"
old={`await call.end(undefined); // void toast`}
new={`await call.end();`}
/>

**Type tighten:** `LayerHandle.dismiss` / `cancelQueued` used to accept a missing response for every `R`. Bare `handle.dismiss()` is now an error when `R` does not admit `undefined` (pass `true`/`false` for confirms).

`LayerClient.dismissAll` / `LayerGroup.dismissAll` stay loosely typed (`response?: unknown`) — stacks on a client are heterogeneous.

:::note[Pin in production]
Lock `@stainless-code/layers` and your adapter package in `package.json`. Review the changelog before upgrading.
:::
2 changes: 1 addition & 1 deletion apps/docs/recipes/progress/angular.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class AppComponent {
this.c.stack.update(layer, { percent });
if (percent >= 100) {
clearInterval(interval);
void this.c.stack.dismiss(layer, undefined as void).then(() => {
void this.c.stack.dismiss(layer).then(() => {
this.status.set("complete");
});
}
Expand Down
8 changes: 3 additions & 5 deletions apps/docs/recipes/progress/preact.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ function Trigger() {
progressLayer.stack.update(layer, { percent });
if (percent >= 100) {
clearInterval(interval);
void progressLayer.stack
.dismiss(layer, undefined as void)
.then(() => {
setStatus("complete");
});
void progressLayer.stack.dismiss(layer).then(() => {
setStatus("complete");
});
}
}, 150);
}}
Expand Down
8 changes: 3 additions & 5 deletions apps/docs/recipes/progress/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ function Trigger() {
progressLayer.stack.update(layer, { percent });
if (percent >= 100) {
clearInterval(interval);
void progressLayer.stack
.dismiss(layer, undefined as void)
.then(() => {
setStatus("complete");
});
void progressLayer.stack.dismiss(layer).then(() => {
setStatus("complete");
});
}
}, 150);
}}
Expand Down
8 changes: 3 additions & 5 deletions apps/docs/recipes/progress/solid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,9 @@ function Trigger() {
progressLayer.stack.update(layer, { percent });
if (percent >= 100) {
clearInterval(interval);
void progressLayer.stack
.dismiss(layer, undefined as void)
.then(() => {
setStatus("complete");
});
void progressLayer.stack.dismiss(layer).then(() => {
setStatus("complete");
});
}
}, 150);
}}
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/recipes/progress/svelte-runes.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
c.stack.update(layer, { percent });
if (percent >= 100) {
clearInterval(interval);
void c.stack.dismiss(layer, undefined as void).then(() => {
void c.stack.dismiss(layer).then(() => {
status = "complete";
});
}
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/recipes/progress/svelte-store.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
c.stack.update(layer, { percent });
if (percent >= 100) {
clearInterval(interval);
void c.stack.dismiss(layer, undefined as void).then(() => {
void c.stack.dismiss(layer).then(() => {
status = "complete";
});
}
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/recipes/progress/vue-host.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function startUpload() {
c.stack.update(layer, { percent });
if (percent >= 100) {
clearInterval(interval);
void c.stack.dismiss(layer, undefined as void).then(() => {
void c.stack.dismiss(layer).then(() => {
status.value = "complete";
});
}
Expand Down
8 changes: 4 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,15 @@ stack.addBlocker(fn: (layer: LayerState) => boolean | Promise<boolean>): () => v
**Async + reject** — predicates may be async; `dismiss` awaits them. A veto **rejects** the attempt (layer stays open; caller re-issues after confirming) rather than deferring the caller's `Promise<R>`. Confirm UI is the consumer's own layer — core never opens UI.

```ts
call.end(response, opts?: { force?: boolean }): Promise<boolean>; // was void
call.dismiss(response, opts?: { force?: boolean }): Promise<boolean>;
call.end(...args: EndArgs<R>): Promise<boolean>;
call.dismiss(...args: EndArgs<R>): Promise<boolean>;
```

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).
Response optional when `undefined extends R` (same gate as `PayloadArg`). 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), `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:
**`dismissAll` modes** — `stack.dismissAll(...args: DismissAllArgs<R>)` is async:

| mode | behavior |
| ------------------------- | ------------------------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/skills/angular-layers/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ When `P` is `void`, `unknown`, `undefined`, or a union containing `undefined`, o

## The `call` context

Each mounted layer gets a `LayerCallContext` from `createCallContext(stack, layer, state)` (or via `useStackHandles().getCall(state)` / `renderStack`'s `call` input). It provides `end`, `dismiss`, `update`, `setRunning`, `settle`, `ended`, `index`, `stackSize`, `root`, `stackId`, `layerId`, and `addBlocker`; the `LayerState` snapshot provides `payload`, `data`, `error`, `phase`, `transition`, `actionStatus`, and `dismissing`. Use `await call.end(response)` to resolve the caller's `await` and dismiss the layer (`Promise<boolean>`; `false` when a blocker vetoes).
Each mounted layer gets a `LayerCallContext` from `createCallContext(stack, layer, state)` (or via `useStackHandles().getCall(state)` / `renderStack`'s `call` input). It provides `end`, `dismiss`, `update`, `setRunning`, `settle`, `ended`, `index`, `stackSize`, `root`, `stackId`, `layerId`, and `addBlocker`; the `LayerState` snapshot provides `payload`, `data`, `error`, `phase`, `transition`, `actionStatus`, and `dismissing`. Use `await call.end(response)` to resolve the caller's `await` and dismiss the layer (`Promise<boolean>`; `false` when a blocker vetoes). When `undefined extends R` (e.g. void toasts), omit the arg — `call.end()` / `call.dismiss()`.

**Key vs id:** `key` is the logical identity used by `find`, `upsert`, and `gcTime`; each mount gets a unique instance `id`. Track `s.id` in `@for` because parallel stacks may contain multiple layers with the same key.

Expand Down
Loading
Loading