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/json-safe-layer-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stainless-code/layers": patch
---

Reject non-JSON-safe layer key segments with `LayerKeyError` from `hashKey` (before stringify). Previously `JSON.stringify` could collide `[undefined]` / `[null]` / `[NaN]` as `"[null]"`.
2 changes: 1 addition & 1 deletion apps/docs/content/adapters/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ npm i @stainless-code/layers
| `notifyManager` | `{ batch, batchCalls }` — coalesce notifications inside a tick |
| `Register` | Module augmentation interface for app-wide `DefaultLayerError` |

The core also exports `Layer`, `Subscribable`, `ControlledPromise`, `PayloadValidationError`, `isPayloadValidationError`, `hashKey`, `keySignature`, `childStackId`, and the full type surface (`LayerState`, `LayerPhase`, `LayerCallContext`, `StackOptions`, …). See [Core API](/reference/core-api).
The core also exports `Layer`, `Subscribable`, `ControlledPromise`, `PayloadValidationError`, `isPayloadValidationError`, `LayerKeyError`, `isLayerKeyError`, `assertLayerKey`, `hashKey`, `keySignature`, `childStackId`, and the full type surface (`LayerState`, `LayerPhase`, `LayerCallContext`, `StackOptions`, …). See [Core API](/reference/core-api).

