Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions apps/website/content/docs/render/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
{
"name": "filteredRepeatInputs",
"type": "Signal<Record<string, unknown>[]>",
"description": "`repeatInputs` filtered per-item to declared component inputs.",
"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.",
"optional": false
},
{
Expand Down Expand Up @@ -78,7 +78,7 @@
{
"name": "repeatInjectors",
"type": "Signal<DestroyableInjector<>[]>",
"description": "One child Injector per repeat item, providing RepeatScope.",
"description": "One child Injector per repeat item, providing RepeatScope and a\n row-scoped RenderHost (so `injectRenderHost().emit(…)` carries the row).",
"optional": false
},
{
Expand All @@ -87,6 +87,18 @@
"description": "Resolved inputs for each repeat item.",
"optional": false
},
{
"name": "repeatMountClasses",
"type": "Signal<AngularComponentRenderer | null[]>",
"description": "Per-item counterpart of mountClass.",
"optional": false
},
{
"name": "repeatNotReady",
"type": "Signal<boolean[]>",
"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.",
"optional": false
},
{
"name": "repeatVisible",
"type": "Signal<boolean[]>",
Expand Down Expand Up @@ -377,7 +389,7 @@
{
"name": "RenderHost",
"kind": "interface",
"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.",
"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.",
"properties": [],
"methods": [
{
Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/render/guides/registry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ registry.names(); // ['Text', 'Card']

### Fallbacks and unregistered types

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

## The component input contract

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

## Talking back through the render host

`injectRenderHost()` gives a mounted component the element-scoped host, which is the supported way to write state, fire events, and announce a result.
`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.

```ts
import { Component, input } from '@angular/core';
Expand Down
21 changes: 20 additions & 1 deletion apps/website/content/docs/render/guides/repeat-loops.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The Run tab shows a split surface. The rendered output is on the left, the JSON
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.

<Callout type="info" title="Two shapes, side by side">
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.
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.
</Callout>

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

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.

## Per-item readiness

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.

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()`.

```json
{
"row": {
"type": "Button",
"props": { "label": { "$item": "title" } },
"repeat": { "statePath": "/tasks" },
"on": { "click": { "action": "openTask", "params": { "id": { "$item": "id" } } } }
}
}
```

Clicking the second row calls `handlers.openTask({ id: /* the second task's id */ })`.

## Item scope in Angular

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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ class DemoCardComponent {
<button class="control-btn" type="button" style="width:100%" (click)="addItem()">+ Add Item</button>
<div style="margin-top:0.6rem">
@for (item of getItems(); track $index) {
<div class="list-row"><span>{{ item }}</span><button class="list-row__remove" type="button" (click)="removeItem($index)">×</button></div>
<div class="list-row"><span>{{ item.label }}</span><button class="list-row__remove" type="button" (click)="removeItem($index)">×</button></div>
}
</div>
<p class="control-hint">Mutates the <code>/items</code> array in the state store.</p>
Expand Down Expand Up @@ -395,25 +395,27 @@ export class RepeatLoopsComponent implements OnDestroy {
Card: DemoCardComponent,
});

protected readonly store = signalStateStore({ items: ['Alpha', 'Beta', 'Gamma'] });
protected readonly store = signalStateStore({
items: [{ label: 'Alpha' }, { label: 'Beta' }, { label: 'Gamma' }],
});
// #endregion

// #region list-state
private counter = 0;

protected getItems(): string[] {
return (this.store.get('/items') as string[]) ?? [];
protected getItems(): { label: string }[] {
return (this.store.get('/items') as { label: string }[]) ?? [];
}

protected addItem(): void {
this.counter++;
const items = this.getItems();
this.store.set('/items', [...items, `Item ${this.counter}`]);
this.store.set('/items', [...items, { label: `Item ${this.counter}` }]);
}

protected removeItem(index: number): void {
const items = this.getItems();
this.store.set('/items', items.filter((_: string, i: number) => i !== index));
this.store.set('/items', items.filter((_: { label: string }, i: number) => i !== index));
}
// #endregion

Expand Down
5 changes: 3 additions & 2 deletions cockpit/render/repeat-loops/angular/src/app/specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ export const REPEAT_LOOPS_SPECS: DemoSpec[] = [
children: ['row'],
},
// One element declaration, one rendered row per entry in /items.
// `{ $item: '' }` resolves to the whole item, which here is a string.
// `{ $item: 'label' }` reads the `label` field of the current item;
// `{ $item: '' }` would resolve to the whole item instead.
row: {
type: 'Text',
repeat: { statePath: '/items' },
props: { content: { $item: '' } },
props: { content: { $item: 'label' } },
},
},
}, null, 2),
Expand Down
4 changes: 3 additions & 1 deletion libs/render/src/lib/contexts/render-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { InjectionToken, inject } from '@angular/core';
* The element-scoped host a mounted view component talks back through.
* Agent-agnostic: `result(value)` just means "this component produced a
* value"; the render lib surfaces it as a RenderResultEvent and never
* interprets it. Provided per-element by RenderElementComponent.
* interprets it. Provided per-element by RenderElementComponent, and once
* per row for a repeating element, so `emit` carries the row that fired it
* and `{ $item: … }` action params resolve in that row's scope.
*/
export interface RenderHost {
/** Write a value to the render state store at a JSON-Pointer path. */
Expand Down
Loading
Loading