From 1739067cee30d0be1389b39e79f3624861b325a4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 13:52:41 -0700 Subject: [PATCH 1/2] fix(render): honor every ActionBinding field, per-item visible, real element lifecycle and state paths Six defects the docs audit had to describe truthfully, each with a spec that fails before the change: - `signalStateStore` paths: `parsePointer` dropped segment 0 unconditionally, so `set('count', 1)` replaced the whole state model. A path must now be `''`/`'/'` (root) or start with `/`; anything else throws an Error naming the path. Silent data loss is worse than a throw, so this applies in production too. - Element-scope lifecycle events fired only for elements carrying an undeclared `lifecycle` field. `` now emits `mounted` and `destroyed` for every element it mounts, which is what makes `mountCount` and `lastMountAt` on `RenderLifecycleService` mean something. - `stateChange` hardcoded `path: '/'` and the full snapshot as `value`. `signalStateStore()` now records its last mutation on the exported `SignalStateStore` interface, and `RenderSpecComponent` reads it to report the real path and value. Foreign stores keep `'/'` plus the snapshot. - `ActionBinding.confirm`, `onSuccess`, `onError` and `preventDefault` were accepted by the spec format and never read. All four are implemented, mirroring `executeAction` in `@json-render/core` (including the `'$error.message'` substitution in an `onError` set map). - Action `params` were spread unresolved, so a `$state` expression reached the handler as a literal object. They now go through `resolveElementProps` with the element's repeat scope, as element props do. - `visible` was ignored on the repeat branch. It is now evaluated per repeated item in that item's scope, so `$item` and `$index` conditions can hide individual rows. Co-Authored-By: Claude Fable 5.1 --- libs/render/src/lib/action-bindings.spec.ts | 381 ++++++++++++++++++ .../src/lib/render-element.component.ts | 150 ++++++- libs/render/src/lib/render-events.spec.ts | 212 ++++++++++ libs/render/src/lib/render-spec.component.ts | 10 +- .../render/src/lib/signal-state-store.spec.ts | 72 ++++ libs/render/src/lib/signal-state-store.ts | 70 +++- libs/render/src/public-api.ts | 1 + 7 files changed, 874 insertions(+), 22 deletions(-) create mode 100644 libs/render/src/lib/action-bindings.spec.ts create mode 100644 libs/render/src/lib/render-events.spec.ts diff --git a/libs/render/src/lib/action-bindings.spec.ts b/libs/render/src/lib/action-bindings.spec.ts new file mode 100644 index 000000000..138d27bb6 --- /dev/null +++ b/libs/render/src/lib/action-bindings.spec.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Component, inject, input } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import type { Spec } from '@json-render/core'; + +import { RenderElementComponent } from './render-element.component'; +import { RENDER_CONTEXT } from './contexts/render-context'; +import { defineAngularRegistry } from './define-angular-registry'; +import { signalStateStore } from './signal-state-store'; +import { injectRenderHost } from './contexts/render-host'; +import { REPEAT_SCOPE } from './contexts/repeat-scope'; + +/** Fires `emit('click')` with no payload when its button is clicked. */ +@Component({ + selector: 'render-test-clicker', + standalone: true, + template: '', +}) +class ClickerComponent { + readonly emit = input<(event: string) => void>(() => undefined); +} + +/** Fires `host.emit('click', payload)` with a caller-supplied payload. */ +@Component({ + selector: 'render-test-payload-clicker', + standalone: true, + template: '', +}) +class PayloadClickerComponent { + private readonly host = injectRenderHost(); + fire(event: Event): void { + this.host.emit('click', event as unknown as Record); + } +} + +function host(spec: Spec) { + @Component({ + standalone: true, + imports: [RenderElementComponent], + template: ``, + }) + class Host { + readonly spec = spec; + } + return Host; +} + +interface Harness { + fixture: ReturnType; + store: ReturnType; + calls: { action: string; params: Record }[]; + click: () => void; +} + +function mount( + spec: Spec, + options: { + initialState?: Record; + handlers?: Record) => unknown>; + component?: unknown; + confirmAnswer?: boolean; + } = {}, +): Harness { + const store = signalStateStore(options.initialState ?? {}); + const calls: { action: string; params: Record }[] = []; + const declared = options.handlers ?? {}; + const handlers: Record) => unknown> = {}; + for (const name of new Set([...Object.keys(declared), 'noop'])) { + handlers[name] = (params: Record) => { + calls.push({ action: name, params }); + return declared[name]?.(params); + }; + } + const HostCmp = host(spec); + TestBed.configureTestingModule({ + imports: [HostCmp], + providers: [ + { + provide: RENDER_CONTEXT, + useValue: { + store, + registry: defineAngularRegistry({ + clicker: (options.component ?? ClickerComponent) as never, + }), + functions: {}, + handlers, + }, + }, + ], + }); + if (options.confirmAnswer !== undefined) { + const doc = TestBed.inject(DOCUMENT); + vi.spyOn(doc.defaultView as Window, 'confirm').mockReturnValue(options.confirmAnswer); + } + const fixture = TestBed.createComponent(HostCmp); + fixture.detectChanges(); + return { + fixture, + store, + calls, + click: () => { + (fixture.nativeElement as HTMLElement).querySelector('button')!.click(); + fixture.detectChanges(); + }, + }; +} + +afterEach(() => vi.restoreAllMocks()); + +function clickSpec(on: unknown): Spec { + return { + root: 'btn', + elements: { btn: { type: 'clicker', props: {}, on } }, + } as unknown as Spec; +} + +// --- (e) params resolution --- + +describe('RenderElementComponent — action params resolution', () => { + it('resolves $state expressions inside params before calling the handler', () => { + const h = mount(clickSpec({ click: { action: 'pick', params: { id: { $state: '/selected' } } } }), { + initialState: { selected: 'row-7' }, + handlers: { pick: () => undefined }, + }); + h.click(); + expect(h.calls).toEqual([{ action: 'pick', params: { id: 'row-7' } }]); + }); + + it('leaves literal params untouched and lets the payload win on key collisions', () => { + const h = mount(clickSpec({ click: { action: 'pick', params: { id: 'literal', keep: 1 } } }), { + handlers: { pick: () => undefined }, + }); + h.click(); + expect(h.calls[0].params).toEqual({ id: 'literal', keep: 1 }); + }); +}); + +// --- (d) confirm / preventDefault / onSuccess / onError --- + +describe('RenderElementComponent — ActionBinding.confirm', () => { + it('skips the handler when the confirmation is declined', () => { + const h = mount( + clickSpec({ click: { action: 'wipe', params: {}, confirm: { title: 'Sure?', message: 'Delete all?' } } }), + { handlers: { wipe: () => undefined }, confirmAnswer: false }, + ); + h.click(); + expect(h.calls).toEqual([]); + }); + + it('runs the handler when the confirmation is accepted, asking with the configured message', () => { + const h = mount( + clickSpec({ click: { action: 'wipe', params: {}, confirm: { title: 'Sure?', message: 'Delete all?' } } }), + { handlers: { wipe: () => undefined }, confirmAnswer: true }, + ); + h.click(); + expect(h.calls.map((c) => c.action)).toEqual(['wipe']); + expect(TestBed.inject(DOCUMENT).defaultView!.confirm).toHaveBeenCalledWith('Delete all?'); + }); +}); + +describe('RenderElementComponent — ActionBinding.preventDefault', () => { + it('calls preventDefault() on an Event payload', () => { + const h = mount(clickSpec({ click: { action: 'nav', params: {}, preventDefault: true } }), { + handlers: { nav: () => undefined }, + component: PayloadClickerComponent, + }); + const button = (h.fixture.nativeElement as HTMLElement).querySelector('button')!; + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + button.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('leaves the event alone when preventDefault is not set', () => { + const h = mount(clickSpec({ click: { action: 'nav', params: {} } }), { + handlers: { nav: () => undefined }, + component: PayloadClickerComponent, + }); + const button = (h.fixture.nativeElement as HTMLElement).querySelector('button')!; + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + button.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); +}); + +describe('RenderElementComponent — ActionBinding.onSuccess', () => { + it('applies a `set` map after a synchronous handler returns', () => { + const h = mount( + clickSpec({ click: { action: 'save', params: {}, onSuccess: { set: { '/saved': true } } } }), + { initialState: { saved: false }, handlers: { save: () => undefined } }, + ); + h.click(); + expect(h.store.get('/saved')).toBe(true); + }); + + it('applies a `set` map only after an async handler resolves', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const h = mount( + clickSpec({ click: { action: 'save', params: {}, onSuccess: { set: { '/saved': true } } } }), + { initialState: { saved: false }, handlers: { save: () => gate } }, + ); + h.click(); + expect(h.store.get('/saved')).toBe(false); + release(); + await gate; + await Promise.resolve(); + expect(h.store.get('/saved')).toBe(true); + }); + + it('dispatches a follow-up action', () => { + const h = mount( + clickSpec({ click: { action: 'save', params: {}, onSuccess: { action: 'toast' } } }), + { handlers: { save: () => undefined, toast: () => undefined } }, + ); + h.click(); + expect(h.calls.map((c) => c.action)).toEqual(['save', 'toast']); + }); + + it('navigates through the document default view', () => { + const h = mount( + clickSpec({ click: { action: 'save', params: {}, onSuccess: { navigate: '/done' } } }), + { handlers: { save: () => undefined } }, + ); + const view = TestBed.inject(DOCUMENT).defaultView!; + const original = Object.getOwnPropertyDescriptor(view, 'location')!; + const assign = vi.fn(); + Object.defineProperty(view, 'location', { configurable: true, value: { assign } }); + try { + h.click(); + } finally { + Object.defineProperty(view, 'location', original); + } + expect(assign).toHaveBeenCalledWith('/done'); + }); +}); + +describe('RenderElementComponent — ActionBinding.onError', () => { + it('applies a `set` map when a synchronous handler throws, and swallows the throw', () => { + const h = mount( + clickSpec({ click: { action: 'save', params: {}, onError: { set: { '/error': '$error.message' } } } }), + { + handlers: { + save: () => { throw new Error('boom'); }, + }, + }, + ); + expect(() => h.click()).not.toThrow(); + expect(h.store.get('/error')).toBe('boom'); + }); + + it('applies a `set` map when an async handler rejects', async () => { + const h = mount( + clickSpec({ click: { action: 'save', params: {}, onError: { set: { '/error': '$error.message' } } } }), + { handlers: { save: () => Promise.reject(new Error('nope')) } }, + ); + h.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(h.store.get('/error')).toBe('nope'); + }); + + it('dispatches a follow-up action on failure and does not run onSuccess', () => { + const h = mount( + clickSpec({ + click: { + action: 'save', + params: {}, + onSuccess: { action: 'toast' }, + onError: { action: 'report' }, + }, + }), + { + handlers: { + save: () => { throw new Error('boom'); }, + toast: () => undefined, + report: () => undefined, + }, + }, + ); + h.click(); + expect(h.calls.map((c) => c.action)).toEqual(['save', 'report']); + }); + + it('does not run onSuccess when an async handler rejects', async () => { + const h = mount( + clickSpec({ + click: { + action: 'save', + params: {}, + onSuccess: { set: { '/saved': true } }, + onError: { set: { '/failed': true } }, + }, + }), + { + initialState: { saved: false, failed: false }, + handlers: { save: () => Promise.reject(new Error('nope')) }, + }, + ); + h.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(h.store.get('/saved')).toBe(false); + expect(h.store.get('/failed')).toBe(true); + }); +}); + +// --- (f) visible on the repeat branch --- + +/** Reads its item straight from the repeat scope, so no prop-readiness gate + * stands between the repeat branch and the assertion about `visible`. */ +@Component({ + selector: 'render-test-row', + standalone: true, + template: '
  • {{ name }}
  • ', +}) +class RowComponent { + private readonly scope = inject(REPEAT_SCOPE, { optional: true }); + get name(): string { + return String((this.scope?.item as { name?: string } | undefined)?.name ?? ''); + } +} + +describe('RenderElementComponent — visible on the repeat branch', () => { + function repeatFixture(visible: unknown, items: unknown[]) { + const spec = { + root: 'rows', + elements: { + rows: { + type: 'row', + props: {}, + repeat: { statePath: '/items' }, + visible, + }, + }, + } as unknown as Spec; + const store = signalStateStore({ items }); + const HostCmp = host(spec); + TestBed.configureTestingModule({ + imports: [HostCmp], + providers: [ + { + provide: RENDER_CONTEXT, + useValue: { + store, + registry: defineAngularRegistry({ row: RowComponent }), + functions: {}, + handlers: {}, + }, + }, + ], + }); + const fx = TestBed.createComponent(HostCmp); + fx.detectChanges(); + return (fx.nativeElement as HTMLElement).querySelectorAll('.row'); + } + + it('renders only the items whose per-item visible condition holds', () => { + const rows = repeatFixture({ $item: 'active', eq: true }, [ + { name: 'a', active: true }, + { name: 'b', active: false }, + { name: 'c', active: true }, + ]); + expect([...rows].map((r) => r.textContent?.trim())).toEqual(['a', 'c']); + }); + + it('renders every item when no visible condition is declared', () => { + const rows = repeatFixture(undefined, [{ name: 'a' }, { name: 'b' }]); + expect(rows).toHaveLength(2); + }); + + it('honors an $index condition per repeated item', () => { + const rows = repeatFixture({ $index: true, eq: 0 }, [{ name: 'a' }, { name: 'b' }]); + expect([...rows].map((r) => r.textContent?.trim())).toEqual(['a']); + }); + + it('hides every item when the condition is a literal false', () => { + const rows = repeatFixture(false, [{ name: 'a' }, { name: 'b' }]); + expect(rows).toHaveLength(0); + }); +}); diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts index 81392a192..c0569364c 100644 --- a/libs/render/src/lib/render-element.component.ts +++ b/libs/render/src/lib/render-element.component.ts @@ -17,7 +17,7 @@ import { type Signal, type Type, } from '@angular/core'; -import { NgComponentOutlet } from '@angular/common'; +import { DOCUMENT, NgComponentOutlet } from '@angular/common'; // eslint-disable-next-line @nx/enforce-module-boundaries -- Keep the postinstall module external through ng-packagr. import { installationToken } from '#development-install'; declare const ngDevMode: boolean; @@ -28,7 +28,13 @@ import { resolveBindings, resolveElementProps, } from '@json-render/core'; -import type { Spec, UIElement } from '@json-render/core'; +import type { + ActionConfirm, + ActionOnError, + ActionOnSuccess, + Spec, + UIElement, +} from '@json-render/core'; import { RENDER_CONTEXT } from './contexts/render-context'; import { RENDER_HOST, type RenderHost } from './contexts/render-host'; @@ -70,6 +76,28 @@ function filterInputsForClass( return out; } +/** Anything carrying a callable `preventDefault` — a DOM `Event`, or a wrapper + * a view component chose to hand to `emit`. */ +function isEventLike(value: unknown): value is { preventDefault: () => void } { + return ( + value != null && + typeof value === 'object' && + typeof (value as { preventDefault?: unknown }).preventDefault === 'function' + ); +} + +/** Honors `ActionBinding.preventDefault`. The payload a component passes to + * `emit(event, payload)` is typed as a record, but in practice it is either + * the DOM event itself or a record carrying it under `event`. */ +function preventDefaultOn(payload: unknown): void { + if (isEventLike(payload)) { + payload.preventDefault(); + return; + } + const nested = (payload as Record | undefined)?.['event']; + if (isEventLike(nested)) nested.preventDefault(); +} + /** * Recursive element renderer. * @@ -100,9 +128,11 @@ function filterInputsForClass( } } @else { @for (repeatInjector of repeatInjectors(); track $index) { - + @if (repeatVisible()[$index]) { + + } } } `, @@ -115,6 +145,7 @@ export class RenderElementComponent implements OnInit { private readonly repeatScope = inject(REPEAT_SCOPE, { optional: true }); readonly parentInjector = inject(Injector); private readonly destroyRef = inject(DestroyRef); + private readonly document = inject(DOCUMENT); private readonly collectionPolicy = inject(DEVELOPMENT_COLLECTION_POLICY, { optional: true }); private readonly outlets = viewChildren(NgComponentOutlet); private readonly observedInstances = new WeakSet(); @@ -141,7 +172,7 @@ export class RenderElementComponent implements OnInit { }); this.destroyRef.onDestroy(() => { const el = this.element(); - if (el && (el as any)['lifecycle'] && this.ctx.emitEvent) { + if (el && this.ctx.emitEvent) { this.ctx.emitEvent({ type: 'lifecycle', event: 'destroyed', @@ -170,7 +201,7 @@ export class RenderElementComponent implements OnInit { ngOnInit(): void { const el = this.element(); - if (el && (el as any)['lifecycle'] && this.ctx.emitEvent) { + if (el && this.ctx.emitEvent) { this.ctx.emitEvent({ type: 'lifecycle', event: 'mounted', @@ -248,7 +279,10 @@ export class RenderElementComponent implements OnInit { return evaluateVisibility(el.visible, this.propCtx()); }); - /** Invokes the element's `on[event]` handler bindings. */ + /** Invokes the element's `on[event]` handler bindings, honoring every field + * of `ActionBinding`: `preventDefault`, `confirm`, `params` (resolved + * through the same expression resolver the element props use) and the + * `onSuccess` / `onError` follow-ups. */ private invokeHandlers(event: string, payload?: Record): void { const el = this.element(); if (!el?.on) return; @@ -256,14 +290,92 @@ export class RenderElementComponent implements OnInit { if (!binding) return; const bindings = Array.isArray(binding) ? binding : [binding]; for (const b of bindings) { + if (b.preventDefault) preventDefaultOn(payload); + if (b.confirm && !this.askForConfirmation(b.confirm)) continue; + const handler = this.ctx.handlers?.[b.action]; - if (handler) { - const params = { ...(b.params as Record ?? {}), ...(payload ?? {}) }; - runInInjectionContext(this.parentInjector, () => handler(params)); + if (!handler) continue; + + // `params` are DynamicValues: `{ $state: '/x' }`, `{ $item: 'y' }` and + // friends resolve against the store and this element's repeat scope, + // exactly as an element prop would. The payload wins on key collisions. + const resolved = resolveElementProps( + (b.params ?? {}) as Record, + this.propCtx(), + ); + const params = { ...resolved, ...(payload ?? {}) }; + + let result: unknown; + try { + result = runInInjectionContext(this.parentInjector, () => handler(params)); + } catch (error) { + if (!b.onError) throw error; + this.runOnError(b.onError, error); + continue; + } + if (result instanceof Promise) { + result.then( + () => this.runOnSuccess(b.onSuccess), + (error: unknown) => { + if (!b.onError) return; + this.runOnError(b.onError, error); + }, + ); + } else { + this.runOnSuccess(b.onSuccess); } } } + /** Asks the user to confirm before running a binding's handler. Returns + * false only when a real `window.confirm` answered no — without a + * `defaultView` (server-side rendering) there is nobody to ask, so the + * handler proceeds. */ + private askForConfirmation(confirm: ActionConfirm): boolean { + const view = this.document.defaultView; + if (!view?.confirm) return true; + return Boolean(view.confirm(confirm.message)); + } + + /** Runs an `ActionBinding.onSuccess` follow-up. */ + private runOnSuccess(onSuccess: ActionOnSuccess | undefined): void { + if (!onSuccess || this.destroyed) return; + if ('navigate' in onSuccess) { + this.document.defaultView?.location.assign(onSuccess.navigate); + return; + } + if ('set' in onSuccess) { + for (const [path, value] of Object.entries(onSuccess.set)) { + this.ctx.store.set(path, value); + } + return; + } + this.dispatchAction(onSuccess.action); + } + + /** Runs an `ActionBinding.onError` follow-up. `'$error.message'` in a `set` + * map is replaced by the thrown error's message, matching `executeAction` + * in `@json-render/core`. */ + private runOnError(onError: ActionOnError, error: unknown): void { + if (this.destroyed) return; + if ('set' in onError) { + const message = error instanceof Error ? error.message : String(error); + for (const [path, value] of Object.entries(onError.set)) { + this.ctx.store.set(path, value === '$error.message' ? message : value); + } + return; + } + this.dispatchAction(onError.action); + } + + /** Dispatches a handler by name with no params — the follow-up form of + * `onSuccess` / `onError`. */ + private dispatchAction(name: string): void { + const handler = this.ctx.handlers?.[name]; + if (!handler) return; + runInInjectionContext(this.parentInjector, () => handler({})); + } + /** Element-scoped host injected by mounted view components via * injectRenderHost(). `set` writes the store; `emit` routes element * handlers; `result` surfaces a RenderResultEvent for this element. */ @@ -363,4 +475,20 @@ export class RenderElementComponent implements OnInit { const cls = this.mountClass() as Type | null; return this.repeatInputs().map(inputs => filterInputsForClass(cls, inputs)); }); + + /** Per-item visibility for repeat elements. The element's own `visible` + * condition is evaluated once per item, in that item's scope, so + * `{ $item: … }` and `{ $index: … }` conditions can hide individual rows — + * the same rule the non-repeat branch applies to a single mount. */ + readonly repeatVisible = computed(() => { + const el = this.element(); + if (!el?.repeat) return []; + if (this.mountClass() === null) return this.repeatScopes().map(() => false); + return this.repeatScopes().map(scope => + evaluateVisibility( + el.visible, + buildPropResolutionContext(this.ctx.store, scope, this.ctx.functions), + ), + ); + }); } diff --git a/libs/render/src/lib/render-events.spec.ts b/libs/render/src/lib/render-events.spec.ts new file mode 100644 index 000000000..9db46b097 --- /dev/null +++ b/libs/render/src/lib/render-events.spec.ts @@ -0,0 +1,212 @@ +import { describe, it, expect } from 'vitest'; +import { Component, input, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { Spec, StateStore } from '@json-render/core'; + +import { RenderElementComponent } from './render-element.component'; +import { RenderSpecComponent } from './render-spec.component'; +import { defineAngularRegistry } from './define-angular-registry'; +import { signalStateStore } from './signal-state-store'; +import { provideRender } from './provide-render'; +import { RENDER_CONTEXT } from './contexts/render-context'; +import { RENDER_LIFECYCLE } from './lifecycle'; +import type { RenderEvent, RenderLifecycleEvent, RenderStateChangeEvent } from './render-event'; + +/** A container that actually mounts its children, as a real registry entry does. */ +@Component({ + selector: 'render-test-container', + standalone: true, + imports: [RenderElementComponent], + template: ` + @for (key of childKeys(); track key) { + + } + `, +}) +class ContainerComponent { + readonly childKeys = input([]); + readonly spec = input(null); +} + +@Component({ + selector: 'render-test-leaf', + standalone: true, + template: '{{ label() }}', +}) +class LeafComponent { + readonly label = input(''); +} + +function spec(children: string[]): Spec { + return { + root: 'shell', + elements: { + shell: { type: 'Container', props: {}, children }, + a: { type: 'Leaf', props: { label: 'a' } }, + b: { type: 'Leaf', props: { label: 'b' } }, + }, + } as unknown as Spec; +} + +@Component({ + standalone: true, + imports: [RenderSpecComponent], + template: ``, +}) +class EventsHost { + spec: Spec = spec(['a', 'b']); + readonly registry = defineAngularRegistry({ + Container: ContainerComponent, + Leaf: LeafComponent, + }); + store: StateStore = signalStateStore({ count: 0 }); + readonly events: RenderEvent[] = []; +} + +function lifecycleEvents(events: RenderEvent[]): RenderLifecycleEvent[] { + return events.filter((e): e is RenderLifecycleEvent => e.type === 'lifecycle'); +} + +describe('element-scope lifecycle events', () => { + it('emits a mounted event for every element the renderer mounts', () => { + TestBed.configureTestingModule({ imports: [EventsHost] }); + const fx = TestBed.createComponent(EventsHost); + fx.detectChanges(); + + const mounted = lifecycleEvents(fx.componentInstance.events).filter( + (e) => e.event === 'mounted', + ); + expect(mounted.filter((e) => e.scope === 'spec')).toHaveLength(1); + expect( + mounted + .filter((e) => e.scope === 'element') + .map((e) => ({ key: e.elementKey, type: e.elementType })) + .sort((x, y) => String(x.key).localeCompare(String(y.key))), + ).toEqual([ + { key: 'a', type: 'Leaf' }, + { key: 'b', type: 'Leaf' }, + { key: 'shell', type: 'Container' }, + ]); + }); + + it('emits an element destroyed event when an element leaves the tree', () => { + const events: RenderEvent[] = []; + + @Component({ + standalone: true, + imports: [RenderElementComponent], + template: ` + @if (show()) { + + } + `, + }) + class ToggleHost { + readonly show = signal(true); + readonly spec = spec(['a']); + } + + TestBed.configureTestingModule({ + imports: [ToggleHost], + providers: [ + { + provide: RENDER_CONTEXT, + useValue: { + store: signalStateStore({}), + registry: defineAngularRegistry({ Leaf: LeafComponent }), + functions: {}, + handlers: {}, + emitEvent: (e: RenderEvent) => events.push(e), + }, + }, + ], + }); + const fx = TestBed.createComponent(ToggleHost); + fx.detectChanges(); + expect( + lifecycleEvents(events).map((e) => `${e.event}:${e.elementKey}`), + ).toEqual(['mounted:a']); + + fx.componentInstance.show.set(false); + fx.detectChanges(); + + expect( + lifecycleEvents(events).map((e) => `${e.event}:${e.elementKey}`), + ).toEqual(['mounted:a', 'destroyed:a']); + expect(lifecycleEvents(events)[1].elementType).toBe('Leaf'); + }); + + it('feeds mountCount and lastMountAt in RenderLifecycleService', () => { + TestBed.configureTestingModule({ + imports: [EventsHost], + providers: [provideRender({})], + }); + const fx = TestBed.createComponent(EventsHost); + fx.detectChanges(); + const lifecycle = TestBed.inject(RENDER_LIFECYCLE); + // 1 spec mount + 3 element mounts. + expect(lifecycle.mountCount()).toBe(4); + expect(lifecycle.lastMountAt()).not.toBeNull(); + }); +}); + +describe('stateChange events report the mutated path', () => { + it('reports the real path and value for a signalStateStore', () => { + TestBed.configureTestingModule({ imports: [EventsHost] }); + const fx = TestBed.createComponent(EventsHost); + fx.detectChanges(); + const host = fx.componentInstance; + host.events.length = 0; + + host.store.set('/count', 7); + + const changes = host.events.filter( + (e): e is RenderStateChangeEvent => e.type === 'stateChange', + ); + expect(changes).toHaveLength(1); + expect(changes[0].path).toBe('/count'); + expect(changes[0].value).toBe(7); + expect(changes[0].snapshot).toEqual({ count: 7 }); + }); + + it('falls back to the root path and full snapshot for a foreign store', () => { + let state: Record = { count: 0 }; + const listeners = new Set<() => void>(); + const foreign: StateStore = { + get: (path: string) => state[path.replace(/^\//, '')], + set: (path: string, value: unknown) => { + state = { ...state, [path.replace(/^\//, '')]: value }; + for (const l of listeners) l(); + }, + update: () => undefined, + getSnapshot: () => state, + subscribe: (l: () => void) => { + listeners.add(l); + return () => { + listeners.delete(l); + }; + }, + }; + + TestBed.configureTestingModule({ imports: [EventsHost] }); + const fx = TestBed.createComponent(EventsHost); + fx.componentInstance.store = foreign; + fx.detectChanges(); + const host = fx.componentInstance; + host.events.length = 0; + + foreign.set('/count', 3); + + const changes = host.events.filter( + (e): e is RenderStateChangeEvent => e.type === 'stateChange', + ); + expect(changes).toHaveLength(1); + expect(changes[0].path).toBe('/'); + expect(changes[0].value).toEqual({ count: 3 }); + }); +}); diff --git a/libs/render/src/lib/render-spec.component.ts b/libs/render/src/lib/render-spec.component.ts index 78cc38967..5b17c3450 100644 --- a/libs/render/src/lib/render-spec.component.ts +++ b/libs/render/src/lib/render-spec.component.ts @@ -19,7 +19,7 @@ import { toRenderRegistry } from './views'; import { RENDER_CONTEXT } from './contexts/render-context'; import type { RenderContext } from './contexts/render-context'; import type { AngularRegistry } from './render.types'; -import { signalStateStore } from './signal-state-store'; +import { signalStateStore, type SignalStateStore } from './signal-state-store'; import type { RenderEvent } from './render-event'; import { RenderLifecycleService } from './render-lifecycle.service'; import { makeGuardedEmit } from './internals/guarded-emit'; @@ -205,10 +205,14 @@ export class RenderSpecComponent implements OnInit { const store = this.resolvedStore(); const unsub = store.subscribe(() => { const snapshot = store.getSnapshot() as Record; + // `StateStore.subscribe` carries no path, so a foreign store can only + // report the root and the whole snapshot. `signalStateStore()` records + // its last mutation, which lets us name the path that actually changed. + const change = (store as SignalStateStore).lastChange?.(); this.emitTapped({ type: 'stateChange', - path: '/', - value: snapshot, + path: change?.path ?? '/', + value: change ? change.value : snapshot, snapshot, }); }); diff --git a/libs/render/src/lib/signal-state-store.spec.ts b/libs/render/src/lib/signal-state-store.spec.ts index 2cf0163d3..23c688b89 100644 --- a/libs/render/src/lib/signal-state-store.spec.ts +++ b/libs/render/src/lib/signal-state-store.spec.ts @@ -69,3 +69,75 @@ describe('signalStateStore', () => { }); }); }); + +describe('signalStateStore — path validation', () => { + it('should throw when get() receives a path without a leading slash', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ count: 0 }); + expect(() => store.get('count')).toThrow(/count/); + expect(() => store.get('count')).toThrow(/leading "\/"/); + }); + }); + + it('should throw rather than replace the whole state when set() omits the leading slash', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ count: 0 }); + expect(() => store.set('count', 1)).toThrow(/count/); + // The silent-data-loss behavior this replaces would have left `1` here. + expect(store.getSnapshot()).toEqual({ count: 0 }); + }); + }); + + it('should throw when update() carries a path without a leading slash', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ x: 0 }); + expect(() => store.update({ x: 1 })).toThrow(/leading "\/"/); + expect(store.getSnapshot()).toEqual({ x: 0 }); + }); + }); + + it('should accept the root pointer forms', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ a: 1 }); + expect(store.get('')).toEqual({ a: 1 }); + expect(store.get('/')).toEqual({ a: 1 }); + }); + }); +}); + +describe('signalStateStore — lastChange', () => { + it('should report the path and value of the most recent set()', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ user: { name: 'Alice' } }); + expect(store.lastChange?.()).toBeUndefined(); + store.set('/user/name', 'Bob'); + expect(store.lastChange?.()).toEqual({ path: '/user/name', value: 'Bob' }); + }); + }); + + it('should report the last applied path from update()', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ x: 0, y: 0 }); + store.update({ '/x': 1, '/y': 2 }); + expect(store.lastChange?.()).toEqual({ path: '/y', value: 2 }); + }); + }); + + it('should be readable from inside a subscriber', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ count: 0 }); + const seen: unknown[] = []; + store.subscribe(() => seen.push(store.lastChange?.())); + store.set('/count', 3); + expect(seen).toEqual([{ path: '/count', value: 3 }]); + }); + }); + + it('should not record a change that was skipped as a no-op', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ count: 1 }); + store.set('/count', 1); + expect(store.lastChange?.()).toBeUndefined(); + }); + }); +}); diff --git a/libs/render/src/lib/signal-state-store.ts b/libs/render/src/lib/signal-state-store.ts index 45c85add3..27df24f0b 100644 --- a/libs/render/src/lib/signal-state-store.ts +++ b/libs/render/src/lib/signal-state-store.ts @@ -1,9 +1,26 @@ import { signal } from '@angular/core'; import type { StateStore, StateModel } from '@json-render/core'; +/** + * Split a JSON Pointer into its unescaped segments. + * + * `''` and `'/'` both address the root. Anything else must start with `/`: + * a slash-less path is rejected rather than silently reinterpreted, because + * dropping its first segment would make `set('count', 1)` replace the whole + * state model — silent data loss that is far worse than a thrown error. + */ function parsePointer(path: string): string[] { - if (!path || path === '/') return []; - return path.split('/').filter((_, i) => i > 0).map(s => s.replace(/~1/g, '/').replace(/~0/g, '~')); + if (path === '' || path === '/') return []; + if (typeof path !== 'string' || !path.startsWith('/')) { + throw new Error( + `Invalid state path ${JSON.stringify(path)}: a state store path is a JSON Pointer and needs a leading "/" ` + + `(write "/${String(path)}" to address that key, or "" for the root).`, + ); + } + return path + .slice(1) + .split('/') + .map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~')); } function getByPath(obj: unknown, segments: string[]): unknown { @@ -33,22 +50,52 @@ function setByPath(obj: unknown, segments: string[], value: unknown): unknown { return record; } +/** The path and value of the mutation that most recently notified subscribers. */ +export interface StateChangeRecord { + /** The JSON Pointer that was written. */ + readonly path: string; + /** The value written at that pointer. */ + readonly value: unknown; +} + +/** + * The {@link StateStore} that {@link signalStateStore} returns. + * + * Adds `lastChange()` on top of the `@json-render/core` interface, which is + * how `` reports the mutated path on a `stateChange` render + * event — `StateStore.subscribe` itself carries no path. A store from any + * other implementation simply omits the member. + */ +export interface SignalStateStore extends StateStore { + /** + * The path and value of the most recent mutation, or `undefined` before the + * first one. Written before subscribers are notified, so a subscriber can + * read it to learn what changed. + */ + lastChange?: () => StateChangeRecord | undefined; +} + /** * Create a signal-backed {@link StateStore} for a generative-UI surface — * holds the bound state that spec `$bindState` paths read and interactive * elements write, with path-addressable get/set and change subscriptions. * + * Every path is a JSON Pointer and must start with `/` (or be `''` / `'/'` + * for the root); a slash-less path throws. + * * @param initialState Optional starting state object. - * @returns A {@link StateStore} bridging Angular signals to the render engine. + * @returns A {@link SignalStateStore} bridging Angular signals to the render engine. * @example * ```ts * const store = signalStateStore({ count: 0 }); * store.set('/count', 1); + * store.lastChange?.(); // { path: '/count', value: 1 } * ``` */ -export function signalStateStore(initialState: StateModel = {}): StateStore { +export function signalStateStore(initialState: StateModel = {}): SignalStateStore { const state = signal(initialState); const listeners = new Set<() => void>(); + let lastChange: StateChangeRecord | undefined; function notify(): void { for (const listener of listeners) listener(); @@ -63,21 +110,23 @@ export function signalStateStore(initialState: StateModel = {}): StateStore { const current = getByPath(state(), segments); if (current === value) return; state.set(setByPath(state(), segments, value) as StateModel); + lastChange = { path, value }; notify(); }, update(updates: Record): void { let current = state(); - let changed = false; + let applied: StateChangeRecord | undefined; for (const [path, value] of Object.entries(updates)) { const segments = parsePointer(path); const existing = getByPath(current, segments); if (existing !== value) { current = setByPath(current, segments, value) as StateModel; - changed = true; + applied = { path, value }; } } - if (changed) { + if (applied) { state.set(current); + lastChange = applied; notify(); } }, @@ -86,7 +135,12 @@ export function signalStateStore(initialState: StateModel = {}): StateStore { }, subscribe(listener: () => void): () => void { listeners.add(listener); - return () => listeners.delete(listener); + return () => { + listeners.delete(listener); + }; + }, + lastChange(): StateChangeRecord | undefined { + return lastChange; }, }; } diff --git a/libs/render/src/public-api.ts b/libs/render/src/public-api.ts index 37ec5c62a..631d66420 100644 --- a/libs/render/src/public-api.ts +++ b/libs/render/src/public-api.ts @@ -24,6 +24,7 @@ export { defineAngularRegistry } from './lib/define-angular-registry'; // State export { signalStateStore } from './lib/signal-state-store'; +export type { SignalStateStore, StateChangeRecord } from './lib/signal-state-store'; // Provider export { provideRender, RENDER_CONFIG } from './lib/provide-render'; From 061c4ca469c6c1212645d00dbdcf263b0422d54b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 13:55:47 -0700 Subject: [PATCH 2/2] docs(render): describe the fixed action bindings, repeat visibility, lifecycle and state paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the library change. The pages the audit made describe these defects now describe the behavior that shipped: - `render/api/signal-state-store.mdx` — the silent-drop warning is replaced by the thrown error, and `lastChange()` plus the `SignalStateStore` return type are documented. - `render/guides/events.mdx` — the action-binding table lists all six fields, with sections for resolved params, confirmation, follow-ups and `preventDefault`; `stateChange` now reports a real path. - `render/guides/lifecycle.mdx` — `mountCount` counts element mounts too. - `render/guides/repeat-loops.mdx` and `render/getting-started/introduction.mdx` — `visible` is evaluated per repeated item. Also regenerates api-docs for the new exports. Co-Authored-By: Claude Fable 5.1 --- .../content/docs/chat/api/api-docs.json | 2 +- .../content/docs/render/api/api-docs.json | 84 ++++++++++++++++++- .../docs/render/api/signal-state-store.mdx | 49 +++++++++-- .../render/getting-started/introduction.mdx | 2 +- .../content/docs/render/guides/events.mdx | 81 +++++++++++++++--- .../content/docs/render/guides/lifecycle.mdx | 6 +- .../docs/render/guides/repeat-loops.mdx | 17 +++- 7 files changed, 213 insertions(+), 28 deletions(-) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index aee9000e8..162182aa1 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -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 }, diff --git a/apps/website/content/docs/render/api/api-docs.json b/apps/website/content/docs/render/api/api-docs.json index 9a80832e0..0702947a5 100644 --- a/apps/website/content/docs/render/api/api-docs.json +++ b/apps/website/content/docs/render/api/api-docs.json @@ -87,6 +87,12 @@ "description": "Resolved inputs for each repeat item.", "optional": false }, + { + "name": "repeatVisible", + "type": "Signal", + "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", @@ -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 `` 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) => 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", @@ -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", @@ -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", @@ -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```" ] }, { diff --git a/apps/website/content/docs/render/api/signal-state-store.mdx b/apps/website/content/docs/render/api/signal-state-store.mdx index e696689ae..dce509699 100644 --- a/apps/website/content/docs/render/api/signal-state-store.mdx +++ b/apps/website/content/docs/render/api/signal-state-store.mdx @@ -16,7 +16,7 @@ import { signalStateStore } from '@threadplane/render'; ## Signature ```typescript -function signalStateStore(initialState: StateModel = {}): StateStore; +function signalStateStore(initialState: StateModel = {}): SignalStateStore; ``` ### Parameters @@ -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 { @@ -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 ``, the `store` field of `provideRender()` -- a `SignalStateStore` is accepted too. + ## Methods ### get(path) @@ -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 `` 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: @@ -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` | - -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'`. - +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 diff --git a/apps/website/content/docs/render/getting-started/introduction.mdx b/apps/website/content/docs/render/getting-started/introduction.mdx index c32803cb7..ef4b21a13 100644 --- a/apps/website/content/docs/render/getting-started/introduction.mdx +++ b/apps/website/content/docs/render/getting-started/introduction.mdx @@ -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 `` for each key in `childKeys`. ## Next Steps diff --git a/apps/website/content/docs/render/guides/events.mdx b/apps/website/content/docs/render/guides/events.mdx index 52be6c104..dae98c416 100644 --- a/apps/website/content/docs/render/guides/events.mdx +++ b/apps/website/content/docs/render/guides/events.mdx @@ -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` | Optional parameters passed to the handler | +| `params?` | `Record` | 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 - -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. - +`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 @@ -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 = { @@ -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); @@ -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. diff --git a/apps/website/content/docs/render/guides/lifecycle.mdx b/apps/website/content/docs/render/guides/lifecycle.mdx index 7c412c045..02b4c3989 100644 --- a/apps/website/content/docs/render/guides/lifecycle.mdx +++ b/apps/website/content/docs/render/guides/lifecycle.mdx @@ -57,8 +57,8 @@ export const RENDER_LIFECYCLE = new InjectionToken('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. - -`` emits exactly one spec-scope `mounted` event, in `ngOnInit`. Element-scope `mounted` events come from ``, 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. + +`` emits one spec-scope `mounted` event in `ngOnInit`, and `` 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. ## Reading the signals @@ -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 diff --git a/apps/website/content/docs/render/guides/repeat-loops.mdx b/apps/website/content/docs/render/guides/repeat-loops.mdx index 273cc28ba..c162bb544 100644 --- a/apps/website/content/docs/render/guides/repeat-loops.mdx +++ b/apps/website/content/docs/render/guides/repeat-loops.mdx @@ -103,7 +103,7 @@ Inside a repeat, prop expressions gain three forms that resolve against the curr `$state` still works and still reads the global model, so an element can mix per-item values with application-wide ones. `$bindItem` behaves like `$bindState`: the prop receives the resolved value, and the write-back path arrives on the component's `bindings` input as `/tasks/1/field`. -The same `$item` and `$index` forms are available in a `visible` condition on the **children** of a repeated element. Each mount provides its iteration context through a child injector, so a child inherits the repeat scope and its condition resolves against that mount's item: +The same `$item` and `$index` forms are available in a `visible` condition, on the repeated element itself and on its **children** alike. Each mount provides its iteration context through a child injector, so a child inherits the repeat scope and its condition resolves against that mount's item: ```json { @@ -120,7 +120,20 @@ The same `$item` and `$index` forms are available in a `visible` condition on th } ``` -A `visible` condition on the element that carries `repeat` is a different matter: the Angular renderer does not evaluate it. That element takes the repeat branch, which mounts every item in the array, so a row cannot hide itself that way. Put the condition on a child, as above, or filter the array in the state model before it reaches the repeat path. +A `visible` condition on the element that carries `repeat` is evaluated once per item, in that item's own scope, so a row can hide itself: + +```json +{ + "row": { + "type": "Text", + "props": { "content": { "$item": "title" } }, + "repeat": { "statePath": "/tasks" }, + "visible": { "$item": "done", "eq": false } + } +} +``` + +Only the entries whose condition holds are mounted; the rest leave no markup behind. Filtering the array in the state model before it reaches the repeat path remains an option, and is the better one when the hidden rows should not be in the model at all. ## Item scope in Angular