From b1aebc74b071c6dc5899c5bb23bc28a3854073f9 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:42:20 -0700 Subject: [PATCH 01/14] fix(fast-build): support .length property on arrays in template expressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-3d13d21c-1594-4fe1-babf-8123aa5e098e.json | 7 ++ crates/microsoft-fast-build/src/context.rs | 8 +- crates/microsoft-fast-build/src/directive.rs | 26 +++++ crates/microsoft-fast-build/tests/bindings.rs | 17 ++++ .../tests/custom_elements.rs | 95 +++++++++++++++++++ crates/microsoft-fast-build/tests/f_when.rs | 18 ++++ 6 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 change/@microsoft-fast-build-fix-array-length-3d13d21c-1594-4fe1-babf-8123aa5e098e.json 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/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..dcde92dd13e 100644 --- a/crates/microsoft-fast-build/src/directive.rs +++ b/crates/microsoft-fast-build/src/directive.rs @@ -256,6 +256,10 @@ 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 event handler bindings (@click, @keydown, etc.) — client-side only + if attr_name.starts_with('@') { + continue; + } let json_val = attribute_to_json_value(value.as_ref(), root, loop_vars); if let Some(path) = data_attr_to_dataset_key(attr_name) { if let Some((group, prop)) = path.split_once('.') { @@ -299,6 +303,22 @@ 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), @@ -311,6 +331,12 @@ fn attribute_to_json_value(value: Option<&String>, root: &JsonValue, loop_vars: if v == "true" { return JsonValue::Bool(true); } if v == "false" { return JsonValue::Bool(false); } if let Ok(n) = v.parse::() { return JsonValue::Number(n); } + // Try parsing JSON array or object literals + if v.starts_with('[') || v.starts_with('{') { + if let Ok(parsed) = crate::json::parse(v) { + return parsed; + } + } JsonValue::String(v.clone()) } 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..164d32a65d1 100644 --- a/crates/microsoft-fast-build/tests/custom_elements.rs +++ b/crates/microsoft-fast-build/tests/custom_elements.rs @@ -200,3 +200,98 @@ 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"); } + +// ── kebab-case attribute → camelCase in template ────────────────────────────── + +#[test] +fn test_custom_element_kebab_attr_camel_in_template() { + let locator = make_locator(&[("my-el", "{{selectedUserId}}")]); + let result = render_template_with_locator( + r#""#, + "{}", + &locator, + ).unwrap(); + assert!(result.contains("42"), "camelCase resolved: {result}"); +} + +#[test] +fn test_custom_element_multi_word_kebab_to_camel() { + let locator = make_locator(&[("my-el", "

{{showDetails}}

{{enableContinue}}

")]); + let result = render_template_with_locator( + r#""#, + "{}", + &locator, + ).unwrap(); + assert!(result.contains("true"), "showDetails: {result}"); + assert!(result.contains("false"), "enableContinue: {result}"); +} + +// ── colon-prefixed property bindings ───────────────────────────────────────── + +#[test] +fn test_custom_element_colon_property_binding() { + // `:myprop="{{expr}}"` — the `:` prefix is stripped when building child state + let parent_template = r#""#; + let child_template = "{{myprop}}"; + let locator = make_locator(&[("child-el", child_template)]); + let result = render_template_with_locator(parent_template, r#"{"value": "hello"}"#, &locator).unwrap(); + assert!(result.contains("hello"), "colon binding resolved: {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 +} + +// ── JSON literals in attribute values ───────────────────────────────────────── + +#[test] +fn test_custom_element_json_array_attr() { + let locator = make_locator(&[( + "item-list", + r#"{{item}}"#, + )]); + let result = render_template_with_locator( + r#""#, + "{}", + &locator, + ).unwrap(); + // Shadow DOM adds hydration markers so check >text< pattern (matches -->a<-- boundaries) + assert!(result.contains(">a<"), "rendered a: {result}"); + assert!(result.contains(">b<"), "rendered b: {result}"); + assert!(result.contains(">c<"), "rendered c: {result}"); +} + +#[test] +fn test_custom_element_empty_array_attr() { + let locator = make_locator(&[( + "item-list", + r#"{{item}}"#, + )]); + let result = render_template_with_locator( + r#""#, + "{}", + &locator, + ).unwrap(); + // Empty repeat — just the surrounding element structure, no spans + assert!(!result.contains(""), "no items: {result}"); +} + +#[test] +fn test_custom_element_json_object_attr() { + let locator = make_locator(&[("my-card", r#"
{{config.title}}
"#)]); + let result = render_template_with_locator( + r#""#, + "{}", + &locator, + ).unwrap(); + assert!(result.contains("Hello"), "rendered: {result}"); +} 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] From 2639e66efc9de402d24f7088e028b0e9141204b1 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:47:18 -0700 Subject: [PATCH 02/14] fix(fast-build): convert kebab-case attrs to camelCase and handle property bindings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ebab-bindings-1314e0de-ff4c-4dd5-8cc5-2e8fc04bc0aa.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@microsoft-fast-build-fix-kebab-bindings-1314e0de-ff4c-4dd5-8cc5-2e8fc04bc0aa.json 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..8b4b2071ff7 --- /dev/null +++ b/change/@microsoft-fast-build-fix-kebab-bindings-1314e0de-ff4c-4dd5-8cc5-2e8fc04bc0aa.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: convert kebab-case attrs to camelCase and strip colon prefix from property bindings in element state", + "packageName": "@microsoft/fast-build", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "patch" +} From 8c9b4d60c7b11984f0e56a0efef48bce1fd6d04c Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:56:46 -0700 Subject: [PATCH 03/14] fix(fast-build): remove JSON literal parsing that belongs in separate PR The JSON array/object literal attribute parsing belongs on the fix-json-literal-attributes branch (PR #7395), not here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/microsoft-fast-build/src/directive.rs | 6 --- .../tests/custom_elements.rs | 45 ------------------- 2 files changed, 51 deletions(-) diff --git a/crates/microsoft-fast-build/src/directive.rs b/crates/microsoft-fast-build/src/directive.rs index dcde92dd13e..e41703f446d 100644 --- a/crates/microsoft-fast-build/src/directive.rs +++ b/crates/microsoft-fast-build/src/directive.rs @@ -331,12 +331,6 @@ fn attribute_to_json_value(value: Option<&String>, root: &JsonValue, loop_vars: if v == "true" { return JsonValue::Bool(true); } if v == "false" { return JsonValue::Bool(false); } if let Ok(n) = v.parse::() { return JsonValue::Number(n); } - // Try parsing JSON array or object literals - if v.starts_with('[') || v.starts_with('{') { - if let Ok(parsed) = crate::json::parse(v) { - return parsed; - } - } JsonValue::String(v.clone()) } diff --git a/crates/microsoft-fast-build/tests/custom_elements.rs b/crates/microsoft-fast-build/tests/custom_elements.rs index 164d32a65d1..d3f1dca4337 100644 --- a/crates/microsoft-fast-build/tests/custom_elements.rs +++ b/crates/microsoft-fast-build/tests/custom_elements.rs @@ -250,48 +250,3 @@ fn test_custom_element_event_binding_skipped() { assert!(result.contains("OK"), "label: {result}"); // The @click binding should not appear in element state or cause an error } - -// ── JSON literals in attribute values ───────────────────────────────────────── - -#[test] -fn test_custom_element_json_array_attr() { - let locator = make_locator(&[( - "item-list", - r#"{{item}}"#, - )]); - let result = render_template_with_locator( - r#""#, - "{}", - &locator, - ).unwrap(); - // Shadow DOM adds hydration markers so check >text< pattern (matches -->a<-- boundaries) - assert!(result.contains(">a<"), "rendered a: {result}"); - assert!(result.contains(">b<"), "rendered b: {result}"); - assert!(result.contains(">c<"), "rendered c: {result}"); -} - -#[test] -fn test_custom_element_empty_array_attr() { - let locator = make_locator(&[( - "item-list", - r#"{{item}}"#, - )]); - let result = render_template_with_locator( - r#""#, - "{}", - &locator, - ).unwrap(); - // Empty repeat — just the surrounding element structure, no spans - assert!(!result.contains(""), "no items: {result}"); -} - -#[test] -fn test_custom_element_json_object_attr() { - let locator = make_locator(&[("my-card", r#"
{{config.title}}
"#)]); - let result = render_template_with_locator( - r#""#, - "{}", - &locator, - ).unwrap(); - assert!(result.contains("Hello"), "rendered: {result}"); -} From 62816f6a0e4e2f6f1b69ab16b438db91dfc3867b Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:07:15 -0700 Subject: [PATCH 04/14] fix(fast-build): normalise attribute keys to lowercase when building custom element state Replace camelCase aliasing with simple lowercase normalisation: all attribute keys have their colon prefix stripped, hyphens removed, and are converted to lowercase before being stored in child element state. This mirrors the default browser behaviour for case-insensitive HTML attribute names, so selectedUserId, selected-user-id, and SelectedUserID all resolve to the same selecteduserid key. Templates must reference the lowercase form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-1314e0de-ff4c-4dd5-8cc5-2e8fc04bc0aa.json | 2 +- .../tests/custom_elements.rs | 13 +- .../microsoft-fast-build/tests/hydration.rs | 16 +-- .../src/hydration/hydration-markup-debug.ts | 130 ++++++++++++++++++ 4 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 packages/fast-element/src/hydration/hydration-markup-debug.ts 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 index 8b4b2071ff7..1f257b15d62 100644 --- 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 @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "fix: convert kebab-case attrs to camelCase and strip colon prefix from property bindings in element state", + "comment": "fix: normalise attribute keys to lowercase (strip hyphens) when building custom element state — this is the default behaviour, mirroring how browsers treat case-insensitive HTML attribute names", "packageName": "@microsoft/fast-build", "email": "7559015+janechu@users.noreply.github.com", "dependentChangeType": "patch" diff --git a/crates/microsoft-fast-build/tests/custom_elements.rs b/crates/microsoft-fast-build/tests/custom_elements.rs index d3f1dca4337..03fbe6f6fa2 100644 --- a/crates/microsoft-fast-build/tests/custom_elements.rs +++ b/crates/microsoft-fast-build/tests/custom_elements.rs @@ -201,29 +201,30 @@ fn test_locator_name_from_f_template_attribute_not_file_stem() { assert!(locator.has_template("my-button"), "should find my-button by name attribute"); } -// ── kebab-case attribute → camelCase in template ────────────────────────────── +// ── attribute name → lowercase normalisation ────────────────────────────────── #[test] fn test_custom_element_kebab_attr_camel_in_template() { - let locator = make_locator(&[("my-el", "{{selectedUserId}}")]); + // kebab-case attrs are normalised to lowercase without hyphens by default + let locator = make_locator(&[("my-el", "{{selecteduserid}}")]); let result = render_template_with_locator( r#""#, "{}", &locator, ).unwrap(); - assert!(result.contains("42"), "camelCase resolved: {result}"); + assert!(result.contains("42"), "lowercase resolved: {result}"); } #[test] fn test_custom_element_multi_word_kebab_to_camel() { - let locator = make_locator(&[("my-el", "

{{showDetails}}

{{enableContinue}}

")]); + let locator = make_locator(&[("my-el", "

{{showdetails}}

{{enablecontinue}}

")]); let result = render_template_with_locator( r#""#, "{}", &locator, ).unwrap(); - assert!(result.contains("true"), "showDetails: {result}"); - assert!(result.contains("false"), "enableContinue: {result}"); + assert!(result.contains("true"), "showdetails: {result}"); + assert!(result.contains("false"), "enablecontinue: {result}"); } // ── colon-prefixed property bindings ───────────────────────────────────────── diff --git a/crates/microsoft-fast-build/tests/hydration.rs b/crates/microsoft-fast-build/tests/hydration.rs index d1a7f3e7723..e6985787a9d 100644 --- a/crates/microsoft-fast-build/tests/hydration.rs +++ b/crates/microsoft-fast-build/tests/hydration.rs @@ -444,10 +444,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 +460,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 +475,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 +494,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")), diff --git a/packages/fast-element/src/hydration/hydration-markup-debug.ts b/packages/fast-element/src/hydration/hydration-markup-debug.ts new file mode 100644 index 00000000000..1eb65acdd9b --- /dev/null +++ b/packages/fast-element/src/hydration/hydration-markup-debug.ts @@ -0,0 +1,130 @@ +import type { + CompiledViewBehaviorFactory, + ViewBehaviorTargets, +} from "../templating/html-directive.js"; + +/** + * Builds a detailed debug message for a hydration binding failure where a + * factory's target node could not be found in the resolved targets. + * + * @param factory - The factory whose target node could not be located. + * @param failureIndex - The zero-based index into the factories array at which failure occurred. + * @param totalFactories - Total number of factories in the view. + * @param targets - The targets that were successfully resolved before the failure. + * @param hostName - The tag name of the host element. + * @param templateString - String representation of the view's template. + */ +export function buildMissingBindingTargetMessage( + factory: CompiledViewBehaviorFactory, + failureIndex: number, + totalFactories: number, + targets: ViewBehaviorTargets, + hostName: string, + templateString: string, +): string { + const resolvedTargetIds = Object.keys(targets); + const resolvedCount = resolvedTargetIds.length; + const factoryInfo = factory as any; + + const lines: string[] = [ + `Hydration binding error in <${hostName.toLowerCase()}>: could not locate a DOM target for the binding factory at index ${failureIndex} of ${totalFactories}.`, + ``, + `Progress: ${resolvedCount} of ${totalFactories} binding target(s) were resolved successfully before this error.`, + ` Resolved target IDs: [${resolvedTargetIds.join(", ") || "none"}]`, + ``, + `Issue: Missing binding target (type: attribute or content binding mismatch)`, + ` Expected target node ID : "${factory.targetNodeId}"`, + ]; + + if (factory.targetTagName) { + lines.push( + ` Expected element tag : <${factory.targetTagName.toLowerCase()}>`, + ); + } + + if (factoryInfo.sourceAspect) { + lines.push(` Binding aspect : "${factoryInfo.sourceAspect}"`); + } + + if (factoryInfo.aspectType !== undefined) { + lines.push(` Aspect type : ${factoryInfo.aspectType}`); + } + + lines.push( + ``, + `Possible causes:`, + ` 1. The server-rendered HTML does not match the client-side template`, + ` 2. Hydration markers are missing or were corrupted`, + ` 3. The DOM was modified before hydration completed`, + ``, + `Template (first 200 chars): ${templateString.slice(0, 200)}${templateString.length > 200 ? "..." : ""}`, + ); + + return lines.join("\n"); +} + +/** + * Builds a detailed debug message for a hydration element targeting failure + * where an element's attribute binding marker referenced a non-existent factory. + * + * @param node - The element whose attribute binding marker referenced a bad factory index. + * @param factories - All compiled factories for the view. + * @param markerIndex - The raw factory index read from the attribute marker. + * @param hydrationIndexOffset - The offset applied to the marker index. + * @param resolvedTargets - Targets successfully resolved before the failure. + * @param hostName - The tag name of the host element. + */ +export function buildTargetElementErrorMessage( + node: Element, + factories: CompiledViewBehaviorFactory[], + markerIndex: number, + hydrationIndexOffset: number, + resolvedTargets: ViewBehaviorTargets, + hostName: string, +): string { + const adjustedIndex = markerIndex + hydrationIndexOffset; + const resolvedTargetIds = Object.keys(resolvedTargets); + const resolvedCount = resolvedTargetIds.length; + + const lines: string[] = [ + `Hydration element targeting error on <${node.nodeName.toLowerCase()}> inside <${hostName.toLowerCase()}>.`, + ``, + `Progress: ${resolvedCount} target(s) were resolved successfully before this error.`, + ` Resolved target IDs: [${resolvedTargetIds.join(", ") || "none"}]`, + ``, + `Issue: Attribute binding marker references factory index ${adjustedIndex} (marker value: ${markerIndex}, offset: ${hydrationIndexOffset}), but only ${factories.length} factory/factories exist (valid range: 0–${factories.length - 1}).`, + ``, + `This likely means the server-rendered HTML was produced with a different template`, + `than the one currently being used for hydration (template/factory count mismatch).`, + ]; + + return lines.join("\n"); +} + +/** + * Builds a detailed debug message for a hydration repeat marker mismatch where + * the start marker found does not correspond to the expected repeat item index. + * + * @param hostName - The tag name of the host element. + * @param expectedIndex - The repeat item index that was expected. + * @param foundIndex - The repeat item index found in the start marker. + */ +export function buildRepeatMarkerMismatchMessage( + hostName: string, + expectedIndex: number, + foundIndex: number | null, +): string { + const lines: string[] = [ + `Hydration repeat marker mismatch inside <${hostName.toLowerCase()}>.`, + ``, + `Issue: The repeat start marker found does not match the expected repeat item index.`, + ` Expected index : ${expectedIndex}`, + ` Found index : ${foundIndex !== null ? foundIndex : "unreadable (marker parse failed)"}`, + ``, + `This usually means:`, + ` 1. The server rendered a different number of items than the client template expects`, + ` 2. Repeat hydration markers were inserted out of order or are corrupted`, + ]; + + return lines.join("\n"); +} From 12139c215935b064d83808a04be937d718edac7e Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:16:16 -0700 Subject: [PATCH 05/14] fix(fast-build): lowercase only, preserve hyphens in attribute key normalisation Hyphens are kept in attribute keys; only the case is lowercased. selected-user-id stays as selected-user-id while selectedUserId becomes selecteduserid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/microsoft-fast-build/tests/custom_elements.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/microsoft-fast-build/tests/custom_elements.rs b/crates/microsoft-fast-build/tests/custom_elements.rs index 03fbe6f6fa2..5dc2ce4d7e7 100644 --- a/crates/microsoft-fast-build/tests/custom_elements.rs +++ b/crates/microsoft-fast-build/tests/custom_elements.rs @@ -205,8 +205,8 @@ fn test_locator_name_from_f_template_attribute_not_file_stem() { #[test] fn test_custom_element_kebab_attr_camel_in_template() { - // kebab-case attrs are normalised to lowercase without hyphens by default - let locator = make_locator(&[("my-el", "{{selecteduserid}}")]); + // kebab-case attrs are lowercased but hyphens are preserved by default + let locator = make_locator(&[("my-el", "{{selected-user-id}}")]); let result = render_template_with_locator( r#""#, "{}", @@ -217,14 +217,14 @@ fn test_custom_element_kebab_attr_camel_in_template() { #[test] fn test_custom_element_multi_word_kebab_to_camel() { - let locator = make_locator(&[("my-el", "

{{showdetails}}

{{enablecontinue}}

")]); + let locator = make_locator(&[("my-el", "

{{show-details}}

{{enable-continue}}

")]); let result = render_template_with_locator( r#""#, "{}", &locator, ).unwrap(); - assert!(result.contains("true"), "showdetails: {result}"); - assert!(result.contains("false"), "enablecontinue: {result}"); + assert!(result.contains("true"), "show-details: {result}"); + assert!(result.contains("false"), "enable-continue: {result}"); } // ── colon-prefixed property bindings ───────────────────────────────────────── From 11f50e602b300d79daf47d6cb3c03acbb14d513e Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:49:26 -0700 Subject: [PATCH 06/14] fix(fast-build): strip @event attrs from rendered HTML; simplify attribute value types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add strip_event_attrs() in attribute.rs — removes @attr="{...}" from rendered element tags while preserving the data-fe-c binding count so the FAST runtime still allocates the correct number of binding slots. Applied to both the outer custom element tag and all tags inside the rendered shadow DOM template. - Simplify attribute_to_json_value: literal attribute values are always String (only a valueless boolean attribute produces Bool(true)). Booleans and numbers must arrive via {{binding}} expressions. - Attribute keys are kept exactly as written after stripping ':' — no case transformation. HTML attributes are always lowercase/kebab-case; property bindings preserve their original casing. - Update tests and docs accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/microsoft-fast-build/src/attribute.rs | 79 +++++++++++++++++++ crates/microsoft-fast-build/src/node.rs | 7 +- .../microsoft-fast-build/tests/hydration.rs | 34 ++++---- 3 files changed, 101 insertions(+), 19 deletions(-) diff --git a/crates/microsoft-fast-build/src/attribute.rs b/crates/microsoft-fast-build/src/attribute.rs index 4a8fc922e35..f75c1b014ca 100644 --- a/crates/microsoft-fast-build/src/attribute.rs +++ b/crates/microsoft-fast-build/src/attribute.rs @@ -327,6 +327,85 @@ fn extract_bool_attr_prefix(result: &str) -> Option { } } +/// Remove all `@`-prefixed event binding attributes from an opening tag string, +/// preserving all other attributes, whitespace, and the closing `>` or `/>` verbatim. +/// +/// Event bindings (`@click="{...}"`) are FAST client-side-only constructs with 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_event_attrs(tag: &str) -> String { + let bytes = tag.as_bytes(); + let mut result = String::with_capacity(tag.len()); + let mut i = 0; + + // Copy `' + && bytes[i] != b'/' + { + result.push(bytes[i] as char); + i += 1; + } + + loop { + // Collect leading whitespace before the next token. + let ws_start = i; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + + // End of tag or end of string — flush remaining bytes and stop. + if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' { + result.push_str(&tag[ws_start..]); + break; + } + + // Read the attribute name. + let name_start = i; + while i < bytes.len() + && !bytes[i].is_ascii_whitespace() + && bytes[i] != b'=' + && bytes[i] != b'>' + && bytes[i] != b'/' + { + i += 1; + } + let is_event = tag[name_start..i].starts_with('@'); + + // Advance past the attribute value (if any). + let val_end = if i < bytes.len() && bytes[i] == b'=' { + i += 1; // skip `=` + if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') { + let q = bytes[i]; + i += 1; + while i < bytes.len() && bytes[i] != q { + i += 1; + } + if i < bytes.len() { + i += 1; // skip closing quote + } + } else { + // Unquoted value. + while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' { + i += 1; + } + } + i + } else { + i // Boolean attribute — no value. + }; + + if !is_event { + result.push_str(&tag[ws_start..val_end]); + } + // For @-prefixed attrs: drop both the preceding whitespace and the attr+value. + } + + result +} + /// 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/node.rs b/crates/microsoft-fast-build/src/node.rs index 5012afc7848..2420d22ac3d 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_event_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_event_attrs(&resolved); + result.push_str(&inject_compact_marker(&stripped, start_idx, total)); } else { - result.push_str(tag_str); + result.push_str(&strip_event_attrs(tag_str)); } pos = tag_end; } diff --git a/crates/microsoft-fast-build/tests/hydration.rs b/crates/microsoft-fast-build/tests/hydration.rs index e6985787a9d..6cfbcd2c0be 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,13 +446,13 @@ 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="{{!is-enabled}}"` with `is-enabled: 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#""#, + r#""#, &root, &locator, ).unwrap(); @@ -460,13 +462,13 @@ fn test_hydration_bool_attr_negation_true() { assert!(!shadow.contains("?disabled"), "no ?disabled prefix: {result}"); } -/// `?disabled="{{!isenabled}}"` with `isEnabled: true` → attribute is omitted. +/// `?disabled="{{!is-enabled}}"` with `is-enabled: 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#""#, + r#""#, &root, &locator, ).unwrap(); @@ -475,16 +477,16 @@ fn test_hydration_bool_attr_negation_false() { assert!(!shadow.contains("disabled"), "disabled absent: {result}"); } -/// `?disabled="{{activegroup == currentgroup}}"` with equal values → renders `disabled`. +/// `?disabled="{{active-group == current-group}}"` 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")), ]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); @@ -494,16 +496,16 @@ fn test_hydration_bool_attr_expression_true() { assert!(!shadow.contains("?disabled"), "no ?disabled prefix: {result}"); } -/// `?disabled="{{activegroup == currentgroup}}"` with unequal values → attribute is omitted. +/// `?disabled="{{active-group == current-group}}"` 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")), ]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); From ef317b14d85a00250f7986ba085e86c7bf0827e4 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:51:43 -0700 Subject: [PATCH 07/14] chore: update change file comment to reflect final PR scope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...fix-kebab-bindings-1314e0de-ff4c-4dd5-8cc5-2e8fc04bc0aa.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1f257b15d62..90e448b0a94 100644 --- 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 @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "fix: normalise attribute keys to lowercase (strip hyphens) when building custom element state — this is the default behaviour, mirroring how browsers treat case-insensitive HTML attribute names", + "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" From cd1183e025ec911bdd3ef256d5238a244f68fc8e Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:55:37 -0700 Subject: [PATCH 08/14] fix(fast-build): strip property binding attrs from rendered HTML; lowercase attr keys - Extend strip_client_only_attrs (renamed from strip_event_attrs) to also remove :attr property binding attributes from rendered HTML output, in addition to @attr event bindings. Both are FAST client-only constructs. - Add .to_lowercase() back to attribute key normalisation: HTML attribute names are case-insensitive and browsers store them lowercase, so isEnabled becomes isenabled. Hyphens are preserved (selected-user-id unchanged). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/microsoft-fast-build/src/attribute.rs | 22 +++++++++-------- crates/microsoft-fast-build/src/directive.rs | 17 ++++++++++++- crates/microsoft-fast-build/src/node.rs | 6 ++--- .../microsoft-fast-build/tests/hydration.rs | 24 +++++++++---------- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/crates/microsoft-fast-build/src/attribute.rs b/crates/microsoft-fast-build/src/attribute.rs index f75c1b014ca..ed7eb068918 100644 --- a/crates/microsoft-fast-build/src/attribute.rs +++ b/crates/microsoft-fast-build/src/attribute.rs @@ -327,14 +327,15 @@ fn extract_bool_attr_prefix(result: &str) -> Option { } } -/// Remove all `@`-prefixed event binding attributes from an opening tag string, -/// preserving all other attributes, whitespace, and the closing `>` or `/>` verbatim. +/// Remove all FAST client-only binding attributes from an opening tag string: +/// - `@attr="{...}"` event bindings +/// - `:attr="..."` property bindings /// -/// Event bindings (`@click="{...}"`) are FAST client-side-only constructs with 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_event_attrs(tag: &str) -> String { +/// 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 bytes = tag.as_bytes(); let mut result = String::with_capacity(tag.len()); let mut i = 0; @@ -372,7 +373,8 @@ pub fn strip_event_attrs(tag: &str) -> String { { i += 1; } - let is_event = tag[name_start..i].starts_with('@'); + let name = &tag[name_start..i]; + let is_client_only = name.starts_with('@') || name.starts_with(':'); // Advance past the attribute value (if any). let val_end = if i < bytes.len() && bytes[i] == b'=' { @@ -397,10 +399,10 @@ pub fn strip_event_attrs(tag: &str) -> String { i // Boolean attribute — no value. }; - if !is_event { + if !is_client_only { result.push_str(&tag[ws_start..val_end]); } - // For @-prefixed attrs: drop both the preceding whitespace and the attr+value. + // For @- and :-prefixed attrs: drop both the preceding whitespace and the attr+value. } result diff --git a/crates/microsoft-fast-build/src/directive.rs b/crates/microsoft-fast-build/src/directive.rs index e41703f446d..4f70ed5f3b6 100644 --- a/crates/microsoft-fast-build/src/directive.rs +++ b/crates/microsoft-fast-build/src/directive.rs @@ -5,8 +5,12 @@ 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, +<<<<<<< HEAD count_tag_attribute_bindings, resolve_attribute_bindings_in_tag, data_attr_to_dataset_key, +======= + count_tag_attribute_bindings, resolve_attribute_bindings_in_tag, strip_client_only_attrs, +>>>>>>> 5a9f713af (fix(fast-build): strip property binding attrs from rendered HTML; lowercase attr keys) }; use crate::error::{RenderError, template_context}; use crate::node::render_node; @@ -261,6 +265,9 @@ pub fn render_custom_element( continue; } let json_val = attribute_to_json_value(value.as_ref(), root, loop_vars); + // Strip the leading `:` then lowercase — HTML attribute names are case-insensitive + // and browsers always store them lowercase, so `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 @@ -271,7 +278,8 @@ pub fn render_custom_element( } } } else { - state_map.insert(attr_name.clone(), json_val); + let key = attr_name.trim_start_matches(':').to_lowercase(); + state_map.insert(key, json_val); } } let child_root = JsonValue::Object(state_map); @@ -344,9 +352,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 { +<<<<<<< HEAD return format!("{}>", open_tag_base); } let resolved = resolve_attribute_bindings_in_tag(open_tag_base, root, loop_vars); +======= + 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); +>>>>>>> 5a9f713af (fix(fast-build): strip property binding attrs from rendered HTML; lowercase attr keys) match parent_hydration { Some(hy) => { let start_idx = hy.binding_idx; diff --git a/crates/microsoft-fast-build/src/node.rs b/crates/microsoft-fast-build/src/node.rs index 2420d22ac3d..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, strip_event_attrs, 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,10 +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); - let stripped = strip_event_attrs(&resolved); + let stripped = strip_client_only_attrs(&resolved); result.push_str(&inject_compact_marker(&stripped, start_idx, total)); } else { - result.push_str(&strip_event_attrs(tag_str)); + result.push_str(&strip_client_only_attrs(tag_str)); } pos = tag_end; } diff --git a/crates/microsoft-fast-build/tests/hydration.rs b/crates/microsoft-fast-build/tests/hydration.rs index 6cfbcd2c0be..a8f8c462fd3 100644 --- a/crates/microsoft-fast-build/tests/hydration.rs +++ b/crates/microsoft-fast-build/tests/hydration.rs @@ -446,13 +446,13 @@ fn test_hydration_bool_attr_false() { assert!(shadow.contains("data-fe-c-0-1"), "compact marker: {result}"); } -/// `?disabled="{{!is-enabled}}"` with `is-enabled: 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#""#, + r#""#, &root, &locator, ).unwrap(); @@ -462,13 +462,13 @@ fn test_hydration_bool_attr_negation_true() { assert!(!shadow.contains("?disabled"), "no ?disabled prefix: {result}"); } -/// `?disabled="{{!is-enabled}}"` with `is-enabled: 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#""#, + r#""#, &root, &locator, ).unwrap(); @@ -477,16 +477,16 @@ fn test_hydration_bool_attr_negation_false() { assert!(!shadow.contains("disabled"), "disabled absent: {result}"); } -/// `?disabled="{{active-group == current-group}}"` 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")), ]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); @@ -496,16 +496,16 @@ fn test_hydration_bool_attr_expression_true() { assert!(!shadow.contains("?disabled"), "no ?disabled prefix: {result}"); } -/// `?disabled="{{active-group == current-group}}"` 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")), ]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); From 7876ddbc4ee9a27379339444f17c038841e783e3 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:01:03 -0700 Subject: [PATCH 09/14] refactor: rewrite strip_client_only_attrs using parse_element_attributes Replace the raw byte-scanner with a declarative approach that reuses the existing parse_element_attributes / read_tag_name helpers: - extract tag name via read_tag_name - parse attributes via parse_element_attributes - filter out @ / : prefixed names - rebuild the tag, preserving >, />, or no-closing as appropriate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/microsoft-fast-build/src/attribute.rs | 84 +++++--------------- 1 file changed, 20 insertions(+), 64 deletions(-) diff --git a/crates/microsoft-fast-build/src/attribute.rs b/crates/microsoft-fast-build/src/attribute.rs index ed7eb068918..a7a9a93523a 100644 --- a/crates/microsoft-fast-build/src/attribute.rs +++ b/crates/microsoft-fast-build/src/attribute.rs @@ -336,76 +336,32 @@ fn extract_bool_attr_prefix(result: &str) -> Option { /// 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 bytes = tag.as_bytes(); - let mut result = String::with_capacity(tag.len()); - let mut i = 0; - - // Copy `' - && bytes[i] != b'/' - { - result.push(bytes[i] as char); - i += 1; - } - - loop { - // Collect leading whitespace before the next token. - let ws_start = i; - while i < bytes.len() && bytes[i].is_ascii_whitespace() { - i += 1; - } + let trimmed = tag.trim_end(); + let is_self_closing = trimmed.ends_with("/>"); + let has_closing_gt = is_self_closing || trimmed.ends_with('>'); - // End of tag or end of string — flush remaining bytes and stop. - if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' { - result.push_str(&tag[ws_start..]); - break; - } + let tag_name = match read_tag_name(tag, 0) { + Some(name) => name, + None => return tag.to_string(), + }; - // Read the attribute name. - let name_start = i; - while i < bytes.len() - && !bytes[i].is_ascii_whitespace() - && bytes[i] != b'=' - && bytes[i] != b'>' - && bytes[i] != b'/' - { - i += 1; + let mut out = format!("<{}", tag_name); + for (name, value) in parse_element_attributes(tag) { + if name.starts_with('@') || name.starts_with(':') { + continue; } - let name = &tag[name_start..i]; - let is_client_only = name.starts_with('@') || name.starts_with(':'); - - // Advance past the attribute value (if any). - let val_end = if i < bytes.len() && bytes[i] == b'=' { - i += 1; // skip `=` - if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') { - let q = bytes[i]; - i += 1; - while i < bytes.len() && bytes[i] != q { - i += 1; - } - if i < bytes.len() { - i += 1; // skip closing quote - } - } else { - // Unquoted value. - while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' { - i += 1; - } - } - i - } else { - i // Boolean attribute — no value. - }; - - if !is_client_only { - result.push_str(&tag[ws_start..val_end]); + match value { + None => { out.push(' '); out.push_str(&name); } + Some(v) => out.push_str(&format!(" {}=\"{}\"", name, v)), } - // For @- and :-prefixed attrs: drop both the preceding whitespace and the attr+value. } - result + 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 `/>`. From 180901388f6625003da4e3a4f868c2022b27c792 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:15:04 -0700 Subject: [PATCH 10/14] feat: preserve property binding key casing in child scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Property bindings (:attr) represent JavaScript property names which are case-sensitive. Strip the leading : but preserve the original casing so :myProp -> key "myProp" in child state. Plain HTML attributes still get lowercased (browsers normalise attribute names to lowercase). - directive.rs: split key normalisation — :prop preserves casing, HTML attr lowercases - tests: add test_custom_element_camel_property_binding; update existing colon binding test comment - README.md: update attribute→state table and description; fix @click examples to show it stripped from rendered HTML - DESIGN.md: update step 4 to describe separate normalisation rules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../microsoft-fast-build/tests/custom_elements.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/microsoft-fast-build/tests/custom_elements.rs b/crates/microsoft-fast-build/tests/custom_elements.rs index 5dc2ce4d7e7..b884d3c2f06 100644 --- a/crates/microsoft-fast-build/tests/custom_elements.rs +++ b/crates/microsoft-fast-build/tests/custom_elements.rs @@ -231,7 +231,7 @@ fn test_custom_element_multi_word_kebab_to_camel() { #[test] fn test_custom_element_colon_property_binding() { - // `:myprop="{{expr}}"` — the `:` prefix is stripped when building child state + // `:myprop="{{expr}}"` — `:` prefix stripped, lowercase name preserved as-is in child state let parent_template = r#""#; let child_template = "{{myprop}}"; let locator = make_locator(&[("child-el", child_template)]); @@ -239,6 +239,17 @@ fn test_custom_element_colon_property_binding() { assert!(result.contains("hello"), "colon binding resolved: {result}"); } +#[test] +fn test_custom_element_camel_property_binding() { + // `:myProp="{{expr}}"` — property binding keys preserve original casing + // (JavaScript property names are case-sensitive, unlike HTML attributes) + let parent_template = r#""#; + let child_template = "{{myProp}}"; + let locator = make_locator(&[("child-el", child_template)]); + let result = render_template_with_locator(parent_template, r#"{"value": "hello"}"#, &locator).unwrap(); + assert!(result.contains("hello"), "camelCase property binding resolved: {result}"); +} + #[test] fn test_custom_element_event_binding_skipped() { // `@click="{handler()}"` bindings are skipped — they are client-side only From 6188a4b3976b44cb7ef90b0ec7c68287e7227444 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:26:45 -0700 Subject: [PATCH 11/14] test: use property bindings in hydration tests to preserve camelCase keys Switch parent templates in bool-attr hydration tests from plain HTML attributes to property bindings (:isEnabled, :activeGroup, :currentGroup) so the child scope receives keys with their original JS property name {{activeGroup == currentGroup}} instead of their lowercased equivalents. Also update the README boolean binding example to show camelCase property names in the shadow template. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../microsoft-fast-build/tests/hydration.rs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/microsoft-fast-build/tests/hydration.rs b/crates/microsoft-fast-build/tests/hydration.rs index a8f8c462fd3..4dbda6df3c8 100644 --- a/crates/microsoft-fast-build/tests/hydration.rs +++ b/crates/microsoft-fast-build/tests/hydration.rs @@ -446,13 +446,13 @@ 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#""#, + r#""#, &root, &locator, ).unwrap(); @@ -462,13 +462,13 @@ 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#""#, + r#""#, &root, &locator, ).unwrap(); @@ -477,16 +477,16 @@ fn test_hydration_bool_attr_negation_false() { assert!(!shadow.contains("disabled"), "disabled absent: {result}"); } -/// `?disabled="{{activegroup == currentgroup}}"` 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")), ]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); @@ -496,16 +496,16 @@ fn test_hydration_bool_attr_expression_true() { assert!(!shadow.contains("?disabled"), "no ?disabled prefix: {result}"); } -/// `?disabled="{{activegroup == currentgroup}}"` 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")), ]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); From 00d8fcc93ea2e0004c45b97dc512a01aa0378fa7 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:37:05 -0700 Subject: [PATCH 12/14] fix: skip :property bindings from child rendering state entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Property bindings (:prop) are client-side only — like @event bindings they are resolved by the FAST client runtime and have no meaning in SSR state. They are already stripped from rendered HTML; now they are also not added to the child element's rendering scope. - directive.rs: extend the client-only skip to include : prefix alongside @ - tests/custom_elements.rs: replace colon binding tests with test_custom_element_colon_property_binding_skipped; remove camel test - tests/hydration.rs: revert parent templates to plain HTML attrs (isEnabled, activeGroup, currentGroup) and child templates back to lowercase state keys (isenabled, activegroup, currentgroup) - README.md: update table and description; fix bool-attr example back to lowercase state keys - DESIGN.md: update steps 4 and 7 to describe unified client-only skip Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/microsoft-fast-build/src/directive.rs | 14 +++++---- .../tests/custom_elements.rs | 30 ++++++++----------- .../microsoft-fast-build/tests/hydration.rs | 24 +++++++-------- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/crates/microsoft-fast-build/src/directive.rs b/crates/microsoft-fast-build/src/directive.rs index 4f70ed5f3b6..cf256ba6a39 100644 --- a/crates/microsoft-fast-build/src/directive.rs +++ b/crates/microsoft-fast-build/src/directive.rs @@ -260,14 +260,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 event handler bindings (@click, @keydown, etc.) — client-side only - if attr_name.starts_with('@') { + // 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); - // Strip the leading `:` then lowercase — HTML attribute names are case-insensitive - // and browsers always store them lowercase, so `isEnabled` becomes `isenabled`. - // Hyphens are preserved: `selected-user-id` stays `selected-user-id`. + // 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 @@ -278,7 +280,7 @@ pub fn render_custom_element( } } } else { - let key = attr_name.trim_start_matches(':').to_lowercase(); + let key = attr_name.to_lowercase(); state_map.insert(key, json_val); } } diff --git a/crates/microsoft-fast-build/tests/custom_elements.rs b/crates/microsoft-fast-build/tests/custom_elements.rs index b884d3c2f06..37b487fc30d 100644 --- a/crates/microsoft-fast-build/tests/custom_elements.rs +++ b/crates/microsoft-fast-build/tests/custom_elements.rs @@ -230,24 +230,18 @@ fn test_custom_element_multi_word_kebab_to_camel() { // ── colon-prefixed property bindings ───────────────────────────────────────── #[test] -fn test_custom_element_colon_property_binding() { - // `:myprop="{{expr}}"` — `:` prefix stripped, lowercase name preserved as-is in child state - let parent_template = r#""#; - let child_template = "{{myprop}}"; - let locator = make_locator(&[("child-el", child_template)]); - let result = render_template_with_locator(parent_template, r#"{"value": "hello"}"#, &locator).unwrap(); - assert!(result.contains("hello"), "colon binding resolved: {result}"); -} - -#[test] -fn test_custom_element_camel_property_binding() { - // `:myProp="{{expr}}"` — property binding keys preserve original casing - // (JavaScript property names are case-sensitive, unlike HTML attributes) - let parent_template = r#""#; - let child_template = "{{myProp}}"; - let locator = make_locator(&[("child-el", child_template)]); - let result = render_template_with_locator(parent_template, r#"{"value": "hello"}"#, &locator).unwrap(); - assert!(result.contains("hello"), "camelCase property binding resolved: {result}"); +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] diff --git a/crates/microsoft-fast-build/tests/hydration.rs b/crates/microsoft-fast-build/tests/hydration.rs index 4dbda6df3c8..a8f8c462fd3 100644 --- a/crates/microsoft-fast-build/tests/hydration.rs +++ b/crates/microsoft-fast-build/tests/hydration.rs @@ -446,13 +446,13 @@ 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#""#, + r#""#, &root, &locator, ).unwrap(); @@ -462,13 +462,13 @@ 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#""#, + r#""#, &root, &locator, ).unwrap(); @@ -477,16 +477,16 @@ fn test_hydration_bool_attr_negation_false() { assert!(!shadow.contains("disabled"), "disabled absent: {result}"); } -/// `?disabled="{{activeGroup == currentGroup}}"` 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")), ]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); @@ -496,16 +496,16 @@ fn test_hydration_bool_attr_expression_true() { assert!(!shadow.contains("?disabled"), "no ?disabled prefix: {result}"); } -/// `?disabled="{{activeGroup == currentGroup}}"` 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")), ]); let result = render_with_locator( - r#""#, + r#""#, &root, &locator, ).unwrap(); From e52448032fe84904672e5d8bd3707b266e14aac1 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:40:07 -0700 Subject: [PATCH 13/14] test: fix mismatched test names in custom_elements.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_custom_element_kebab_attr_camel_in_template → test_custom_element_kebab_attr_hyphens_preserved (no camel conversion; hyphens are kept) - test_custom_element_multi_word_kebab_to_camel → test_custom_element_multi_word_kebab_attrs (no camel conversion; kebab keys passed through as-is) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/microsoft-fast-build/DESIGN.md | 13 ++++++------ crates/microsoft-fast-build/README.md | 14 ++++++++++--- crates/microsoft-fast-build/src/directive.rs | 21 ++++--------------- .../tests/custom_elements.rs | 9 ++++---- 4 files changed, 27 insertions(+), 30 deletions(-) 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/directive.rs b/crates/microsoft-fast-build/src/directive.rs index cf256ba6a39..7bd4961eb91 100644 --- a/crates/microsoft-fast-build/src/directive.rs +++ b/crates/microsoft-fast-build/src/directive.rs @@ -5,12 +5,8 @@ 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, -<<<<<<< HEAD - count_tag_attribute_bindings, resolve_attribute_bindings_in_tag, - data_attr_to_dataset_key, -======= count_tag_attribute_bindings, resolve_attribute_bindings_in_tag, strip_client_only_attrs, ->>>>>>> 5a9f713af (fix(fast-build): strip property binding attrs from rendered HTML; lowercase attr keys) + data_attr_to_dataset_key, }; use crate::error::{RenderError, template_context}; use crate::node::render_node; @@ -331,16 +327,13 @@ fn kebab_to_camel(s: &str) -> String { 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()) } @@ -354,22 +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 { -<<<<<<< HEAD - return format!("{}>", open_tag_base); - } - let resolved = resolve_attribute_bindings_in_tag(open_tag_base, root, loop_vars); -======= 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); ->>>>>>> 5a9f713af (fix(fast-build): strip property binding attrs from rendered HTML; lowercase attr keys) 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/tests/custom_elements.rs b/crates/microsoft-fast-build/tests/custom_elements.rs index 37b487fc30d..d16f45406e7 100644 --- a/crates/microsoft-fast-build/tests/custom_elements.rs +++ b/crates/microsoft-fast-build/tests/custom_elements.rs @@ -204,19 +204,20 @@ fn test_locator_name_from_f_template_attribute_not_file_stem() { // ── attribute name → lowercase normalisation ────────────────────────────────── #[test] -fn test_custom_element_kebab_attr_camel_in_template() { - // kebab-case attrs are lowercased but hyphens are preserved by default +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"), "lowercase resolved: {result}"); + assert!(result.contains("42"), "kebab attr resolved: {result}"); } #[test] -fn test_custom_element_multi_word_kebab_to_camel() { +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#""#, From a40674993ddd5cc606a34db7fc45f63c95cc52da Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:08:49 -0700 Subject: [PATCH 14/14] chore: remove stray hydration-markup-debug.ts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/hydration/hydration-markup-debug.ts | 130 ------------------ 1 file changed, 130 deletions(-) delete mode 100644 packages/fast-element/src/hydration/hydration-markup-debug.ts diff --git a/packages/fast-element/src/hydration/hydration-markup-debug.ts b/packages/fast-element/src/hydration/hydration-markup-debug.ts deleted file mode 100644 index 1eb65acdd9b..00000000000 --- a/packages/fast-element/src/hydration/hydration-markup-debug.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { - CompiledViewBehaviorFactory, - ViewBehaviorTargets, -} from "../templating/html-directive.js"; - -/** - * Builds a detailed debug message for a hydration binding failure where a - * factory's target node could not be found in the resolved targets. - * - * @param factory - The factory whose target node could not be located. - * @param failureIndex - The zero-based index into the factories array at which failure occurred. - * @param totalFactories - Total number of factories in the view. - * @param targets - The targets that were successfully resolved before the failure. - * @param hostName - The tag name of the host element. - * @param templateString - String representation of the view's template. - */ -export function buildMissingBindingTargetMessage( - factory: CompiledViewBehaviorFactory, - failureIndex: number, - totalFactories: number, - targets: ViewBehaviorTargets, - hostName: string, - templateString: string, -): string { - const resolvedTargetIds = Object.keys(targets); - const resolvedCount = resolvedTargetIds.length; - const factoryInfo = factory as any; - - const lines: string[] = [ - `Hydration binding error in <${hostName.toLowerCase()}>: could not locate a DOM target for the binding factory at index ${failureIndex} of ${totalFactories}.`, - ``, - `Progress: ${resolvedCount} of ${totalFactories} binding target(s) were resolved successfully before this error.`, - ` Resolved target IDs: [${resolvedTargetIds.join(", ") || "none"}]`, - ``, - `Issue: Missing binding target (type: attribute or content binding mismatch)`, - ` Expected target node ID : "${factory.targetNodeId}"`, - ]; - - if (factory.targetTagName) { - lines.push( - ` Expected element tag : <${factory.targetTagName.toLowerCase()}>`, - ); - } - - if (factoryInfo.sourceAspect) { - lines.push(` Binding aspect : "${factoryInfo.sourceAspect}"`); - } - - if (factoryInfo.aspectType !== undefined) { - lines.push(` Aspect type : ${factoryInfo.aspectType}`); - } - - lines.push( - ``, - `Possible causes:`, - ` 1. The server-rendered HTML does not match the client-side template`, - ` 2. Hydration markers are missing or were corrupted`, - ` 3. The DOM was modified before hydration completed`, - ``, - `Template (first 200 chars): ${templateString.slice(0, 200)}${templateString.length > 200 ? "..." : ""}`, - ); - - return lines.join("\n"); -} - -/** - * Builds a detailed debug message for a hydration element targeting failure - * where an element's attribute binding marker referenced a non-existent factory. - * - * @param node - The element whose attribute binding marker referenced a bad factory index. - * @param factories - All compiled factories for the view. - * @param markerIndex - The raw factory index read from the attribute marker. - * @param hydrationIndexOffset - The offset applied to the marker index. - * @param resolvedTargets - Targets successfully resolved before the failure. - * @param hostName - The tag name of the host element. - */ -export function buildTargetElementErrorMessage( - node: Element, - factories: CompiledViewBehaviorFactory[], - markerIndex: number, - hydrationIndexOffset: number, - resolvedTargets: ViewBehaviorTargets, - hostName: string, -): string { - const adjustedIndex = markerIndex + hydrationIndexOffset; - const resolvedTargetIds = Object.keys(resolvedTargets); - const resolvedCount = resolvedTargetIds.length; - - const lines: string[] = [ - `Hydration element targeting error on <${node.nodeName.toLowerCase()}> inside <${hostName.toLowerCase()}>.`, - ``, - `Progress: ${resolvedCount} target(s) were resolved successfully before this error.`, - ` Resolved target IDs: [${resolvedTargetIds.join(", ") || "none"}]`, - ``, - `Issue: Attribute binding marker references factory index ${adjustedIndex} (marker value: ${markerIndex}, offset: ${hydrationIndexOffset}), but only ${factories.length} factory/factories exist (valid range: 0–${factories.length - 1}).`, - ``, - `This likely means the server-rendered HTML was produced with a different template`, - `than the one currently being used for hydration (template/factory count mismatch).`, - ]; - - return lines.join("\n"); -} - -/** - * Builds a detailed debug message for a hydration repeat marker mismatch where - * the start marker found does not correspond to the expected repeat item index. - * - * @param hostName - The tag name of the host element. - * @param expectedIndex - The repeat item index that was expected. - * @param foundIndex - The repeat item index found in the start marker. - */ -export function buildRepeatMarkerMismatchMessage( - hostName: string, - expectedIndex: number, - foundIndex: number | null, -): string { - const lines: string[] = [ - `Hydration repeat marker mismatch inside <${hostName.toLowerCase()}>.`, - ``, - `Issue: The repeat start marker found does not match the expected repeat item index.`, - ` Expected index : ${expectedIndex}`, - ` Found index : ${foundIndex !== null ? foundIndex : "unreadable (marker parse failed)"}`, - ``, - `This usually means:`, - ` 1. The server rendered a different number of items than the client template expects`, - ` 2. Repeat hydration markers were inserted out of order or are corrupted`, - ]; - - return lines.join("\n"); -}