From cffb7af27236807e6cd08f8ac250ab00ab4af60b Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 8 Aug 2026 13:24:24 +0300 Subject: [PATCH 1/7] harden: EndArgs void dismiss ergonomics Omit call/stack/handle dismiss response when undefined extends R (twin of PayloadArg). Tightens LayerHandle always-optional hole; docs, skills, and changeset aligned. --- .changeset/end-args-void-ergonomics.md | 5 + apps/docs/content/concepts/blockers.mdx | 8 +- apps/docs/content/concepts/overview.mdx | 2 +- .../content/guides/dismissal-blockers.mdx | 5 +- apps/docs/content/reference/core-api.mdx | 20 +-- apps/docs/content/reference/migration.mdx | 14 +++ apps/docs/recipes/progress/angular.ts | 2 +- apps/docs/recipes/progress/preact.tsx | 8 +- apps/docs/recipes/progress/react.tsx | 8 +- apps/docs/recipes/progress/solid.tsx | 8 +- .../docs/recipes/progress/svelte-runes.svelte | 2 +- .../docs/recipes/progress/svelte-store.svelte | 2 +- apps/docs/recipes/progress/vue-host.vue | 2 +- docs/architecture.md | 8 +- packages/core/skills/layers/SKILL.md | 8 +- packages/core/src/callContext.test.ts | 11 ++ packages/core/src/callContext.ts | 14 ++- packages/core/src/createLayer.ts | 24 ++-- packages/core/src/index.test-d.ts | 116 ++++++++++++++++++ packages/core/src/layerStack.test.ts | 8 ++ packages/core/src/layerStack.ts | 43 ++++--- packages/core/src/types.ts | 29 ++++- packages/react/skills/react-layers/SKILL.md | 2 +- 23 files changed, 270 insertions(+), 79 deletions(-) create mode 100644 .changeset/end-args-void-ergonomics.md diff --git a/.changeset/end-args-void-ergonomics.md b/.changeset/end-args-void-ergonomics.md new file mode 100644 index 0000000..f200a2d --- /dev/null +++ b/.changeset/end-args-void-ergonomics.md @@ -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`. diff --git a/apps/docs/content/concepts/blockers.mdx b/apps/docs/content/concepts/blockers.mdx index 8529809..a822dd4 100644 --- a/apps/docs/content/concepts/blockers.mdx +++ b/apps/docs/content/concepts/blockers.mdx @@ -23,11 +23,11 @@ stack.addBlocker(fn: (layer: LayerState) => boolean | Promise): () => 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`. Confirm UI is the consumer's own layer — core never opens UI. ```ts -call.end(response, opts?: { force?: boolean }): Promise; // return = "did it dismiss?" -call.dismiss(response, opts?: { force?: boolean }): Promise; +call.end(...args: EndArgs): Promise; // return = "did it dismiss?" +call.dismiss(...args: EndArgs): 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). +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 @@ -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)` is async (omit response when `undefined extends R`): | mode | behavior | | ------------------------- | ------------------------------------------------- | diff --git a/apps/docs/content/concepts/overview.mdx b/apps/docs/content/concepts/overview.mdx index 32a92f7..e75a359 100644 --- a/apps/docs/content/concepts/overview.mdx +++ b/apps/docs/content/concepts/overview.mdx @@ -16,7 +16,7 @@ LayerClient ──┬── LayerStack (named, ordered) ── Layer[] 1. **Declare** — `layerOptions({ stack, key, ... })` brands the key 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 diff --git a/apps/docs/content/guides/dismissal-blockers.mdx b/apps/docs/content/guides/dismissal-blockers.mdx index a02d0c1..f91d152 100644 --- a/apps/docs/content/guides/dismissal-blockers.mdx +++ b/apps/docs/content/guides/dismissal-blockers.mdx @@ -33,7 +33,7 @@ Both return a disposer. Predicates may be async; `dismiss` awaits them. `call.end` and `call.dismiss` return `Promise` — `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 } @@ -60,7 +60,7 @@ function Editor({ call, dismissing }: LayerComponentProps) { ## dismissAll modes -`stack.dismissAll(response, opts?)` is async: +`stack.dismissAll(...args)` is async (omit response for void-R): | Mode | Behavior | | ---- | -------- | @@ -70,6 +70,7 @@ function Editor({ call, dismissing }: LayerComponentProps) { ```ts await stack.dismissAll(undefined, { mode: "stopAtBlocked" }); +// void-R + default mode: await stack.dismissAll(); ``` Default mode is configurable via `StackOptions.dismissAllMode` or `LayerClientOptions.defaultStackOptions`. diff --git a/apps/docs/content/reference/core-api.mdx b/apps/docs/content/reference/core-api.mdx index 9aa6416..dc42f1e 100644 --- a/apps/docs/content/reference/core-api.mdx +++ b/apps/docs/content/reference/core-api.mdx @@ -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; - dismissAll(response?, opts?: DismissAllOptions): Promise; + dismiss(layer, ...args: EndArgs): Promise; + dismissAll(...args: DismissAllArgs): Promise; cancelAll(opts?: { reason?: LayerCancelReason }): Promise; addBlocker(fn: StackBlockerFn): () => void; - cancelQueued(key: LayerKey, response, opts?: { id?: string }): boolean; + cancelQueued(key: LayerKey, ...args: CancelQueuedArgs): boolean; settle(layer): void; setRunning(layer, running: boolean): void; update(layer, patch: Partial

): void; @@ -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 @@ -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` still resolve with `R`. See [Error handling](/guides/error-handling). ```ts type LayerCancelReason = @@ -228,10 +228,10 @@ function createLayer, 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 ``` ( ): LayerCallContext; ``` -`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()`). + + + +**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. ::: diff --git a/apps/docs/recipes/progress/angular.ts b/apps/docs/recipes/progress/angular.ts index a6e5189..7a3cea6 100644 --- a/apps/docs/recipes/progress/angular.ts +++ b/apps/docs/recipes/progress/angular.ts @@ -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"); }); } diff --git a/apps/docs/recipes/progress/preact.tsx b/apps/docs/recipes/progress/preact.tsx index b22e7d2..b6fa248 100644 --- a/apps/docs/recipes/progress/preact.tsx +++ b/apps/docs/recipes/progress/preact.tsx @@ -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); }} diff --git a/apps/docs/recipes/progress/react.tsx b/apps/docs/recipes/progress/react.tsx index e75170d..9c27d40 100644 --- a/apps/docs/recipes/progress/react.tsx +++ b/apps/docs/recipes/progress/react.tsx @@ -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); }} diff --git a/apps/docs/recipes/progress/solid.tsx b/apps/docs/recipes/progress/solid.tsx index a2e8248..b59c1be 100644 --- a/apps/docs/recipes/progress/solid.tsx +++ b/apps/docs/recipes/progress/solid.tsx @@ -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); }} diff --git a/apps/docs/recipes/progress/svelte-runes.svelte b/apps/docs/recipes/progress/svelte-runes.svelte index ef7708f..3e04718 100644 --- a/apps/docs/recipes/progress/svelte-runes.svelte +++ b/apps/docs/recipes/progress/svelte-runes.svelte @@ -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"; }); } diff --git a/apps/docs/recipes/progress/svelte-store.svelte b/apps/docs/recipes/progress/svelte-store.svelte index d2dde10..101951c 100644 --- a/apps/docs/recipes/progress/svelte-store.svelte +++ b/apps/docs/recipes/progress/svelte-store.svelte @@ -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"; }); } diff --git a/apps/docs/recipes/progress/vue-host.vue b/apps/docs/recipes/progress/vue-host.vue index 65b8235..31a058b 100644 --- a/apps/docs/recipes/progress/vue-host.vue +++ b/apps/docs/recipes/progress/vue-host.vue @@ -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"; }); } diff --git a/docs/architecture.md b/docs/architecture.md index 4bc0f1f..01f237b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,15 +121,15 @@ stack.addBlocker(fn: (layer: LayerState) => boolean | Promise): () => 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`. Confirm UI is the consumer's own layer — core never opens UI. ```ts -call.end(response, opts?: { force?: boolean }): Promise; // was void -call.dismiss(response, opts?: { force?: boolean }): Promise; +call.end(...args: EndArgs): Promise; +call.dismiss(...args: EndArgs): 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). +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)` is async: | mode | behavior | | ------------------------- | ------------------------------------------------- | diff --git a/packages/core/skills/layers/SKILL.md b/packages/core/skills/layers/SKILL.md index 12ed34e..1e4ba49 100644 --- a/packages/core/skills/layers/SKILL.md +++ b/packages/core/skills/layers/SKILL.md @@ -149,7 +149,7 @@ const ok = await client.open({ ## The `call` context -`createCallContext` gives an active layer `end`, `dismiss`, `addBlocker`, `update`, `setRunning`, and `settle`, plus `ended`, `index`, `stackSize`, `root`, `stackId`, and `layerId`. `await call.end(response)` and `await call.dismiss(response)` request dismissal; when allowed, they resolve the caller and begin exit. Both return `Promise`, with `false` meaning a blocker vetoed dismissal. `update` patches payload, `setRunning` controls `actionStatus`, and `settle` completes the current enter or exit transition. +`createCallContext` gives an active layer `end`, `dismiss`, `addBlocker`, `update`, `setRunning`, and `settle`, plus `ended`, `index`, `stackSize`, `root`, `stackId`, and `layerId`. `await call.end(response)` and `await call.dismiss(response)` request dismissal; when allowed, they resolve the caller and begin exit. Both return `Promise`, with `false` meaning a blocker vetoed dismissal. When `undefined extends R` (e.g. `R = void`), omit the response — `call.end()` / `call.dismiss()` — same gate as `PayloadArg` / `.open()` (`EndArgs`). `update` patches payload, `setRunning` controls `actionStatus`, and `settle` completes the current enter or exit transition. `key` is logical identity for `find`, `upsert`, and `gcTime`; every mount gets a unique instance `id` shaped as `${hashKey(key)}#`. Keys must be JSON-safe (`string` | `boolean` | `null` | finite `number` | plain objects/arrays of those); `hashKey` / `keySignature` assert that domain and throw `LayerKeyError` otherwise. Parallel stacks may contain multiple same-key instances. @@ -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`. 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`. +`dismissing` is `true` while blockers run, and `{ force: true }` bypasses them. `stack.dismissAll(...args)` supports `skipBlocked`, `stopAtBlocked`, and `force`; the default is `skipBlocked`, configurable per stack with `dismissAllMode` in `defaultStackOptions`. Omit the response when `undefined extends R`. 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`, which still resolve with `R`. ## Payload validation @@ -220,7 +220,7 @@ const completion = client.open({ const layer = toast.find(["toast", "export"])!; toast.update(layer, { msg: "Done" }); -await toast.dismiss(layer, undefined); +await toast.dismiss(layer); await completion; ``` @@ -282,7 +282,7 @@ The complete public API surface: | Layer model types | `LayerKey`, `LayerPhase`, `LayerTransition`, `LayerActionStatus`, `LayerState`, `LayerOptions`, `OpenLayerOptions` | | Stack and client types | `StackOptions`, `StackDefaults`, `LayerClientOptions` | | Dismissal types | `BlockerFn`, `StackBlockerFn`, `DismissOptions`, `DismissAllOptions`, `DismissAllMode` | -| Utility and configuration types | `Resolve`, `Reject`, `PayloadArg`, `OmitKeyof`, `Register`, `DefaultLayerError` | +| Utility and configuration types | `Resolve`, `Reject`, `PayloadArg`, `EndArgs`, `OmitKeyof`, `Register`, `DefaultLayerError` | ## Agnostic contract diff --git a/packages/core/src/callContext.test.ts b/packages/core/src/callContext.test.ts index b2b5807..6b3a87e 100644 --- a/packages/core/src/callContext.test.ts +++ b/packages/core/src/callContext.test.ts @@ -77,4 +77,15 @@ describe("createCallContext", () => { expect(await dismissResult).toBe(true); expect(stack.getSnapshot()).toHaveLength(0); }); + + it("end() with no args resolves void-R layers", async () => { + const stack = new LayerStack<{ n: number }, void>("s"); + const layer = stack.open({ key: ["a"], payload: { n: 1 } }); + const state = stack.getSnapshot()[0]!; + const call = createCallContext(stack, layer, state); + + await expect(call.end()).resolves.toBe(true); + await expect(layer.promise.promise).resolves.toBe(undefined); + expect(stack.getSnapshot()).toHaveLength(0); + }); }); diff --git a/packages/core/src/callContext.ts b/packages/core/src/callContext.ts index b4deb97..8522e6a 100644 --- a/packages/core/src/callContext.ts +++ b/packages/core/src/callContext.ts @@ -2,7 +2,7 @@ import type { Layer } from "./layer"; import { LayerStack } from "./layerStack"; import type { DefaultLayerError, - DismissOptions, + EndArgs, LayerCallContext, LayerState, } from "./types"; @@ -18,10 +18,14 @@ export function createCallContext( rootProps?: RootProps, ): LayerCallContext { return { - end: (response: R, opts?: DismissOptions) => - stack.dismiss(layer, response, opts), - dismiss: (response: R, opts?: DismissOptions) => - stack.dismiss(layer, response, opts), + end: (...args: EndArgs) => { + const [response, opts] = args; + return stack.dismiss(layer, response as R, opts); + }, + dismiss: (...args: EndArgs) => { + const [response, opts] = args; + return stack.dismiss(layer, response as R, opts); + }, addBlocker: (fn) => layer.addBlocker(fn), update: (patch: Partial

) => stack.update(layer, patch), setRunning: (running: boolean) => stack.setRunning(layer, running), diff --git a/packages/core/src/createLayer.ts b/packages/core/src/createLayer.ts index e893f19..e706408 100644 --- a/packages/core/src/createLayer.ts +++ b/packages/core/src/createLayer.ts @@ -3,8 +3,9 @@ import type { Layer } from "./layer"; import type { LayerClient } from "./layerClient"; import type { LayerStack } from "./layerStack"; import type { + CancelQueuedArgs, DefaultLayerError, - DismissOptions, + HandleDismissArgs, LayerKey, LayerOptions, PayloadArg, @@ -39,16 +40,18 @@ type NoValidateOptions = Opts extends { validate: Validator } export interface LayerHandle { open: (payload: PayloadArg

["payload"]) => Promise; upsert: (payload: PayloadArg

["payload"]) => Promise; - dismiss: ( - response?: R, - opts?: DismissOptions & { id?: string }, - ) => Promise; + /** + * Dismiss the bound instance (or `{ id }`). + * Response optional iff `undefined extends R` — see {@link HandleDismissArgs}. + */ + dismiss: (...args: HandleDismissArgs) => Promise; update: (patch: Partial

, opts?: { id?: string }) => void; /** * Resolves and removes a serially queued layer without mounting (skips blockers). * No `id` → FIFO head for this key; `{ id }` → exact queued match. + * Response may be omitted when `undefined extends R`. */ - cancelQueued: (response?: R, opts?: { id?: string }) => boolean; + cancelQueued: (...args: CancelQueuedArgs) => boolean; readonly client: LayerClient; readonly stack: LayerStack; readonly options: LayerOptions & { @@ -156,7 +159,8 @@ export function createLayer< mine = stack.open({ ...toOpenOpts(payload as P), upsert: true }); return mine.promise.promise as Promise; }, - dismiss: (response, o) => { + dismiss: (...args) => { + const [response, o] = args; const l = target(o?.id); return l ? stack.dismiss(l, response as R, { force: o?.force }) @@ -166,8 +170,10 @@ export function createLayer< const l = target(o?.id); if (l) stack.update(l, patch); }, - cancelQueued: (response, o) => - stack.cancelQueued(opts.key, response ?? (undefined as R), o), + cancelQueued: (...args) => { + const [response, o] = args; + return stack.cancelQueued(opts.key, response as R, o); + }, client, stack, options: opts, diff --git a/packages/core/src/index.test-d.ts b/packages/core/src/index.test-d.ts index 02e1d2d..59f180f 100644 --- a/packages/core/src/index.test-d.ts +++ b/packages/core/src/index.test-d.ts @@ -7,9 +7,13 @@ import { LayerClient, createLayer, layerKey, layerOptions } from "./index"; import type { DataTag, DefaultLayerError, + DismissAllArgs, + EndArgs, InferDataTagError, InferDataTagResponse, + LayerCallContext, LayerKey, + LayerStack, OmitKeyof, StandardSchemaV1, } from "./index"; @@ -267,3 +271,115 @@ export type _CreateLayerCurrentPayload = Expect< { title: string } > >; + +// EndArgs — response optional iff `undefined extends R` (twin of PayloadArg). +declare const voidCall: LayerCallContext; +function endVoidOmitted() { + return voidCall.end(); +} +void endVoidOmitted; +function endVoidUndefined() { + return voidCall.end(undefined); +} +void endVoidUndefined; +function endVoidForce() { + return voidCall.end(undefined, { force: true }); +} +void endVoidForce; +function endVoidOptsOnly() { + // @ts-expect-error opts-only — pass response (or undefined) before force + return voidCall.end({ force: true }); +} +void endVoidOptsOnly; +function endVoidTrue() { + // @ts-expect-error void response is not boolean + return voidCall.end(true); +} +void endVoidTrue; + +declare const boolCall: LayerCallContext; +function endBoolOmitted() { + // @ts-expect-error boolean response is required + return boolCall.end(); +} +void endBoolOmitted; +function endBoolTrue() { + return boolCall.end(true); +} +void endBoolTrue; + +declare const optCall: LayerCallContext; +function endOptOmitted() { + return optCall.end(); +} +void endOptOmitted; +function endOptTrue() { + return optCall.end(true); +} +void endOptTrue; + +export type _EndArgsVoidOptional = Expect< + Equal, [response?: void, opts?: { force?: boolean }]> +>; +export type _EndArgsBoolRequired = Expect< + Equal, [response: boolean, opts?: { force?: boolean }]> +>; + +// Handle dismiss — same gate (closes always-optional hole). +const voidDismissHandle = createLayer( + layerOptions<{ n: number }, void>({ key: ["void-dismiss"] }), + confirmClient, +); +function dismissVoidHandleOmitted() { + return voidDismissHandle.dismiss(); +} +void dismissVoidHandleOmitted; + +const boolDismissHandle = createLayer( + layerOptions<{ n: number }, boolean>({ key: ["bool-dismiss"] }), + confirmClient, +); +function dismissBoolHandleOmitted() { + // @ts-expect-error boolean response is required on the handle + return boolDismissHandle.dismiss(); +} +void dismissBoolHandleOmitted; +function dismissBoolHandleTrue() { + return boolDismissHandle.dismiss(true); +} +void dismissBoolHandleTrue; + +function cancelQueuedVoidHandleOmitted() { + return voidDismissHandle.cancelQueued(); +} +void cancelQueuedVoidHandleOmitted; +function cancelQueuedBoolHandleOmitted() { + // @ts-expect-error boolean response is required on cancelQueued + return boolDismissHandle.cancelQueued(); +} +void cancelQueuedBoolHandleOmitted; +function cancelQueuedBoolHandleFalse() { + return boolDismissHandle.cancelQueued(false); +} +void cancelQueuedBoolHandleFalse; + +declare const voidStack: LayerStack<{ n: number }, void>; +function dismissAllVoidOmitted() { + return voidStack.dismissAll(); +} +void dismissAllVoidOmitted; +declare const boolStack: LayerStack<{ n: number }, boolean>; +function dismissAllBoolOmitted() { + // @ts-expect-error boolean response is required on dismissAll + return boolStack.dismissAll(); +} +void dismissAllBoolOmitted; +export type _DismissAllArgsVoidOptional = Expect< + Equal< + DismissAllArgs, + [ + response?: void, + opts?: { mode?: "skipBlocked" | "stopAtBlocked" | "force" }, + ] + > +>; diff --git a/packages/core/src/layerStack.test.ts b/packages/core/src/layerStack.test.ts index 6c384f2..5f7636d 100644 --- a/packages/core/src/layerStack.test.ts +++ b/packages/core/src/layerStack.test.ts @@ -432,6 +432,14 @@ describe("LayerStack — scope serial", () => { expect(stack.getSnapshot()).toHaveLength(0); }); + it("dismissAll() omits response for void R", async () => { + const stack = new LayerStack<{ n: number }, void>("s"); + const layer = stack.open({ key: ["a"], payload: { n: 1 } }); + await stack.dismissAll(); + 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 f0ee490..d5eefdc 100644 --- a/packages/core/src/layerStack.ts +++ b/packages/core/src/layerStack.ts @@ -5,9 +5,10 @@ import { createLayerGcCache } from "./layerGcCache"; import { notifyManager } from "./notifyManager"; import { Subscribable } from "./subscribable"; import type { + CancelQueuedArgs, DefaultLayerError, - DismissAllOptions, - DismissOptions, + DismissAllArgs, + EndArgs, LayerKey, LayerNotifyView, LayerState, @@ -257,20 +258,19 @@ export class LayerStack< /** * Resolves the caller and aborts in-flight loading. * Exiting layers remain mounted until their transition settles. + * Response may be omitted when `undefined extends R` (see {@link EndArgs}). */ - dismiss( - layer: Layer, - response: R, - opts?: DismissOptions, - ): Promise { + dismiss(layer: Layer, ...args: EndArgs): Promise { + const [response, opts] = args; + const r = response as R; if (opts?.force) { - this.#commitDismiss(layer, response); + this.#commitDismiss(layer, r); return Promise.resolve(true); } if (layer.dismissPending) { return layer.dismissPending; } - layer.dismissPending = this.#guardedDismiss(layer, response).finally(() => { + layer.dismissPending = this.#guardedDismiss(layer, r).finally(() => { layer.dismissPending = undefined; }); return layer.dismissPending; @@ -380,11 +380,13 @@ 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 + * `response` (including omitted/`undefined` when `undefined extends R`). + * Honors {@link DismissAllMode}; does not reject — use {@link cancelAll} for * teardown without a completion value. */ - async dismissAll(response: R, opts?: DismissAllOptions): Promise { + async dismissAll(...args: DismissAllArgs): Promise { + const [response, opts] = args; + const r = response as R; const mode = opts?.mode ?? this.options.dismissAllMode ?? "skipBlocked"; // Final labeled snapshot: active-only stacks only emit per-layer `"dismiss"`. const shouldEmit = this.#scopeQueue.length > 0 || this.#layers.length > 0; @@ -395,12 +397,12 @@ export class LayerStack< // without ever mounting. for (const entry of this.#scopeQueue) { entry.layer.abort(); - entry.layer.resolve(response); + entry.layer.resolve(r); entry.layer.setPartial({ phase: "dismissed", transition: "settled", ended: true, - response, + response: r, }); } this.#scopeQueue = []; @@ -408,10 +410,10 @@ export class LayerStack< }); for (const l of this.#layers) { if (mode === "force") { - await this.dismiss(l, response, { force: true }); + await this.dismiss(l, r, { force: true }); continue; } - const ok = await this.dismiss(l, response); + const ok = await this.dismiss(l, r); if (!ok && mode === "stopAtBlocked") { return; } @@ -484,8 +486,11 @@ export class LayerStack< /** * Resolves and removes a serially queued layer without mounting it (skips blockers). * No `id` → FIFO head for the key; `{ id }` → exact queued match. + * Response may be omitted when `undefined extends R`. */ - cancelQueued(key: LayerKey, response: R, opts?: { id?: string }): boolean { + cancelQueued(key: LayerKey, ...args: CancelQueuedArgs): boolean { + const [response, opts] = args; + const r = response as R; return notifyManager.batch(() => { return this.#dispatch("cancelQueued", () => { const sig = keySignature(key); @@ -499,12 +504,12 @@ export class LayerStack< } const entry = this.#scopeQueue[idx]!; entry.layer.abort(); - entry.layer.resolve(response); + entry.layer.resolve(r); entry.layer.setPartial({ phase: "dismissed", transition: "settled", ended: true, - response, + response: r, }); this.#scopeQueue.splice(idx, 1); this.#flush(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 473a05c..ca78a63 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -73,8 +73,10 @@ export interface LayerState< /** Imperative controls available to a rendered layer. */ export interface LayerCallContext { - end: (response: R, opts?: DismissOptions) => Promise; - dismiss: (response: R, opts?: DismissOptions) => Promise; + /** Resolve and dismiss — response optional iff `undefined extends R` ({@link EndArgs}). */ + end: (...args: EndArgs) => Promise; + /** Alias of {@link LayerCallContext.end}. */ + dismiss: (...args: EndArgs) => Promise; addBlocker: (fn: BlockerFn) => () => void; update: (patch: Partial

) => void; setRunning: (running: boolean) => void; @@ -149,6 +151,29 @@ export type PayloadArg

= undefined extends P ? { payload?: P } : { payload: P }; +/** Rest-tuple factory: response optional iff `undefined extends R`. */ +export type ResponseArgTuple = undefined extends R + ? [response?: R, opts?: Opts] + : [response: R, opts?: Opts]; + +/** + * Rest-args for `call.end` / `call.dismiss`. + * Response is optional only when `R` admits `undefined` — twin of {@link PayloadArg}. + */ +export type EndArgs = ResponseArgTuple; + +/** `LayerStack.dismissAll` rest-args (same gate as {@link EndArgs}). */ +export type DismissAllArgs = ResponseArgTuple; + +/** `cancelQueued` rest-args (same gate as {@link EndArgs}). */ +export type CancelQueuedArgs = ResponseArgTuple; + +/** `LayerHandle.dismiss` rest-args (same gate as {@link EndArgs}; opts may include `id`). */ +export type HandleDismissArgs = ResponseArgTuple< + R, + DismissOptions & { id?: string } +>; + export type OpenLayerOptions< P = unknown, R = void, diff --git a/packages/react/skills/react-layers/SKILL.md b/packages/react/skills/react-layers/SKILL.md index d3382f6..6f7eed5 100644 --- a/packages/react/skills/react-layers/SKILL.md +++ b/packages/react/skills/react-layers/SKILL.md @@ -116,7 +116,7 @@ Low-level: `client.open({ ...confirm, payload })` or core `createLayer(confirm, ## The `call` context -Each layer component receives `call` (`end`/`dismiss`/`update`/`setRunning`/`settle`/`ended`/`index`/`stackSize`/`root`/`stackId`/`layerId`/`addBlocker`), `payload`, `data`, `error`, `phase`, `transition`, `actionStatus`, `dismissing`. Use `await call.end(response)` to resolve the caller's `await` and dismiss the layer (`Promise` — `false` if a blocker vetoes). `setRunning(true|false)` flips `actionStatus` manually; `useMutationFlow` (below) wraps `setRunning` + `end` for the common save-then-close case. +Each layer component receives `call` (`end`/`dismiss`/`update`/`setRunning`/`settle`/`ended`/`index`/`stackSize`/`root`/`stackId`/`layerId`/`addBlocker`), `payload`, `data`, `error`, `phase`, `transition`, `actionStatus`, `dismissing`. Use `await call.end(response)` to resolve the caller's `await` and dismiss the layer (`Promise` — `false` if a blocker vetoes). When `undefined extends R` (e.g. void toasts), omit the arg — `call.end()` / `call.dismiss()`. `setRunning(true|false)` flips `actionStatus` manually; `useMutationFlow` (below) wraps `setRunning` + `end` for the common save-then-close case. **Key vs id:** `key` is the logical identity (`find`/`upsert`/`gcTime`); each mount gets a unique instance `id`. Use `s.id` for React list keys; `parallel` stacks may hold multiple same-key layers. From b3499ecfc2590c3f8e15f6165ed8316fcec16603 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 8 Aug 2026 13:30:28 +0300 Subject: [PATCH 2/7] harden: EndArgs docs, JSDoc, and handle omit tests --- .agents/skills/harden-pr/LEDGER.md | 1 + .../content/guides/dismissal-blockers.mdx | 1 + packages/core/skills/layers/SKILL.md | 2 +- packages/core/src/createLayer.test.ts | 32 +++++++++++++++++++ packages/core/src/createLayer.ts | 6 ++-- packages/core/src/layerStack.ts | 8 +++-- packages/core/src/types.ts | 6 ++-- 7 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.agents/skills/harden-pr/LEDGER.md b/.agents/skills/harden-pr/LEDGER.md index ad683c2..5a7a7ab 100644 --- a/.agents/skills/harden-pr/LEDGER.md +++ b/.agents/skills/harden-pr/LEDGER.md @@ -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 `