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
7 changes: 7 additions & 0 deletions packages/core/src/headless/resolve-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
*
* @changes
* - 2026-08-25: created (headless core, wave 1).
* - 2026-09-27: nodes resolved inside `each` carry their loop variables as
* `loop`, so handlers dispatched later see the same `item` / `index` the
* props were resolved with.
*/

import type { UINode } from '../schema/ui-spec.js';
Expand Down Expand Up @@ -216,6 +219,10 @@ function resolveNode(node: UINode, ctx: ResolveContext): ResolvedNode[] {
const events = collectEvents(node);
if (events) resolved.events = events;

// 6. Loop scope, kept for handlers. Events stay raw specs until dispatch,
// and by then the loop that produced this node is gone.
if (ctx.loop && Object.keys(ctx.loop).length > 0) resolved.loop = ctx.loop;

return [resolved];
}

Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/headless/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,37 @@ describe('RippleHeadless', () => {
expect(rt.state.get('done')).toBe(1);
});

describe('handlers inside each', () => {
const listSpec = node({
type: 'each',
items: '{state.rows}',
item_as: 'row',
index_as: 'i',
children: [
node({
type: 'button',
on_click: { action: 'set', target: 'picked', value: '{row.id}:{i}' }
})
]
} as Partial<UINode>);

it('resolves loop variables when the handler runs', async () => {
const rt = createHeadlessRuntime({
spec: listSpec,
state: { rows: [{ id: 'a' }, { id: 'b' }], picked: null }
});
// Two copies: the second is the discriminating one. With a single row a
// lost loop context could still look right by accident.
await rt.dispatch(rt.tree.nodes[1], 'onclick');
expect(rt.state.get('picked')).toBe('b:1');
});

it('carries loop variables only on nodes that were inside a loop', () => {
const rt = createHeadlessRuntime({ spec: counterSpec, state: { count: 0 } });
expect(rt.findById('inc')!.loop).toBeUndefined();
});
});

it('is a no-op when the node has no handler for the event', async () => {
const rt = createHeadlessRuntime({ spec: node({ type: 'text', id: 't' }), state: {} });
await expect(rt.dispatch(rt.findById('t')!, 'onclick')).resolves.toBeUndefined();
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/headless/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
*
* @changes
* - 2026-08-25: created (headless core, wave 1).
* - 2026-09-27: `dispatch` resolves handlers with the node's loop variables,
* so an action inside `each` reads its own row. It used to see state and
* data only, and `{item.id}` resolved to nothing.
*/

import type { UINode } from '../schema/ui-spec.js';
Expand Down Expand Up @@ -146,14 +149,21 @@ export class RippleHeadless {
}
const handler = node.events?.[event];
if (!handler) return;
await this.dispatchHandler(handler, value);
await this.dispatchHandler(handler, value, node.loop);
}

/** Run a handler spec directly, outside any node. */
async dispatchHandler(handler: EventHandlerOrArray, value?: unknown): Promise<void> {
/**
* Run a handler spec directly, outside any node. `loop` layers loop
* variables (`item`, `index`, ...) into the resolver context.
*/
async dispatchHandler(
handler: EventHandlerOrArray,
value?: unknown,
loop?: Record<string, unknown>
): Promise<void> {
await this.dispatcher.dispatch(
handler,
{ state: this.state.state, data: this.data },
{ state: this.state.state, data: this.data, ...(loop ?? {}) },
value
);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/headless/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*
* @changes
* - 2026-08-25: created (headless core, wave 1).
* - 2026-09-27: `ResolvedNode.loop`, the loop variables a node was resolved
* under, so a handler inside `each` can still read `{item.x}` when it runs.
*/

import type { UINode } from '../schema/ui-spec.js';
Expand Down Expand Up @@ -55,6 +57,13 @@ export interface ResolvedNode {
* one against live state.
*/
events?: Record<string, EventHandlerOrArray>;
/**
* Loop variables in scope when this node was resolved (`item`, `index`,
* or custom `item_as` / `index_as` names). Present only for nodes inside an
* `each`. `dispatch` layers them back in so `{item.id}` in a handler reads
* the row it was rendered for, as it does in NodeRenderer.
*/
loop?: Record<string, unknown>;
/** The spec node this was resolved from. Useful for editors and debugging. */
source?: UINode;
}
Expand Down
Loading