diff --git a/.changeset/serial-onload-error.md b/.changeset/serial-onload-error.md new file mode 100644 index 0000000..793f52d --- /dev/null +++ b/.changeset/serial-onload-error.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/layers": patch +--- + +Serial stacks: failed `loadFn` occupies the lane by default (`onLoadError: "block"`), fixing leapfrog where a later open could mount while an error layer stayed up. Opt into `onLoadError: "advance"` to remove the failed layer and drain the queue. diff --git a/apps/docs/content/concepts/glossary.mdx b/apps/docs/content/concepts/glossary.mdx index fac4eaa..91e163d 100644 --- a/apps/docs/content/concepts/glossary.mdx +++ b/apps/docs/content/concepts/glossary.mdx @@ -44,7 +44,7 @@ From `LayerClient` down to `keySignature` — every term in code, docs, and issu - **upsert** — with `upsert: true` (or handle `.upsert`), reusing an active key updates its payload instead of stacking (singleton toasts/progress). - - **scope** — per-stack queueing on `StackOptions`: `scope: { strategy: "serial" | "parallel" }` (`parallel` when omitted); `serial` = one active layer at a time, `parallel` = stack freely. + - **scope** — per-stack queueing on `StackOptions`: `scope: { strategy: "serial" | "parallel", onLoadError?: "block" | "advance" }` (`parallel` / `block` when omitted); `serial` = one occupying layer at a time, `parallel` = stack freely. `onLoadError`: `block` (default) keeps a failed `loadFn` until dismiss; `advance` removes it and drains the queue. - **gcTime** — keep dismissed layers in a cache so re-opening the same key restores `data` without re-running `loadFn`. One slot per key (last-dismissed-wins); evicted after `gcTime` with explicit teardown. - **loadFn** — optional async load run on `open`; cancelable via `AbortController`; `pending` → `active` with `data` on success, `error` on throw. diff --git a/apps/docs/content/concepts/lifecycle.mdx b/apps/docs/content/concepts/lifecycle.mdx index 52594f9..9323958 100644 --- a/apps/docs/content/concepts/lifecycle.mdx +++ b/apps/docs/content/concepts/lifecycle.mdx @@ -16,8 +16,8 @@ stateDiagram-v2 pending --> active: loadFn resolves pending --> error: loadFn throws pending --> queued: serial scope (waiting) - queued --> pending: active dismissed (has loadFn) - queued --> active: active dismissed (no loadFn) + queued --> pending: occupant leaves (has loadFn) + queued --> active: occupant leaves (no loadFn) active --> dismissed: end / dismiss dismissed --> removed: exitingDelay or settle() removed --> cached: gcTime > 0, data set @@ -26,7 +26,7 @@ stateDiagram-v2 removed --> [*]: gcTime = 0 ``` -**Serial scope** adds `queued`: later opens wait behind an active layer and are not in `getSnapshot()` — use `getQueuedSnapshot()` to observe them. +**Serial scope** adds `queued`: later opens wait behind the occupying layer and are not in `getSnapshot()` — use `getQueuedSnapshot()` to observe them. ## The three axes @@ -41,7 +41,7 @@ actionStatus: "idle" | "running"; // in-flight action | Phase | Meaning | | ----- | ------- | | `pending` | `loadFn` in flight (cancelable via `AbortController`). | -| `queued` | Serial scope only: waiting behind an active layer; not in `getSnapshot()`; visible via `getQueuedSnapshot()`. | +| `queued` | Serial scope only: waiting behind the occupying layer; not in `getSnapshot()`; visible via `getQueuedSnapshot()`. | | `active` | Mounted; component receives `call`, `payload`, `data`, `error`, `phase`, `transition`, `actionStatus`, `dismissing`. | | `dismissed` | Caller's `await` resolved (`ended=true`); layer may still be mounted while `transition: "exiting"`. | | `error` | `loadFn` threw; the caller's `await` rejects. | diff --git a/apps/docs/content/concepts/stacks-scope-gc.mdx b/apps/docs/content/concepts/stacks-scope-gc.mdx index e448c5f..d2e9c32 100644 --- a/apps/docs/content/concepts/stacks-scope-gc.mdx +++ b/apps/docs/content/concepts/stacks-scope-gc.mdx @@ -12,15 +12,18 @@ A **named stack** (`LayerStack`) is an ordered collection of active layers for o Per-stack queueing is configured on `StackOptions`: ```ts -scope: { strategy: "serial" | "parallel" } // parallel when omitted +scope: { + strategy: "serial" | "parallel", // parallel when omitted + onLoadError?: "block" | "advance", // serial only; default "block" +} ``` | Strategy | Behavior | | -------- | -------- | | `parallel` (default) | Layers stack freely; multiple `pending`/`active` layers at once. | -| `serial` | Only one `pending`/`active` layer at a time; later opens queue as `phase: "queued"` (not in `getSnapshot()`). | +| `serial` | One occupying layer at a time (`pending` / `active` / `error` under default `onLoadError: "block"`); later opens queue as `phase: "queued"` (not in `getSnapshot()`). | -Queued layers are visible via `getQueuedSnapshot()`. When the active layer dismisses, the next queued layer activates. `dismissAll` drains the queue, resolving queued callers without mounting them. +Queued layers are visible via `getQueuedSnapshot()`. The next queued layer activates when the occupant leaves — dismiss, or `onLoadError: "advance"` after a failed `loadFn`. `dismissAll` drains the queue without mounting queued callers. :::tip Use serial scope for one-at-a-time flows — onboarding steps, queued confirms, or any surface that must not show two layers simultaneously. diff --git a/apps/docs/content/guides/error-handling.mdx b/apps/docs/content/guides/error-handling.mdx index 35de6a8..fc9e251 100644 --- a/apps/docs/content/guides/error-handling.mdx +++ b/apps/docs/content/guides/error-handling.mdx @@ -91,6 +91,8 @@ try { The loaded `data` is available on the layer state when `loadFn` succeeds; on error the layer enters `phase: "error"`. +Serial stacks keep that error in the lane until dismiss (`onLoadError: "block"`). Use `onLoadError: "advance"` to skip it — [Serial queues](/guides/serial-queues). + :::note A `Result`-returning `open` was deliberately rejected to preserve direct `await`. Narrow in `catch` instead. Bag-form `client.open({ …opts, payload })` rejects the same way. ::: diff --git a/apps/docs/content/guides/serial-queues.mdx b/apps/docs/content/guides/serial-queues.mdx index f1e71d3..08ef39e 100644 --- a/apps/docs/content/guides/serial-queues.mdx +++ b/apps/docs/content/guides/serial-queues.mdx @@ -1,6 +1,6 @@ --- title: Serial queues -description: One active layer at a time with scope serial, getQueuedSnapshot, and queued-confirm flows. +description: One occupying layer at a time with scope serial, getQueuedSnapshot, and queued-confirm flows. search: tags: ["guide"] --- @@ -39,7 +39,7 @@ stack.subscribe(() => { }); ``` -Queued layers are not mounted — their components do not render until the active layer dismisses. +Queued layers are not mounted — their components do not render until the occupying layer leaves. ## Onboarding flow @@ -52,7 +52,7 @@ const steps = [ for (const step of steps) { void client.open(step); // [!code highlight] - // only one active; rest queue + // only one occupying; rest queue } ``` @@ -97,6 +97,20 @@ stack.cancelQueued(["confirm", "remove"], false, { id: queuedState.id }); // [!c On a wired handle, `confirmLayer.cancelQueued(false)` is FIFO for the bound key; `confirmLayer.cancelQueued(false, { id })` targets one row. +## Handle failed loads + +By default (`onLoadError: "block"`), a rejecting `loadFn` leaves `phase: "error"` mounted. That layer still occupies the lane — queued work waits, and further opens queue — until you dismiss it from error UI or `catch`. + +For a silent skip (remove the failed layer and drain the next queued open): + +```ts +client.ensureStack("onboarding", { + scope: { strategy: "serial", onLoadError: "advance" }, +}); +``` + +Narrow rejections in [Error handling](/guides/error-handling). + ## Edge cases - **`dismissAll`** drains the queue — queued callers resolve without mounting. diff --git a/apps/docs/content/reference/core-api.mdx b/apps/docs/content/reference/core-api.mdx index ab3a061..376b062 100644 --- a/apps/docs/content/reference/core-api.mdx +++ b/apps/docs/content/reference/core-api.mdx @@ -77,7 +77,7 @@ class LayerStack { | Method | Purpose | | ------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `getSnapshot` | Stable-ref snapshot of mounted layers (`pending`/`active`/`dismissed`/`error`) | -| `getQueuedSnapshot` | Serial-scope queue — layers waiting behind an active layer | +| `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) | @@ -103,6 +103,7 @@ Immutable snapshot of one layer, consumed by selectors. Per-stack options passed to `ensureStack` or `LayerClientOptions.defaultStackOptions`. + ## layerOptions diff --git a/docs/architecture.md b/docs/architecture.md index 00e9d04..30ccea3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,7 @@ serial scope: later opens wait as `queued` (not mounted) — see `getQueuedSnaps ``` - `pending` — `loadFn` in flight (cancelable via `AbortController`). -- `queued` — serial scope only: waiting behind an active layer; not in `getSnapshot()`; visible via `getQueuedSnapshot()`. +- `queued` — serial scope only: waiting behind the occupying layer; not in `getSnapshot()`; visible via `getQueuedSnapshot()`. - `active` — mounted; component receives `call` (`end`/`dismiss`/`update`/`setRunning`/`settle`/`ended`/`index`/`stackSize`/`root`/`stackId`/`layerId`/`addBlocker`), `payload`, `data`, `error`, `phase`, `transition`, `actionStatus`, `dismissing`. - `dismissed` — caller's `await` resolved (`ended=true`); `transition: "exiting"` keeps the layer mounted for `exitingDelay` (or until `settle`); on removal, cached for `gcTime` so re-opening the same key restores `data` without re-running `loadFn`. - `error` — `loadFn` threw; the caller's `await` rejects. @@ -157,7 +157,7 @@ Narrow in a `catch` with the shipped guards (`isPayloadValidationError`) or the ## Stacks, scope, gcTime - **Named stacks** — `LayerClient.getStack(id)`; isolated. `open({ stack })` picks one. -- **`scope: { strategy: 'serial' }`** — only one `pending`/`active` layer at a time; later opens queue (`phase: 'queued'`, visible via `getQueuedSnapshot()`) and activate when the active one is dismissed. `parallel` (default) stacks freely. `dismissAll` (async; see § Blockers) drains the queue, resolving queued callers without mounting. +- **`scope: { strategy: 'serial', onLoadError?: 'block' | 'advance' }`** — one occupying layer at a time (`pending` / `active` / `error` under default `onLoadError: 'block'`); later opens queue (`phase: 'queued'`, visible via `getQueuedSnapshot()`) and activate when the occupant leaves. `onLoadError: 'advance'` removes a failed `loadFn` layer and drains the next queued open. `parallel` (default) stacks freely. `dismissAll` (async; see § Blockers) drains the queue, resolving queued callers without mounting. - **`gcTime`** — dismissed layers cached (in the internal `layerGcCache` module — a small removable-cache primitive) so re-opening the same key skips `loadFn`. **One slot per key signature — last-dismissed-wins**; the displaced entry is evicted with explicit teardown, and re-open cancels the timer. A cached layer is off-`getSnapshot()` and inherently unobserved, so the timer-runs-while-cached model already matches observer-gated gc. _Rejected: pausing gc while the stack is unmounted (would serve unbounded-stale data — no `staleTime`/refetch model — and risk retention), and a per-layer observer refcount (a stack-level proxy for it); revisit only alongside a real freshness model._ ## Notifications diff --git a/docs/glossary.md b/docs/glossary.md index e3a454e..804ea78 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -28,7 +28,7 @@ Ubiquitous language for the `@stainless-code/layers` domain. Keep terms stable a - **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. - **dismiss mode** — `DismissAllMode` for `stack.dismissAll`: `"skipBlocked"` (default — close permitted, leave blocked open), `"stopAtBlocked"` (halt at first blocked), `"force"` (bypass blockers). Default per stack via `StackOptions.dismissAllMode`. - **upsert** — reusing an active key updates its payload instead of stacking (singleton toasts/progress). -- **scope** — per-stack queueing on `StackOptions`: `scope: { strategy: "serial" | "parallel" }` (`parallel` when omitted); `serial` = one active layer at a time, `parallel` = stack freely. +- **scope** — per-stack queueing on `StackOptions`: `scope: { strategy: "serial" | "parallel", onLoadError?: "block" | "advance" }` (`parallel` / `block` when omitted); `serial` = one occupying layer at a time, `parallel` = stack freely. Serial `onLoadError`: `block` keeps a failed `loadFn` as `phase: "error"` until dismiss; `advance` removes it and drains the queue. - **gcTime** — keep dismissed layers in a cache so re-opening the same key restores `data` without re-running `loadFn`. One slot per key (last-dismissed-wins); evicted after `gcTime` with explicit teardown. - **loadFn** — optional async load run on `open`; cancelable via `AbortController`; `pending` → `active` with `data` on success, `error` on throw. - **Observer / selector** — adapters subscribe to `LayerStack.getSnapshot()` and project via a selector (React/Preact `useSyncExternalStore`, Solid `from`, Angular `signal`, Vue `shallowRef`, Lit reactive controllers, Alpine `Alpine.reactive` / data `states`, Svelte runes). diff --git a/packages/core/skills/layers/SKILL.md b/packages/core/skills/layers/SKILL.md index 6ec1ef2..b06fdaa 100644 --- a/packages/core/skills/layers/SKILL.md +++ b/packages/core/skills/layers/SKILL.md @@ -232,13 +232,13 @@ const serialClient = new LayerClient({ confirm: { scope: { strategy: "serial" }, gcTime: 5_000 }, }, }); -// only one confirm pending/active at a time; later open() queues (phase: "queued") +// only one occupying layer at a time; later open() queues (phase: "queued") const stack = serialClient.getStack("confirm"); stack.getQueuedSnapshot(); // inspect queued layers stack.cancelQueued(["confirm", "remove"], false); // FIFO head; pass `{ id }` for exact queued ``` -Serial scope allows only one `pending` or `active` layer; later opens remain unmounted until their turn. `cancelQueued` resolves a waiting caller without mounting (FIFO, or `{ id }` for exact). `gcTime` caches dismissed data after removal, so reopening the same key restores `data` without rerunning `loadFn`. +Serial scope allows only one occupying layer (`pending`, `active`, or `error` when `onLoadError` is `"block"`, the default); later opens remain unmounted until their turn. With `onLoadError: "advance"`, a rejecting `loadFn` removes the failed layer and drains the next queued open. `cancelQueued` resolves a waiting caller without mounting (FIFO, or `{ id }` for exact). `gcTime` caches dismissed data after removal, so reopening the same key restores `data` without rerunning `loadFn`. ## Nested layers diff --git a/packages/core/src/layerStack.test.ts b/packages/core/src/layerStack.test.ts index 5f211ba..7ad9f48 100644 --- a/packages/core/src/layerStack.test.ts +++ b/packages/core/src/layerStack.test.ts @@ -240,6 +240,153 @@ describe("LayerStack — scope serial", () => { expect(stack.getQueuedSnapshot()).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", { + scope: { strategy: "serial" }, + }); + const a = stack.open({ + key: ["a"], + payload: { n: 1 }, + loadFn: () => + new Promise((_, reject) => { + rejectLoad = reject; + }), + }); + stack.open({ key: ["b"], payload: { n: 2 } }); + expect(stack.getQueuedSnapshot()).toHaveLength(1); + + rejectLoad(new Error("boom")); + await expect(a.promise.promise).rejects.toThrow("boom"); + expect(stack.getSnapshot()).toEqual([ + expect.objectContaining({ phase: "error", payload: { n: 1 } }), + ]); + expect(stack.getQueuedSnapshot()).toEqual([ + expect.objectContaining({ phase: "queued", payload: { n: 2 } }), + ]); + + const c = stack.open({ key: ["c"], payload: { n: 3 } }); + expect(stack.getSnapshot()).toHaveLength(1); + expect(stack.getQueuedSnapshot().map((l) => l.payload.n)).toEqual([2, 3]); + expect(c.state.phase).toBe("queued"); + + await stack.dismiss(a, false); + expect(stack.getSnapshot()).toEqual([ + expect.objectContaining({ phase: "active", payload: { n: 2 } }), + ]); + expect(stack.getQueuedSnapshot()).toEqual([ + expect.objectContaining({ payload: { n: 3 } }), + ]); + }); + + it("onLoadError advance: reject removes layer and drains queue", async () => { + let rejectLoad!: (error: Error) => void; + const stack = new LayerStack<{ n: number }, boolean, Error>("s", { + scope: { strategy: "serial", onLoadError: "advance" }, + }); + const a = stack.open({ + key: ["a"], + payload: { n: 1 }, + loadFn: () => + new Promise((_, reject) => { + rejectLoad = reject; + }), + }); + stack.open({ key: ["b"], payload: { n: 2 } }); + expect(stack.getQueuedSnapshot()).toHaveLength(1); + + rejectLoad(new Error("boom")); + await expect(a.promise.promise).rejects.toThrow("boom"); + expect(stack.getSnapshot()).toEqual([ + expect.objectContaining({ phase: "active", payload: { n: 2 } }), + ]); + expect(stack.getQueuedSnapshot()).toHaveLength(0); + + const c = stack.open({ key: ["c"], payload: { n: 3 } }); + expect(c.state.phase).toBe("queued"); + expect(stack.getQueuedSnapshot().map((l) => l.payload.n)).toEqual([3]); + }); + + it("onLoadError advance: empty queue clears the failed layer", async () => { + const stack = new LayerStack<{ n: number }, boolean, Error>("s", { + scope: { strategy: "serial", onLoadError: "advance" }, + }); + const a = stack.open({ + key: ["a"], + payload: { n: 1 }, + loadFn: async () => { + throw new Error("boom"); + }, + }); + await expect(a.promise.promise).rejects.toThrow("boom"); + expect(stack.getSnapshot()).toHaveLength(0); + expect(stack.getQueuedSnapshot()).toHaveLength(0); + }); + + it("onLoadError advance: fires onLayerDismiss before remove", async () => { + const dismissed: string[] = []; + let phaseAtHook: string | undefined; + const stack = new LayerStack<{ n: number }, boolean, Error>("s", { + scope: { strategy: "serial", onLoadError: "advance" }, + }); + stack.onLayerDismiss = (layer) => { + dismissed.push(layer.id); + phaseAtHook = layer.state.phase; + }; + const a = stack.open({ + key: ["a"], + payload: { n: 1 }, + loadFn: async () => { + throw new Error("boom"); + }, + }); + await expect(a.promise.promise).rejects.toThrow("boom"); + expect(dismissed).toEqual([a.id]); + expect(phaseAtHook).toBe("dismissed"); + }); + + it("onLoadError advance: dismiss on failed handle is a no-op for the next layer", async () => { + let rejectLoad!: (error: Error) => void; + const stack = new LayerStack<{ n: number }, boolean, Error>("s", { + scope: { strategy: "serial", onLoadError: "advance" }, + }); + const a = stack.open({ + key: ["a"], + payload: { n: 1 }, + loadFn: () => + new Promise((_, reject) => { + rejectLoad = reject; + }), + }); + const b = stack.open({ key: ["b"], payload: { n: 2 } }); + rejectLoad(new Error("boom")); + await expect(a.promise.promise).rejects.toThrow("boom"); + expect(stack.getSnapshot()[0]?.id).toBe(b.id); + + await expect(stack.dismiss(a, false)).resolves.toBe(true); + expect(stack.getSnapshot()).toEqual([ + expect.objectContaining({ id: b.id, phase: "active" }), + ]); + await expect(a.promise.promise).rejects.toThrow("boom"); + }); + + it("onLoadError advance is ignored on parallel stacks", async () => { + const stack = new LayerStack<{ n: number }, boolean, Error>("s", { + scope: { strategy: "parallel", onLoadError: "advance" }, + }); + const a = stack.open({ + key: ["a"], + payload: { n: 1 }, + loadFn: async () => { + throw new Error("boom"); + }, + }); + await expect(a.promise.promise).rejects.toThrow("boom"); + expect(stack.getSnapshot()).toEqual([ + expect.objectContaining({ phase: "error", payload: { n: 1 } }), + ]); + }); + it("cancelQueued resolves a queued layer without mounting it", async () => { const stack = new LayerStack<{ n: number }, boolean>("s", { scope: { strategy: "serial" }, diff --git a/packages/core/src/layerStack.ts b/packages/core/src/layerStack.ts index fadb9c6..7a515e3 100644 --- a/packages/core/src/layerStack.ts +++ b/packages/core/src/layerStack.ts @@ -101,9 +101,13 @@ export class LayerStack< return this.options.scope?.strategy === "serial"; } + /** Serial occupancy — pending, active, or mounted error (block policy). */ #hasActive(): boolean { return this.#layers.some( - (l) => l.state.phase === "pending" || l.state.phase === "active", + (l) => + l.state.phase === "pending" || + l.state.phase === "active" || + l.state.phase === "error", ); } @@ -223,6 +227,23 @@ export class LayerStack< if (layer.aborted) { return; } + const isAdvance = + this.#serial && + (this.options.scope?.onLoadError ?? "block") === "advance"; + if (isAdvance) { + // Drop the failed occupant and drain — no lasting error UI. + // Mirror #commitDismiss: mark dismissed, then onLayerDismiss, then remove. + layer.reject(error as E); + layer.setPartial({ + error: error as E, + phase: "dismissed", + transition: "settled", + ended: true, + }); + this.onLayerDismiss?.(layer); + this.#remove(layer); + return; + } layer.setPartial({ error: error as E, phase: "error" }); layer.reject(error as E); this.#dispatch("phase", () => { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b30a12e..6d3e486 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -143,9 +143,24 @@ export type OpenLayerOptions< /** Rejects keys that do not exist on `T`. */ export type OmitKeyof = Omit; +/** Serial policy when a mounted layer's `loadFn` rejects. */ +export type SerialOnLoadError = "block" | "advance"; + export interface StackOptions { - /** Serial scope queues unmounted opens until the pending or active layer dismisses. @default { strategy: "parallel" } */ - scope?: { strategy: "serial" | "parallel" }; + /** + * Serial scope queues unmounted opens until the occupying layer leaves + * (`pending` / `active` / `error` for `onLoadError: "block"`). + * @default { strategy: "parallel" } + */ + scope?: { + strategy: "serial" | "parallel"; + /** + * Serial only. `block` — keep `phase: "error"` until dismiss. + * `advance` — remove the failed layer and drain the next queued open. + * @default "block" + */ + onLoadError?: SerialOnLoadError; + }; /** Retains loaded data for same-key restoration. @default 0 */ gcTime?: number; /** @default "skipBlocked" */