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
2 changes: 1 addition & 1 deletion apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,7 @@
},
{
"name": "liveStore",
"type": "StateStore",
"type": "SignalStateStore",
"description": "Surface-owned live state store: `$bindState` props read it and input\ncomponents write user edits into it, so event-time logic (checks,\naction context) sees CURRENT values instead of the agent-seeded\nsnapshot. Seeded from spec.state with user edits preserved. Public so\nhosts (and tests) can read the live values of a rendered surface.",
"optional": false
},
Expand Down
84 changes: 80 additions & 4 deletions apps/website/content/docs/render/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@
"description": "Resolved inputs for each repeat item.",
"optional": false
},
{
"name": "repeatVisible",
"type": "Signal<boolean[]>",
"description": "Per-item visibility for repeat elements. The element's own `visible`\n condition is evaluated once per item, in that item's scope, so\n `{ $item: … }` and `{ $index: … }` conditions can hide individual rows —\n the same rule the non-repeat branch applies to a single mount.",
"optional": false
},
{
"name": "resolvedInputs",
"type": "Signal<object>",
Expand Down Expand Up @@ -620,6 +626,56 @@
],
"examples": []
},
{
"name": "SignalStateStore",
"kind": "interface",
"description": "The StateStore that signalStateStore returns.\n\nAdds `lastChange()` on top of the `@json-render/core` interface, which is\nhow `<render-spec>` reports the mutated path on a `stateChange` render\nevent — `StateStore.subscribe` itself carries no path. A store from any\nother implementation simply omits the member.",
"properties": [
{
"name": "get",
"type": "(path: string) => unknown",
"description": "Read a value by JSON Pointer path.",
"optional": false
},
{
"name": "getServerSnapshot",
"type": "() => StateModel",
"description": "Optional server snapshot for SSR (passed to `useSyncExternalStore`). Falls back to `getSnapshot` when omitted.",
"optional": true
},
{
"name": "getSnapshot",
"type": "() => StateModel",
"description": "Return the full state object (used by `useSyncExternalStore`).",
"optional": false
},
{
"name": "lastChange",
"type": "() => StateChangeRecord | undefined",
"description": "The path and value of the most recent mutation, or `undefined` before the\nfirst one. Written before subscribers are notified, so a subscriber can\nread it to learn what changed.",
"optional": true
},
{
"name": "set",
"type": "(path: string, value: unknown) => void",
"description": "Write a value by JSON Pointer path and notify subscribers.\nEquality is checked by reference (`===`), not deep comparison.\nCallers must pass a new object/array reference for changes to be detected.",
"optional": false
},
{
"name": "subscribe",
"type": "(listener: () => void) => () => void",
"description": "Register a listener that is called on every state change. Returns an unsubscribe function.",
"optional": false
},
{
"name": "update",
"type": "(updates: Record<string, unknown>) => void",
"description": "Write multiple values at once and notify subscribers (single notification).\nEach value is compared by reference (`===`); only paths whose value\nactually changed are applied.",
"optional": false
}
],
"examples": []
},
{
"name": "StandardSchemaV1",
"kind": "interface",
Expand All @@ -634,6 +690,26 @@
],
"examples": []
},
{
"name": "StateChangeRecord",
"kind": "interface",
"description": "The path and value of the mutation that most recently notified subscribers.",
"properties": [
{
"name": "path",
"type": "string",
"description": "The JSON Pointer that was written.",
"optional": false
},
{
"name": "value",
"type": "unknown",
"description": "The value written at that pointer.",
"optional": false
}
],
"examples": []
},
{
"name": "AngularComponentRenderer",
"kind": "type",
Expand Down Expand Up @@ -818,8 +894,8 @@
{
"name": "signalStateStore",
"kind": "function",
"description": "Create a signal-backed StateStore for a generative-UI surface —\nholds the bound state that spec `$bindState` paths read and interactive\nelements write, with path-addressable get/set and change subscriptions.",
"signature": "signalStateStore(initialState: StateModel): StateStore",
"description": "Create a signal-backed StateStore for a generative-UI surface —\nholds the bound state that spec `$bindState` paths read and interactive\nelements write, with path-addressable get/set and change subscriptions.\n\nEvery path is a JSON Pointer and must start with `/` (or be `''` / `'/'`\nfor the root); a slash-less path throws.",
"signature": "signalStateStore(initialState: StateModel): SignalStateStore",
"params": [
{
"name": "initialState",
Expand All @@ -829,11 +905,11 @@
}
],
"returns": {
"type": "StateStore",
"type": "SignalStateStore",
"description": ""
},
"examples": [
"```ts\nconst store = signalStateStore({ count: 0 });\nstore.set('/count', 1);\n```"
"```ts\nconst store = signalStateStore({ count: 0 });\nstore.set('/count', 1);\nstore.lastChange?.(); // { path: '/count', value: 1 }\n```"
]
},
{
Expand Down
49 changes: 44 additions & 5 deletions apps/website/content/docs/render/api/signal-state-store.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { signalStateStore } from '@threadplane/render';
## Signature

```typescript
function signalStateStore(initialState: StateModel = {}): StateStore;
function signalStateStore(initialState: StateModel = {}): SignalStateStore;
```

### Parameters
Expand All @@ -27,7 +27,7 @@ function signalStateStore(initialState: StateModel = {}): StateStore;

### Returns

A `StateStore` object with the following interface:
A `SignalStateStore` -- the `StateStore` interface from `@json-render/core` plus one extra member:

```typescript
interface StateStore {
Expand All @@ -38,10 +38,21 @@ interface StateStore {
getServerSnapshot?: () => StateModel;
subscribe: (listener: () => void) => () => void;
}

interface SignalStateStore extends StateStore {
lastChange?: () => StateChangeRecord | undefined;
}

interface StateChangeRecord {
readonly path: string;
readonly value: unknown;
}
```

`getServerSnapshot` is an optional member of the `@json-render/core` interface used for server-side rendering. `signalStateStore()` does not implement it, so consumers fall back to `getSnapshot()`.

`SignalStateStore` and `StateChangeRecord` are both exported from `@threadplane/render`. Anywhere a plain `StateStore` is accepted -- the `store` input on `<render-spec>`, the `store` field of `provideRender()` -- a `SignalStateStore` is accepted too.

## Methods

### get(path)
Expand Down Expand Up @@ -127,6 +138,28 @@ store.set('/count', 2); // no log

**Returns:** `() => void` -- an unsubscribe function.

### lastChange()

Returns the path and value of the most recent mutation, or `undefined` before the first one. It is written before subscribers are notified, so a subscriber can read it to learn what changed -- `subscribe()` itself hands the listener nothing.

```typescript
const store = signalStateStore({ user: { name: 'Alice' } });

store.lastChange(); // undefined
store.set('/user/name', 'Bob');
store.lastChange(); // { path: '/user/name', value: 'Bob' }

store.subscribe(() => {
console.log('changed at', store.lastChange()?.path);
});
```

A write that is skipped as a no-op does not update it. `update()` records the last path it actually applied.

**Returns:** `StateChangeRecord | undefined`.

This is what lets `<render-spec>` report a real `path` on its `stateChange` render event. See the [Events guide](/docs/render/guides/events).

## JSON Pointer Format

Paths follow the [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) JSON Pointer specification:
Expand All @@ -144,9 +177,15 @@ Paths follow the [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) JSON
| `/items/2/name` | `name` property of third array element |
| `/a~1b` | Property named `a/b` |

<Callout type="warning" title="The leading slash is required">
A path without a leading `/` silently drops its first segment. `store.get('count')` returns the entire state object, and `store.set('count', 1)` replaces the entire state with `1` rather than writing `/count`. Always write `'/count'`.
</Callout>
The leading `/` is required. `''` and `'/'` both address the root; any other path that does not start with `/` throws an `Error` naming the path, on `get()`, `set()` and `update()` alike:

```typescript
store.set('count', 1);
// Error: Invalid state path "count": a state store path is a JSON Pointer
// and needs a leading "/" (write "/count" to address that key, or "" for the root).
```

A rejected `update()` applies none of its entries, so a bad path in a batch leaves the state untouched.

## Reactive Behavior

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ The rendering pipeline works as follows:
- Evaluates the `visible` condition
- Resolves prop expressions and bindings using `@json-render/core`
- Renders the component via `NgComponentOutlet` with the resolved inputs
3. For elements with `repeat`, the library iterates over the state array and creates a child `Injector` with a `RepeatScope` for each item. The `visible` condition is evaluated only on the non-repeating path -- a repeating element renders one instance per item regardless of `visible`.
3. For elements with `repeat`, the library iterates over the state array and creates a child `Injector` with a `RepeatScope` for each item. The `visible` condition is evaluated once per item, in that item's scope, so an `$item` or `$index` condition hides individual rows.
4. Children are not rendered automatically -- a component that wants them must mount `<render-element>` for each key in `childKeys`.

## Next Steps
Expand Down
81 changes: 69 additions & 12 deletions apps/website/content/docs/render/guides/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,75 @@ The `on` property on a `UIElement` maps event names to action bindings:
}
```

Each binding has:
Each binding is an `ActionBinding` from `@json-render/core`, and this renderer honors every field of it:

| Property | Type | Description |
|----------|------|-------------|
| `action` | `string` | The key used to look up the handler function |
| `params?` | `Record<string, DynamicValue>` | Optional parameters passed to the handler |
| `params?` | `Record<string, DynamicValue>` | Parameters passed to the handler, resolved before the call |
| `confirm?` | `ActionConfirm` | Ask the user to confirm before running the handler |
| `onSuccess?` | `ActionOnSuccess` | What to do once the handler settles successfully |
| `onError?` | `ActionOnError` | What to do if the handler throws or rejects |
| `preventDefault?` | `boolean` | Call `preventDefault()` on the emitted DOM event |

The `ActionBinding` type in `@json-render/core` also declares `confirm`, `onSuccess`, `onError`, and `preventDefault`. This renderer reads only `action` and `params`; the other four fields are accepted by the spec format but are not honored here.
### Resolved params

<Callout type="warning" title="Params are not resolved">
Params are passed through verbatim. A `$state` expression inside `params` is **not** resolved before the handler runs. Read dynamic values from the store inside the handler instead.
</Callout>
`params` are `DynamicValue`s and go through the same resolver an element prop does, in the element's repeat scope. A `$state` expression reads the store, and `$item` / `$index` resolve against the current repeat item:

```typescript
{
type: 'Button',
props: { label: 'Open' },
on: {
click: { action: 'open', params: { id: { $state: '/selected' } } },
},
}
```

With `/selected` holding `'row-7'`, the handler receives `{ id: 'row-7' }`. Anything a component passes as the emit payload is merged on top, so a payload key wins over a param of the same name.

### Confirmation

`confirm` asks before the handler runs. A declined confirmation skips the handler, and the `onSuccess` and `onError` follow-ups with it:

```typescript
on: {
click: {
action: 'deleteAccount',
confirm: { title: 'Delete account', message: 'This cannot be undone. Continue?' },
},
}
```

The prompt is the browser's own confirmation dialog, asked through the injected `DOCUMENT`'s default view. Where there is no default view -- server-side rendering -- there is nobody to ask, and the handler proceeds.

### Follow-ups

`onSuccess` runs after the handler returns, or after the promise it returned resolves. It takes one of three shapes:

| Shape | Effect |
|-------|--------|
| `{ set: { '/path': value } }` | Writes each entry into the state store |
| `{ action: 'name' }` | Dispatches another registered handler, with no params |
| `{ navigate: '/path' }` | Navigates the browser to that path |

`onError` runs when the handler throws or its promise rejects, and takes the `set` and `action` shapes. Inside an `onError` `set` map the literal string `'$error.message'` is replaced by the thrown error's message:

```typescript
on: {
click: {
action: 'saveForm',
onSuccess: { set: { '/saved': true } },
onError: { set: { '/error': '$error.message' } },
},
}
```

Without an `onError`, an error from the handler propagates as it always did.

### preventDefault

`preventDefault: true` calls `preventDefault()` on the emitted payload when that payload is a DOM `Event` -- either the event itself, or a payload record carrying it under `event`. Use it for a component that emits the raw event from a link or a form submit.

### Multiple Handlers per Event

Expand Down Expand Up @@ -236,7 +293,7 @@ const handlers = {

## Async Handlers

Handlers can be asynchronous. The library does not await the return value before continuing, though a returned Promise is observed so the `handler` render event can carry its settled `result`:
Handlers can be asynchronous. The library does not block on the return value, but it does observe a returned Promise: the `handler` render event carries its settled `result`, and the binding's `onSuccess` or `onError` follow-up runs once it settles.

```typescript
const handlers = {
Expand Down Expand Up @@ -274,7 +331,7 @@ onEvent(event: RenderEvent) {
console.log('handler ran:', event.action, event.params, event.result);
break;
case 'stateChange':
console.log('state changed:', event.snapshot);
console.log('state changed:', event.path, event.value);
break;
case 'lifecycle':
console.log('lifecycle:', event.event, event.scope, event.elementType);
Expand All @@ -291,13 +348,13 @@ onEvent(event: RenderEvent) {
| `type` | Interface | Fires when | Notable fields |
|--------|-----------|------------|----------------|
| `'handler'` | `RenderHandlerEvent` | A handler finishes running | `action`, `params`, `result?` |
| `'stateChange'` | `RenderStateChangeEvent` | The store value changes | `path` (always `'/'`), `value` (the full snapshot), `snapshot` |
| `'lifecycle'` | `RenderLifecycleEvent` | The spec mounts or is destroyed | `event` (`'mounted'` \| `'destroyed'`), `scope` (`'spec'` \| `'element'`), `elementKey?`, `elementType?` |
| `'stateChange'` | `RenderStateChangeEvent` | The store value changes | `path` (the mutated pointer), `value` (the value written there), `snapshot` |
| `'lifecycle'` | `RenderLifecycleEvent` | The spec or one of its elements mounts or is destroyed | `event` (`'mounted'` \| `'destroyed'`), `scope` (`'spec'` \| `'element'`), `elementKey?`, `elementType?` |
| `'result'` | `RenderResultEvent` | A mounted view component calls `injectRenderHost().result(value)` | `value`, `elementKey?` |

A `stateChange` event does not report which path changed: the store notifies subscribers without a path, so `path` is always `'/'` and `value` is the same full snapshot as `snapshot`. Diff the snapshot yourself if you need the changed key.
A `stateChange` event names the path that changed. `StateStore.subscribe` hands its listener nothing, so the path comes from `lastChange()` on the store: a store built by [`signalStateStore()`](/docs/render/api/signal-state-store) records its last mutation and the event reports that pointer and the value written there. A store from another implementation has no `lastChange`, so its events fall back to `path: '/'` with the full snapshot as `value`. Either way `snapshot` is the whole state model.

Element-scope lifecycle events (`scope: 'element'`) fire only for elements that carry a truthy `lifecycle` field in the spec. `UIElement` does not declare that field, so in practice the two spec-scope events are the only lifecycle events most applications see.
Element-scope lifecycle events (`scope: 'element'`) fire for every element the renderer mounts, carrying that element's `elementKey` and `elementType`. A spec with three elements therefore emits one spec-scope `mounted` event and three element-scope ones, and an element that leaves the tree emits an element-scope `destroyed` event as it is torn down.

All four interfaces are exported from `@threadplane/render`. This output is the single source the [Lifecycle guide](/docs/render/guides/lifecycle) builds its `RENDER_LIFECYCLE` signals on top of -- both observe the same stream, so there is no double-counting.

Expand Down
6 changes: 3 additions & 3 deletions apps/website/content/docs/render/guides/lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ export const RENDER_LIFECYCLE = new InjectionToken<RenderLifecycle>('RENDER_LIFE

Only mounts are recorded. `destroyed` lifecycle events are pushed through the same tap but the service ignores them, so nothing decrements and no signal reports teardown. See the [Events guide](/docs/render/guides/events) for the full event union.

<Callout type="warning" title="mountCount counts spec mounts, not elements">
`<render-spec>` emits exactly one spec-scope `mounted` event, in `ngOnInit`. Element-scope `mounted` events come from `<render-element>`, and it emits one only when the element carries a truthy `lifecycle` field in the spec -- a field `UIElement` does not declare. For a spec whose elements do not set it, `mountCount` reaches `1` and stays there, and `lastMountAt` keeps the timestamp of that single mount. Treat these two signals as "the spec mounted at", not as a live element census.
<Callout type="info" title="mountCount counts mounts, not live elements">
`<render-spec>` emits one spec-scope `mounted` event in `ngOnInit`, and `<render-element>` emits one element-scope `mounted` event per element it mounts. A three-element spec therefore leaves `mountCount` at `4`. The counter only ever climbs: `destroyed` events are ignored, so a spec that mounts and tears down elements as it streams keeps adding to the total rather than reporting how many elements are on screen right now.
</Callout>

## Reading the signals
Expand All @@ -82,7 +82,7 @@ export class MyComponent {
}
```

`lastStateChangeAt` and `lastHandlerInvokedAt` are the two signals that keep moving in an ordinary application: every store write and every dispatched handler updates one of them.
All four of the non-sticky signals keep moving in an ordinary application: every store write, every dispatched handler, and every element the renderer mounts updates one of them.

## Reset semantics

Expand Down
Loading
Loading