Skip to content

Commit c0bd525

Browse files
fix: hydrate strict event arguments in repeat scopes (#322)
1 parent 11b6a6d commit c0bd525

13 files changed

Lines changed: 294 additions & 136 deletions

File tree

DESIGN.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,10 +1235,10 @@ Each component's compiled template is registered in `window.__webui.templates[ta
12351235
| `cl` | `SlotPath[]` | Conditional anchor slots aligned to `c[]` |
12361236
| `r` | `[collection, itemVar, blockIndex][]` | Repeat blocks |
12371237
| `rl` | `SlotPath[]` | Repeat anchor slots aligned to `r[]` |
1238-
| `e` | `[event, handler, argSpecs, targetPath][]` | Body events |
1238+
| `e` | `[event, handler, argSpecs, targetPath][]` | Body events |
12391239
| `b` | `TemplateBlockMeta[]` | Nested compiled block table referenced by `c` / `r` |
12401240
| `sa` | `string` | Optional module-mode adopted stylesheet specifier copied from `shadowrootadoptedstylesheets` |
1241-
| `re` | `[event, handler, argSpecs][]` | Root events, attached to the host element |
1241+
| `re` | `[event, handler, argSpecs][]` | Root events, attached to the host element |
12421242

12431243
All arrays are optional — omitted from the output when empty to minimize payload.
12441244

@@ -1270,15 +1270,15 @@ Logical operators also match the protocol enum values:
12701270
- `[name, 2, ConditionExpr]` — boolean attribute binding, e.g. `?disabled="{{expr}}"`
12711271
- `[name, 3, parts]` — mixed/template attribute binding, e.g. `class="item {{state}}"`
12721272

1273-
`argSpecs` for event handlers are resolved at dispatch time against the captured scope chain for the rendered template block:
1274-
1275-
- `["e"]` passes the DOM event object
1276-
- `["p", path]` resolves a component or active `<for>` scope path, e.g. `item.id`
1277-
- `["s", value]`, `["n", value]`, `["b", 0|1]`, and `["z"]` pass string, number, boolean, and `null` literals
1278-
1279-
For example, `@click="{selectItem(item.id)}"` calls `selectItem` with the current repeat item id, while `@click="{selectItem(item.id, e)}"` passes the item id followed by the event object. `@click="{selectItem(e)}"` keeps the existing event-passing behavior, and `@click="{selectItem()}"` calls the handler with no arguments.
1280-
1281-
### Compilation rules
1273+
`argSpecs` for event handlers are resolved at dispatch time against the captured scope chain for the rendered template block:
1274+
1275+
- `["e"]` passes the DOM event object
1276+
- `["p", path]` resolves a component or active `<for>` scope path, e.g. `item.id`
1277+
- `["s", value]`, `["n", value]`, `["b", 0|1]`, and `["z"]` pass string, number, boolean, and `null` literals
1278+
1279+
For example, `@click="{selectItem(item.id)}"` calls `selectItem` with the current repeat item id, while `@click="{selectItem(item.id, e)}"` passes the item id followed by the event object. `@click="{selectItem(e)}"` keeps the existing event-passing behavior, and `@click="{selectItem()}"` calls the handler with no arguments.
1280+
1281+
### Compilation rules
12821282

12831283
The Rust compiler (`generate_compiled_template` in `webui-parser/src/plugin/webui.rs`) transforms the HTML template in a single forward pass, then finalizes it into marker-free client HTML plus locator metadata:
12841284

@@ -1291,7 +1291,7 @@ The Rust compiler (`generate_compiled_template` in `webui-parser/src/plugin/webu
12911291
| `:config="{{settings}}"`, `:value="{{searchQuery}}"` | `a[]` + `ag[]` | element kept marker-free |
12921292
| `<if condition="expr">body</if>` | `c[]` + `cl[]` + `b[]` | block removed; anchor slot stored |
12931293
| `<for each="v in coll">body</for>` | `r[]` + `rl[]` + `b[]` | block removed; anchor slot stored |
1294-
| `@event="{handler(item.id, e)}"` | `e[]` | element kept marker-free |
1294+
| `@event="{handler(item.id, e)}"` | `e[]` | element kept marker-free |
12951295
| `@event` on `<template>` wrapper | `re[N]` | *(stripped)* |
12961296
| `w-ref="name"` | *(stays)* | *(unchanged)* |
12971297
| `<outlet />` | *(stays)* | `<outlet></outlet>` |
@@ -1333,13 +1333,15 @@ WebUI SSR marker formats are:
13331333

13341334
The WebUI handler plugin emits only these five comment markers. Text bindings, attribute bindings, and event handlers are resolved from compiled metadata path indices at hydration time - no DOM attribute markers are needed. The handler only emits markers in active child scopes; the root page scope remains marker-free. During hydration the framework keeps `<!--wr-->` and `<!--wc-->` as runtime anchors and removes `<!--/wr-->`, `<!--/wc-->`, and `<!--wi-->` markers.
13351335

1336+
WebUI Framework hydration assumes the SSR DOM, hydration markers, and compiled metadata were generated by the same trusted WebUI compiler/handler version. Hand-authored or partially modified marker streams are unsupported; missing structural closing markers are invalid input, not a recoverable runtime condition.
1337+
13361338
### Runtime contract
13371339

13381340
`@microsoft/webui-framework` consumes the metadata object above plus the SSR markers emitted by `WebUIHydrationPlugin`. This follows an Islands Architecture approach: the server delivers fully-rendered HTML, and only interactive Web Components hydrate on the client — leaving static content untouched.
13391341

13401342
- SSR hydration uses one DOM walk to discover `<!--wr-->`, `<!--wi-->`, and `<!--wc-->` comment markers, wire the relevant bindings using compiled metadata path indices, then remove SSR-only markers.
1341-
- Client-created DOM never reparses template syntax; it clones marker-free `h` and resolves `tx`, `ag`, `cl`, `rl`, and `el` locators directly.
1342-
- Events are resolved from compiled `e[]` metadata entries using path indices. The runtime installs listeners on target elements and resolves handler arguments against the scope captured when that block was rendered. Root events from `re[]` attach directly to the host element.
1343+
- Client-created DOM never reparses template syntax; it clones marker-free `h` and resolves `tx`, `ag`, `cl`, `rl`, and event target paths directly.
1344+
- Events are resolved from compiled `e[]` metadata entries using path indices. The runtime installs listeners on target elements and resolves handler arguments against the scope captured when that block was rendered. Root events from `re[]` attach directly to the host element.
13431345
- The full package entrypoint supports repeat metadata (`r[]` / `rl[]`). The additive `@microsoft/webui-framework/element-no-repeat` entrypoint preserves the same public `WebUIElement` API but must reject compiled templates that contain repeat metadata.
13441346

13451347
Detailed component examples, decorators, and package entrypoint guidance live in [packages/webui-framework/README.md](packages/webui-framework/README.md) rather than being duplicated in this design spec.

crates/webui-parser/src/plugin/webui.rs

Lines changed: 143 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
//! a single `<script>` tag; during SPA navigation the router evaluates
1111
//! them directly. Each metadata object contains
1212
//! **marker-free static HTML** plus locator arrays for client-created DOM
13-
//! (`tx`, `ag`, `cl`, `rl`, `el`) and semantic arrays (`a`, `c`, `r`, `e`,
13+
//! (`tx`, `ag`, `cl`, `rl`) and semantic arrays (`a`, `c`, `r`, `e`,
1414
//! `re`, `b`). The client runtime resolves those locators once and then
1515
//! patches direct node references — **no template string parsing, no regex,
1616
//! no DOM scanning** on the client-created path.
@@ -25,11 +25,10 @@
2525
//! ag: [[[0], 0, 1]],
2626
//! c: [[[1, "state", 3, "'done'"], 0]],
2727
//! cl: [[[0], 1]],
28-
//! e: [["click", "onClick", 0]],
29-
//! el: [[0]],
28+
//! e: [["click", "onClick", [], [0]]],
3029
//! b: [{ h: "<span class=\"check\">✓</span>" }],
3130
//! sa: "my-component",
32-
//! re: [["submit", "onSubmit", 1]],
31+
//! re: [["submit", "onSubmit", [["e"]]]],
3332
//! };
3433
//! ```
3534
//!
@@ -337,7 +336,7 @@ enum CompiledAttrPart {
337336
/// | `<for each="v in coll">body</for>` | `r[]` + `rl[]` + `b[]` | block removed; anchor slot stored |
338337
/// | `<link>` / `<style>` child nodes | `h` | preserved in static HTML |
339338
/// | module adopted stylesheet specifier | `sa` | stored from `<template>` wrapper |
340-
/// | `@event="{handler(e)}"` | `e[]` + `el[]` | element kept marker-free |
339+
/// | `@event="{handler(e)}"` | `e[]` | element kept marker-free |
341340
/// | `w-ref="name"` / `w-ref={name}` | *(stays in HTML)* | *(unchanged)* |
342341
/// | `<outlet />` / `<outlet>` | *(stays in HTML)* | `<outlet></outlet>` |
343342
pub fn generate_compiled_template(tag_name: &str, html_content: &str) -> String {
@@ -2016,41 +2015,54 @@ fn parse_event_handler(raw_value: &str) -> EventHandler {
20162015
Some(close) if close > paren => close,
20172016
_ => return EventHandler::Invalid(inner.to_string()),
20182017
};
2018+
if !inner[close + 1..].trim().is_empty() {
2019+
return EventHandler::Invalid(inner.to_string());
2020+
}
20192021
let handler_name = inner[..paren].trim();
2020-
if handler_name.is_empty() {
2022+
if !is_valid_identifier(handler_name) {
20212023
return EventHandler::Invalid(inner.to_string());
20222024
}
2023-
EventHandler::Valid(
2024-
handler_name.to_string(),
2025-
parse_event_args(&inner[paren + 1..close]),
2026-
)
2025+
let Some(args) = parse_event_args(&inner[paren + 1..close]) else {
2026+
return EventHandler::Invalid(inner.to_string());
2027+
};
2028+
EventHandler::Valid(handler_name.to_string(), args)
20272029
}
20282030
None => EventHandler::Invalid(inner.to_string()),
20292031
}
20302032
}
20312033

2032-
fn parse_event_args(raw_args: &str) -> Vec<EventArg> {
2033-
split_event_args(raw_args)
2034-
.into_iter()
2035-
.filter_map(|arg| parse_event_arg(arg.trim()))
2036-
.collect()
2034+
fn parse_event_args(raw_args: &str) -> Option<Vec<EventArg>> {
2035+
if raw_args.trim().is_empty() {
2036+
return Some(Vec::new());
2037+
}
2038+
2039+
let raw_parts = split_event_args(raw_args)?;
2040+
let mut args = Vec::with_capacity(raw_parts.len());
2041+
for arg in raw_parts {
2042+
let trimmed = arg.trim();
2043+
if trimmed.is_empty() {
2044+
return None;
2045+
}
2046+
args.push(parse_event_arg(trimmed)?);
2047+
}
2048+
Some(args)
20372049
}
20382050

2039-
fn split_event_args(raw_args: &str) -> Vec<&str> {
2051+
fn split_event_args(raw_args: &str) -> Option<Vec<&str>> {
20402052
let mut args = Vec::new();
20412053
let mut start = 0usize;
20422054
let mut quote: Option<u8> = None;
20432055
let mut escaped = false;
20442056
for (idx, byte) in raw_args.bytes().enumerate() {
2045-
if escaped {
2046-
escaped = false;
2047-
continue;
2048-
}
2049-
if byte == b'\\' {
2050-
escaped = true;
2051-
continue;
2052-
}
20532057
if let Some(q) = quote {
2058+
if escaped {
2059+
escaped = false;
2060+
continue;
2061+
}
2062+
if byte == b'\\' {
2063+
escaped = true;
2064+
continue;
2065+
}
20542066
if byte == q {
20552067
quote = None;
20562068
}
@@ -2065,8 +2077,11 @@ fn split_event_args(raw_args: &str) -> Vec<&str> {
20652077
start = idx + 1;
20662078
}
20672079
}
2080+
if quote.is_some() || escaped {
2081+
return None;
2082+
}
20682083
args.push(&raw_args[start..]);
2069-
args
2084+
Some(args)
20702085
}
20712086

20722087
fn parse_event_arg(arg: &str) -> Option<EventArg> {
@@ -2088,10 +2103,13 @@ fn parse_event_arg(arg: &str) -> Option<EventArg> {
20882103
if let Some(value) = parse_quoted_event_string(arg) {
20892104
return Some(EventArg::String(value));
20902105
}
2106+
if is_quoted_event_arg_start(arg) {
2107+
return None;
2108+
}
20912109
if is_number_literal(arg) {
20922110
return Some(EventArg::Number(arg.to_string()));
20932111
}
2094-
Some(EventArg::Path(arg.to_string()))
2112+
is_valid_event_path(arg).then(|| EventArg::Path(arg.to_string()))
20952113
}
20962114

20972115
fn parse_quoted_event_string(arg: &str) -> Option<String> {
@@ -2118,6 +2136,49 @@ fn is_number_literal(arg: &str) -> bool {
21182136
.all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+' | b'e' | b'E'))
21192137
}
21202138

2139+
fn is_quoted_event_arg_start(arg: &str) -> bool {
2140+
matches!(arg.as_bytes().first(), Some(b'"' | b'\''))
2141+
}
2142+
2143+
fn is_valid_event_path(path: &str) -> bool {
2144+
let mut parts = path.split('.');
2145+
let Some(first) = parts.next() else {
2146+
return false;
2147+
};
2148+
if !is_valid_identifier(first) {
2149+
return false;
2150+
}
2151+
for part in parts {
2152+
if part.is_empty() || (!is_valid_identifier(part) && !is_ascii_digits(part)) {
2153+
return false;
2154+
}
2155+
}
2156+
true
2157+
}
2158+
2159+
fn is_valid_identifier(value: &str) -> bool {
2160+
let mut bytes = value.bytes();
2161+
let Some(first) = bytes.next() else {
2162+
return false;
2163+
};
2164+
if !is_identifier_start(first) {
2165+
return false;
2166+
}
2167+
bytes.all(is_identifier_continue)
2168+
}
2169+
2170+
fn is_identifier_start(byte: u8) -> bool {
2171+
byte.is_ascii_alphabetic() || byte == b'_' || byte == b'$'
2172+
}
2173+
2174+
fn is_identifier_continue(byte: u8) -> bool {
2175+
is_identifier_start(byte) || byte.is_ascii_digit()
2176+
}
2177+
2178+
fn is_ascii_digits(value: &str) -> bool {
2179+
!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
2180+
}
2181+
21212182
fn extract_single_handlebars(value: &str) -> Option<String> {
21222183
let trimmed = value.trim();
21232184
if let Some(inner) = trimmed
@@ -2483,7 +2544,7 @@ mod tests {
24832544
);
24842545
assert_no_client_markers(&result);
24852546
assert!(result.contains(r#"h:"<p>hi</p>""#));
2486-
assert!(result.contains(r#",re:[["click","onClick",0]]"#));
2547+
assert!(result.contains(r#",re:[["click","onClick",[]]]"#));
24872548
}
24882549

24892550
#[test]
@@ -2612,7 +2673,9 @@ mod tests {
26122673
.unwrap();
26132674
let templates = plugin.take_component_templates();
26142675
assert_eq!(templates.len(), 1);
2615-
assert!(templates[0].1.contains(r#",re:[["click","onClick",1]]"#));
2676+
assert!(templates[0]
2677+
.1
2678+
.contains(r#",re:[["click","onClick",[["e"]]]]"#));
26162679
}
26172680

26182681
#[test]
@@ -3079,6 +3142,58 @@ mod tests {
30793142
));
30803143
}
30813144

3145+
#[test]
3146+
fn test_parse_event_handler_rejects_trailing_tokens() {
3147+
assert!(matches!(
3148+
parse_event_handler("{onClick(e)} trailing"),
3149+
EventHandler::Invalid(ref raw) if raw == "{onClick(e)} trailing"
3150+
));
3151+
assert!(matches!(
3152+
parse_event_handler("{onClick(e) trailing}"),
3153+
EventHandler::Invalid(ref raw) if raw == "onClick(e) trailing"
3154+
));
3155+
}
3156+
3157+
#[test]
3158+
fn test_parse_event_handler_rejects_empty_args() {
3159+
assert!(matches!(
3160+
parse_event_handler("{onClick(, e)}"),
3161+
EventHandler::Invalid(ref raw) if raw == "onClick(, e)"
3162+
));
3163+
assert!(matches!(
3164+
parse_event_handler("{onClick(e,)}"),
3165+
EventHandler::Invalid(ref raw) if raw == "onClick(e,)"
3166+
));
3167+
}
3168+
3169+
#[test]
3170+
fn test_parse_event_handler_rejects_malformed_args() {
3171+
assert!(matches!(
3172+
parse_event_handler("{onClick('unterminated)}"),
3173+
EventHandler::Invalid(ref raw) if raw == "onClick('unterminated)"
3174+
));
3175+
assert!(matches!(
3176+
parse_event_handler("{onClick(other())}"),
3177+
EventHandler::Invalid(ref raw) if raw == "onClick(other())"
3178+
));
3179+
assert!(matches!(
3180+
parse_event_handler("{onClick(count + 1)}"),
3181+
EventHandler::Invalid(ref raw) if raw == "onClick(count + 1)"
3182+
));
3183+
}
3184+
3185+
#[test]
3186+
fn test_parse_event_handler_rejects_invalid_handler_name() {
3187+
assert!(matches!(
3188+
parse_event_handler("{handler.name()}"),
3189+
EventHandler::Invalid(ref raw) if raw == "handler.name()"
3190+
));
3191+
assert!(matches!(
3192+
parse_event_handler("{1handler()}"),
3193+
EventHandler::Invalid(ref raw) if raw == "1handler()"
3194+
));
3195+
}
3196+
30823197
#[test]
30833198
fn test_parse_event_handler_empty_value() {
30843199
assert!(matches!(parse_event_handler("{}"), EventHandler::Empty));

docs/ai.md

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,14 @@ Supported operators: `==`, `!=`, `>`, `<`, `>=`, `<=`, `&&`, `||`, `!`
187187
```html
188188
<button @click="{handleClick()}">Click me</button>
189189
<input @keydown="{onKeydown(e)}" />
190+
<button @click="{selectItem(item.id, 'details', e)}">Select</button>
190191
<div @mouseenter="{onHover()}" @mouseleave="{onLeave()}">Hover</div>
191192
```
192193

194+
Event handler arguments can be `e`, dotted component or repeat-scope paths,
195+
string/number/boolean/null literals, or a mix of those. Nested JavaScript
196+
expressions are not parsed in templates.
197+
193198
### DOM references
194199

195200
```html
@@ -766,17 +771,9 @@ togglePanel(): void { this.isPanelOpen = !this.isPanelOpen; }
766771
</for>
767772
```
768773
769-
Note: `remove(item.id)` does not work as written because template event
770-
handlers cannot pass arguments from loop scope. Instead, use a child
771-
component that emits a custom event:
772-
773-
```html
774-
<for each="item in items">
775-
<list-item id="{{item.id}}" text="{{item.text}}"
776-
@remove-item="{onRemove(e)}">
777-
</list-item>
778-
</for>
779-
```
774+
`remove(item.id)` receives the current loop item's id. The framework captures
775+
the active repeat scope during hydration and resolves the path when the click
776+
event fires.
780777
781778
### Boolean attribute styling
782779

docs/guide/concepts/how-it-works.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ After the browser renders the server HTML, JavaScript loads and Web Components *
8181
1. **Custom elements upgrade** - the browser calls `connectedCallback` for each registered Web Component
8282
2. **Shadow root detection** - the framework finds the existing Declarative Shadow DOM root (it does **not** recreate the DOM)
8383
3. **Bindings wired** - template expressions (`{{count}}`, `?disabled`) are connected to class properties
84-
4. **Events connected** - `@click`, `@keydown`, and other event handlers are attached
84+
4. **Events connected** - `@click`, `@keydown`, and other handlers are attached with their compiled argument scopes
8585
5. **Reactive state activated** - `@observable` properties become live; changes trigger targeted DOM updates
8686

8787
### Islands Architecture

0 commit comments

Comments
 (0)