Skip to content

Event handler bindings inside <for> loops ignore arguments (loop variables not passed) #273

@mohamedmansour

Description

@mohamedmansour

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

  1. Splits on ( and keeps only the handler identifier.
  2. Sets needs_event based on a literal substring search for (e).
  3. 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

  1. 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.
  2. 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).
  3. 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.
  4. Update DESIGN.md to specify the new binding semantics, including how e is referenced and how loop scope is captured.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions