Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
13 changes: 7 additions & 6 deletions crates/microsoft-fast-build/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<value>)` 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
<my-button label="Hi">
<template shadowrootmode="open" shadowroot="open">[shadow DOM]</template>
Expand Down
14 changes: 11 additions & 3 deletions crates/microsoft-fast-build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": <value of bar from parent state>}` |
| `selected-user-id="42"` | `{"selected-user-id": "42"}` |
| `isEnabled="{{isEnabled}}"` | `{"isenabled": <resolved value>}` |
| `data-date-of-birth="1990-01-01"` | `{"dataset": {"dateOfBirth": "1990-01-01"}}` |
| `data-date-of-birth="{{dob}}"` | `{"dataset": {"dateOfBirth": <value of dob from parent state>}}` |
| `: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

Expand Down
37 changes: 37 additions & 0 deletions crates/microsoft-fast-build/src/attribute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,43 @@ fn extract_bool_attr_prefix(result: &str) -> Option<String> {
}
}

/// 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);
Expand Down
8 changes: 6 additions & 2 deletions crates/microsoft-fast-build/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@ pub fn get_nested_property(value: &JsonValue, path: &str) -> Option<JsonValue> {
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,
};
Expand Down
42 changes: 33 additions & 9 deletions crates/microsoft-fast-build/src/directive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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::<f64>() { return JsonValue::Number(n); }
JsonValue::String(v.clone())
}

Expand All @@ -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),
}
}
7 changes: 4 additions & 3 deletions crates/microsoft-fast-build/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
17 changes: 17 additions & 0 deletions crates/microsoft-fast-build/tests/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
57 changes: 57 additions & 0 deletions crates/microsoft-fast-build/tests/custom_elements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<span>{{selected-user-id}}</span>")]);
let result = render_template_with_locator(
r#"<my-el selected-user-id="42"></my-el>"#,
"{}",
&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", "<p>{{show-details}}</p><p>{{enable-continue}}</p>")]);
let result = render_template_with_locator(
r#"<my-el show-details="true" enable-continue="false"></my-el>"#,
"{}",
&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", "<button>{{label}}</button>")]);
let result = render_template_with_locator(
r#"<my-btn label="OK" :myProp="{{value}}"></my-btn>"#,
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", "<button>{{label}}</button>")]);
let result = render_template_with_locator(
r#"<my-btn label="OK" @click="{handleClick()}"></my-btn>"#,
"{}",
&locator,
).unwrap();
assert!(result.contains("OK"), "label: {result}");
// The @click binding should not appear in element state or cause an error
}
18 changes: 18 additions & 0 deletions crates/microsoft-fast-build/tests/f_when.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<f-when value="{{items.length > 0}}">has items</f-when>"#, r#"{"items": ["x"]}"#),
"has items",
);
}

#[test]
fn test_when_array_length_zero() {
assert_eq!(
ok(r#"<f-when value="{{items.length > 0}}">has items</f-when>"#, r#"{"items": []}"#),
"",
);
}

// ── chained expressions ───────────────────────────────────────────────────────

#[test]
Expand Down
Loading
Loading