diff --git a/change/@microsoft-fast-build-fix-array-length-3d13d21c-1594-4fe1-babf-8123aa5e098e.json b/change/@microsoft-fast-build-fix-array-length-3d13d21c-1594-4fe1-babf-8123aa5e098e.json new file mode 100644 index 00000000000..68deb4cb5ca --- /dev/null +++ b/change/@microsoft-fast-build-fix-array-length-3d13d21c-1594-4fe1-babf-8123aa5e098e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: support .length property access on arrays in template expressions", + "packageName": "@microsoft/fast-build", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@microsoft-fast-build-fix-kebab-bindings-1314e0de-ff4c-4dd5-8cc5-2e8fc04bc0aa.json b/change/@microsoft-fast-build-fix-kebab-bindings-1314e0de-ff4c-4dd5-8cc5-2e8fc04bc0aa.json new file mode 100644 index 00000000000..90e448b0a94 --- /dev/null +++ b/change/@microsoft-fast-build-fix-kebab-bindings-1314e0de-ff4c-4dd5-8cc5-2e8fc04bc0aa.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: strip @event binding attributes from rendered HTML; attribute values are always strings unless a {{binding}} expression is used; property binding keys preserve their original casing", + "packageName": "@microsoft/fast-build", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/crates/microsoft-fast-build/DESIGN.md b/crates/microsoft-fast-build/DESIGN.md index ee5c44a107c..631ba9c8c7d 100644 --- a/crates/microsoft-fast-build/DESIGN.md +++ b/crates/microsoft-fast-build/DESIGN.md @@ -206,15 +206,16 @@ A custom element is any opening tag whose name contains a hyphen, excluding `f-w 2. **Detect self-closing** — if the character before `>` (ignoring whitespace) is `/`, the element is self-closing. The output always uses non-self-closing form. 3. **Parse attributes** — `parse_element_attributes` walks the opening tag string and extracts `(name, Option)` pairs. 4. **Build child state** from the attributes: - - No value (boolean attribute) → `Bool(true)` - - `"true"` / `"false"` → `Bool` - - Numeric string → `Number(f64)` - - `"{{binding}}"` → resolve from parent state (property binding with optional rename) - - Anything else → `String` + - Attributes starting with `@` (event handlers) or `:` (property bindings) are **skipped** — both are resolved entirely by the FAST client runtime and have no meaning in server-side rendering state. + - **HTML attribute keys are lowercased** — HTML attribute names are case-insensitive and browsers always store them lowercase. `isEnabled` becomes `isenabled`; hyphens are preserved: `selected-user-id` stays `selected-user-id`. - `data-*` attributes (e.g. `data-date-of-birth`) are **grouped under a nested `"dataset"` key** using the `attribute::data_attr_to_dataset_key` helper, which returns the full dot-notation path (`data-date-of-birth` → `"dataset.dateOfBirth"`). The caller splits on `.` and inserts into the nested map. This means `{{dataset.dateOfBirth}}` in the shadow template resolves via ordinary dot-notation. + - No value (boolean attribute) → `Bool(true)` + - `"{{binding}}"` → resolve from parent state (can be any `JsonValue` type) + - Anything else → `String` (attribute values are always strings; booleans and numbers must be passed via `{{binding}}` expressions) 5. **Render the shadow template** by calling `render_node` recursively with the child state as root and a **fresh `HydrationScope`** (always active). The `Locator` is threaded through so nested custom elements are expanded too. 6. **Extract light DOM children** via `extract_directive_content` (reuses the same nesting-aware scanner as directives). -7. **Emit Declarative Shadow DOM** with hydration attributes: +7. **Strip client-only binding attributes** (`@attr` event bindings and `:attr` property bindings) from both the outer element's opening tag and from all tags inside the rendered shadow template. These are skipped in step 4 (not added to child state) and also removed from the rendered HTML — they are resolved entirely by the FAST client runtime. The `data-fe-c` binding count is preserved — these bindings are still counted so the FAST client runtime allocates the correct number of binding slots. +8. **Emit Declarative Shadow DOM** with hydration attributes: ```html diff --git a/crates/microsoft-fast-build/README.md b/crates/microsoft-fast-build/README.md index ba7a898010c..36e88736509 100644 --- a/crates/microsoft-fast-build/README.md +++ b/crates/microsoft-fast-build/README.md @@ -273,14 +273,22 @@ Attributes on a custom element become the state passed to its template: |---|---| | `disabled` (boolean, no value) | `{"disabled": true}` | | `label="Click me"` | `{"label": "Click me"}` | -| `count="42"` | `{"count": 42}` | +| `count="42"` | `{"count": "42"}` | | `foo="{{bar}}"` | `{"foo": }` | +| `selected-user-id="42"` | `{"selected-user-id": "42"}` | +| `isEnabled="{{isEnabled}}"` | `{"isenabled": }` | | `data-date-of-birth="1990-01-01"` | `{"dataset": {"dateOfBirth": "1990-01-01"}}` | | `data-date-of-birth="{{dob}}"` | `{"dataset": {"dateOfBirth": }}` | +| `:myProp="{{expr}}"` | *(skipped — client-side only)* | +| `@click="{handler()}"` | *(skipped — client-side only)* | -`data-*` attributes are always grouped under a nested `"dataset"` key. `data_attr_to_dataset_key` returns the full dot-notation path (e.g. `"dataset.dateOfBirth"`), which is split on `.` when building the nested state, making `{{dataset.X}}` bindings work naturally in shadow templates. +**HTML attribute keys are lowercased** — HTML attribute names are case-insensitive and browsers always store them lowercase. `isEnabled` becomes `isenabled`; hyphens are preserved so `selected-user-id` stays `selected-user-id`. Templates must reference the lowercase form. -The last form is a **property binding with renaming**: `foo="{{bar}}"` resolves `bar` from the _parent_ state and passes it into the child template under the key `foo`. +**Attribute values are always strings** — except for boolean attributes (no value), which become `true`. Booleans and numbers must be passed via `{{binding}}` expressions so the resolved value from parent state (which can be any type) is used. + +**`data-*` attributes** are always grouped under a nested `"dataset"` key. `data_attr_to_dataset_key` returns the full dot-notation path (e.g. `"dataset.dateOfBirth"`), which is split on `.` when building the nested state, making `{{dataset.X}}` bindings work naturally in shadow templates. + +**Client-only bindings stripped from HTML and skipped from state**: both `@attr` event bindings and `:attr` property bindings are removed from the rendered HTML output and are not added to the child element's rendering scope — they are resolved entirely by the FAST client runtime. The `data-fe-c` binding count still includes them so the FAST runtime allocates the correct number of binding slots. ### Output Format diff --git a/crates/microsoft-fast-build/src/attribute.rs b/crates/microsoft-fast-build/src/attribute.rs index 4a8fc922e35..a7a9a93523a 100644 --- a/crates/microsoft-fast-build/src/attribute.rs +++ b/crates/microsoft-fast-build/src/attribute.rs @@ -327,6 +327,43 @@ fn extract_bool_attr_prefix(result: &str) -> Option { } } +/// Remove all FAST client-only binding attributes from an opening tag string: +/// - `@attr="{...}"` event bindings +/// - `:attr="..."` property bindings +/// +/// These are resolved or reconnected entirely by the FAST client runtime and +/// have no meaning in static HTML. The `data-fe-c` hydration binding count is +/// unaffected — callers use `count_tag_attribute_bindings` on the *original* +/// tag string so the FAST runtime still allocates the correct number of binding slots. +pub fn strip_client_only_attrs(tag: &str) -> String { + let trimmed = tag.trim_end(); + let is_self_closing = trimmed.ends_with("/>"); + let has_closing_gt = is_self_closing || trimmed.ends_with('>'); + + let tag_name = match read_tag_name(tag, 0) { + Some(name) => name, + None => return tag.to_string(), + }; + + let mut out = format!("<{}", tag_name); + for (name, value) in parse_element_attributes(tag) { + if name.starts_with('@') || name.starts_with(':') { + continue; + } + match value { + None => { out.push(' '); out.push_str(&name); } + Some(v) => out.push_str(&format!(" {}=\"{}\"", name, v)), + } + } + + if is_self_closing { + out.push_str(" />"); + } else if has_closing_gt { + out.push('>'); + } + out +} + /// Insert `data-fe-c-{start}-{count}` as an attribute just before the closing `>` or `/>`. pub fn inject_compact_marker(tag: &str, start_idx: usize, count: usize) -> String { let marker = format!(" data-fe-c-{}-{}", start_idx, count); diff --git a/crates/microsoft-fast-build/src/context.rs b/crates/microsoft-fast-build/src/context.rs index 7a36fa31cd7..2ca349c4c79 100644 --- a/crates/microsoft-fast-build/src/context.rs +++ b/crates/microsoft-fast-build/src/context.rs @@ -31,8 +31,12 @@ pub fn get_nested_property(value: &JsonValue, path: &str) -> Option { map.get(part)?.clone() } JsonValue::Array(ref arr) => { - let idx: usize = part.parse().ok()?; - arr.get(idx)?.clone() + if part == "length" { + JsonValue::Number(arr.len() as f64) + } else { + let idx: usize = part.parse().ok()?; + arr.get(idx)?.clone() + } } _ => return None, }; diff --git a/crates/microsoft-fast-build/src/directive.rs b/crates/microsoft-fast-build/src/directive.rs index 1cac8895dda..7bd4961eb91 100644 --- a/crates/microsoft-fast-build/src/directive.rs +++ b/crates/microsoft-fast-build/src/directive.rs @@ -5,7 +5,7 @@ use crate::attribute::{ find_str, find_directive, extract_directive_expr, extract_directive_content, find_single_brace, skip_single_brace_expr, find_tag_end, read_tag_name, parse_element_attributes, find_custom_element, - count_tag_attribute_bindings, resolve_attribute_bindings_in_tag, + count_tag_attribute_bindings, resolve_attribute_bindings_in_tag, strip_client_only_attrs, data_attr_to_dataset_key, }; use crate::error::{RenderError, template_context}; @@ -256,7 +256,16 @@ pub fn render_custom_element( let attrs = parse_element_attributes(open_tag_content); let mut state_map = std::collections::HashMap::new(); for (attr_name, value) in &attrs { + // Skip client-side-only bindings: @event handlers and :property bindings. + // Both are resolved entirely by the FAST client runtime and have no meaning + // in server-side rendering state. + if attr_name.starts_with('@') || attr_name.starts_with(':') { + continue; + } let json_val = attribute_to_json_value(value.as_ref(), root, loop_vars); + // HTML attribute names are case-insensitive; browsers always store them lowercase. + // `isEnabled` becomes `isenabled`; hyphens are preserved: `selected-user-id` + // stays `selected-user-id`. if let Some(path) = data_attr_to_dataset_key(attr_name) { if let Some((group, prop)) = path.split_once('.') { let group_val = state_map @@ -267,7 +276,8 @@ pub fn render_custom_element( } } } else { - state_map.insert(attr_name.clone(), json_val); + let key = attr_name.to_lowercase(); + state_map.insert(key, json_val); } } let child_root = JsonValue::Object(state_map); @@ -299,18 +309,31 @@ pub fn render_custom_element( Ok((output, after)) } +fn kebab_to_camel(s: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = false; + for c in s.chars() { + if c == '-' { + capitalize_next = true; + } else if capitalize_next { + result.extend(c.to_uppercase()); + capitalize_next = false; + } else { + result.push(c); + } + } + result +} + fn attribute_to_json_value(value: Option<&String>, root: &JsonValue, loop_vars: &[(String, JsonValue)]) -> JsonValue { let v = match value { - None => return JsonValue::Bool(true), + None => return JsonValue::Bool(true), // boolean attribute (no value) Some(s) => s, }; if v.starts_with("{{") && v.ends_with("}}") { let binding = v[2..v.len() - 2].trim(); return resolve_value(binding, root, loop_vars).unwrap_or(JsonValue::Null); } - if v == "true" { return JsonValue::Bool(true); } - if v == "false" { return JsonValue::Bool(false); } - if let Ok(n) = v.parse::() { return JsonValue::Number(n); } JsonValue::String(v.clone()) } @@ -324,15 +347,16 @@ fn build_element_open_tag( let (db, sb) = count_tag_attribute_bindings(open_tag_content); let total_attr = db + sb; if total_attr == 0 { - return format!("{}>", open_tag_base); + return format!("{}>", strip_client_only_attrs(open_tag_base)); } let resolved = resolve_attribute_bindings_in_tag(open_tag_base, root, loop_vars); + let stripped = strip_client_only_attrs(&resolved); match parent_hydration { Some(hy) => { let start_idx = hy.binding_idx; hy.binding_idx += total_attr; - format!("{} data-fe-c-{}-{}>", resolved, start_idx, total_attr) + format!("{} data-fe-c-{}-{}>", stripped, start_idx, total_attr) } - None => format!("{}>", resolved), + None => format!("{}>", stripped), } } diff --git a/crates/microsoft-fast-build/src/node.rs b/crates/microsoft-fast-build/src/node.rs index 5012afc7848..cb7453f5224 100644 --- a/crates/microsoft-fast-build/src/node.rs +++ b/crates/microsoft-fast-build/src/node.rs @@ -6,7 +6,7 @@ use crate::locator::Locator; use crate::hydration::HydrationScope; use crate::attribute::{ find_next_plain_html_tag, count_tag_attribute_bindings, - resolve_attribute_bindings_in_tag, inject_compact_marker, find_tag_end, + resolve_attribute_bindings_in_tag, strip_client_only_attrs, inject_compact_marker, find_tag_end, }; /// Recursively render a template fragment against root state and loop variables. @@ -71,9 +71,10 @@ fn process_hydration_tags( let start_idx = hy.binding_idx; hy.binding_idx += total; let resolved = resolve_attribute_bindings_in_tag(tag_str, root, loop_vars); - result.push_str(&inject_compact_marker(&resolved, start_idx, total)); + let stripped = strip_client_only_attrs(&resolved); + result.push_str(&inject_compact_marker(&stripped, start_idx, total)); } else { - result.push_str(tag_str); + result.push_str(&strip_client_only_attrs(tag_str)); } pos = tag_end; } diff --git a/crates/microsoft-fast-build/tests/bindings.rs b/crates/microsoft-fast-build/tests/bindings.rs index d7553f909d0..86b2400e014 100644 --- a/crates/microsoft-fast-build/tests/bindings.rs +++ b/crates/microsoft-fast-build/tests/bindings.rs @@ -31,6 +31,23 @@ fn test_array_index_second_element() { assert_eq!(ok("{{list.1}}", r#"{"list": ["a", "b", "c"]}"#), "b"); } +// ── array .length ───────────────────────────────────────────────────────────── + +#[test] +fn test_array_length() { + assert_eq!(ok("{{items.length}}", r#"{"items": ["a", "b", "c"]}"#), "3"); +} + +#[test] +fn test_array_length_empty() { + assert_eq!(ok("{{items.length}}", r#"{"items": []}"#), "0"); +} + +#[test] +fn test_array_length_nested() { + assert_eq!(ok("{{user.orders.length}}", r#"{"user": {"orders": [1, 2]}}"#), "2"); +} + // ── dot-notation — two levels ───────────────────────────────────────────────── #[test] diff --git a/crates/microsoft-fast-build/tests/custom_elements.rs b/crates/microsoft-fast-build/tests/custom_elements.rs index c99d7055e63..d16f45406e7 100644 --- a/crates/microsoft-fast-build/tests/custom_elements.rs +++ b/crates/microsoft-fast-build/tests/custom_elements.rs @@ -200,3 +200,60 @@ fn test_locator_name_from_f_template_attribute_not_file_stem() { let locator = Locator::from_patterns(&["tests/fixtures/my-button.html"]).unwrap(); assert!(locator.has_template("my-button"), "should find my-button by name attribute"); } + +// ── attribute name → lowercase normalisation ────────────────────────────────── + +#[test] +fn test_custom_element_kebab_attr_hyphens_preserved() { + // kebab-case attr names are lowercased; hyphens are preserved + let locator = make_locator(&[("my-el", "{{selected-user-id}}")]); + let result = render_template_with_locator( + r#""#, + "{}", + &locator, + ).unwrap(); + assert!(result.contains("42"), "kebab attr resolved: {result}"); +} + +#[test] +fn test_custom_element_multi_word_kebab_attrs() { + // multiple kebab-case attrs are lowercased and passed to the child scope as-is + let locator = make_locator(&[("my-el", "

