Skip to content

Commit 9320e56

Browse files
bloveclaude
andauthored
fix(render): schema-readiness gate for view/ask streaming lifecycle (NG0950/NG0953) (#680)
* refactor(render): registry exposes getEntry (preserve schema/description); drop get/getFallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(render): pure isElementReady readiness policy (undefined-prop + sync schema gate) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(render): use render- prefixed selectors in registry spec stubs (lint) * fix(render): gate element mount on isElementReady (schema-aware) — fixes NG0950 for view tools Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(render): destroy-safe event emission via makeGuardedEmit — fixes NG0953 on ask teardown Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(chat): propagate view/ask tool schema into the render view registry 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) <noreply@anthropic.com> * 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 <noreply@anthropic.com> * test(render): const-bind unmutated destroyed flag in guarded-emit spec (lint) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ed56e52 commit 9320e56

20 files changed

Lines changed: 1112 additions & 155 deletions

apps/website/content/docs/render/api/api-docs.json

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
"description": "",
3434
"optional": false
3535
},
36+
{
37+
"name": "entry",
38+
"type": "Signal<NormalizedEntry | undefined>",
39+
"description": "The full normalized registry entry for this element type.",
40+
"optional": false
41+
},
3642
{
3743
"name": "filteredRepeatInputs",
3844
"type": "Signal<Record<string, unknown>[]>",
@@ -60,7 +66,7 @@
6066
{
6167
"name": "notReady",
6268
"type": "Signal<boolean>",
63-
"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.",
69+
"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.",
6470
"optional": false
6571
},
6672
{
@@ -221,22 +227,9 @@
221227
"properties": [],
222228
"methods": [
223229
{
224-
"name": "get",
225-
"signature": "get(name: string): AngularComponentRenderer | undefined",
226-
"description": "",
227-
"params": [
228-
{
229-
"name": "name",
230-
"type": "string",
231-
"description": "",
232-
"optional": false
233-
}
234-
]
235-
},
236-
{
237-
"name": "getFallback",
238-
"signature": "getFallback(name: string): AngularComponentRenderer | undefined",
239-
"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.",
230+
"name": "getEntry",
231+
"signature": "getEntry(name: string): NormalizedEntry | undefined",
232+
"description": "The full normalized entry for a registered name, or undefined. The single\naccessor — component, fallback, schema, and description all hang off it.",
240233
"params": [
241234
{
242235
"name": "name",
@@ -583,7 +576,7 @@
583576
{
584577
"name": "schema",
585578
"type": "StandardSchemaV1<unknown, unknown>",
586-
"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.",
579+
"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.",
587580
"optional": true
588581
}
589582
],

apps/website/content/docs/render/api/define-angular-registry.mdx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,25 @@ Use the object form to configure a custom per-entry fallback (see [Per-Component
3535

3636
### Returns
3737

38-
An `AngularRegistry` object with two methods:
38+
An `AngularRegistry` object with a single entry accessor:
3939

4040
```typescript
4141
interface AngularRegistry {
42-
get(name: string): AngularComponentRenderer | undefined;
43-
getFallback(name: string): AngularComponentRenderer | undefined;
42+
getEntry(name: string): NormalizedEntry | undefined;
4443
names(): string[];
4544
}
45+
46+
interface NormalizedEntry {
47+
component: Type<unknown>;
48+
fallback: Type<unknown>;
49+
schema?: StandardSchemaV1;
50+
description?: string;
51+
}
4652
```
4753

4854
| Method | Description |
4955
|--------|-------------|
50-
| `get(name)` | Returns the component class for the given type name, or `undefined` if not registered |
51-
| `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. |
56+
| `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. |
5257
| `names()` | Returns an array of all registered type name strings |
5358

5459
## Usage
@@ -65,10 +70,10 @@ const registry = defineAngularRegistry({
6570
Card: CardComponent,
6671
});
6772

68-
registry.get('Text'); // TextComponent
69-
registry.get('Card'); // CardComponent
70-
registry.get('Missing'); // undefined
71-
registry.names(); // ['Text', 'Card']
73+
registry.getEntry('Text')?.component; // TextComponent
74+
registry.getEntry('Card')?.component; // CardComponent
75+
registry.getEntry('Missing'); // undefined
76+
registry.names(); // ['Text', 'Card']
7277
```
7378

7479
### With provideRender()
@@ -118,16 +123,16 @@ const registry = defineAngularRegistry({
118123
Card: { component: CardComponent, fallback: CardSkeletonComponent },
119124
});
120125

121-
registry.getFallback('Card'); // CardSkeletonComponent (the configured fallback)
122-
registry.getFallback('Text'); // DefaultFallbackComponent (entry omits one)
123-
registry.getFallback('Missing'); // undefined (not registered)
126+
registry.getEntry('Card')?.fallback; // CardSkeletonComponent (the configured fallback)
127+
registry.getEntry('Text')?.fallback; // DefaultFallbackComponent (entry omits one)
128+
registry.getEntry('Missing'); // undefined (not registered)
124129
```
125130

126131
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.
127132

128133
## Internal Behavior
129134

130-
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:
135+
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`:
131136

132137
```typescript
133138
function normalize(
@@ -137,10 +142,13 @@ function normalize(
137142
if (typeof entry === 'function') {
138143
return { component: entry, fallback: DefaultFallbackComponent };
139144
}
140-
// Object form — preserve component; use configured fallback or default.
145+
// Object form — preserve component; use configured fallback or default;
146+
// carry the optional schema/description through untouched.
141147
return {
142148
component: entry.component,
143149
fallback: entry.fallback ?? DefaultFallbackComponent,
150+
schema: entry.schema,
151+
description: entry.description,
144152
};
145153
}
146154

@@ -152,8 +160,7 @@ function defineAngularRegistry(
152160
map.set(name, normalize(entry));
153161
}
154162
return {
155-
get: (name: string) => map.get(name)?.component,
156-
getFallback: (name: string) => map.get(name)?.fallback,
163+
getEntry: (name: string) => map.get(name),
157164
names: () => [...map.keys()],
158165
};
159166
}

apps/website/content/docs/render/guides/registry.mdx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,16 @@ export const uiRegistry = defineAngularRegistry({
2121
});
2222
```
2323

24-
The returned `AngularRegistry` object has three methods:
24+
The returned `AngularRegistry` object has two methods:
2525

26-
- `get(name: string)` -- returns the component class for the given type name, or `undefined` if not registered
27-
- `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
26+
- `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.
2827
- `names()` -- returns an array of all registered type names
2928

3029
```typescript
31-
uiRegistry.get('Text'); // TextComponent
32-
uiRegistry.get('Unknown'); // undefined
33-
uiRegistry.getFallback('Text'); // fallback renderer (or default)
34-
uiRegistry.names(); // ['Text', 'Card', 'Button', 'Container']
30+
uiRegistry.getEntry('Text')?.component; // TextComponent
31+
uiRegistry.getEntry('Unknown'); // undefined
32+
uiRegistry.getEntry('Text')?.fallback; // fallback renderer (or default)
33+
uiRegistry.names(); // ['Text', 'Card', 'Button', 'Container']
3534
```
3635

3736
### Fallback Rendering
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Published-stack live audit — 2026-06-17
2+
3+
**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.
4+
5+
**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).
6+
7+
**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.
8+
9+
## Dimensions (per demo)
10+
- **Happy path** — the core real-user flows for each capability.
11+
- **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.
12+
- **Performance** — initial load, time-to-first-token, streaming smoothness, console errors/warnings, network shape, memory growth on repeated actions.
13+
- **Correctness** — state consistency (localStorage/panels), ask-card freeze, continuation after tool round-trips, citations, theme + mode (Embed/Popup/Sidebar) switching.
14+
15+
## Demos & run configs
16+
- **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).
17+
- **D2 `cockpit/ag-ui/client-tools`** — python `uvicorn src.server:app --port 5325` (PyPI); angular `:4325`. Same three tools over AG-UI.
18+
- **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.
19+
- **D4 `examples/chat`** (canonical chat) — `nx run examples-chat-python:serve` + `examples-chat-angular:serve`. Full chat capability set.
20+
21+
## Findings log
22+
(filled during execution)
23+
24+
### D1 — cockpit/langgraph/client-tools (npm 0.0.2)
25+
- 🟢 Happy: action (get_weather), view (weather_card), ask Confirm — all work; continuations stream.
26+
- 🟢 Ask **Cancel** → freezes to "Booking cancelled", model acknowledges, no error.
27+
- 🟢 Empty submit → no-op (correct).
28+
- 🟢 Ambiguous "weather" → model asks "Which location?" (graceful).
29+
- 🟢 Warm dev load fast (TTFB 15ms, interactive 37ms).
30+
- 🟠 **Backend down** → error surfaces but message is just **"HTTP 500:"** (empty body, unhelpful) and takes **~20s** to appear; spinner clears + input recovers afterward.
31+
- 🟠 **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).
32+
- 🟡 No thread restoration on page reload — conversation cleared.
33+
34+
### D2 — cockpit/ag-ui/client-tools (PyPI)
35+
- 🟢 View tool (weather_card) renders (Rome 78°F card) + continuation over the **AG-UI transport on the published PyPI `threadplane-middleware`** backend. Console clean.
36+
- 🟢 Published PyPI backend works end-to-end (validates the rename + PyPI publish).
37+
- (action/ask share the same framework path validated in D1; not re-run exhaustively.)
38+
- ⚪ 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.
39+
40+
### D3 — examples/ag-ui (itinerary canonical)
41+
- 🟢 Backend (examples/ag-ui/python) runs clean on **published PyPI `threadplane-middleware`** post-rename.
42+
- 🟢 `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.
43+
- 🔴 **`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**.
44+
- 🟡 Welcome shows only **1 of 7 capability chips** + "More prompts ▾" dropdown at 1483px — 6 capabilities hidden behind a dropdown (discoverability).
45+
- 🟡 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.
46+
47+
### D4 — examples/chat (canonical chat) — CLEANEST
48+
- 🟢 Gen-UI / **A2UI**: "render a contact form" → `render_a2ui_surface` → full form (Name/Email/Subject/Message + Send) renders clean. No errors.
49+
- 🟢 Basic streaming chat works; multi-turn in one thread.
50+
- 🟢 **Thread persistence + URL routing** (`/embed/<thread-id>`) + **auto-titling** ("Untitled" → "Contact form HTML example"). Full app shell (projects, search, recent, archived).
51+
- 🟢 **Zero console errors** across the whole session — notably better than the client-tools demos.
52+
53+
### Cross-cutting (perf, console, source-vs-published)
54+
- 🔴/🟠 **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.
55+
- 🟠 **Error UX**: backend-down surfaces a bare "HTTP 500:" after ~20s. Generic, slow, no retry affordance.
56+
- 🟡 **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.)
57+
- 🟡 **Capability discoverability**: itinerary welcome shows 1 of 7 chips + "More prompts" dropdown at desktop width.
58+
- 🟡 **Demo data hygiene**: itinerary panel persists stale localStorage test data; "Reset demo data" exists but isn't auto-applied.
59+
-**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).
60+
- ⚪ Harness: post-navigation programmatic typing flakes (Angular signal input) — test-tool artifact, not a product bug.
61+
62+
## Severity-ranked summary
63+
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.
64+
2. 🟠 **NG0953 — ask component emits after destroy** (D1). Framework: lifecycle/teardown of resolved ask components.
65+
3. 🟠 **Backend-failure UX** — bare "HTTP 500:", ~20s to surface, no retry (D1).
66+
4. 🟡 Inconsistent thread persistence across demos · capability-chip discoverability · stale demo localStorage.
67+
- ✅ All core capabilities function (client tools action/view/ask, gen-UI/A2UI, streaming, threads, modes); published backends validated; chat demo is pristine.

0 commit comments

Comments
 (0)