diff --git a/apps/website/content/docs/render/api/api-docs.json b/apps/website/content/docs/render/api/api-docs.json index 35e8c0965..88c267f7e 100644 --- a/apps/website/content/docs/render/api/api-docs.json +++ b/apps/website/content/docs/render/api/api-docs.json @@ -42,7 +42,7 @@ { "name": "filteredRepeatInputs", "type": "Signal[]>", - "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 }, { @@ -78,7 +78,7 @@ { "name": "repeatInjectors", "type": "Signal[]>", - "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 }, { @@ -87,6 +87,18 @@ "description": "Resolved inputs for each repeat item.", "optional": false }, + { + "name": "repeatMountClasses", + "type": "Signal", + "description": "Per-item counterpart of mountClass.", + "optional": false + }, + { + "name": "repeatNotReady", + "type": "Signal", + "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", @@ -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": [ { diff --git a/apps/website/content/docs/render/guides/registry.mdx b/apps/website/content/docs/render/guides/registry.mdx index 616eedd41..04af82dd4 100644 --- a/apps/website/content/docs/render/guides/registry.mdx +++ b/apps/website/content/docs/render/guides/registry.mdx @@ -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 @@ -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'; diff --git a/apps/website/content/docs/render/guides/repeat-loops.mdx b/apps/website/content/docs/render/guides/repeat-loops.mdx index c162bb544..0a0c70cc8 100644 --- a/apps/website/content/docs/render/guides/repeat-loops.mdx +++ b/apps/website/content/docs/render/guides/repeat-loops.mdx @@ -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. -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. ## How it is built @@ -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: diff --git a/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts index 5f1bd4831..6a613bda6 100644 --- a/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts +++ b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts @@ -353,7 +353,7 @@ class DemoCardComponent {
@for (item of getItems(); track $index) { -
{{ item }}
+
{{ item.label }}
}

Mutates the /items array in the state store.

@@ -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 diff --git a/cockpit/render/repeat-loops/angular/src/app/specs.ts b/cockpit/render/repeat-loops/angular/src/app/specs.ts index 35e755770..5ae66dc92 100644 --- a/cockpit/render/repeat-loops/angular/src/app/specs.ts +++ b/cockpit/render/repeat-loops/angular/src/app/specs.ts @@ -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), diff --git a/libs/render/src/lib/contexts/render-host.ts b/libs/render/src/lib/contexts/render-host.ts index 66f2491d5..ad288a85f 100644 --- a/libs/render/src/lib/contexts/render-host.ts +++ b/libs/render/src/lib/contexts/render-host.ts @@ -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. */ diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts index c0569364c..2493357cf 100644 --- a/libs/render/src/lib/render-element.component.ts +++ b/libs/render/src/lib/render-element.component.ts @@ -130,7 +130,11 @@ function preventDefaultOn(payload: unknown): void { @for (repeatInjector of repeatInjectors(); track $index) { @if (repeatVisible()[$index]) { } } @@ -160,7 +164,12 @@ export class RenderElementComponent implements OnInit { constructor() { this.destroyRef.onDestroy(() => this.development.dispose()); afterEveryRender(() => { - if (this.destroyed || !this.visible() || this.notReady()) return; + // Evidence is the mounted outlet itself: an element that is hidden, + // unready, unknown or repeating over an empty array either renders no + // outlet at all or renders the fallback, and the identity check below + // rejects both. That holds per repeat item too, where a single + // element-level readiness flag never could. + if (this.destroyed) return; const component = this.componentClass(); if (!component) return; for (const outlet of this.outlets()) { @@ -191,12 +200,25 @@ export class RenderElementComponent implements OnInit { effect(() => { if (this.mountedReal()) return; const el = this.element(); - if (!el) return; + if (!el || el.repeat) return; // Only latch when notReady is false AND a real component is registered. if (!this.notReady() && this.entry()?.component) { this.mountedReal.set(true); } }); + + // The same latch, one flag per repeat index. Indexes are the identity the + // `@for` block tracks, so a component instance and its latch line up. + effect(() => { + const el = this.element(); + if (!el?.repeat || !this.entry()?.component) return; + const raw = this.repeatRawNotReady(); + const latched = this.repeatMountedReal(); + const next = raw.map((notReady, index) => (latched[index] ?? false) || !notReady); + if (next.length !== latched.length || next.some((v, i) => v !== latched[i])) { + this.repeatMountedReal.set(next); + } + }); } ngOnInit(): void { @@ -282,8 +304,15 @@ export class RenderElementComponent implements OnInit { /** Invokes the element's `on[event]` handler bindings, honoring every field * of `ActionBinding`: `preventDefault`, `confirm`, `params` (resolved * through the same expression resolver the element props use) and the - * `onSuccess` / `onError` follow-ups. */ - private invokeHandlers(event: string, payload?: Record): void { + * `onSuccess` / `onError` follow-ups. + * + * `repeatIndex` names which repeated instance fired, so `{ $item: … }` + * params resolve in that row's scope rather than the parent's. */ + private invokeHandlers( + event: string, + payload?: Record, + repeatIndex?: number, + ): void { const el = this.element(); if (!el?.on) return; const binding = el.on[event]; @@ -301,7 +330,9 @@ export class RenderElementComponent implements OnInit { // exactly as an element prop would. The payload wins on key collisions. const resolved = resolveElementProps( (b.params ?? {}) as Record, - this.propCtx(), + repeatIndex === undefined + ? this.propCtx() + : this.repeatPropCtxs()[repeatIndex] ?? this.propCtx(), ); const params = { ...resolved, ...(payload ?? {}) }; @@ -385,6 +416,19 @@ export class RenderElementComponent implements OnInit { result: (value: unknown) => { if (this.destroyed) return; this.ctx.emitEvent?.({ type: 'result', value, elementKey: this.elementKey() }); }, }; + /** The same host, bound to one repeated instance so `host.emit(…)` resolves + * `$item` action params in that row's scope. */ + private hostForRepeatIndex(index: number): RenderHost { + return { + set: this.host.set, + result: this.host.result, + emit: (event: string, payload?: Record) => { + if (this.destroyed) return; + this.invokeHandlers(event, payload, index); + }, + }; + } + /** Emit function passed to mounted view components as the `emit` framework * input. Delegates to the element's `on[event]` handler bindings. */ private readonly emitFn = (event: string) => { @@ -437,32 +481,77 @@ export class RenderElementComponent implements OnInit { } satisfies RepeatScope)); }); - /** One child Injector per repeat item, providing RepeatScope. */ + /** One prop-resolution context per repeat item. Every per-item derivation — + * props, bindings, readiness, visibility, action params — resolves through + * these, so `{ $item: … }`, `{ $index: … }` and `{ $bindItem: … }` see the + * row they belong to instead of the parent scope. */ + private readonly repeatPropCtxs = computed(() => + this.repeatScopes().map(scope => + buildPropResolutionContext(this.ctx.store, scope, this.ctx.functions), + ), + ); + + /** One child Injector per repeat item, providing RepeatScope and a + * row-scoped RenderHost (so `injectRenderHost().emit(…)` carries the row). */ readonly repeatInjectors = computed(() => { - return this.repeatScopes().map(scope => + return this.repeatScopes().map((scope, index) => Injector.create({ - providers: [{ provide: REPEAT_SCOPE, useValue: scope }], + providers: [ + { provide: REPEAT_SCOPE, useValue: scope }, + { provide: RENDER_HOST, useValue: this.hostForRepeatIndex(index) }, + ], parent: this.parentInjector, }), ); }); + /** Per-index latch behind {@link repeatNotReady}; written by a constructor + * effect because Angular forbids signal writes inside a computed. */ + private readonly repeatMountedReal = signal([]); + + /** Per-item readiness before the mounted latch is applied. */ + private readonly repeatRawNotReady = computed(() => { + const el = this.element(); + if (!el?.repeat) return []; + const props = el.props; + if (!props) return this.repeatPropCtxs().map(() => false); + const entry = this.entry(); + return this.repeatPropCtxs().map( + ctx => !isElementReady(entry, resolveElementProps(props, ctx)), + ); + }); + + /** Per-item counterpart of {@link notReady}: a row whose `$item`-bound props + * have not resolved yet shows the fallback while its ready siblings mount + * the real component. Latched per index, exactly as the single mount is. */ + readonly repeatNotReady = computed(() => { + const latched = this.repeatMountedReal(); + return this.repeatRawNotReady().map((notReady, index) => + latched[index] ? false : notReady, + ); + }); + + /** Per-item counterpart of {@link mountClass}. */ + readonly repeatMountClasses = computed<(AngularComponentRenderer | null)[]>(() => { + const el = this.element(); + if (!el?.repeat) return []; + const entry = this.entry(); + const real = entry?.component ?? null; + const fallback = entry?.fallback ?? null; + return this.repeatNotReady().map(notReady => (notReady ? fallback : real)); + }); + /** Resolved inputs for each repeat item. */ readonly repeatInputs = computed(() => { const el = this.element(); if (!el?.repeat) return []; - return this.repeatScopes().map(scope => { - const ctx = buildPropResolutionContext( - this.ctx.store, - scope, - this.ctx.functions, - ); + return this.repeatPropCtxs().map((ctx, index) => { const resolved = resolveElementProps(el.props ?? {}, ctx); const bindings = resolveBindings(el.props ?? {}, ctx); return { ...resolved, bindings, - emit: this.emitFn, + emit: (event: string) => this.invokeHandlers(event, undefined, index), loading: this.ctx.loading ?? false, childKeys: el.children ?? [], spec: this.spec(), @@ -470,10 +559,14 @@ export class RenderElementComponent implements OnInit { }); }); - /** `repeatInputs` filtered per-item to declared component inputs. */ + /** `repeatInputs` filtered per-item to declared component inputs — against + * that item's own mount class, which may be the fallback while a sibling + * already shows the real component. */ readonly filteredRepeatInputs = computed(() => { - const cls = this.mountClass() as Type | null; - return this.repeatInputs().map(inputs => filterInputsForClass(cls, inputs)); + const classes = this.repeatMountClasses(); + return this.repeatInputs().map((inputs, index) => + filterInputsForClass(classes[index] as Type | null, inputs), + ); }); /** Per-item visibility for repeat elements. The element's own `visible` @@ -483,12 +576,9 @@ export class RenderElementComponent implements OnInit { readonly repeatVisible = computed(() => { const el = this.element(); if (!el?.repeat) return []; - if (this.mountClass() === null) return this.repeatScopes().map(() => false); - return this.repeatScopes().map(scope => - evaluateVisibility( - el.visible, - buildPropResolutionContext(this.ctx.store, scope, this.ctx.functions), - ), + const classes = this.repeatMountClasses(); + return this.repeatPropCtxs().map( + (ctx, index) => classes[index] !== null && evaluateVisibility(el.visible, ctx), ); }); } diff --git a/libs/render/src/lib/repeat-scope.spec.ts b/libs/render/src/lib/repeat-scope.spec.ts new file mode 100644 index 000000000..9b9caf60a --- /dev/null +++ b/libs/render/src/lib/repeat-scope.spec.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; +import { Component, inject, input } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { z } from 'zod/v4'; +import type { Spec } from '@json-render/core'; + +import { RenderElementComponent } from './render-element.component'; +import { RENDER_CONTEXT } from './contexts/render-context'; +import { defineAngularRegistry } from './define-angular-registry'; +import { signalStateStore } from './signal-state-store'; +import { injectRenderHost } from './contexts/render-host'; +import type { AngularRegistry } from './render.types'; + +/** Renders a plain `content` prop, so a repeated element only shows text when + * its `$item`-bound props resolved in that item's own repeat scope. */ +@Component({ + selector: 'render-test-item-text', + standalone: true, + template: '

{{ content() }}

', +}) +class ItemTextComponent { + readonly content = input(''); +} + +/** Distinguishable fallback so a per-item readiness gate is observable. */ +@Component({ + selector: 'render-test-item-skeleton', + standalone: true, + template: '

loading

', +}) +class ItemSkeletonComponent {} + +/** Fires `emit('click')` — the framework `emit` input path. */ +@Component({ + selector: 'render-test-item-clicker', + standalone: true, + template: '', +}) +class ItemClickerComponent { + readonly emit = input<(event: string) => void>(() => undefined); +} + +/** Fires `host.emit('click')` — the `injectRenderHost()` path. */ +@Component({ + selector: 'render-test-item-host-clicker', + standalone: true, + template: '', +}) +class ItemHostClickerComponent { + readonly host = injectRenderHost(); +} + +function host(spec: Spec) { + @Component({ + standalone: true, + imports: [RenderElementComponent], + template: ``, + }) + class Host { + readonly spec = spec; + } + return Host; +} + +function mount( + spec: Spec, + options: { + items: unknown[]; + registry: AngularRegistry; + handlers?: Record) => unknown>; + }, +) { + const store = signalStateStore({ items: options.items }); + const calls: { action: string; params: Record }[] = []; + const handlers: Record) => unknown> = {}; + for (const name of Object.keys(options.handlers ?? {})) { + handlers[name] = (params: Record) => { + calls.push({ action: name, params }); + return options.handlers?.[name]?.(params); + }; + } + const HostCmp = host(spec); + TestBed.configureTestingModule({ + imports: [HostCmp], + providers: [ + { + provide: RENDER_CONTEXT, + useValue: { store, registry: options.registry, functions: {}, handlers }, + }, + ], + }); + const fixture = TestBed.createComponent(HostCmp); + fixture.detectChanges(); + const root = fixture.nativeElement as HTMLElement; + return { + fixture, + store, + calls, + text: (selector = '.item') => + [...root.querySelectorAll(selector)].map((n) => n.textContent?.trim()), + clickRow: (index: number) => { + root.querySelectorAll('.row-button')[index].click(); + fixture.detectChanges(); + }, + }; +} + +function repeatSpec(element: Record): Spec { + return { + root: 'rows', + elements: { rows: { repeat: { statePath: '/items' }, ...element } }, + } as unknown as Spec; +} + +describe('RenderElementComponent — repeat scope drives readiness and props', () => { + it('renders each item of an object array through an $item-bound prop', () => { + const h = mount( + repeatSpec({ type: 'text', props: { content: { $item: 'label' } } }), + { + items: [{ label: 'Alpha' }, { label: 'Beta' }, { label: 'Gamma' }], + registry: defineAngularRegistry({ text: ItemTextComponent }), + }, + ); + expect(h.text()).toEqual(['Alpha', 'Beta', 'Gamma']); + }); + + it('renders each item of a string array through a whole-item $item prop', () => { + const h = mount(repeatSpec({ type: 'text', props: { content: { $item: '' } } }), { + items: ['Alpha', 'Beta'], + registry: defineAngularRegistry({ text: ItemTextComponent }), + }); + expect(h.text()).toEqual(['Alpha', 'Beta']); + }); + + it('resolves $bindItem props through each instance basePath', () => { + const h = mount( + repeatSpec({ type: 'text', props: { content: { $bindItem: 'label' } } }), + { + items: [{ label: 'Alpha' }, { label: 'Beta' }], + registry: defineAngularRegistry({ text: ItemTextComponent }), + }, + ); + expect(h.text()).toEqual(['Alpha', 'Beta']); + }); + + it('shows the fallback only for the items whose props are genuinely undefined', () => { + const h = mount( + repeatSpec({ type: 'text', props: { content: { $item: 'label' } } }), + { + items: [{ label: 'Alpha' }, {}, { label: 'Gamma' }], + registry: defineAngularRegistry({ + text: { component: ItemTextComponent, fallback: ItemSkeletonComponent }, + }), + }, + ); + expect(h.text()).toEqual(['Alpha', 'Gamma']); + expect(h.text('.skeleton')).toEqual(['loading']); + }); + + it('runs a registry Standard Schema per repeated item', () => { + const h = mount( + repeatSpec({ type: 'text', props: { content: { $item: 'label' } } }), + { + items: [{ label: 'Alpha' }, { label: 42 }, { label: 'Gamma' }], + registry: defineAngularRegistry({ + text: { + component: ItemTextComponent, + fallback: ItemSkeletonComponent, + schema: z.object({ content: z.string() }), + }, + }), + }, + ); + expect(h.text()).toEqual(['Alpha', 'Gamma']); + expect(h.text('.skeleton')).toEqual(['loading']); + }); + + it('keeps the per-item visible condition working alongside $item-bound props', () => { + const h = mount( + repeatSpec({ + type: 'text', + props: { content: { $item: 'label' } }, + visible: { $item: 'active', eq: true }, + }), + { + items: [ + { label: 'Alpha', active: true }, + { label: 'Beta', active: false }, + { label: 'Gamma', active: true }, + ], + registry: defineAngularRegistry({ text: ItemTextComponent }), + }, + ); + expect(h.text()).toEqual(['Alpha', 'Gamma']); + }); + + it('resolves $item-bound action params in the clicked item scope', () => { + const h = mount( + repeatSpec({ + type: 'clicker', + props: {}, + on: { click: { action: 'pick', params: { id: { $item: 'id' } } } }, + }), + { + items: [{ id: 'a' }, { id: 'b' }], + registry: defineAngularRegistry({ clicker: ItemClickerComponent }), + handlers: { pick: () => undefined }, + }, + ); + h.clickRow(1); + expect(h.calls).toEqual([{ action: 'pick', params: { id: 'b' } }]); + }); + + it('resolves $item-bound action params for injectRenderHost() emitters', () => { + const h = mount( + repeatSpec({ + type: 'clicker', + props: {}, + on: { click: { action: 'pick', params: { id: { $item: 'id' } } } }, + }), + { + items: [{ id: 'a' }, { id: 'b' }], + registry: defineAngularRegistry({ clicker: ItemHostClickerComponent }), + handlers: { pick: () => undefined }, + }, + ); + h.clickRow(0); + expect(h.calls).toEqual([{ action: 'pick', params: { id: 'a' } }]); + }); +});