{{show-details}}

{{enable-continue}}

")]); + let result = render_template_with_locator( + r#""#, + "{}", + &locator, + ).unwrap(); + assert!(result.contains("true"), "show-details: {result}"); + assert!(result.contains("false"), "enable-continue: {result}"); +} + +// ── colon-prefixed property bindings ───────────────────────────────────────── + +#[test] +fn test_custom_element_colon_property_binding_skipped() { + // `:prop` bindings are client-side only and are NOT added to the child rendering scope. + // They are stripped from the rendered HTML just like @event bindings. + let locator = make_locator(&[("my-btn", "")]); + let result = render_template_with_locator( + r#""#, + r#"{"value": "ignored"}"#, + &locator, + ).unwrap(); + assert!(result.contains("OK"), "label: {result}"); + assert!(!result.contains(":myProp"), "colon attr stripped: {result}"); + assert!(!result.contains("ignored"), "prop value not in output: {result}"); +} + +#[test] +fn test_custom_element_event_binding_skipped() { + // `@click="{handler()}"` bindings are skipped — they are client-side only + let locator = make_locator(&[("my-btn", "")]); + let result = render_template_with_locator( + r#""#, + "{}", + &locator, + ).unwrap(); + assert!(result.contains("OK"), "label: {result}"); + // The @click binding should not appear in element state or cause an error +} diff --git a/crates/microsoft-fast-build/tests/f_when.rs b/crates/microsoft-fast-build/tests/f_when.rs index 35a1a5d356c..b014ebb4b5b 100644 --- a/crates/microsoft-fast-build/tests/f_when.rs +++ b/crates/microsoft-fast-build/tests/f_when.rs @@ -81,6 +81,24 @@ fn test_f_when_numeric_comparison() { ); } +// ── array .length in conditions ─────────────────────────────────────────────── + +#[test] +fn test_when_array_length_gt_zero() { + assert_eq!( + ok(r#"has items"#, r#"{"items": ["x"]}"#), + "has items", + ); +} + +#[test] +fn test_when_array_length_zero() { + assert_eq!( + ok(r#"has items"#, r#"{"items": []}"#), + "", + ); +} + // ── chained expressions ─────────────────────────────────────────────────────── #[test] diff --git a/crates/microsoft-fast-build/tests/hydration.rs b/crates/microsoft-fast-build/tests/hydration.rs index d1a7f3e7723..a8f8c462fd3 100644 --- a/crates/microsoft-fast-build/tests/hydration.rs +++ b/crates/microsoft-fast-build/tests/hydration.rs @@ -79,7 +79,7 @@ fn test_hydration_attribute_binding_compact() { assert!(!result.contains("data-fe-c-0-2"), "should not have count 2: {result}"); } -/// Single-brace `@click="{handler()}"` event binding — compact marker. +/// Single-brace `@click="{handler()}"` event binding — compact marker present, attr stripped. #[test] fn test_hydration_event_binding_compact() { let locator = make_locator(&[("test-element", r#""#)]); @@ -90,11 +90,12 @@ fn test_hydration_event_binding_compact() { ).unwrap(); assert!(result.contains("data-fe-c-0-1"), "compact marker: {result}"); - assert!(result.contains(r#"@click="{handleClick()}""#), "event attr passthrough: {result}"); + assert!(!result.contains(r#"@click"#), "event attr stripped: {result}"); assert!(result.contains("Label"), "label: {result}"); } /// Multiple event bindings on separate elements use incrementing start indices. +/// The @click attributes are stripped from the HTML output. #[test] fn test_hydration_multiple_event_bindings() { let locator = make_locator(&[( @@ -109,6 +110,7 @@ fn test_hydration_multiple_event_bindings() { assert!(result.contains("data-fe-c-0-1"), "first button: {result}"); assert!(result.contains("data-fe-c-1-1"), "second button: {result}"); + assert!(!result.contains("@click"), "event attrs stripped: {result}"); } /// Mixed: attribute binding + content binding on the same element. @@ -160,7 +162,7 @@ fn test_hydration_f_when_falsy() { let locator = make_locator(&[("test-element", r#"Hello"#)]); let root = hand_root(vec![("show", bool_val(false))]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); @@ -434,7 +436,7 @@ fn test_hydration_bool_attr_false() { let locator = make_locator(&[("test-element", r#""#)]); let root = hand_root(vec![("show", bool_val(false))]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); @@ -444,10 +446,10 @@ fn test_hydration_bool_attr_false() { assert!(shadow.contains("data-fe-c-0-1"), "compact marker: {result}"); } -/// `?disabled="{{!isEnabled}}"` with `isEnabled: false` → renders `disabled`. +/// `?disabled="{{!isenabled}}"` with `isEnabled: false` → renders `disabled`. #[test] fn test_hydration_bool_attr_negation_true() { - let locator = make_locator(&[("test-element", r#""#)]); + let locator = make_locator(&[("test-element", r#""#)]); let root = hand_root(vec![("isEnabled", bool_val(false))]); let result = render_with_locator( r#""#, @@ -460,10 +462,10 @@ fn test_hydration_bool_attr_negation_true() { assert!(!shadow.contains("?disabled"), "no ?disabled prefix: {result}"); } -/// `?disabled="{{!isEnabled}}"` with `isEnabled: true` → attribute is omitted. +/// `?disabled="{{!isenabled}}"` with `isEnabled: true` → attribute is omitted. #[test] fn test_hydration_bool_attr_negation_false() { - let locator = make_locator(&[("test-element", r#""#)]); + let locator = make_locator(&[("test-element", r#""#)]); let root = hand_root(vec![("isEnabled", bool_val(true))]); let result = render_with_locator( r#""#, @@ -475,10 +477,10 @@ fn test_hydration_bool_attr_negation_false() { assert!(!shadow.contains("disabled"), "disabled absent: {result}"); } -/// `?disabled="{{a == b}}"` with equal values → renders `disabled`. +/// `?disabled="{{activegroup == currentgroup}}"` with equal values → renders `disabled`. #[test] fn test_hydration_bool_attr_expression_true() { - let locator = make_locator(&[("test-element", r#""#)]); + let locator = make_locator(&[("test-element", r#""#)]); let root = hand_root(vec![ ("activeGroup", str_val("work")), ("currentGroup", str_val("work")), @@ -494,10 +496,10 @@ fn test_hydration_bool_attr_expression_true() { assert!(!shadow.contains("?disabled"), "no ?disabled prefix: {result}"); } -/// `?disabled="{{a == b}}"` with unequal values → attribute is omitted. +/// `?disabled="{{activegroup == currentgroup}}"` with unequal values → attribute is omitted. #[test] fn test_hydration_bool_attr_expression_false() { - let locator = make_locator(&[("test-element", r#""#)]); + let locator = make_locator(&[("test-element", r#""#)]); let root = hand_root(vec![ ("activeGroup", str_val("work")), ("currentGroup", str_val("home")),