Skip to content

Commit 34a1215

Browse files
bloveclaude
andcommitted
fix(render): resolve repeated element readiness and props in the row's own scope
A repeating element resolved its mount-readiness gate and its mount class from the NON-repeat prop context: `notReady` and `mountClass` ran `resolveElementProps` against the parent scope, where every `$item`, `$index` and `$bindItem` expression resolves to `undefined`. An element whose props were all `$item`-bound therefore tripped the readiness gate and rendered its fallback (or nothing) for every row, forever. The streaming demo hid this because a partially parsed `props: {}` latches `mountedReal` before the `$item` keys arrive. Readiness, mount class, input filtering, visibility and action `params` are now all derived from one per-row prop-resolution context, and the fallback latch is kept per row index — the identity the `@for` block already tracks. A row still shows its fallback while its own props are genuinely undefined or fail the entry's Standard Schema, while its ready siblings mount the real component. Each row also gets its own `RENDER_HOST`, so a component that emits through `injectRenderHost()` inside a repeat resolves `{ $item: … }` action params against the row that fired the event, matching the `emit` input path. The mount-evidence telemetry guard now relies on the outlet identity check alone: an element-level `visible`/`notReady` pair cannot describe a list whose rows differ. The repeat-loops example holds objects and binds `{ $item: 'label' }`, which the fix makes renderable; docs updated for per-row readiness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2cba7b4 commit 34a1215

8 files changed

Lines changed: 396 additions & 40 deletions

