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
5 changes: 5 additions & 0 deletions .changeset/serial-onload-error.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/docs/content/concepts/glossary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ From `LayerClient` down to `keySignature` — every term in code, docs, and issu
</AccordionItem>
<AccordionItem title="Scoping & caching">
- **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.
</AccordionItem>
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/content/concepts/lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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. |
Expand Down
9 changes: 6 additions & 3 deletions apps/docs/content/concepts/stacks-scope-gc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/guides/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
:::
Expand Down
20 changes: 17 additions & 3 deletions apps/docs/content/guides/serial-queues.mdx
Original file line number Diff line number Diff line change
@@ -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"]
---
Expand Down Expand Up @@ -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

Expand All @@ -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
}
```

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/content/reference/core-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -103,6 +103,7 @@ Immutable snapshot of one layer, consumed by selectors.
Per-stack options passed to `ensureStack` or `LayerClientOptions.defaultStackOptions`.

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

## layerOptions

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions packages/core/skills/layers/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
147 changes: 147 additions & 0 deletions packages/core/src/layerStack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>((_, 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<never>((_, 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<never>((_, 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" },
Expand Down
Loading
Loading