Summary
Event handler bindings inside <for> loops cannot receive loop variables as arguments. The template parser discards everything between the parens of the handler call, and at runtime the handler is always invoked with the DOM Event as its sole argument — regardless of what was written in the binding expression.
This makes the natural pattern below silently broken:
<for each="item in items">
<button @click="{selectItem(item.id)}">{{item.name}}</button>
</for>
selectItem(id: string) {
// `id` is never the item id — it is the PointerEvent object.
}
Reduced repro
A passing Playwright fixture lives at packages/webui-framework/tests/fixtures/for-event-args/ (added in the same branch where this was diagnosed). Core pieces:
Template (src/test-for-event-args/test-for-event-args.html):
<for each="item in items">
<li>
<button class="loop-arg" @click="{onLoopArg(item.id)}">{{item.name}}</button>
</li>
</for>
<div class="last-loop-arg">{{lastLoopArg}}</div>
Component (element.ts):
onLoopArg(arg: unknown): void {
this.lastLoopArg = `arg=${String(arg)} typeof=${typeof arg} args.length=${arguments.length}`;
}
Spec assertion that passes (proving the bug):
// Click the second item (id="b").
await page.locator('.loop-arg').nth(1).click();
const result = await page.locator('.last-loop-arg').textContent();
// The loop variable value "b" is NOT received:
expect(result).not.toContain('arg=b');
// Instead, the handler always receives the Event object:
expect(result).toBe('arg=[object PointerEvent] typeof=object args.length=1');
Run it:
cd packages/webui-framework && pnpm test:e2e -- for-event-args
Root cause
crates/webui-parser/src/plugin/webui.rs — parse_event_attr (around L1690-L1741):
let paren = inner.find('(')?;
let handler_name = inner[..paren].to_string(); // everything BEFORE '('
let needs_event = inner.contains("(e)"); // literal-substring check only
Some((event_name, handler_name, needs_event, total_consumed))
The parser:
- Splits on
( and keeps only the handler identifier.
- Sets
needs_event based on a literal substring search for (e).
- Throws away every other character between the parens.
The runtime then calls handler(event) regardless of the binding's argument list.
Expected behavior
Event handler bindings should support passing loop variables (and other in-scope expressions) as arguments. Both of these should work and pass the expected values:
<button @click="{selectItem(item.id)}">…</button>
<button @click="{selectItem(item.id, e)}">…</button>
Behaviorally:
selectItem(item.id) → selectItem("b")
selectItem(item.id, e) → selectItem("b", PointerEvent)
selectItem(e) → selectItem(PointerEvent) (current behavior — keep)
selectItem() → selectItem() (no args — currently passes Event; this is a separate bug)
This brings event bindings in line with the rest of the template engine, where item.foo already resolves correctly inside attribute interpolation, boolean attributes, text content, and <if> conditions.
Workaround
Pass per-item data via data-* attributes and read e.currentTarget.dataset in the handler:
<for each="item in items">
<button data-id="{{item.id}}" @click="{onClick(e)}">{{item.name}}</button>
</for>
onClick(e: Event): void {
const id = (e.currentTarget as HTMLElement).dataset.id;
// …
}
This is verified by the second test in the same fixture (WORKAROUND: data-* attribute + e.currentTarget.dataset works).
Suggested implementation sketch
- In
parse_event_attr, parse the full argument list into AST expressions instead of discarding it. Store them alongside the handler name in the event metadata tuple.
- Extend the compiled template metadata
e slot from [eventName, handlerName, needsEvent] to [eventName, handlerName, argSpecs] where each argSpec describes how to resolve the value at dispatch time (literal, state path, loop variable, or the special e event).
- In the runtime event dispatcher (
packages/webui-framework/src/template.ts / element.ts), resolve each argSpec against the current scope chain (host state + active <for> scope captured at render time) and call the handler with the resolved arguments.
- Update
DESIGN.md to specify the new binding semantics, including how e is referenced and how loop scope is captured.
- Add fixtures: pass loop var, pass loop var + event, pass nested-loop vars, pass literals, pass host state.
Related
- Discovered while migrating the docs playground to a fully-declarative
<for>-driven tab bar (docs/.webui-docs/components/docs-playground/).
- The fixture
packages/webui-framework/tests/fixtures/for-event-args/ is intentionally written to pass against current behavior so it acts as a regression marker; once this issue is fixed, the assertion should be flipped to assert the loop variable is received.
Summary
Event handler bindings inside
<for>loops cannot receive loop variables as arguments. The template parser discards everything between the parens of the handler call, and at runtime the handler is always invoked with the DOMEventas its sole argument — regardless of what was written in the binding expression.This makes the natural pattern below silently broken:
Reduced repro
A passing Playwright fixture lives at
packages/webui-framework/tests/fixtures/for-event-args/(added in the same branch where this was diagnosed). Core pieces:Template (
src/test-for-event-args/test-for-event-args.html):Component (
element.ts):Spec assertion that passes (proving the bug):
Run it:
Root cause
crates/webui-parser/src/plugin/webui.rs—parse_event_attr(around L1690-L1741):The parser:
(and keeps only the handler identifier.needs_eventbased on a literal substring search for(e).The runtime then calls
handler(event)regardless of the binding's argument list.Expected behavior
Event handler bindings should support passing loop variables (and other in-scope expressions) as arguments. Both of these should work and pass the expected values:
Behaviorally:
selectItem(item.id)→selectItem("b")selectItem(item.id, e)→selectItem("b", PointerEvent)selectItem(e)→selectItem(PointerEvent)(current behavior — keep)selectItem()→selectItem()(no args — currently passes Event; this is a separate bug)This brings event bindings in line with the rest of the template engine, where
item.fooalready resolves correctly inside attribute interpolation, boolean attributes, text content, and<if>conditions.Workaround
Pass per-item data via
data-*attributes and reade.currentTarget.datasetin the handler:This is verified by the second test in the same fixture (
WORKAROUND: data-* attribute + e.currentTarget.dataset works).Suggested implementation sketch
parse_event_attr, parse the full argument list into AST expressions instead of discarding it. Store them alongside the handler name in the event metadata tuple.eslot from[eventName, handlerName, needsEvent]to[eventName, handlerName, argSpecs]where each argSpec describes how to resolve the value at dispatch time (literal, state path, loop variable, or the specialeevent).packages/webui-framework/src/template.ts/element.ts), resolve each argSpec against the current scope chain (host state + active<for>scope captured at render time) and call the handler with the resolved arguments.DESIGN.mdto specify the new binding semantics, including howeis referenced and how loop scope is captured.Related
<for>-driven tab bar (docs/.webui-docs/components/docs-playground/).packages/webui-framework/tests/fixtures/for-event-args/is intentionally written to pass against current behavior so it acts as a regression marker; once this issue is fixed, the assertion should be flipped to assert the loop variable is received.