## Headless quick start

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/concepts/glossary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ From `LayerClient` down to `keySignature` — every term in code, docs, and issu
- **StackNotifyEvent** — JSON-safe record of one observable stack transition (`action` + `active`/`queued` views + optional projected `payload`). Emitted by core; Devtools bridges to TanStack `EventClient`.
- **subscribeNotify** — `LayerClient`: `(listener) => unsubscribe` for `StackNotifyEvent`s. Distinct from `subscribe` / `subscribeStacks` (snapshot / stack-created).
- **seedNotify** — `LayerClient`: re-emit a `register` `StackNotifyEvent` for one stack, or all materialized stacks when omitted (late-subscriber / Devtools attach seed).
- **Key** — the **logical** identity of a layer (`find`/`upsert`/`gcTime` operate on `keySignature(key)`); multiple live layers may share a key in a `parallel` stack.
- **Key** — the **logical** identity of a layer (`find`/`upsert`/`gcTime` operate on `keySignature(key)`). Must be JSON-safe (`string` \| `boolean` \| `null` \| finite `number` \| plain objects/arrays of those); invalid segments throw `LayerKeyError`. Multiple live layers may share a key in a `parallel` stack.
- **Instance id** (`LayerState.id`) — the **physical**, unique id of one `Layer` instance (`` `${hashKey(key)}#n` ``); used for rendering keys and instance lookup/removal (`getLayer`, `#remove`).
- **Call context** (`LayerCallContext`) — imperative handle handed to a layer component: `end`/`dismiss` (async — resolve the caller's `await`; return `Promise<boolean>`), `addBlocker`, `update` (patch payload live), `setRunning` (drives `actionStatus`), `settle` (drives `transition`), `ended`, `index`, `stackSize`, `root`, `stackId`, `layerId`. Built by `createCallContext`.
- **Layer group** — a child stack owned by a parent layer and tied to its lifetime: created via `createLayerGroup` / adapter `useLayerGroup`; when the parent dismisses, the group's stack auto-drains. Same `LayerClient`, no second client.
Expand Down
18 changes: 17 additions & 1 deletion apps/docs/content/concepts/identity-and-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,29 @@ Two opens with the same `key` share **logical identity**; each still gets a uniq

| Concept | Role |
| ------- | ---- |
| **Key** | Logical identity. `find`, `upsert`, and `gcTime` operate on `keySignature(key)`. Multiple live layers may share a key in a `parallel` stack. |
| **Key** | Logical identity. Must be **JSON-safe** (see below). `find`, `upsert`, and `gcTime` operate on `keySignature(key)`. Multiple live layers may share a key in a `parallel` stack. |
| **Instance id** (`LayerState.id`) | Physical, unique id of one `Layer` instance (`` `${hashKey(key)}#n` ``). Used for rendering keys and instance lookup/removal (`getLayer`, `#remove`). |

:::note[`find` returns the topmost match]
`LayerStack.find(key)` walks the stack and returns the **last** layer with that key signature — the topmost instance when several share a key in a `parallel` stack. Wired handles route `dismiss`/`update` without `{ id }` through this same lookup.
:::

## JSON-safe key domain

`hashKey` / `keySignature` serialize with sorted-object `JSON.stringify`. Keys must stay inside that domain or `hashKey`, `open` / `find` / `cancelQueued`, and `createLayer` (eager `keySignature`) throw `LayerKeyError` synchronously (narrow with `isLayerKeyError`).

| Allowed | Rejected |
| ------- | -------- |
| `string`, `boolean`, `null`, finite `number` | `undefined` (anywhere, including object props) |
| Plain objects / arrays of the above (shared refs / DAGs ok) | `NaN`, `±Infinity`, `bigint`, `symbol`, functions |
| Property order in plain objects (sorted for equality) | Non-plain objects (`Date`, class instances), cycles |

Prefer `null` or an explicit sentinel (`filterId ?? "none"`) over `undefined`. Omit optional object fields instead of setting them to `undefined`.

:::note[Earlier collision behavior]
Before this assert, `JSON.stringify` made `[undefined]`, `[null]`, and `[NaN]` collide as `"[null]"`; object `undefined` props were omitted; BigInt/cycles threw opaque `TypeError`s from `JSON.stringify`. Invalid segments now fail closed as `LayerKeyError` before hash.
:::

## Wired handle path

Bind a declaration with `useLayer(options)` (adapter) or `createLayer(options, client)` (headless). Payload-only `open`/`upsert` infer `R` from the same `DataTag` on `options.key`:
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/content/guides/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ search:

`open()` returns `Promise<R>` — the direct-`await` contract. Resolution types flow through `DataTag`; **rejections do not** (a TypeScript limitation). Narrow errors in `catch` with runtime guards.

## Sync throws vs Promise rejects

A non-JSON-safe `key` throws `LayerKeyError` **synchronously** (before any promise) — not a Promise rejection. Narrow with `isLayerKeyError`. Throw sites: [Identity & types](/concepts/identity-and-types). Wrap the call site when keys may be untrusted.

## What rejects

The promise rejects when:
Expand Down
14 changes: 14 additions & 0 deletions apps/docs/content/reference/core-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,20 @@ function isPayloadValidationError(
): value is PayloadValidationError;
```

## Layer key errors

Non-JSON-safe keys throw `LayerKeyError` synchronously (not a Promise rejection). Throw sites: [Identity & types](/concepts/identity-and-types).

```ts
class LayerKeyError extends Error {
readonly path: ReadonlyArray<PropertyKey>;
}

function isLayerKeyError(value: unknown): value is LayerKeyError;

function assertLayerKey(key: unknown): asserts key is LayerKey;
```

## createLayer

Wire `layerOptions` + a `LayerClient` into a headless handle. Adapters wrap this in reactive `useLayer` / `injectLayer` / `createLayer`.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Delay `0` (default) flips the axis synchronously — no transition frame observe

Layers are client-only today (React/Preact `getServerSnapshot` returns `[]`); when SSR hydration lands, initial `transition` must be `"settled"`.

**Identity:** each layer has a unique **instance id** (`LayerState.id`, `` `${hashKey(key)}#n` ``) for rendering keys and instance lookup/removal. The **key** is the logical identity (`find`/`upsert`/`gcTime` use `keySignature(key)`); multiple live layers may share a key in a `parallel` stack.
**Identity:** each layer has a unique **instance id** (`LayerState.id`, `` `${hashKey(key)}#n` ``) for rendering keys and instance lookup/removal. The **key** is the logical identity (`find`/`upsert`/`gcTime` use `keySignature(key)`); keys must be JSON-safe or `hashKey` throws `LayerKeyError`. Multiple live layers may share a key in a `parallel` stack.

## Blockers

Expand Down
2 changes: 1 addition & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Ubiquitous language for the `@stainless-code/layers` domain. Keep terms stable a
- **StackNotifyEvent** — JSON-safe record of one observable stack transition (`action` + `active`/`queued` views + optional projected `payload`). Emitted by core; Devtools bridges to TanStack `EventClient`.
- **subscribeNotify** — `LayerClient`: `(listener) => unsubscribe` for `StackNotifyEvent`s. Distinct from `subscribe` / `subscribeStacks` (snapshot / stack-created).
- **seedNotify** — `LayerClient`: re-emit a `register` `StackNotifyEvent` for one stack, or all when omitted (Devtools attach / late subscribers).
- **Key** — the **logical** identity of a layer (`find`/`upsert`/`gcTime` operate on `keySignature(key)`); multiple live layers may share a key in a `parallel` stack.
- **Key** — the **logical** identity of a layer (`find`/`upsert`/`gcTime` operate on `keySignature(key)`). Must be JSON-safe (`string` \| `boolean` \| `null` \| finite `number` \| plain objects/arrays of those); invalid segments throw `LayerKeyError`. Multiple live layers may share a key in a `parallel` stack.
- **Instance id** (`LayerState.id`) — the **physical**, unique id of one `Layer` instance (`` `${hashKey(key)}#n` ``); used for rendering keys and instance lookup/removal (`getLayer`, `#remove`).
- **Phase** — resolution lifecycle axis: `pending` → `active` → `dismissed`; or `queued` (serial-waiting, not mounted); or `error`. Does **not** include `exiting` — animation is on `transition`. Distinct from `actionStatus` and `transition`.
- **Transition** — animation axis (`entering` | `settled` | `exiting`), orthogonal to `phase` (like `actionStatus`). `dismissed + exiting` = playing exit anim; `dismissed + settled` = cached/done.
Expand Down
3 changes: 2 additions & 1 deletion packages/core/skills/layers/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ const ok = await client.open({

`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<boolean>`, with `false` meaning a blocker vetoed dismissal. `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)}#<n>`. `hashKey` and its alias `keySignature` compare keys structurally. Parallel stacks may contain multiple same-key instances.
`key` is logical identity for `find`, `upsert`, and `gcTime`; every mount gets a unique instance `id` shaped as `${hashKey(key)}#<n>`. 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.

## Lifecycle

Expand Down Expand Up @@ -271,6 +271,7 @@ The complete public API surface:
| Engine classes | `LayerClient`, `LayerStack`, `Layer` |
| Infrastructure classes | `Subscribable`, `ControlledPromise` |
| Validation class | `PayloadValidationError` |
| Key errors | `LayerKeyError`, `isLayerKeyError`, `assertLayerKey` |
| Declaration and inference | `layerOptions`, `layerKey`, `DataTag`, `InferDataTagResponse`, `InferDataTagError`, `ResponseOf`, `ErrorOf` |
| Wired handles | `createLayer`, `LayerHandle`, `ValidatedLayerHandle` |
| Rendering seam | `createCallContext`, `LayerCallContext`, `LayerComponentProps`, `LayerComponent` |
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,33 @@ export function isPayloadValidationError(
): value is PayloadValidationError {
return value instanceof PayloadValidationError;
}

/** Thrown when a layer key is not JSON-safe (from `assertLayerKey` / `hashKey`). */
export class LayerKeyError extends Error {
readonly path: ReadonlyArray<PropertyKey>;
constructor(message: string, path: ReadonlyArray<PropertyKey> = []) {
super(message);
this.name = "LayerKeyError";
this.path = path;
}
}

/**
* Narrows an unknown synchronous throw to {@link LayerKeyError}.
*
* @example
* ```ts
* import { isLayerKeyError } from "@stainless-code/layers";
*
* try {
* client.open({ key: [maybeId], payload });
* } catch (error) {
* if (isLayerKeyError(error)) {
* console.error(error.path, error.message);
* }
* }
* ```
*/
export function isLayerKeyError(value: unknown): value is LayerKeyError {
return value instanceof LayerKeyError;
}
14 changes: 12 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
export * from "./types";
export { hashKey, keySignature, shallowArrayEqual } from "./utils";
export {
assertLayerKey,
hashKey,
keySignature,
shallowArrayEqual,
} from "./utils";
export { Subscribable } from "./subscribable";
export { notifyManager } from "./notifyManager";
export { ControlledPromise } from "./controlledPromise";
Expand Down Expand Up @@ -28,5 +33,10 @@ export type {
InferValidatorOutput,
OpenValidatePayload,
} from "./validators";
export { PayloadValidationError, isPayloadValidationError } from "./errors";
export {
PayloadValidationError,
isPayloadValidationError,
LayerKeyError,
isLayerKeyError,
} from "./errors";
export type { ValidationIssue } from "./errors";
91 changes: 89 additions & 2 deletions packages/core/src/layerStack.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { describe, expect, it } from "bun:test";

import { isPayloadValidationError, PayloadValidationError } from "./errors";
import {
isLayerKeyError,
isPayloadValidationError,
LayerKeyError,
PayloadValidationError,
} from "./errors";
import { LayerClient } from "./layerClient";
import { layerOptions } from "./layerOptions";
import { LayerStack } from "./layerStack";
import { notifyManager } from "./notifyManager";
import { hashKey, keySignature, shallowArrayEqual } from "./utils";
import {
assertLayerKey,
hashKey,
keySignature,
shallowArrayEqual,
} from "./utils";

describe("hashKey / keySignature", () => {
it("is stable across key-object key order", () => {
Expand All @@ -16,6 +26,69 @@ describe("hashKey / keySignature", () => {
keySignature(["confirm", "y"]),
);
});
it("accepts JSON-safe primitives, arrays, and plain objects", () => {
expect(() =>
assertLayerKey(["modal", 1, true, null, { nested: ["a", 2] }]),
).not.toThrow();
expect(hashKey([])).toBe("[]");
expect(hashKey([["a"]])).toBe(JSON.stringify([["a"]]));
expect(hashKey(["modal", 1, true, null])).toBe(
JSON.stringify(["modal", 1, true, null]),
);
const nullProto = Object.create(null) as Record<string, unknown>;
nullProto.id = 1;
expect(hashKey([nullProto])).toBe(JSON.stringify([{ id: 1 }]));
});
it("accepts shared object refs (DAGs), not only trees", () => {
const shared = { id: 1 };
expect(hashKey([{ x: shared, y: shared }])).toBe(
JSON.stringify([{ x: { id: 1 }, y: { id: 1 } }]),
);
expect(hashKey([shared, shared])).toBe(
JSON.stringify([{ id: 1 }, { id: 1 }]),
);
});
it("preserves own __proto__ keys in the hash (no prototype clobber)", () => {
const withProto = JSON.parse('{"__proto__":{"a":1}}') as Record<
string,
unknown
>;
expect(hashKey([withProto])).not.toBe(hashKey([{}]));
expect(hashKey([withProto])).toBe(JSON.stringify([withProto]));
});
it("rejects undefined, non-finite numbers, bigint, and non-plain objects", () => {
const cases: Array<{ key: unknown; pathHint: string }> = [
{ key: [undefined], pathHint: "key[0]" },
{ key: [{ a: undefined }], pathHint: "key[0].a" },
{ key: [NaN], pathHint: "key[0]" },
{ key: [Infinity], pathHint: "key[0]" },
{ key: [-Infinity], pathHint: "key[0]" },
{ key: [1n], pathHint: "key[0]" },
{ key: [() => {}], pathHint: "key[0]" },
{ key: [new Date()], pathHint: "key[0]" },
{ key: [Symbol("x")], pathHint: "key[0]" },
];
for (const { key, pathHint } of cases) {
expect(() => hashKey(key as never)).toThrow(LayerKeyError);
try {
hashKey(key as never);
} catch (error) {
expect(isLayerKeyError(error)).toBe(true);
if (isLayerKeyError(error)) {
expect(error.message).toContain(pathHint);
}
}
}
});
it("rejects cyclic structures", () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
expect(() => hashKey([cyclic])).toThrow(LayerKeyError);
});
it("null is accepted; undefined throws (no longer collide)", () => {
expect(hashKey([null])).toBe(JSON.stringify([null]));
expect(() => hashKey([undefined])).toThrow(LayerKeyError);
});
});

describe("shallowArrayEqual", () => {
Expand All @@ -28,6 +101,20 @@ describe("shallowArrayEqual", () => {
});

describe("LayerStack — basics", () => {
it("open/find/cancelQueued throw LayerKeyError synchronously for non-JSON-safe keys", () => {
const stack = new LayerStack("s");
expect(() =>
stack.open({
key: [undefined],
payload: {},
}),
).toThrow(LayerKeyError);
expect(() => stack.find([undefined])).toThrow(LayerKeyError);
expect(() => stack.cancelQueued([undefined], undefined)).toThrow(
LayerKeyError,
);
});

it("open pushes, indexes, and goes active without loadFn", () => {
const stack = new LayerStack<{ n: number }, void>("s");
stack.open({ key: ["a"], payload: { n: 1 } });
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { Validator } from "./validators";

/** Structurally comparable layer identity. */
/**
* Logical identity for a layer (`find` / `upsert` / `gcTime`).
*
* Must be JSON-safe: `string` | `boolean` | `null` | finite `number`, or
* plain objects / arrays of those. Compared via sorted `JSON.stringify` —
* two keys with the same values in a different object-property order are equal.
* Invalid keys throw `LayerKeyError` from any path that hashes the key
* (`hashKey`, `open`, `find`, `cancelQueued`, `createLayer`).
*/
export type LayerKey = ReadonlyArray<unknown>;

export type LayerPhase =
Expand Down
Loading
Loading