From 87108ab939b1e3465bd499594ceab98308d3617b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 21:35:31 -0700 Subject: [PATCH 1/9] refactor(render): registry exposes getEntry (preserve schema/description); drop get/getFallback Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/define-angular-registry.spec.ts | 88 ++++++------------- .../render/src/lib/define-angular-registry.ts | 14 +-- .../src/lib/render-element.component.spec.ts | 6 +- .../src/lib/render-element.component.ts | 16 ++-- .../src/lib/render-spec.component.spec.ts | 2 +- libs/render/src/lib/render-spec.component.ts | 2 +- libs/render/src/lib/render.types.ts | 27 +++--- libs/render/src/lib/views.spec.ts | 4 +- 8 files changed, 64 insertions(+), 95 deletions(-) diff --git a/libs/render/src/lib/define-angular-registry.spec.ts b/libs/render/src/lib/define-angular-registry.spec.ts index 67ea60ab9..50f63ecba 100644 --- a/libs/render/src/lib/define-angular-registry.spec.ts +++ b/libs/render/src/lib/define-angular-registry.spec.ts @@ -1,77 +1,39 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect } from 'vitest'; import { Component } from '@angular/core'; +import { describe, it, expect } from 'vitest'; +import { z } from 'zod/v4'; import { defineAngularRegistry } from './define-angular-registry'; import { DefaultFallbackComponent } from './default-fallback.component'; -@Component({ selector: 'render-test-card', standalone: true, template: '
card
' }) -class TestCardComponent {} - -@Component({ selector: 'render-test-button', standalone: true, template: '' }) -class TestButtonComponent {} - -@Component({ standalone: true, template: 'real' }) -class FakeRealComponent {} - -@Component({ standalone: true, template: 'fallback' }) -class FakeFallbackComponent {} - -describe('defineAngularRegistry', () => { - it('should create a registry mapping component names to Angular components', () => { - const registry = defineAngularRegistry({ - Card: TestCardComponent, - Button: TestButtonComponent, - }); - expect(registry.get('Card')).toBe(TestCardComponent); - expect(registry.get('Button')).toBe(TestButtonComponent); - }); - - it('should return undefined for unregistered component names', () => { - const registry = defineAngularRegistry({ Card: TestCardComponent }); - expect(registry.get('Unknown')).toBeUndefined(); - }); - - it('should return all registered component names', () => { - const registry = defineAngularRegistry({ - Card: TestCardComponent, - Button: TestButtonComponent, - }); - expect(registry.names()).toEqual(['Card', 'Button']); - }); -}); - -describe('defineAngularRegistry — fallback API', () => { - it('bare type entry: get returns the type; getFallback returns the default', () => { - const reg = defineAngularRegistry({ button: FakeRealComponent }); - expect(reg.get('button')).toBe(FakeRealComponent); - expect(reg.getFallback('button')).toBe(DefaultFallbackComponent); - }); +@Component({ selector: 'x-real', standalone: true, template: '' }) +class RealComponent {} +@Component({ selector: 'x-fallback', standalone: true, template: '' }) +class CustomFallback {} - it('object entry with fallback: get returns component; getFallback returns the configured fallback', () => { +describe('defineAngularRegistry / getEntry', () => { + it('preserves component, fallback, schema, and description for object entries', () => { + const schema = z.object({ day: z.number() }); const reg = defineAngularRegistry({ - button: { component: FakeRealComponent, fallback: FakeFallbackComponent }, + card: { component: RealComponent, fallback: CustomFallback, schema, description: 'a card' }, }); - expect(reg.get('button')).toBe(FakeRealComponent); - expect(reg.getFallback('button')).toBe(FakeFallbackComponent); - }); - - it('object entry without fallback: getFallback returns the default', () => { - const reg = defineAngularRegistry({ button: { component: FakeRealComponent } }); - expect(reg.get('button')).toBe(FakeRealComponent); - expect(reg.getFallback('button')).toBe(DefaultFallbackComponent); + const entry = reg.getEntry('card'); + expect(entry?.component).toBe(RealComponent); + expect(entry?.fallback).toBe(CustomFallback); + expect(entry?.schema).toBe(schema); + expect(entry?.description).toBe('a card'); }); - it('unknown name: get returns undefined; getFallback returns undefined', () => { - const reg = defineAngularRegistry({ button: FakeRealComponent }); - expect(reg.get('unknown')).toBeUndefined(); - expect(reg.getFallback('unknown')).toBeUndefined(); + it('bare Type entries get the default fallback and no schema', () => { + const reg = defineAngularRegistry({ plain: RealComponent }); + const entry = reg.getEntry('plain'); + expect(entry?.component).toBe(RealComponent); + expect(entry?.fallback).toBe(DefaultFallbackComponent); + expect(entry?.schema).toBeUndefined(); }); - it('names() returns all registered keys regardless of entry shape', () => { - const reg = defineAngularRegistry({ - button: FakeRealComponent, - card: { component: FakeRealComponent, fallback: FakeFallbackComponent }, - }); - expect(reg.names().sort()).toEqual(['button', 'card']); + it('returns undefined for an unregistered name; names() lists keys', () => { + const reg = defineAngularRegistry({ a: RealComponent }); + expect(reg.getEntry('missing')).toBeUndefined(); + expect(reg.names()).toEqual(['a']); }); }); diff --git a/libs/render/src/lib/define-angular-registry.ts b/libs/render/src/lib/define-angular-registry.ts index 154795a61..b671333c7 100644 --- a/libs/render/src/lib/define-angular-registry.ts +++ b/libs/render/src/lib/define-angular-registry.ts @@ -1,24 +1,19 @@ // SPDX-License-Identifier: MIT import { Type } from '@angular/core'; -import type { AngularRegistry, RenderViewEntry } from './render.types'; +import type { AngularRegistry, NormalizedEntry, RenderViewEntry } from './render.types'; import { DefaultFallbackComponent } from './default-fallback.component'; type RegistryInput = Record | RenderViewEntry>; -interface NormalizedEntry { - component: Type; - fallback: Type; -} - function normalize(entry: Type | RenderViewEntry): NormalizedEntry { - // Bare Type — register with the default fallback. if (typeof entry === 'function') { return { component: entry, fallback: DefaultFallbackComponent }; } - // Object form — preserve component; use configured fallback or default. return { component: entry.component, fallback: entry.fallback ?? DefaultFallbackComponent, + schema: entry.schema, + description: entry.description, }; } @@ -28,8 +23,7 @@ export function defineAngularRegistry(componentMap: RegistryInput): AngularRegis map.set(name, normalize(entry)); } return { - get: (name: string) => map.get(name)?.component, - getFallback: (name: string) => map.get(name)?.fallback, + getEntry: (name: string) => map.get(name), names: () => [...map.keys()], }; } diff --git a/libs/render/src/lib/render-element.component.spec.ts b/libs/render/src/lib/render-element.component.spec.ts index 631f114ae..c408f376e 100644 --- a/libs/render/src/lib/render-element.component.spec.ts +++ b/libs/render/src/lib/render-element.component.spec.ts @@ -48,7 +48,7 @@ describe('RenderElementComponent — pipeline logic', () => { const el = spec.elements['root']; expect(el).toBeDefined(); expect(el.type).toBe('Text'); - expect(registry.get(el.type)).toBe(TestTextComponent); + expect(registry.getEntry(el.type)?.component).toBe(TestTextComponent); }); it('should return undefined for unknown element type', () => { @@ -57,7 +57,7 @@ describe('RenderElementComponent — pipeline logic', () => { root: { type: 'UnknownWidget', props: { label: 'Nope' } }, }); const el = spec.elements['root']; - expect(registry.get(el.type)).toBeUndefined(); + expect(registry.getEntry(el.type)).toBeUndefined(); }); it('should return undefined for missing element key', () => { @@ -232,7 +232,7 @@ describe('RenderElementComponent — children rendering', () => { for (const childKey of inputs.childKeys) { const childEl = spec.elements[childKey]; expect(childEl).toBeDefined(); - expect(registry.get(childEl.type)).toBe(TestTextComponent); + expect(registry.getEntry(childEl.type)?.component).toBe(TestTextComponent); } }); }); diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts index f552aafe9..92c6b3a71 100644 --- a/libs/render/src/lib/render-element.component.ts +++ b/libs/render/src/lib/render-element.component.ts @@ -28,7 +28,7 @@ import { RENDER_HOST, type RenderHost } from './contexts/render-host'; import { REPEAT_SCOPE } from './contexts/repeat-scope'; import type { RepeatScope } from './contexts/repeat-scope'; import { buildPropResolutionContext } from './internals/prop-signal'; -import type { AngularComponentRenderer } from './render.types'; +import type { AngularComponentRenderer, NormalizedEntry } from './render.types'; /** Cache of declared input names per component class. NgComponentOutlet * passes every key in its `inputs` prop to the target; Angular dev mode @@ -131,7 +131,7 @@ export class RenderElementComponent implements OnInit { const el = this.element(); if (!el) return; // Only latch when notReady is false AND a real component is registered. - if (!this.notReady() && this.ctx.registry.get(el.type)) { + if (!this.notReady() && this.entry()?.component) { this.mountedReal.set(true); } }); @@ -156,11 +156,17 @@ export class RenderElementComponent implements OnInit { { equal: Object.is }, ); + /** The full normalized registry entry for this element type. */ + readonly entry = computed(() => { + const el = this.element(); + return el ? this.ctx.registry.getEntry(el.type) : undefined; + }); + /** The Angular component class for this element type. */ readonly componentClass = computed(() => { const el = this.element(); if (!el) return null; - return this.ctx.registry.get(el.type) ?? null; + return this.entry()?.component ?? null; }); /** Prop resolution context built from store + repeat scope. */ @@ -197,9 +203,9 @@ export class RenderElementComponent implements OnInit { readonly mountClass = computed(() => { const el = this.element(); if (!el) return null; - const real = this.ctx.registry.get(el.type) ?? null; + const real = this.entry()?.component ?? null; if (this.notReady()) { - return this.ctx.registry.getFallback(el.type) ?? null; + return this.entry()?.fallback ?? null; } return real; }); diff --git a/libs/render/src/lib/render-spec.component.spec.ts b/libs/render/src/lib/render-spec.component.spec.ts index 13ab32e5c..27b1ccb6f 100644 --- a/libs/render/src/lib/render-spec.component.spec.ts +++ b/libs/render/src/lib/render-spec.component.spec.ts @@ -127,7 +127,7 @@ describe('RenderSpecComponent — context resolution', () => { // Verify the VIEW_REGISTRY token is bound and resolves 'Text' → TestTextComponent. const tokenRegistry = TestBed.inject(VIEW_REGISTRY); const angularReg = defineAngularRegistry(tokenRegistry as Record); - expect(angularReg.get('Text')).toBe(TestTextComponent); + expect(angularReg.getEntry('Text')?.component).toBe(TestTextComponent); // Also verify config has no registry (so the token is the only source). const config = TestBed.inject(RENDER_CONFIG); expect(config.registry).toBeUndefined(); diff --git a/libs/render/src/lib/render-spec.component.ts b/libs/render/src/lib/render-spec.component.ts index 2225cc187..c97d7bd94 100644 --- a/libs/render/src/lib/render-spec.component.ts +++ b/libs/render/src/lib/render-spec.component.ts @@ -96,7 +96,7 @@ export class RenderSpecComponent implements OnInit { if (configRegistry) return configRegistry; if (this.viewRegistry) return toRenderRegistry(this.viewRegistry); // Fallback: empty registry - return { get: () => undefined, getFallback: () => undefined, names: () => [] }; + return { getEntry: () => undefined, names: () => [] }; }); /** Wraps input handlers to emit RenderHandlerEvent after execution. */ diff --git a/libs/render/src/lib/render.types.ts b/libs/render/src/lib/render.types.ts index 7fc9dbbb1..e4ee847bd 100644 --- a/libs/render/src/lib/render.types.ts +++ b/libs/render/src/lib/render.types.ts @@ -32,22 +32,29 @@ export interface RenderViewEntry { component: Type; fallback?: Type; /** Optional props contract for this component (Zod/Valibot/ArkType via - * Standard Schema). Carried + exposed by the render lib but NOT enforced - * on mount; consumers (e.g. client-tools) read it to advertise the - * component to a model and to validate incoming props. */ + * Standard Schema). Enforced as a MOUNT-READINESS GATE: while a streaming + * tool call's props do not yet validate against this schema, the element's + * fallback is shown instead of the real component (sync validation only). + * Consumers (e.g. client-tools) also read it to advertise the component + * to a model and to validate incoming props. */ schema?: StandardSchemaV1; /** Optional human/model-facing description of what this component renders. */ description?: string; } +/** A fully-normalized registry entry: real component + a guaranteed fallback, + * plus the optional props schema (mount-readiness gate) and description. */ +export interface NormalizedEntry { + component: Type; + fallback: Type; + schema?: StandardSchemaV1; + description?: string; +} + export interface AngularRegistry { - get(name: string): AngularComponentRenderer | undefined; - /** - * Returns the configured fallback for a registered name, OR the - * lib's default fallback if the entry omits one, OR undefined if - * the name is not registered. - */ - getFallback(name: string): AngularComponentRenderer | undefined; + /** The full normalized entry for a registered name, or undefined. The single + * accessor — component, fallback, schema, and description all hang off it. */ + getEntry(name: string): NormalizedEntry | undefined; names(): string[]; } diff --git a/libs/render/src/lib/views.spec.ts b/libs/render/src/lib/views.spec.ts index 01057047a..f6769e382 100644 --- a/libs/render/src/lib/views.spec.ts +++ b/libs/render/src/lib/views.spec.ts @@ -100,8 +100,8 @@ describe('toRenderRegistry()', () => { it('converts ViewRegistry to AngularRegistry', () => { const reg = views({ 'a': CompA, 'b': CompB }); const renderReg = toRenderRegistry(reg); - expect(renderReg.get('a')).toBe(CompA); - expect(renderReg.get('b')).toBe(CompB); + expect(renderReg.getEntry('a')?.component).toBe(CompA); + expect(renderReg.getEntry('b')?.component).toBe(CompB); expect(renderReg.names()).toContain('a'); expect(renderReg.names()).toContain('b'); }); From f5dafc20f0d7f7ce459da7359486cbff2e64db10 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 22:01:19 -0700 Subject: [PATCH 2/9] feat(render): pure isElementReady readiness policy (undefined-prop + sync schema gate) Co-Authored-By: Claude Sonnet 4.6 --- .../lib/internals/element-readiness.spec.ts | 49 +++++++++++++++++++ .../src/lib/internals/element-readiness.ts | 32 ++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 libs/render/src/lib/internals/element-readiness.spec.ts create mode 100644 libs/render/src/lib/internals/element-readiness.ts diff --git a/libs/render/src/lib/internals/element-readiness.spec.ts b/libs/render/src/lib/internals/element-readiness.spec.ts new file mode 100644 index 000000000..ca913f804 --- /dev/null +++ b/libs/render/src/lib/internals/element-readiness.spec.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { z } from 'zod/v4'; +import { isElementReady } from './element-readiness'; +import type { NormalizedEntry } from '../render.types'; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const C = (() => {}) as never; +const entry = (over: Partial = {}): NormalizedEntry => + ({ component: C, fallback: C, ...over }); + +describe('isElementReady', () => { + it('ready when no schema and no undefined props', () => { + expect(isElementReady(entry(), { a: 1, b: 'x' })).toBe(true); + }); + + it('not ready when any prop value is undefined (json-render state binding loading)', () => { + expect(isElementReady(entry(), { a: 1, b: undefined })).toBe(false); + }); + + it('not ready while a sync schema does not validate (required keys absent during streaming)', () => { + const schema = z.object({ day: z.number(), places: z.array(z.string()) }); + expect(isElementReady(entry({ schema }), { status: 'running' })).toBe(false); + expect(isElementReady(entry({ schema }), { day: 2 })).toBe(false); + }); + + it('ready once a sync schema validates (extra status/result keys ignored by non-strict object)', () => { + const schema = z.object({ day: z.number(), places: z.array(z.string()) }); + expect( + isElementReady(entry({ schema }), { day: 2, places: ['Eiffel'], status: 'complete', result: {} }), + ).toBe(true); + }); + + it('ready when there is no schema regardless of which keys are present', () => { + expect(isElementReady(entry(), { anything: true })).toBe(true); + }); + + it('ready (not gated) when the schema validates asynchronously (Promise result)', () => { + const asyncSchema = { + '~standard': { version: 1 as const, vendor: 'test', validate: () => Promise.resolve({ issues: [{ message: 'x' }] }) }, + }; + expect(isElementReady(entry({ schema: asyncSchema as never }), {})).toBe(true); + }); + + it('treats an undefined entry as having no schema (ready unless undefined props)', () => { + expect(isElementReady(undefined, { a: 1 })).toBe(true); + expect(isElementReady(undefined, { a: undefined })).toBe(false); + }); +}); diff --git a/libs/render/src/lib/internals/element-readiness.ts b/libs/render/src/lib/internals/element-readiness.ts new file mode 100644 index 000000000..5c49d3102 --- /dev/null +++ b/libs/render/src/lib/internals/element-readiness.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +import type { NormalizedEntry } from '../render.types'; + +function isPromise(v: unknown): v is Promise { + return typeof (v as { then?: unknown } | null)?.then === 'function'; +} + +/** + * Decide whether the REAL component may mount, or the fallback skeleton should + * show. Pure (no Angular, no signals) so it is trivially unit-testable. + * + * - Any undefined-valued prop → pending (a json-render state binding is still + * loading). + * - A schema-declared contract → pending until the (possibly streaming) props + * validate against it. SYNC validation only: render is synchronous, so an + * async (Promise) validate result cannot gate a sync mount and is treated as + * ready. View schemas should therefore be synchronous (Zod is). + */ +export function isElementReady( + entry: NormalizedEntry | undefined, + resolvedProps: Record, +): boolean { + for (const v of Object.values(resolvedProps)) { + if (v === undefined) return false; + } + const schema = entry?.schema; + if (schema) { + const out = schema['~standard'].validate(resolvedProps); + if (!isPromise(out) && out.issues !== undefined) return false; + } + return true; +} From 45f56e4383645462cb6d34e55f712e8fde28ec25 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 22:05:19 -0700 Subject: [PATCH 3/9] test(render): use render- prefixed selectors in registry spec stubs (lint) --- libs/render/src/lib/define-angular-registry.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/render/src/lib/define-angular-registry.spec.ts b/libs/render/src/lib/define-angular-registry.spec.ts index 50f63ecba..f376bb735 100644 --- a/libs/render/src/lib/define-angular-registry.spec.ts +++ b/libs/render/src/lib/define-angular-registry.spec.ts @@ -5,9 +5,9 @@ import { z } from 'zod/v4'; import { defineAngularRegistry } from './define-angular-registry'; import { DefaultFallbackComponent } from './default-fallback.component'; -@Component({ selector: 'x-real', standalone: true, template: '' }) +@Component({ selector: 'render-x-real', standalone: true, template: '' }) class RealComponent {} -@Component({ selector: 'x-fallback', standalone: true, template: '' }) +@Component({ selector: 'render-x-fallback', standalone: true, template: '' }) class CustomFallback {} describe('defineAngularRegistry / getEntry', () => { From 1e7a822243b042ea75ef4196eb287595f6d3b812 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 22:25:23 -0700 Subject: [PATCH 4/9] =?UTF-8?q?fix(render):=20gate=20element=20mount=20on?= =?UTF-8?q?=20isElementReady=20(schema-aware)=20=E2=80=94=20fixes=20NG0950?= =?UTF-8?q?=20for=20view=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/render-element.component.spec.ts | 46 ++++++++++++++++++- .../src/lib/render-element.component.ts | 14 +++--- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/libs/render/src/lib/render-element.component.spec.ts b/libs/render/src/lib/render-element.component.spec.ts index c408f376e..841d9173b 100644 --- a/libs/render/src/lib/render-element.component.spec.ts +++ b/libs/render/src/lib/render-element.component.spec.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { Component, input } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { z } from 'zod/v4'; +import { isElementReady } from './internals/element-readiness'; +import { DefaultFallbackComponent } from './default-fallback.component'; import type { Spec } from '@json-render/core'; import { evaluateVisibility, @@ -626,3 +629,44 @@ describe('RenderSpecComponent — VIEW_REGISTRY token-fallback (Task 2)', () => expect(fx.nativeElement.querySelector('[data-test="fallback"]')).toBeNull(); }); }); + +// --- Readiness gate tests — schema-aware (RT3) --- + +@Component({ selector: 'render-day-card', standalone: true, template: '
{{ day() }}
' }) +class DayCardStub { + readonly day = input.required(); + readonly places = input.required(); +} + +describe('RenderElementComponent — readiness gate (schema)', () => { + const schema = z.object({ day: z.number(), places: z.array(z.string()) }); + const registry = defineAngularRegistry({ + day_card: { component: DayCardStub, fallback: DefaultFallbackComponent, schema }, + }); + + it('is NOT ready (→ fallback) while streamed props miss required schema keys', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const ctx = buildPropResolutionContext(signalStateStore({})); + // mid-stream: only status present, day/places absent + const resolved = resolveElementProps({ status: 'running' }, ctx); + expect(isElementReady(registry.getEntry('day_card'), resolved)).toBe(false); + // → mountClass would pick the fallback (DefaultFallbackComponent), so the + // real DayCardStub (with input.required) never mounts: no NG0950. + expect(registry.getEntry('day_card')?.fallback).toBe(DefaultFallbackComponent); + }); + }); + + it('IS ready (→ real component) once streamed props satisfy the schema', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const ctx = buildPropResolutionContext(signalStateStore({})); + const resolved = resolveElementProps( + { day: 2, places: ['Eiffel'], status: 'complete' }, + ctx, + ); + expect(isElementReady(registry.getEntry('day_card'), resolved)).toBe(true); + expect(registry.getEntry('day_card')?.component).toBe(DayCardStub); + }); + }); +}); diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts index 92c6b3a71..8040f89f1 100644 --- a/libs/render/src/lib/render-element.component.ts +++ b/libs/render/src/lib/render-element.component.ts @@ -28,6 +28,7 @@ import { RENDER_HOST, type RenderHost } from './contexts/render-host'; import { REPEAT_SCOPE } from './contexts/repeat-scope'; import type { RepeatScope } from './contexts/repeat-scope'; import { buildPropResolutionContext } from './internals/prop-signal'; +import { isElementReady } from './internals/element-readiness'; import type { AngularComponentRenderer, NormalizedEntry } from './render.types'; /** Cache of declared input names per component class. NgComponentOutlet @@ -182,19 +183,18 @@ export class RenderElementComponent implements OnInit { * prop later becomes undefined. Per-instance monotonic gate. */ private readonly mountedReal = signal(false); - /** True when ANY resolved prop value is undefined (i.e. a state - * binding points at a path the store hasn't populated). Framework- - * injected keys (bindings, emit, loading, childKeys, spec) are + /** True when the element is not yet ready to mount the real component. + * Delegates to `isElementReady` which checks: + * 1. Any undefined-valued resolved prop (state binding still loading). + * 2. A sync Standard-Schema gate if the registry entry declares a schema. + * Framework-injected keys (bindings, emit, loading, childKeys, spec) are * excluded — only consumer-resolved props matter for readiness. */ readonly notReady = computed(() => { if (this.mountedReal()) return false; const el = this.element(); if (!el || !el.props) return false; const resolved = resolveElementProps(el.props, this.propCtx()); - for (const v of Object.values(resolved)) { - if (v === undefined) return true; - } - return false; + return !isElementReady(this.entry(), resolved); }); /** Picks fallback or real based on notReady. The mountedReal latch is From 10ed9b4f998d12480559d8a49968fce7e09bbe85 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 22:31:04 -0700 Subject: [PATCH 5/9] =?UTF-8?q?fix(render):=20destroy-safe=20event=20emiss?= =?UTF-8?q?ion=20via=20makeGuardedEmit=20=E2=80=94=20fixes=20NG0953=20on?= =?UTF-8?q?=20ask=20teardown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/internals/guarded-emit.spec.ts | 25 +++++++++++++++++++ libs/render/src/lib/internals/guarded-emit.ts | 16 ++++++++++++ .../src/lib/render-element.component.ts | 10 +++++--- libs/render/src/lib/render-spec.component.ts | 14 +++++++++-- 4 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 libs/render/src/lib/internals/guarded-emit.spec.ts create mode 100644 libs/render/src/lib/internals/guarded-emit.ts diff --git a/libs/render/src/lib/internals/guarded-emit.spec.ts b/libs/render/src/lib/internals/guarded-emit.spec.ts new file mode 100644 index 000000000..931b331ed --- /dev/null +++ b/libs/render/src/lib/internals/guarded-emit.spec.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { makeGuardedEmit } from './guarded-emit'; + +describe('makeGuardedEmit', () => { + it('forwards events while not destroyed', () => { + const seen: number[] = []; + let destroyed = false; + const emit = makeGuardedEmit((n) => seen.push(n), () => destroyed); + emit(1); + emit(2); + expect(seen).toEqual([1, 2]); + }); + + it('no-ops once destroyed (never calls the underlying emit)', () => { + const seen: number[] = []; + let destroyed = false; + const emit = makeGuardedEmit((n) => seen.push(n), () => destroyed); + emit(1); + destroyed = true; + emit(2); + emit(3); + expect(seen).toEqual([1]); + }); +}); diff --git a/libs/render/src/lib/internals/guarded-emit.ts b/libs/render/src/lib/internals/guarded-emit.ts new file mode 100644 index 000000000..3f5f9d4ea --- /dev/null +++ b/libs/render/src/lib/internals/guarded-emit.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +/** + * Wraps an emit function so it becomes a no-op once `isDestroyed()` returns + * true. Prevents Angular NG0953 ("emit on a destroyed OutputRef") when a late + * event (e.g. an ask client-tool resolving during teardown) tries to fire + * after the owning component has been destroyed. + */ +export function makeGuardedEmit( + emit: (event: E) => void, + isDestroyed: () => boolean, +): (event: E) => void { + return (event: E) => { + if (isDestroyed()) return; + emit(event); + }; +} diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts index 8040f89f1..6d0086570 100644 --- a/libs/render/src/lib/render-element.component.ts +++ b/libs/render/src/lib/render-element.component.ts @@ -109,6 +109,8 @@ export class RenderElementComponent implements OnInit { readonly parentInjector = inject(Injector); private readonly destroyRef = inject(DestroyRef); + private destroyed = false; + constructor() { this.destroyRef.onDestroy(() => { const el = this.element(); @@ -121,6 +123,7 @@ export class RenderElementComponent implements OnInit { elementType: el.type, }); } + this.destroyed = true; }); // Latch mountedReal=true once the real component is selected. Lives in @@ -238,10 +241,9 @@ export class RenderElementComponent implements OnInit { * injectRenderHost(). `set` writes the store; `emit` routes element * handlers; `result` surfaces a RenderResultEvent for this element. */ readonly host: RenderHost = { - set: (path: string, value: unknown) => this.ctx.store?.set(path, value), - emit: (event: string, payload?: Record) => this.invokeHandlers(event, payload), - result: (value: unknown) => - this.ctx.emitEvent?.({ type: 'result', value, elementKey: this.elementKey() }), + set: (path: string, value: unknown) => { if (this.destroyed) return; this.ctx.store?.set(path, value); }, + emit: (event: string, payload?: Record) => { if (this.destroyed) return; this.invokeHandlers(event, payload); }, + result: (value: unknown) => { if (this.destroyed) return; this.ctx.emitEvent?.({ type: 'result', value, elementKey: this.elementKey() }); }, }; /** Emit function passed to mounted view components as the `emit` framework diff --git a/libs/render/src/lib/render-spec.component.ts b/libs/render/src/lib/render-spec.component.ts index c97d7bd94..4951eabfe 100644 --- a/libs/render/src/lib/render-spec.component.ts +++ b/libs/render/src/lib/render-spec.component.ts @@ -22,6 +22,7 @@ import type { AngularRegistry } from './render.types'; import { signalStateStore } from './signal-state-store'; import type { RenderEvent } from './render-event'; import { RenderLifecycleService } from './render-lifecycle.service'; +import { makeGuardedEmit } from './internals/guarded-emit'; /** * Top-level entry point for rendering a json-render spec. @@ -69,6 +70,14 @@ export class RenderSpecComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly lifecycle = inject(RenderLifecycleService, { optional: true }); + private destroyed = false; + + /** Guarded OutputRef emit — no-ops after destroy (NG0953). */ + private readonly guardedEmit = makeGuardedEmit( + (e) => this.events.emit(e), + () => this.destroyed, + ); + /** Internal store, lazily created once and reused across spec changes. */ private _internalStore: StateStore | undefined; @@ -128,8 +137,8 @@ export class RenderSpecComponent implements OnInit { /** Emits a RenderEvent through the events output and notifies the * lifecycle service (single tap point — all events flow through here). */ private readonly emitTapped = (event: RenderEvent): void => { - this.events.emit(event); - if (!this.lifecycle) return; + this.guardedEmit(event); + if (this.destroyed || !this.lifecycle) return; switch (event.type) { case 'lifecycle': this.lifecycle.notifyLifecycle({ @@ -180,6 +189,7 @@ export class RenderSpecComponent implements OnInit { this.destroyRef.onDestroy(() => { this.emitTapped({ type: 'lifecycle', event: 'destroyed', scope: 'spec' }); + this.destroyed = true; }); } From c5594859bcda4b6dfca1c84992dec85a141c6194 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 22:42:47 -0700 Subject: [PATCH 6/9] fix(chat): propagate view/ask tool schema into the render view registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-tools coordinator built the view registry from bare component Types, dropping each tool's Standard Schema — so the render lib's new schema-readiness gate had no schema to gate on and the real view component still mounted before its required args streamed in (NG0950 persisted live despite green unit tests). Map view/ask tools to RenderViewEntry { component, schema } so the schema reaches the registry. Caught by the live smoke. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client-tools-coordinator.spec.ts | 20 +++++++++++++++++++ .../client-tools/client-tools-coordinator.ts | 16 ++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts b/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts index 678ee2676..ddfc2aa37 100644 --- a/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts +++ b/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts @@ -274,3 +274,23 @@ describe('createClientToolsCoordinator()', () => { }).not.toThrow(); }); }); + +describe('viewRegistry carries each view/ask tool schema (render mount-readiness gate)', () => { + it('attaches the Standard Schema to view and ask registry entries', () => { + const viewSchema = z.object({ day: z.number(), places: z.array(z.string()) }); + const askSchema = z.object({ day: z.number() }); + const registry = tools({ + get_it: action('read', z.object({}), async () => ({})), + day_card: view('show a day', viewSchema, FakeViewComponent), + clear_day: ask('confirm clear', askSchema, FakeAskComponent), + }); + const { viewRegistry } = createClientToolsCoordinator(registry); + // Entries are RenderViewEntry objects { component, schema } — the schema must + // survive so the render lib can gate the real mount until streamed props validate. + expect((viewRegistry['day_card'] as { component: unknown }).component).toBe(FakeViewComponent); + expect((viewRegistry['day_card'] as { schema: unknown }).schema).toBe(viewSchema); + expect((viewRegistry['clear_day'] as { schema: unknown }).schema).toBe(askSchema); + // function (non-view/ask) tools are not in the view registry. + expect(viewRegistry['get_it']).toBeUndefined(); + }); +}); diff --git a/libs/chat/src/lib/client-tools/client-tools-coordinator.ts b/libs/chat/src/lib/client-tools/client-tools-coordinator.ts index f7701bfe0..e92da4a24 100644 --- a/libs/chat/src/lib/client-tools/client-tools-coordinator.ts +++ b/libs/chat/src/lib/client-tools/client-tools-coordinator.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -import { effect, type Type } from '@angular/core'; +import { effect } from '@angular/core'; import { views, type ViewRegistry } from '@threadplane/render'; -import type { RenderEvent } from '@threadplane/render'; +import type { RenderEvent, RenderViewEntry } from '@threadplane/render'; import type { Agent, ToolCall } from '../agent'; import type { ClientToolRegistry, ClientToolDef } from './tool-def'; import type { ClientToolSpec } from './to-json-schema'; @@ -28,10 +28,16 @@ export function toClientToolSpecs(registry: ClientToolRegistry): ClientToolSpec[ })); } -function viewComponents(registry: ClientToolRegistry): Record> { - const out: Record> = {}; +/** Map each view/ask tool to a RenderViewEntry that carries its schema, so the + * render lib can gate the real component's mount on schema-readiness (showing + * the fallback skeleton while a streaming tool call's args are still + * incomplete) instead of mounting a required-input component too early. */ +function viewComponents(registry: ClientToolRegistry): Record { + const out: Record = {}; for (const [name, def] of Object.entries(registry)) { - if (def.kind === 'view' || def.kind === 'ask') out[name] = def.component; + if (def.kind === 'view' || def.kind === 'ask') { + out[name] = { component: def.component, schema: def.schema }; + } } return out; } From e9c7b034cc591ec1066037e85370d73a82936f6e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 22:45:39 -0700 Subject: [PATCH 7/9] docs(render): audit, design spec, and TDD plan for view/ask lifecycle fix Records the published-stack audit findings, the schema-readiness-gate design (Approach B), and the 7-task TDD plan. The spec includes the live-smoke correction: the client-tools coordinator must propagate each view/ask tool schema into the render view registry, or the gate never engages in production. Co-Authored-By: Claude Fable 5 --- .../2026-06-17-published-stack-audit.md | 67 +++ .../2026-06-17-render-view-ask-lifecycle.md | 550 ++++++++++++++++++ ...ols-view-ask-streaming-lifecycle-design.md | 168 ++++++ 3 files changed, 785 insertions(+) create mode 100644 docs/superpowers/audits/2026-06-17-published-stack-audit.md create mode 100644 docs/superpowers/plans/2026-06-17-render-view-ask-lifecycle.md create mode 100644 docs/superpowers/specs/2026-06-17-client-tools-view-ask-streaming-lifecycle-design.md diff --git a/docs/superpowers/audits/2026-06-17-published-stack-audit.md b/docs/superpowers/audits/2026-06-17-published-stack-audit.md new file mode 100644 index 000000000..7ba76de6e --- /dev/null +++ b/docs/superpowers/audits/2026-06-17-published-stack-audit.md @@ -0,0 +1,67 @@ +# Published-stack live audit — 2026-06-17 + +**Goal:** Act as a real user across the canonical demos + client-tools demos (published backends), exercising happy paths, unhappy paths, performance, and correctness via Chrome MCP. Log findings; defer fixes to a later brainstorm. + +**Scope (locked with Brian):** canonical demos (`examples/ag-ui`, `examples/chat`) + client-tools demos on published backends (`cockpit/langgraph/client-tools` JS node on npm `@threadplane/middleware@0.0.2`, `cockpit/ag-ui/client-tools` + `examples/ag-ui` python on PyPI `threadplane-middleware`). Backends run published packages; Angular frontends build from in-repo lib source (noted as a source-vs-published risk, not separately probed). + +**Method:** one demo at a time — start backend+frontend, drive in Chrome with a real OpenAI key, record findings, tear down. Severity: 🔴 broken · 🟠 wrong/confusing · 🟡 polish/perf · 🟢 works well. + +## Dimensions (per demo) +- **Happy path** — the core real-user flows for each capability. +- **Unhappy path** — empty submit, nonsense/ambiguous input, cancel mid-run, rapid double-submit, stop backend mid-stream, retry after error, very long input, refresh mid-state. +- **Performance** — initial load, time-to-first-token, streaming smoothness, console errors/warnings, network shape, memory growth on repeated actions. +- **Correctness** — state consistency (localStorage/panels), ask-card freeze, continuation after tool round-trips, citations, theme + mode (Embed/Popup/Sidebar) switching. + +## Demos & run configs +- **D1 `cockpit/langgraph/client-tools`** — node backend `langgraphjs dev :5308` (npm `@threadplane/middleware@0.0.2`); angular `:4308`. Tools: get_weather (action), weather_card (view), confirm_booking (ask). +- **D2 `cockpit/ag-ui/client-tools`** — python `uvicorn src.server:app --port 5325` (PyPI); angular `:4325`. Same three tools over AG-UI. +- **D3 `examples/ag-ui`** (itinerary canonical) — python `:8000` (PyPI); angular `:4201`. Itinerary panel + 7 capability chips + client tools (get/add/move/clear_day/day_card) + Embed/Popup/Sidebar modes. +- **D4 `examples/chat`** (canonical chat) — `nx run examples-chat-python:serve` + `examples-chat-angular:serve`. Full chat capability set. + +## Findings log +(filled during execution) + +### D1 — cockpit/langgraph/client-tools (npm 0.0.2) +- 🟢 Happy: action (get_weather), view (weather_card), ask Confirm — all work; continuations stream. +- 🟢 Ask **Cancel** → freezes to "Booking cancelled", model acknowledges, no error. +- 🟢 Empty submit → no-op (correct). +- 🟢 Ambiguous "weather" → model asks "Which location?" (graceful). +- 🟢 Warm dev load fast (TTFB 15ms, interactive 37ms). +- 🟠 **Backend down** → error surfaces but message is just **"HTTP 500:"** (empty body, unhelpful) and takes **~20s** to appear; spinner clears + input recovers afterward. +- 🟠 **Console warning `NG0953: Unexpected emit for destroyed OutputRef`** — a component emits after destroy (likely a client-tools view/ask component or render host on unmount). +- 🟡 No thread restoration on page reload — conversation cleared. + +### D2 — cockpit/ag-ui/client-tools (PyPI) +- 🟢 View tool (weather_card) renders (Rome 78°F card) + continuation over the **AG-UI transport on the published PyPI `threadplane-middleware`** backend. Console clean. +- 🟢 Published PyPI backend works end-to-end (validates the rename + PyPI publish). +- (action/ask share the same framework path validated in D1; not re-run exhaustively.) +- ⚪ Harness note: the first programmatic type right after `navigate` often doesn't register (Angular signal input) — a Chrome-MCP pixel-typing quirk, NOT a demo bug; reliable path is click→type→verify-value→Enter. + +### D3 — examples/ag-ui (itinerary canonical) +- 🟢 Backend (examples/ag-ui/python) runs clean on **published PyPI `threadplane-middleware`** post-rename. +- 🟢 `add_stop` client tool → Colosseum added to Day 2, **panel updates live**; model chains `get_itinerary`; gen-UI (json-render) "Day 2" recap card renders; final summary correct. Rich multi-capability turn works. +- 🔴 **`DayCardComponent` (day_card view tool) throws `NG0950: Input "day" is required but no value is available yet` — 6×** during streaming render. The view-tool component mounts before its required `day`/`places` inputs are streamed in → `input.required()` throws repeatedly until args arrive. Renders fine visually, but floods console with runtime errors. **Framework-level** (render-host/chat-tool-views mounting timing). Pairs with D1's NG0953 → a **client-tools view/ask component lifecycle bug class**. +- 🟡 Welcome shows only **1 of 7 capability chips** + "More prompts ▾" dropdown at 1483px — 6 capabilities hidden behind a dropdown (discoverability). +- 🟡 Itinerary panel persists **stale localStorage** test data across sessions (Pompidou/Sainte-Chapelle from earlier) — not reset to seed; correct persistence but confusing for a fresh demo. + +### D4 — examples/chat (canonical chat) — CLEANEST +- 🟢 Gen-UI / **A2UI**: "render a contact form" → `render_a2ui_surface` → full form (Name/Email/Subject/Message + Send) renders clean. No errors. +- 🟢 Basic streaming chat works; multi-turn in one thread. +- 🟢 **Thread persistence + URL routing** (`/embed/`) + **auto-titling** ("Untitled" → "Contact form HTML example"). Full app shell (projects, search, recent, archived). +- 🟢 **Zero console errors** across the whole session — notably better than the client-tools demos. + +### Cross-cutting (perf, console, source-vs-published) +- 🔴/🟠 **Client-tools view/ask component lifecycle bug class** (D1 + D3): `NG0950` (required input not available during streaming mount of view component) + `NG0953` (emit after destroy on ask unmount). The chat demo (no client-tools views) is error-free → the regression is specific to the **client-tools render-host mounting/teardown timing**, the surface we just shipped. Highest-priority finding. +- 🟠 **Error UX**: backend-down surfaces a bare "HTTP 500:" after ~20s. Generic, slow, no retry affordance. +- 🟡 **Thread persistence is inconsistent across demos**: examples/chat persists threads + URL-routes + auto-titles; the client-tools demos (D1/D3) lose the conversation on reload. (May be intentional per-demo, but inconsistent UX story.) +- 🟡 **Capability discoverability**: itinerary welcome shows 1 of 7 chips + "More prompts" dropdown at desktop width. +- 🟡 **Demo data hygiene**: itinerary panel persists stale localStorage test data; "Reset demo data" exists but isn't auto-applied. +- ⚪ **Published-package validation**: PyPI `threadplane-middleware` (D2 + D3 python backends) and npm `@threadplane/middleware@0.0.2` (D1 node backend) both run clean end-to-end. Frontends are in-repo source (not separately probed, per scope). +- ⚪ Harness: post-navigation programmatic typing flakes (Angular signal input) — test-tool artifact, not a product bug. + +## Severity-ranked summary +1. 🔴 **NG0950 — day_card view tool throws "required input not available" 6× during streaming render** (D3). Framework: render host mounts view components before streamed args populate required inputs. +2. 🟠 **NG0953 — ask component emits after destroy** (D1). Framework: lifecycle/teardown of resolved ask components. +3. 🟠 **Backend-failure UX** — bare "HTTP 500:", ~20s to surface, no retry (D1). +4. 🟡 Inconsistent thread persistence across demos · capability-chip discoverability · stale demo localStorage. +- ✅ All core capabilities function (client tools action/view/ask, gen-UI/A2UI, streaming, threads, modes); published backends validated; chat demo is pristine. diff --git a/docs/superpowers/plans/2026-06-17-render-view-ask-lifecycle.md b/docs/superpowers/plans/2026-06-17-render-view-ask-lifecycle.md new file mode 100644 index 000000000..ed5408a22 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-render-view-ask-lifecycle.md @@ -0,0 +1,550 @@ +# Render view/ask streaming lifecycle — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Subagents follow superpowers:test-driven-development. + +**Goal:** Fix the client-tools view/ask streaming-lifecycle errors (NG0950 mount-before-args, NG0953 emit-on-teardown) via a targeted `libs/render` refactor — registry preserves the full entry behind a unified `getEntry`, readiness becomes a pure `isElementReady` policy, and the event emit is destroy-safe. + +**Architecture:** All changes in `libs/render`. (1) `AngularRegistry` exposes `getEntry(name): NormalizedEntry | undefined` (preserving `component/fallback/schema/description`) instead of parallel `get`/`getFallback`. (2) A pure `isElementReady(entry, resolvedProps)` module replaces the ad-hoc undefined-prop heuristic in `RenderElementComponent.notReady`, adding sync Standard-Schema validation as the readiness gate. (3) A destroyed-guard in `RenderSpecComponent.emitTapped` (the single event tap point) + `RenderElementComponent.host.*`. No backwards compatibility; no `libs/chat`/consumer/demo changes. + +**Tech Stack:** Angular 21 (signals, `input.required`, `computed`, `DestroyRef`), `@nx/vitest`, json-render, Standard Schema (vendored, types-only). + +--- + +## Spec + +`docs/superpowers/specs/2026-06-17-client-tools-view-ask-streaming-lifecycle-design.md` + +## Background the engineer needs + +- `RenderElementComponent` (`libs/render/src/lib/render-element.component.ts`) renders one json-render element. It chooses **real component vs. fallback skeleton** via a `notReady` computed, with a monotonic `mountedReal` latch (once real mounts, never reverts). It reads the registry off `RENDER_CONTEXT` (`this.ctx.registry`). +- `chat-tool-views` (libs/chat) wraps a streaming tool call into a synthetic one-element spec with `props: { ...args, ...result, status }` and renders it through `RenderSpecComponent` → `RenderElementComponent`. While streaming, `args` is often `{}`, so a view component's `input.required()` field is **absent** → NG0950. Today `notReady` only catches *undefined-valued* props, so the real component mounts anyway. +- `define-angular-registry.ts` normalizes entries to `{ component, fallback }` — it **drops `schema`/`description`** today (the root reason a readiness gate has nothing to read). +- The NG0953 `OutputRef` is `RenderSpecComponent.events`, emitted only through `emitTapped` (the single tap point). `libs/chat` components only forward it. +- **Standard Schema** (`libs/render/src/lib/standard-schema.ts`) is types-only; its `~standard.validate(value)` may return a result OR a Promise. Render is synchronous, so we only gate on sync results. +- The render lib tests with vitest: `npx nx test render`. Existing specs: `render-element.component.spec.ts`, `default-fallback.component.spec.ts`, `contexts/render-host.spec.ts`. Example prod build gate (catches `strict:false` issues per repo convention): `npx nx build examples-ag-ui-angular`. + +## File map (all under `libs/render/src/lib/`) + +- `render.types.ts` — **modify**: export `NormalizedEntry`; change `AngularRegistry` to `{ getEntry(name): NormalizedEntry | undefined; names(): string[] }`; update the `RenderViewEntry.schema` doc comment (now enforced as a mount-readiness gate). +- `internals/element-readiness.ts` — **create**: pure `isElementReady(entry, resolvedProps)` + inline `isPromise`. +- `internals/element-readiness.spec.ts` — **create**: unit tests. +- `define-angular-registry.ts` — **modify**: `normalize` preserves `schema`/`description`; return `{ getEntry, names }`. +- `define-angular-registry.spec.ts` — **create** (if absent) or extend: `getEntry` tests. +- `render-element.component.ts` — **modify**: add an `entry` computed; route the 4 `get`/`getFallback` sites through it; `notReady` delegates to `isElementReady`; destroyed-guard on `host.*`. +- `render-spec.component.ts` — **modify**: empty-fallback registry literal → `{ getEntry: () => undefined, names: () => [] }`; destroyed-guard in `emitTapped`. + +--- + +## Task 0: Branch + +- [ ] **Step 1: Create the branch from latest main** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/quirky-haslett-d443a4 +git fetch origin +git checkout -b claude/render-view-ask-lifecycle origin/main +``` + +--- + +## Task 1: Registry preserves the full entry behind `getEntry` + +**Files:** +- Modify: `libs/render/src/lib/render.types.ts` +- Modify: `libs/render/src/lib/define-angular-registry.ts` +- Create: `libs/render/src/lib/define-angular-registry.spec.ts` +- Modify (compile fix, same task): `libs/render/src/lib/render-element.component.ts`, `libs/render/src/lib/render-spec.component.ts` + +- [ ] **Step 1: Write the failing registry test** + +Create `libs/render/src/lib/define-angular-registry.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { Component } from '@angular/core'; +import { describe, it, expect } from 'vitest'; +import { z } from 'zod/v4'; +import { defineAngularRegistry } from './define-angular-registry'; +import { DefaultFallbackComponent } from './default-fallback.component'; + +@Component({ selector: 'x-real', standalone: true, template: '' }) +class RealComponent {} +@Component({ selector: 'x-fallback', standalone: true, template: '' }) +class CustomFallback {} + +describe('defineAngularRegistry / getEntry', () => { + it('preserves component, fallback, schema, and description for object entries', () => { + const schema = z.object({ day: z.number() }); + const reg = defineAngularRegistry({ + card: { component: RealComponent, fallback: CustomFallback, schema, description: 'a card' }, + }); + const entry = reg.getEntry('card'); + expect(entry?.component).toBe(RealComponent); + expect(entry?.fallback).toBe(CustomFallback); + expect(entry?.schema).toBe(schema); + expect(entry?.description).toBe('a card'); + }); + + it('bare Type entries get the default fallback and no schema', () => { + const reg = defineAngularRegistry({ plain: RealComponent }); + const entry = reg.getEntry('plain'); + expect(entry?.component).toBe(RealComponent); + expect(entry?.fallback).toBe(DefaultFallbackComponent); + expect(entry?.schema).toBeUndefined(); + }); + + it('returns undefined for an unregistered name; names() lists keys', () => { + const reg = defineAngularRegistry({ a: RealComponent }); + expect(reg.getEntry('missing')).toBeUndefined(); + expect(reg.names()).toEqual(['a']); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx nx test render -- define-angular-registry` +Expected: FAIL — `getEntry` does not exist on the registry. + +- [ ] **Step 3: Update the types** + +In `libs/render/src/lib/render.types.ts`, export `NormalizedEntry` and replace the `AngularRegistry` accessors: + +```ts +/** A fully-normalized registry entry: real component + a guaranteed fallback, + * plus the optional props schema (mount-readiness gate) and description. */ +export interface NormalizedEntry { + component: Type; + fallback: Type; + schema?: StandardSchemaV1; + description?: string; +} + +export interface AngularRegistry { + /** The full normalized entry for a registered name, or undefined. The single + * accessor — component, fallback, schema, and description all hang off it. */ + getEntry(name: string): NormalizedEntry | undefined; + names(): string[]; +} +``` + +Also update the `RenderViewEntry.schema` doc comment — replace *"Carried + exposed by the render lib but NOT enforced on mount"* with: + +```ts + /** Optional props contract (Zod/Valibot/ArkType via Standard Schema). + * Enforced as a MOUNT-READINESS GATE: while a streaming tool call's props + * do not yet validate against this schema, the element's fallback is shown + * instead of the real component (sync validation only). Also used by + * client-tools to advertise the component to a model. */ + schema?: StandardSchemaV1; +``` + +- [ ] **Step 4: Update `define-angular-registry.ts`** + +Replace the file's `NormalizedEntry`/`normalize`/return with the entry-preserving form: + +```ts +// SPDX-License-Identifier: MIT +import { Type } from '@angular/core'; +import type { AngularRegistry, NormalizedEntry, RenderViewEntry } from './render.types'; +import { DefaultFallbackComponent } from './default-fallback.component'; + +type RegistryInput = Record | RenderViewEntry>; + +function normalize(entry: Type | RenderViewEntry): NormalizedEntry { + // Bare Type — register with the default fallback, no schema/description. + if (typeof entry === 'function') { + return { component: entry, fallback: DefaultFallbackComponent }; + } + // Object form — preserve everything; default the fallback. + return { + component: entry.component, + fallback: entry.fallback ?? DefaultFallbackComponent, + schema: entry.schema, + description: entry.description, + }; +} + +export function defineAngularRegistry(componentMap: RegistryInput): AngularRegistry { + const map = new Map(); + for (const [name, entry] of Object.entries(componentMap)) { + map.set(name, normalize(entry)); + } + return { + getEntry: (name: string) => map.get(name), + names: () => [...map.keys()], + }; +} +``` + +- [ ] **Step 5: Update the `libs/render` call sites (compile fix)** + +In `render-element.component.ts`, add an `entry` computed near the other computeds and route the four `get`/`getFallback` reads through it: + +```ts +/** The normalized registry entry for this element's type (component, fallback, + * schema). Single source for all registry reads in this component. */ +readonly entry = computed(() => { + const el = this.element(); + return el ? this.ctx.registry.getEntry(el.type) : undefined; +}); +``` +Then: +- constructor effect (was `!this.notReady() && this.ctx.registry.get(el.type)`) → `if (!this.notReady() && this.entry()?.component)` +- `componentClass` (was `this.ctx.registry.get(el.type) ?? null`) → `return this.entry()?.component ?? null;` +- `mountClass`: `const real = this.entry()?.component ?? null;` and the fallback branch `return this.entry()?.fallback ?? null;` + +Add `NormalizedEntry` to the `render.types` import. In `render-spec.component.ts`, change the empty-fallback literal: + +```ts +// was: return { get: () => undefined, getFallback: () => undefined, names: () => [] }; +return { getEntry: () => undefined, names: () => [] }; +``` + +- [ ] **Step 6: Scan for any other `AngularRegistry` mock using `get`/`getFallback`** + +Run: `grep -rn "getFallback\|registry.get\b" libs/render/src libs/chat/src | grep -v "templateRegistry\|Map<"` +Fix any test/mocks that construct an `AngularRegistry` with `get`/`getFallback` to use `getEntry` (return `{ component, fallback }` shaped entries). Map-based `.get` (chat-tool-calls) is unrelated — leave it. + +- [ ] **Step 7: Run registry test + full render suite** + +Run: `npx nx test render --skip-nx-cache` +Expected: the new `define-angular-registry` tests PASS; all existing render tests PASS (behavior unchanged — `getEntry` returns the same component/fallback the old accessors did). + +- [ ] **Step 8: Commit** + +```bash +git add libs/render/src/lib/render.types.ts libs/render/src/lib/define-angular-registry.ts libs/render/src/lib/define-angular-registry.spec.ts libs/render/src/lib/render-element.component.ts libs/render/src/lib/render-spec.component.ts +git commit -m "refactor(render): registry exposes getEntry (preserve schema/description); drop get/getFallback" +``` + +--- + +## Task 2: Pure `isElementReady` readiness policy + +**Files:** +- Create: `libs/render/src/lib/internals/element-readiness.ts` +- Create: `libs/render/src/lib/internals/element-readiness.spec.ts` + +- [ ] **Step 1: Write the failing readiness tests** + +Create `libs/render/src/lib/internals/element-readiness.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { z } from 'zod/v4'; +import { isElementReady } from './element-readiness'; +import type { NormalizedEntry } from '../render.types'; + +const C = (() => {}) as never; // stand-in component Type for entries +const entry = (over: Partial = {}): NormalizedEntry => + ({ component: C, fallback: C, ...over }); + +describe('isElementReady', () => { + it('ready when no schema and no undefined props', () => { + expect(isElementReady(entry(), { a: 1, b: 'x' })).toBe(true); + }); + + it('not ready when any prop value is undefined (json-render state binding loading)', () => { + expect(isElementReady(entry(), { a: 1, b: undefined })).toBe(false); + }); + + it('not ready while a sync schema does not validate (required keys absent during streaming)', () => { + const schema = z.object({ day: z.number(), places: z.array(z.string()) }); + expect(isElementReady(entry({ schema }), { status: 'running' })).toBe(false); + expect(isElementReady(entry({ schema }), { day: 2 })).toBe(false); + }); + + it('ready once a sync schema validates (extra status/result keys are ignored by non-strict object)', () => { + const schema = z.object({ day: z.number(), places: z.array(z.string()) }); + expect( + isElementReady(entry({ schema }), { day: 2, places: ['Eiffel'], status: 'complete', result: {} }), + ).toBe(true); + }); + + it('ready when there is no schema regardless of which keys are present', () => { + expect(isElementReady(entry(), { anything: true })).toBe(true); + }); + + it('ready (not gated) when the schema validates asynchronously (Promise result)', () => { + const asyncSchema = { + '~standard': { version: 1 as const, vendor: 'test', validate: () => Promise.resolve({ issues: [{ message: 'x' }] }) }, + }; + expect(isElementReady(entry({ schema: asyncSchema as never }), {})).toBe(true); + }); + + it('treats an undefined entry as having no schema (ready unless undefined props)', () => { + expect(isElementReady(undefined, { a: 1 })).toBe(true); + expect(isElementReady(undefined, { a: undefined })).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx nx test render -- element-readiness` +Expected: FAIL — cannot find `./element-readiness`. + +- [ ] **Step 3: Implement `element-readiness.ts`** + +Create `libs/render/src/lib/internals/element-readiness.ts`: + +```ts +// SPDX-License-Identifier: MIT +import type { NormalizedEntry } from '../render.types'; + +function isPromise(v: unknown): v is Promise { + return typeof (v as { then?: unknown } | null)?.then === 'function'; +} + +/** + * Decide whether the REAL component may mount, or the fallback skeleton should + * show. Pure (no Angular, no signals) so it is trivially unit-testable. + * + * - Any undefined-valued prop → pending (a json-render state binding is still + * loading). + * - A schema-declared contract → pending until the (possibly streaming) props + * validate against it. SYNC validation only: render is synchronous, so an + * async (Promise) validate result cannot gate a sync mount and is treated as + * ready. View schemas should therefore be synchronous (Zod is). + */ +export function isElementReady( + entry: NormalizedEntry | undefined, + resolvedProps: Record, +): boolean { + for (const v of Object.values(resolvedProps)) { + if (v === undefined) return false; + } + const schema = entry?.schema; + if (schema) { + const out = schema['~standard'].validate(resolvedProps); + if (!isPromise(out) && out.issues !== undefined) return false; + } + return true; +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `npx nx test render -- element-readiness` +Expected: PASS (all 7 cases). + +- [ ] **Step 5: Commit** + +```bash +git add libs/render/src/lib/internals/element-readiness.ts libs/render/src/lib/internals/element-readiness.spec.ts +git commit -m "feat(render): pure isElementReady readiness policy (undefined-prop + sync schema gate)" +``` + +--- + +## Task 3: Wire `isElementReady` into `RenderElementComponent.notReady` + +**Files:** +- Modify: `libs/render/src/lib/render-element.component.ts` +- Modify: `libs/render/src/lib/render-element.component.spec.ts` + +- [ ] **Step 1: Write the failing component test** + +Add to `libs/render/src/lib/render-element.component.spec.ts` (follow the file's existing harness for mounting `RenderSpecComponent`/`RenderElementComponent` with a registry + spec; mirror an existing test's setup). The test renders an element whose registry entry has a schema and asserts fallback-while-invalid → real-once-valid: + +```ts +it('shows the fallback while streamed props fail the schema, then mounts the real component', async () => { + // Registry: a view with a required schema + a distinguishable fallback. + const schema = z.object({ day: z.number(), places: z.array(z.string()) }); + const registry = defineAngularRegistry({ + day_card: { component: DayCardStub, fallback: FallbackStub, schema }, + }); + + // 1) streaming: props lack required keys → fallback mounted, no NG0950 thrown. + const fixture = renderElement({ registry, props: { status: 'running' } }); // helper per existing spec + expect(fixture.nativeElement.querySelector('fallback-stub')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('day-card-stub')).toBeNull(); + + // 2) args complete → real component mounts and latches. + fixture.updateProps({ day: 2, places: ['Eiffel'], status: 'complete' }); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('day-card-stub')).toBeTruthy(); +}); +``` + +> NOTE to implementer: the exact harness (`renderElement`/`updateProps`) must match what `render-element.component.spec.ts` already uses to drive `notReady` with state-bound props — reuse that helper rather than inventing one. `DayCardStub` declares `day = input.required()` to prove no NG0950; `FallbackStub` is a trivial standalone component with selector `fallback-stub`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx nx test render -- render-element` +Expected: FAIL — today the real `DayCardStub` mounts immediately (and its required input throws), so the fallback assertion fails / an NG0950 surfaces. + +- [ ] **Step 3: Delegate `notReady` to `isElementReady`** + +In `render-element.component.ts`, import `isElementReady` and replace the `notReady` body (keep the `mountedReal` latch): + +```ts +import { isElementReady } from './internals/element-readiness'; + +readonly notReady = computed(() => { + if (this.mountedReal()) return false; + const el = this.element(); + if (!el || !el.props) return false; + const resolved = resolveElementProps(el.props, this.propCtx()); + return !isElementReady(this.entry(), resolved); +}); +``` + +(Removes the inline `for (const v of resolved) …` loop — that logic now lives in `isElementReady`, which also applies the schema gate.) + +- [ ] **Step 4: Run it to verify it passes** + +Run: `npx nx test render -- render-element` +Expected: PASS — fallback while invalid, real component after, no NG0950. + +- [ ] **Step 5: Commit** + +```bash +git add libs/render/src/lib/render-element.component.ts libs/render/src/lib/render-element.component.spec.ts +git commit -m "fix(render): gate element mount on isElementReady (schema-aware) — fixes NG0950 for view tools" +``` + +--- + +## Task 4: Destroy-safe event emission + +**Files:** +- Modify: `libs/render/src/lib/render-spec.component.ts` +- Modify: `libs/render/src/lib/render-element.component.ts` +- Modify: `libs/render/src/lib/render-spec.component.spec.ts` (or create if absent) + +- [ ] **Step 1: Write the failing test** + +Add to `render-spec.component.spec.ts` (mirror its existing harness for mounting `RenderSpecComponent` and capturing `(events)`): + +```ts +it('does not emit events after the component is destroyed (NG0953 guard)', () => { + const events: RenderEvent[] = []; + const fixture = renderSpec({ /* spec + registry per existing harness */ }); + fixture.componentInstance.events.subscribe((e) => events.push(e)); + fixture.destroy(); + // Simulate a late emit from a torn-down child (e.g. an ask result resolving + // during teardown). emitTapped must no-op rather than hit a destroyed OutputRef. + expect(() => fixture.componentInstance['emitEvent']({ type: 'result', value: 1, elementKey: 'x' })) + .not.toThrow(); + const before = events.length; + expect(events.length).toBe(before); // no event delivered post-destroy +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx nx test render -- render-spec` +Expected: FAIL — `emitEvent` after destroy still calls `this.events.emit`, throwing/emitting on a destroyed `OutputRef`. + +- [ ] **Step 3: Guard `emitTapped` in `render-spec.component.ts`** + +Add a destroyed flag set in the existing `destroyRef.onDestroy` and short-circuit `emitTapped`: + +```ts +private destroyed = false; +// in constructor, alongside the existing onDestroy lifecycle emit: +this.destroyRef.onDestroy(() => { this.destroyed = true; }); + +private readonly emitTapped = (event: RenderEvent): void => { + if (this.destroyed) return; // NG0953: never emit on a destroyed OutputRef + this.events.emit(event); + if (!this.lifecycle) return; + // …unchanged switch… +}; +``` + +> Ordering note: set `this.destroyed = true` in an `onDestroy` registered BEFORE the existing one that emits the `spec` `destroyed` lifecycle event — otherwise that final emit would be suppressed. Simplest: emit the `destroyed` lifecycle event directly via `this.events.emit(...)` in that handler (bypassing the guard) OR register the destroyed-flag handler last and keep the lifecycle emit first. Implement so the `spec` `destroyed` lifecycle event still fires exactly once. + +- [ ] **Step 4: Guard `host.*` in `render-element.component.ts`** + +Add a destroyed flag (the component already injects `DestroyRef`) and guard the host methods: + +```ts +private destroyed = false; +// in the existing constructor destroyRef.onDestroy: +this.destroyRef.onDestroy(() => { this.destroyed = true; /* …existing lifecycle emit… */ }); + +readonly host: RenderHost = { + set: (path, value) => { if (this.destroyed) return; this.ctx.store?.set(path, value); }, + emit: (event, payload) => { if (this.destroyed) return; this.invokeHandlers(event, payload); }, + result: (value) => { if (this.destroyed) return; this.ctx.emitEvent?.({ type: 'result', value, elementKey: this.elementKey() }); }, +}; +``` + +- [ ] **Step 5: Run it to verify it passes** + +Run: `npx nx test render -- render-spec render-element` +Expected: PASS — no emit/throw post-destroy; existing event tests still green. + +- [ ] **Step 6: Commit** + +```bash +git add libs/render/src/lib/render-spec.component.ts libs/render/src/lib/render-element.component.ts libs/render/src/lib/render-spec.component.spec.ts +git commit -m "fix(render): destroy-safe event emission (emitTapped + host.*) — fixes NG0953 on ask teardown" +``` + +--- + +## Task 5: Full verification (suite + lint + example build + chat) + +- [ ] **Step 1: Render lib green** + +Run: `npx nx test render --skip-nx-cache && npx nx lint render && npx nx build render` +Expected: all PASS, 0 lint errors. + +- [ ] **Step 2: Downstream consumers compile (no API break leaked)** + +Run: `npx nx test chat --skip-nx-cache && npx nx build chat` +Expected: PASS — `@threadplane/chat` consumes the render registry; confirms the `getEntry` change didn't leak. + +- [ ] **Step 3: Example prod build (strict:false gate)** + +Run: `npx nx build examples-ag-ui-angular` +Expected: PASS — catches `strict:false` union-narrowing issues the unit tests can't (repo convention: always build one example before claiming a lib change green). + +- [ ] **Step 4: Commit (only if any fix was needed)** + +```bash +git add -A && git commit -m "chore(render): downstream + example build green" +``` +(If nothing changed, skip.) + +--- + +## Task 6: Live-LLM smoke (controller-run, manual gate) + +> Not a subagent step — the executing controller drives Chrome (per the standing live-smoke gate). The published-stack audit's D3 flow is the regression signal. + +- [ ] **Step 1: Serve examples/ag-ui** (python backend `:8000` with `OPENAI_API_KEY` + `AG_UI_INTERNAL_TOKEN=''`, angular `:4201`), open `http://localhost:4201/`. +- [ ] **Step 2: Drive the `day_card` view path** ("Add the Colosseum to day 2 of my trip"). Assert: a fallback skeleton appears during streaming, the day card renders with correct props, and **the console shows ZERO `NG0950`** during the streamed render. +- [ ] **Step 3: Drive an `ask` resolve** (consent-gated clear, or a cockpit client-tools `confirm_booking`) → Confirm/Cancel. Assert **ZERO `NG0953`** on resolve + freeze. +- [ ] **Step 4:** Record the result in the PR. + +--- + +## Task 7: Open PR + +- [ ] **Step 1: Push + PR** + +```bash +git push -u origin claude/render-view-ask-lifecycle +gh pr create --base main --head claude/render-view-ask-lifecycle \ + --title "fix(render): view/ask streaming lifecycle — schema-readiness gate + destroy-safe emit" \ + --body "Fixes the client-tools view/ask lifecycle errors found in the live audit (NG0950 mount-before-args; NG0953 emit-on-teardown), via a libs/render refactor: registry preserves the full entry behind getEntry; readiness extracted to a pure isElementReady policy (sync Standard-Schema gate); destroy-safe emit at the single emitTapped tap point. No backwards compat; no libs/chat/consumer/demo changes. Spec: docs/superpowers/specs/2026-06-17-client-tools-view-ask-streaming-lifecycle-design.md. + +Verified: render unit suite (incl. new isElementReady + getEntry + gate + destroy-guard tests), lint, render/chat builds, examples-ag-ui-angular prod build, and a live-LLM smoke (day_card streams a skeleton→card with zero NG0950; ask resolve/freeze with zero NG0953). + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +gh pr merge --squash --auto claude/render-view-ask-lifecycle +``` + +--- + +## Self-review + +- **Spec coverage:** registry getEntry/entry-preservation (Task 1) ✓; pure isElementReady incl. undefined/sync-schema/async/extra-keys (Task 2) ✓; notReady delegation + fallback-then-real (Task 3, fixes NG0950) ✓; destroy-safe emit at emitTapped + host (Task 4, fixes NG0953) ✓; back-compat for schemaless/async (covered by isElementReady tests) ✓; testing (unit + example build + live smoke) ✓; "no libs/chat change" honored (only Map-based chat-tool-calls `.get`, left alone) ✓; out-of-scope cleanups not touched ✓. +- **Placeholders:** the only soft spot is the test harness in Tasks 3/4 (`renderElement`/`renderSpec`/`updateProps`) — explicitly instructed to reuse the existing spec files' harness rather than invent one, because those helpers already exist and vary; the assertions and component stubs are concrete. +- **Type consistency:** `NormalizedEntry` (exported from render.types) is the single entry type used by `getEntry`, `isElementReady`, and the `entry` computed. `getEntry(name): NormalizedEntry | undefined` is consistent across registry, render-element, render-spec. `isElementReady(entry, resolvedProps): boolean` matches its call site. diff --git a/docs/superpowers/specs/2026-06-17-client-tools-view-ask-streaming-lifecycle-design.md b/docs/superpowers/specs/2026-06-17-client-tools-view-ask-streaming-lifecycle-design.md new file mode 100644 index 000000000..08b045617 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-client-tools-view-ask-streaming-lifecycle-design.md @@ -0,0 +1,168 @@ +# Client-tools view/ask streaming lifecycle — schema-readiness gate + destroy-safe emit + +**Date:** 2026-06-17 +**Status:** Design — awaiting review +**Author:** Brian Love (with Claude) +**Source:** live published-stack audit (`docs/superpowers/audits/2026-06-17-published-stack-audit.md`) + +## Goal + +Eliminate the client-tools **view/ask streaming-lifecycle** error class found in the live audit, at the framework layer, so any consumer can write an idiomatic Angular view component (including `input.required()`) backed by a Standard Schema and have it render safely while the model streams tool args: + +- **NG0950** — a view component (`day_card`) throws *"Input 'day' is required but no value is available yet"* 6× during streaming, because it is mounted before its required args have streamed in. +- **NG0953** — an `ask` component emits a value on a destroyed `OutputRef` during teardown. + +Functional behavior is already correct (cards render, panels update, asks resolve); this removes the runtime errors that flood the console and would hard-fail apps with strict error handling. + +## Root cause (confirmed in code) + +The client-tools view/ask path differs from the json-render dashboard path: + +1. `libs/chat/.../chat-tool-views.component.ts` wraps each tool call into a synthetic one-element render spec with `props: { ...args, ...result, status }` and renders it through `chat-generative-ui → RenderElementComponent`. +2. `RenderElementComponent` (`libs/render`) already has a readiness gate — `notReady` mounts the per-type **fallback** (a shimmer `DefaultFallbackComponent`) and latches to the real component via `mountedReal` once ready. **But `notReady` only fires on _undefined-valued_ props.** While a tool call streams, `args` is often empty `{}`, so the required props (`day`, `places`) are **absent entirely** — there are no undefined *values*, just missing keys. `notReady` returns `false`, the real `DayCardComponent` mounts, and `input.required()` throws NG0950. +3. `RenderViewEntry.schema` (the view's Standard Schema) **already exists on every `view()` entry** — but its own doc comment says it is *"NOT enforced on mount."* That non-enforcement is precisely the gap. +4. NG0953: when an `ask` resolves, the emitted result flows `RenderElementComponent.host.result → ctx.emitEvent → chat-tool-views (events) output`. The resolution also triggers a re-render that can destroy the element in the same cycle; a late emit then hits a destroyed `OutputRef`. + +**Cross-framework note (informing the design, not referenced in code):** the most-similar reference framework gates the real-component mount on *schema-resolved readiness* (fallback skeleton until the typed value resolves) — the same idea as this design, expressed via streaming JSON-AST. A second reference renders progressively with a `status` flag and makes the component handle `Partial` (no runtime-required inputs), and for HITL transitions status **in place** rather than unmounting — which is the cure pattern for NG0953. + +## Design + +This is a **targeted refactor of the render lib's readiness/registry architecture**, not a bolt-on — chosen after reviewing the current code (see "Why refactor"). No backwards compatibility is required. Three changes; the first two are the refactor, the third is a one-line guard. All reuse `DefaultFallbackComponent`, the `mountedReal` latch, and the `~standard.validate` surface already used in `client-tools/execute.ts`. **No consumer API change.** + +### Why refactor (not bolt-on) + +- **The registry silently drops the schema.** `define-angular-registry.ts` normalizes every entry to `{ component, fallback }` — `RenderViewEntry.schema` (and `description`) are discarded at registration, so a bolt-on `getSchema` would have nothing to read. The registry must preserve the entry regardless. +- **Parallel accessors don't scale.** `AngularRegistry` exposes `get` + `getFallback`; adding `getSchema` extends a smell. A single entry accessor is cleaner and future-proof. +- **Readiness is an ad-hoc heuristic inside a 330-line god-component.** `notReady` = "any undefined prop," buried in `RenderElementComponent` alongside element lookup, visibility, prop/binding resolution, repeat, host, and lifecycle. Adding a second rule inline deepens the tangle; readiness deserves to be a pure, isolated, testable unit. + +### 1. Registry preserves the full entry (refactor) + +- Normalization keeps all fields: `NormalizedEntry = { component, fallback, schema?, description? }` — stop discarding `schema`/`description`. +- Replace the parallel `get`/`getFallback` on `AngularRegistry` with a single **`getEntry(name): NormalizedEntry | undefined`** (+ `names()`). Callers read `getEntry(t)?.component` / `?.fallback` / `?.schema`. New entry metadata never needs a new accessor again. +- Update the call sites: `render-element.component.ts` (`get`/`getFallback` → `getEntry`), the empty-fallback registry literal in `render-spec.component.ts`, `define-angular-registry.ts`, `toRenderRegistry`. + +### 2. Readiness as a first-class, pure policy (refactor — fixes NG0950) + +Extract readiness out of `RenderElementComponent` into a pure, independently tested module: + +```ts +// libs/render/src/lib/internals/element-readiness.ts +import { isPromise } from '../standard-schema'; +import type { NormalizedEntry } from '../render.types'; + +/** Decide whether the REAL component may mount, or the fallback should show. + * Pure: no Angular, no signals — trivially unit-testable. */ +export function isElementReady( + entry: NormalizedEntry | undefined, + resolvedProps: Record, +): boolean { + // undefined-valued prop → pending (json-render state binding still loading) + for (const v of Object.values(resolvedProps)) { + if (v === undefined) return false; + } + // schema-declared contract → pending until the streamed props validate. + // SYNC only — render is synchronous; an async (Promise) result cannot gate a + // sync mount, so we treat it as ready (documented: view schemas should be sync; Zod is). + const schema = entry?.schema; + if (schema) { + const out = schema['~standard'].validate(resolvedProps); + if (!isPromise(out) && out.issues !== undefined) return false; + } + return true; +} +``` + +`RenderElementComponent` collapses its readiness to a thin consumer (latch unchanged): + +```ts +readonly notReady = computed(() => { + if (this.mountedReal()) return false; // monotonic latch unchanged + const el = this.element(); + if (!el) return false; + const entry = this.ctx.registry.getEntry(el.type); + return !isElementReady(entry, resolveElementProps(el.props ?? {}, this.propCtx())); +}); +``` + +- `notReady === true` → `mountClass()` returns the entry's **fallback** (shimmer skeleton) — same code path. +- Once props are ready, the real component mounts and the existing `mountedReal` effect latches it. +- **Behavioral note (no back-compat needed, but preserved anyway):** schemaless elements keep the undefined-prop behavior; async schemas mount immediately (documented). + +**Streaming timeline (day_card):** +``` +args {} → validate ✗ (day, places missing) → fallback skeleton +args {"day":2 → validate ✗ (places missing) → fallback skeleton +args {"day":2,"places":["Eiffel","Colos"]} ✓ → mount DayCardComponent, latch +``` +No NG0950: the real component is never mounted without the props its schema requires. + +**Consumer experience:** unchanged. They write `view(desc, zodSchema, Component)` and a normal component with `input.required()`; the framework makes streaming safe. Optional: a custom skeleton via the existing `fallback` on the registry entry. + +### 3. Destroy-safe result emission (fixes NG0953) + +Guard the result/event emission so a resolved-then-unmounting element cannot emit on a destroyed `OutputRef`. + +**The emitting `OutputRef` is entirely in `libs/render`.** `RENDER_CONTEXT.emitEvent` is implemented in `render-spec.component.ts` (`private emitEvent = (e) => { …; this.events.emit(e); }`), and `this.events` is `RenderSpecComponent`'s own `output()`. The `libs/chat` components (`chat-generative-ui`, `chat-tool-views`) only *forward* it via `(events)="events.emit($event)"`. So guarding the source covers the whole chain — **no `libs/chat` change for the emit guard.** (The schema-propagation fix in `client-tools-coordinator.ts` is a separate, required `libs/chat` change — see Files touched.) + +- In `RenderSpecComponent` (`render-spec.component.ts`), track destroyed state via the existing `DestroyRef` and make `emitEvent`/`this.events.emit(...)` a no-op once destroyed. +- In `RenderElementComponent`, likewise guard `host.result` / `host.emit` / the lifecycle `emitEvent` calls when the element is destroyed (belt-and-suspenders at the call site). + +This matches the "transition status in place, don't emit across teardown" pattern; combined with the existing FU2 freeze (resolved ask re-renders in place), the ask lifecycle becomes error-free. + +## Files touched — entirely within `libs/render` + +Registry refactor (entry preservation + unified accessor): +- `libs/render/src/lib/render.types.ts` — `AngularRegistry`: replace `get`/`getFallback` with `getEntry(name): NormalizedEntry | undefined`; export the `NormalizedEntry` type (`{ component, fallback, schema?, description? }`). +- `libs/render/src/lib/define-angular-registry.ts` — `normalize` preserves `schema`/`description`; return `{ getEntry, names }`. +- `libs/render/src/lib/views.ts` (`toRenderRegistry`) — passthrough (unchanged signature). + +Readiness policy (extraction) + gate: +- `libs/render/src/lib/internals/element-readiness.ts` **(new)** — pure `isElementReady(entry, resolvedProps)`. +- `libs/render/src/lib/render-element.component.ts` — `notReady` collapses to `!isElementReady(getEntry(type), resolvedProps)`; `mountClass`/fallback read via `getEntry`; destroyed-guard on `host.*`. +- `libs/render/src/lib/standard-schema.ts` — small `isPromise` helper (or inline in element-readiness). + +Destroy-safe emit: +- `libs/render/src/lib/render-spec.component.ts` — empty-fallback registry literal uses `getEntry`; destroyed-guard in `emitTapped` (the single tap point / NG0953 `OutputRef`). + +One `libs/chat` change — **required**, surfaced by the live smoke (see correction below): +- `libs/chat/src/lib/client-tools/client-tools-coordinator.ts` — `viewComponents` must map each `view`/`ask` tool to a `RenderViewEntry { component, schema }` (previously it returned the bare component `Type`, which dropped the schema). Without this the registry's `getEntry(name).schema` is `undefined`, the readiness gate never engages, and NG0950 persists in production despite green render-lib unit tests. + +> **Live-smoke correction:** an earlier draft of this spec claimed "no `libs/chat` change." That was wrong. The render-lib unit tests built a registry *with* a schema directly, so the gate fired in tests — but production builds the view registry through the coordinator's `viewComponents`, which was discarding the schema. The fix is the one-line `viewComponents` change above. No `@threadplane/chat` `view()/ask()` public signatures change, and no demo component changes. + +- **Explicitly NOT in scope** (unrelated cleanups, noted for a future pass): splitting `RenderElementComponent`'s repeat vs non-repeat paths, extracting the inline `host` object, restructuring prop resolution. + +## Error handling & edge cases + +- **Async schema:** not gated (sync-only); documented. Zod's Standard Schema validate is synchronous. +- **No schema:** current undefined-prop behavior preserved (json-render dashboards). +- **Partial-but-valid args:** if a schema marks fields optional, the component mounts as soon as the required subset validates (progressive-friendly). +- **Validation cost:** runs only while `!mountedReal` (i.e., during streaming, then never again per instance); views are small. Negligible. +- **Result/value props:** `{ ...args, ...result }` — the schema validates the merged props; `result`/`status` are extra keys the schema ignores (Standard Schema validate on a superset object passes for object schemas that don't `.strict()`). Confirm the demo schemas are non-strict (they are: plain `z.object`). + +## Testing + +**Unit (`libs/render`, vitest):** +- `isElementReady` (pure, the bulk of coverage): ready when no undefined props + no schema; pending on any undefined prop; pending when a sync schema's validate returns issues; ready when it validates; ready when no schema; ready (not gated) for an async/Promise-returning schema; result/status extra keys don't break validation of a non-strict object schema. +- `defineAngularRegistry` / `getEntry`: preserves `component`/`fallback`/`schema`/`description`; bare-`Type` entries get the default fallback and `schema: undefined`; `getEntry` returns undefined for unregistered names. +- `RenderElementComponent`: `notReady` reflects `isElementReady` against the registered entry + resolved props; fallback shows while pending, real component mounts + latches once ready. +- Destroyed-guard: `host.result`/`emit` and `emitTapped` no-op after `DestroyRef` fires. + +**Integration / e2e (examples/ag-ui):** +- Drive `day_card` (e.g. "Add X to day 2") and assert **zero NG0950** in the console during streaming, fallback skeleton appears, then the real card renders with correct props. +- Drive an `ask` resolve (cockpit ag-ui/langgraph client-tools `confirm_booking`) and assert **zero NG0953** on resolve + freeze. + +**Live-LLM smoke (standing gate):** re-run the audit's D1/D3 flows against the published backends; confirm a clean console (the audit's primary regression signal). + +## Out of scope (separate follow-ups) + +- **TypeScript DX / intellisense audit** of the `@threadplane/*` public surface (hover types, generic inference, JSDoc accuracy/readability) — Brian wants this next, as its own brainstorm→spec. +- **Backend-failure error UX** (bare "HTTP 500:", ~20s timeout, no retry) — separate spec. +- **Thread-persistence consistency** across demos — separate, larger product decision. +- Progressive (mid-stream partial) reveal of view components — the gate enables it, but this design only requires fallback-until-ready. + +## Success criteria + +- examples/ag-ui `day_card` streaming render: **0× NG0950**; skeleton→card transition; correct final props. +- client-tools `ask` resolve/freeze: **0× NG0953**. +- json-render dashboards and all existing render/chat unit + e2e suites: unchanged/green. +- No change required to any consumer's `view()/ask()` declaration or component code. From 3876046598db5510307e8c5d043c4accf9bdc82b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 22:47:22 -0700 Subject: [PATCH 8/9] test(render): const-bind unmutated destroyed flag in guarded-emit spec (lint) Co-Authored-By: Claude Fable 5 --- libs/render/src/lib/internals/guarded-emit.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/render/src/lib/internals/guarded-emit.spec.ts b/libs/render/src/lib/internals/guarded-emit.spec.ts index 931b331ed..c08fb9d95 100644 --- a/libs/render/src/lib/internals/guarded-emit.spec.ts +++ b/libs/render/src/lib/internals/guarded-emit.spec.ts @@ -5,7 +5,7 @@ import { makeGuardedEmit } from './guarded-emit'; describe('makeGuardedEmit', () => { it('forwards events while not destroyed', () => { const seen: number[] = []; - let destroyed = false; + const destroyed = false; const emit = makeGuardedEmit((n) => seen.push(n), () => destroyed); emit(1); emit(2); From f669634e79cb7876da7a69423f3dff2b1c2f43d6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 22:53:49 -0700 Subject: [PATCH 9/9] docs(render): sync registry API docs to getEntry accessor The view/ask lifecycle fix replaced AngularRegistry's get()/getFallback() with a single getEntry() returning the full NormalizedEntry. Update the define-angular-registry reference + registry guide, and regenerate api-docs.json from source. No backwards compatibility, so the old accessors are gone from the public surface. Co-Authored-By: Claude Fable 5 --- .../content/docs/render/api/api-docs.json | 29 ++++++-------- .../render/api/define-angular-registry.mdx | 39 +++++++++++-------- .../content/docs/render/guides/registry.mdx | 13 +++---- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/apps/website/content/docs/render/api/api-docs.json b/apps/website/content/docs/render/api/api-docs.json index 78c2676ab..ecd8c2388 100644 --- a/apps/website/content/docs/render/api/api-docs.json +++ b/apps/website/content/docs/render/api/api-docs.json @@ -33,6 +33,12 @@ "description": "", "optional": false }, + { + "name": "entry", + "type": "Signal", + "description": "The full normalized registry entry for this element type.", + "optional": false + }, { "name": "filteredRepeatInputs", "type": "Signal[]>", @@ -60,7 +66,7 @@ { "name": "notReady", "type": "Signal", - "description": "True when ANY resolved prop value is undefined (i.e. a state\n binding points at a path the store hasn't populated). Framework-\n injected keys (bindings, emit, loading, childKeys, spec) are\n excluded — only consumer-resolved props matter for readiness.", + "description": "True when the element is not yet ready to mount the real component.\n Delegates to `isElementReady` which checks:\n 1. Any undefined-valued resolved prop (state binding still loading).\n 2. A sync Standard-Schema gate if the registry entry declares a schema.\n Framework-injected keys (bindings, emit, loading, childKeys, spec) are\n excluded — only consumer-resolved props matter for readiness.", "optional": false }, { @@ -221,22 +227,9 @@ "properties": [], "methods": [ { - "name": "get", - "signature": "get(name: string): AngularComponentRenderer | undefined", - "description": "", - "params": [ - { - "name": "name", - "type": "string", - "description": "", - "optional": false - } - ] - }, - { - "name": "getFallback", - "signature": "getFallback(name: string): AngularComponentRenderer | undefined", - "description": "Returns the configured fallback for a registered name, OR the\nlib's default fallback if the entry omits one, OR undefined if\nthe name is not registered.", + "name": "getEntry", + "signature": "getEntry(name: string): NormalizedEntry | undefined", + "description": "The full normalized entry for a registered name, or undefined. The single\naccessor — component, fallback, schema, and description all hang off it.", "params": [ { "name": "name", @@ -583,7 +576,7 @@ { "name": "schema", "type": "StandardSchemaV1", - "description": "Optional props contract for this component (Zod/Valibot/ArkType via\nStandard Schema). Carried + exposed by the render lib but NOT enforced\non mount; consumers (e.g. client-tools) read it to advertise the\ncomponent to a model and to validate incoming props.", + "description": "Optional props contract for this component (Zod/Valibot/ArkType via\nStandard Schema). Enforced as a MOUNT-READINESS GATE: while a streaming\ntool call's props do not yet validate against this schema, the element's\nfallback is shown instead of the real component (sync validation only).\nConsumers (e.g. client-tools) also read it to advertise the component\nto a model and to validate incoming props.", "optional": true } ], diff --git a/apps/website/content/docs/render/api/define-angular-registry.mdx b/apps/website/content/docs/render/api/define-angular-registry.mdx index 0a928cd63..ba845af4c 100644 --- a/apps/website/content/docs/render/api/define-angular-registry.mdx +++ b/apps/website/content/docs/render/api/define-angular-registry.mdx @@ -35,20 +35,25 @@ Use the object form to configure a custom per-entry fallback (see [Per-Component ### Returns -An `AngularRegistry` object with two methods: +An `AngularRegistry` object with a single entry accessor: ```typescript interface AngularRegistry { - get(name: string): AngularComponentRenderer | undefined; - getFallback(name: string): AngularComponentRenderer | undefined; + getEntry(name: string): NormalizedEntry | undefined; names(): string[]; } + +interface NormalizedEntry { + component: Type; + fallback: Type; + schema?: StandardSchemaV1; + description?: string; +} ``` | Method | Description | |--------|-------------| -| `get(name)` | Returns the component class for the given type name, or `undefined` if not registered | -| `getFallback(name)` | Returns the configured fallback renderer for a registered name -- the entry's own `fallback`, the library's default fallback if the entry omits one, or `undefined` if the name isn't registered. | +| `getEntry(name)` | Returns the fully-normalized entry for a registered name, or `undefined` if not registered. The component, the resolved `fallback` (the entry's own or the library default), and any optional `schema`/`description` all hang off the returned object. | | `names()` | Returns an array of all registered type name strings | ## Usage @@ -65,10 +70,10 @@ const registry = defineAngularRegistry({ Card: CardComponent, }); -registry.get('Text'); // TextComponent -registry.get('Card'); // CardComponent -registry.get('Missing'); // undefined -registry.names(); // ['Text', 'Card'] +registry.getEntry('Text')?.component; // TextComponent +registry.getEntry('Card')?.component; // CardComponent +registry.getEntry('Missing'); // undefined +registry.names(); // ['Text', 'Card'] ``` ### With provideRender() @@ -118,16 +123,16 @@ const registry = defineAngularRegistry({ Card: { component: CardComponent, fallback: CardSkeletonComponent }, }); -registry.getFallback('Card'); // CardSkeletonComponent (the configured fallback) -registry.getFallback('Text'); // DefaultFallbackComponent (entry omits one) -registry.getFallback('Missing'); // undefined (not registered) +registry.getEntry('Card')?.fallback; // CardSkeletonComponent (the configured fallback) +registry.getEntry('Text')?.fallback; // DefaultFallbackComponent (entry omits one) +registry.getEntry('Missing'); // undefined (not registered) ``` An entry that omits `fallback` -- including every bare-component entry like `Text` above -- falls back to the library's `DefaultFallbackComponent`. Once the real component mounts, the choice is monotonic for that element instance: a later prop resolving to `undefined` never reverts it to the fallback. ## Internal Behavior -The function normalizes each input entry into a `{ component, fallback }` pair and stores them in an internal `Map` for O(1) lookups. A bare component class is paired with `DefaultFallbackComponent`; an object entry keeps its own `fallback` or falls back to the default: +The function normalizes each input entry into a `NormalizedEntry` (`{ component, fallback, schema?, description? }`) and stores them in an internal `Map` for O(1) lookups. A bare component class is paired with `DefaultFallbackComponent`; an object entry keeps its own `fallback` (or the default) and preserves any `schema`/`description`: ```typescript function normalize( @@ -137,10 +142,13 @@ function normalize( if (typeof entry === 'function') { return { component: entry, fallback: DefaultFallbackComponent }; } - // Object form — preserve component; use configured fallback or default. + // Object form — preserve component; use configured fallback or default; + // carry the optional schema/description through untouched. return { component: entry.component, fallback: entry.fallback ?? DefaultFallbackComponent, + schema: entry.schema, + description: entry.description, }; } @@ -152,8 +160,7 @@ function defineAngularRegistry( map.set(name, normalize(entry)); } return { - get: (name: string) => map.get(name)?.component, - getFallback: (name: string) => map.get(name)?.fallback, + getEntry: (name: string) => map.get(name), names: () => [...map.keys()], }; } diff --git a/apps/website/content/docs/render/guides/registry.mdx b/apps/website/content/docs/render/guides/registry.mdx index 24412d334..c0b7731f5 100644 --- a/apps/website/content/docs/render/guides/registry.mdx +++ b/apps/website/content/docs/render/guides/registry.mdx @@ -21,17 +21,16 @@ export const uiRegistry = defineAngularRegistry({ }); ``` -The returned `AngularRegistry` object has three methods: +The returned `AngularRegistry` object has two methods: -- `get(name: string)` -- returns the component class for the given type name, or `undefined` if not registered -- `getFallback(name: string)` -- returns the configured fallback renderer for a registered name -- the entry's own `fallback`, or the library's default fallback if the entry omits one, or `undefined` if the name isn't registered +- `getEntry(name: string)` -- returns the fully-normalized entry (`{ component, fallback, schema?, description? }`) for a registered name, or `undefined` if not registered. The resolved `fallback` is the entry's own renderer, or the library's default when the entry omits one. - `names()` -- returns an array of all registered type names ```typescript -uiRegistry.get('Text'); // TextComponent -uiRegistry.get('Unknown'); // undefined -uiRegistry.getFallback('Text'); // fallback renderer (or default) -uiRegistry.names(); // ['Text', 'Card', 'Button', 'Container'] +uiRegistry.getEntry('Text')?.component; // TextComponent +uiRegistry.getEntry('Unknown'); // undefined +uiRegistry.getEntry('Text')?.fallback; // fallback renderer (or default) +uiRegistry.names(); // ['Text', 'Card', 'Button', 'Container'] ``` ### Fallback Rendering