File tree

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
{
4343
"name": "filteredRepeatInputs",
4444
"type": "Signal<Record<string, unknown>[]>",
45-
"description": "`repeatInputs` filtered per-item to declared component inputs.",
45+
"description": "`repeatInputs` filtered per-item to declared component inputs — against\n that item's own mount class, which may be the fallback while a sibling\n already shows the real component.",
4646
"optional": false
4747
},
4848
{
@@ -78,7 +78,7 @@
7878
{
7979
"name": "repeatInjectors",
8080
"type": "Signal<DestroyableInjector<>[]>",
81-
"description": "One child Injector per repeat item, providing RepeatScope.",
81+
"description": "One child Injector per repeat item, providing RepeatScope and a\n row-scoped RenderHost (so `injectRenderHost().emit(…)` carries the row).",
8282
"optional": false
8383
},
8484
{
@@ -87,6 +87,18 @@
8787
"description": "Resolved inputs for each repeat item.",
8888
"optional": false
8989
},
90+
{
91+
"name": "repeatMountClasses",
92+
"type": "Signal<AngularComponentRenderer | null[]>",
93+
"description": "Per-item counterpart of mountClass.",
94+
"optional": false
95+
},
96+
{
97+
"name": "repeatNotReady",
98+
"type": "Signal<boolean[]>",
99+
"description": "Per-item counterpart of notReady: a row whose `$item`-bound props\n have not resolved yet shows the fallback while its ready siblings mount\n the real component. Latched per index, exactly as the single mount is.",
100+
"optional": false
101+
},
90102
{
91103
"name": "repeatVisible",
92104
"type": "Signal<boolean[]>",
@@ -377,7 +389,7 @@
377389
{
378390
"name": "RenderHost",
379391
"kind": "interface",
380-
"description": "The element-scoped host a mounted view component talks back through.\nAgent-agnostic: `result(value)` just means \"this component produced a\nvalue\"; the render lib surfaces it as a RenderResultEvent and never\ninterprets it. Provided per-element by RenderElementComponent.",
392+
"description": "The element-scoped host a mounted view component talks back through.\nAgent-agnostic: `result(value)` just means \"this component produced a\nvalue\"; the render lib surfaces it as a RenderResultEvent and never\ninterprets it. Provided per-element by RenderElementComponent, and once\nper row for a repeating element, so `emit` carries the row that fired it\nand `{ $item: … }` action params resolve in that row's scope.",
381393
"properties": [],
382394
"methods": [
383395
{

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ registry.names(); // ['Text', 'Card']
9494

9595
### Fallbacks and unregistered types
9696

97-
An element whose type is not registered has no entry at all, so nothing mounts and the element renders nothing. When the type is registered, the entry's fallback fills the gap while a prop is still resolving to `undefined`, or while a declared `schema` does not yet validate the resolved props. Once the real component mounts it stays mounted: the switch is one-way per element instance, so a prop that later becomes undefined never reverts the element to its fallback.
97+
An element whose type is not registered has no entry at all, so nothing mounts and the element renders nothing. When the type is registered, the entry's fallback fills the gap while a prop is still resolving to `undefined`, or while a declared `schema` does not yet validate the resolved props. Once the real component mounts it stays mounted: the switch is one-way per element instance, so a prop that later becomes undefined never reverts the element to its fallback. A repeating element is gated one row at a time — each mount is judged on the props it resolved in its own item scope, so a row that is still missing a value shows the fallback while its ready siblings show the real component.
9898

9999
## The component input contract
100100

@@ -146,7 +146,7 @@ the component receives `value` resolved to the stored value and `bindings` set t
146146

147147
## Talking back through the render host
148148

149-
`injectRenderHost()` gives a mounted component the element-scoped host, which is the supported way to write state, fire events, and announce a result.
149+
`injectRenderHost()` gives a mounted component the element-scoped host, which is the supported way to write state, fire events, and announce a result. Inside a repeat the host is scoped to the row, so an event it fires resolves `{ "$item": … }` action params against that row's item.
150150

151151
```ts
152152
import { Component, input } from '@angular/core';

apps/website/content/docs/render/guides/repeat-loops.mdx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ The Run tab shows a split surface. The rendered output is on the left, the JSON
1313
Below the JSON is a List Controls panel over the store. **Add Item** appends a new entry to the `/items` array and the small cross beside each row removes it, and because Simple List binds a repeating element to `/items`, the rendered surface on the left gains or loses a row as you do. The other two specs, Task List and Sections, name their rows as explicit children with literal text, which is the enumerated form a repeat replaces.
1414

1515
<Callout type="info" title="Two shapes, side by side">
16-
Simple List is the repeat form: one `Text` element with `repeat: { statePath: '/items' }` under a `Heading`, so adding an item adds a rendered row. Task List and Sections are the enumerated form the repeat collapses, so their rows do not move when the array does. The store holds plain strings rather than objects, which is why the repeated element binds `content` to `{ "$item": "" }` — the whole item — rather than to a field on it.
16+
Simple List is the repeat form: one `Text` element with `repeat: { statePath: '/items' }` under a `Heading`, so adding an item adds a rendered row. Task List and Sections are the enumerated form the repeat collapses, so their rows do not move when the array does. The store holds objects, so the repeated element binds `content` to `{ "$item": "label" }`a field on the current item. Binding to `{ "$item": "" }` would hand the component the whole item instead.
1717
</Callout>
1818

1919
## How it is built
@@ -135,6 +135,25 @@ A `visible` condition on the element that carries `repeat` is evaluated once per
135135

136136
Only the entries whose condition holds are mounted; the rest leave no markup behind. Filtering the array in the state model before it reaches the repeat path remains an option, and is the better one when the hidden rows should not be in the model at all.
137137

138+
## Per-item readiness
139+
140+
Each mount is gated on its own resolved props, not on the element's. A row whose `$item`-bound props have not arrived yet shows the registry entry's fallback while its ready siblings show the real component, and a registry `schema` is validated once per row against that row's props. The switch from fallback to real component is one-way per row, so a value that later becomes `undefined` never sends a mounted row back to its fallback.
141+
142+
Action params follow the same rule: `{ "$item": … }` inside an `ActionBinding`'s `params` resolves in the scope of the row whose component emitted the event, whether the component fires through the `emit` input or through `injectRenderHost()`.
143+
144+
```json
145+
{
146+
"row": {
147+
"type": "Button",
148+
"props": { "label": { "$item": "title" } },
149+
"repeat": { "statePath": "/tasks" },
150+
"on": { "click": { "action": "openTask", "params": { "id": { "$item": "id" } } } }
151+
}
152+
}
153+
```
154+
155+
Clicking the second row calls `handlers.openTask({ id: /* the second task's id */ })`.
156+
138157
## Item scope in Angular
139158

140159
Each repeated mount gets its own child injector carrying a `RepeatScope`, which a registered component can inject when it needs the raw iteration context rather than resolved props:

cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ class DemoCardComponent {
353353
<button class="control-btn" type="button" style="width:100%" (click)="addItem()">+ Add Item</button>
354354
<div style="margin-top:0.6rem">
355355
@for (item of getItems(); track $index) {
356-
<div class="list-row"><span>{{ item }}</span><button class="list-row__remove" type="button" (click)="removeItem($index)">×</button></div>
356+
<div class="list-row"><span>{{ item.label }}</span><button class="list-row__remove" type="button" (click)="removeItem($index)">×</button></div>
357357
}
358358
</div>
359359
<p class="control-hint">Mutates the <code>/items</code> array in the state store.</p>
@@ -395,25 +395,27 @@ export class RepeatLoopsComponent implements OnDestroy {
395395
Card: DemoCardComponent,
396396
});
397397

398-
protected readonly store = signalStateStore({ items: ['Alpha', 'Beta', 'Gamma'] });
398+
protected readonly store = signalStateStore({
399+
items: [{ label: 'Alpha' }, { label: 'Beta' }, { label: 'Gamma' }],
400+
});
399401
// #endregion
400402

401403
// #region list-state
402404
private counter = 0;
403405

404-
protected getItems(): string[] {
405-
return (this.store.get('/items') as string[]) ?? [];
406+
protected getItems(): { label: string }[] {
407+
return (this.store.get('/items') as { label: string }[]) ?? [];
406408
}
407409

408410
protected addItem(): void {
409411
this.counter++;
410412
const items = this.getItems();
411-
this.store.set('/items', [...items, `Item ${this.counter}`]);
413+
this.store.set('/items', [...items, { label: `Item ${this.counter}` }]);
412414
}
413415

414416
protected removeItem(index: number): void {
415417
const items = this.getItems();
416-
this.store.set('/items', items.filter((_: string, i: number) => i !== index));
418+
this.store.set('/items', items.filter((_: { label: string }, i: number) => i !== index));
417419
}
418420
// #endregion
419421

cockpit/render/repeat-loops/angular/src/app/specs.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ export const REPEAT_LOOPS_SPECS: DemoSpec[] = [
1212
children: ['row'],
1313
},
1414
// One element declaration, one rendered row per entry in /items.
15-
// `{ $item: '' }` resolves to the whole item, which here is a string.
15+
// `{ $item: 'label' }` reads the `label` field of the current item;
16+
// `{ $item: '' }` would resolve to the whole item instead.
1617
row: {
1718
type: 'Text',
1819
repeat: { statePath: '/items' },
19-
props: { content: { $item: '' } },
20+
props: { content: { $item: 'label' } },
2021
},
2122
},
2223
}, null, 2),

libs/render/src/lib/contexts/render-host.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { InjectionToken, inject } from '@angular/core';
44
* The element-scoped host a mounted view component talks back through.
55
* Agent-agnostic: `result(value)` just means "this component produced a
66
* value"; the render lib surfaces it as a RenderResultEvent and never
7-
* interprets it. Provided per-element by RenderElementComponent.
7+
* interprets it. Provided per-element by RenderElementComponent, and once
8+
* per row for a repeating element, so `emit` carries the row that fired it
9+
* and `{ $item: … }` action params resolve in that row's scope.
810
*/
911
export interface RenderHost {
1012
/** Write a value to the render state store at a JSON-Pointer path. */

0 commit comments

Comments
 (0)