Skip to content
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": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: parse JSON array and object literals in HTML attribute values",
"packageName": "@microsoft/fast-build",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
5 changes: 3 additions & 2 deletions crates/microsoft-fast-build/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Both return `RenderError::EmptyBinding` for blank expressions, `RenderError::Unc
2. If the expression is `varname.prop.path`, and `varname` matches a loop variable, apply `get_nested_property` to the loop variable's value with the remaining path.
3. Fall back to `get_nested_property(root, expr)`.

`get_nested_property` walks a dot-separated path through `JsonValue::Object` and `JsonValue::Array` nodes. Numeric path segments (e.g. `list.0`) are used as array indices.
`get_nested_property` walks a dot-separated path through `JsonValue::Object` and `JsonValue::Array` nodes. Numeric path segments (e.g. `list.0`) are used as array indices. The special segment `"length"` on an array returns the array length as a number (e.g. `{{items.length}}`).

### Loop variable scoping

Expand Down Expand Up @@ -225,7 +225,8 @@ A custom element is any opening tag whose name contains a hyphen, excluding `f-w
- `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, including arrays and objects)
- Anything else β†’ `String` (attribute values are always strings; arrays, objects, booleans, and numbers must be passed via `:prop="{{binding}}"` or `prop="{{binding}}"` so the resolved value from parent state is used)
- Value starting with `[` or `{` β†’ parsed as a JSON array or object literal (e.g. `items='["a","b","c"]'` or `config='{"key":"val"}'`). If parsing fails the value falls back to `String`.
- Anything else β†’ `String` (plain literal values like `count="42"` are strings; use `count="{{count}}"` to resolve from parent state as a number)
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. **Build the outer opening tag** via `build_element_open_tag`, which handles attribute resolution and optionally injects hydration markers. The behaviour differs by context:
Expand Down
21 changes: 17 additions & 4 deletions crates/microsoft-fast-build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,22 @@ Inside `<f-repeat>`, `{{item}}` resolves to the current loop variable and `{{$in
</f-when>
```

### Property Access and Array Indexing
### Property Access, Array Indexing, and `.length`

Dot-notation and numeric indices are both supported:
Dot-notation, numeric indices, and the special `.length` property on arrays are all supported:

```html
{{user.address.city}}
{{list.0.name}}
{{items.length}}
```

`{{items.length}}` resolves to the number of elements in the array. It can be used in both content bindings and `<f-when>` expressions:

```html
<f-when value="{{items.length > 0}}">
<p>{{items.length}} items found</p>
</f-when>
```

---
Expand Down Expand Up @@ -359,9 +368,13 @@ Attributes on a custom element become the state passed to its template:

**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.

**Attribute values are always strings** β€” except for boolean attributes (no value), which become `true`, and `{{binding}}` expressions, which resolve to whatever type the value holds in the parent state (string, number, boolean, array, or object). A literal string like `count="42"` yields `{"count": "42"}`; use `count="{{count}}"` with `count: 42` in state to get a number.
**Attribute value coercion** β€” attribute values are resolved in this order:
- No value (boolean attribute) β†’ `true`
- `"{{binding}}"` β†’ resolved from parent state (any type: string, number, boolean, array, or object)
- Value starting with `[` or `{` β†’ parsed as a JSON array or object literal (e.g. `items='["a","b","c"]'`)
- Anything else β†’ `String` (e.g. `count="42"` yields `{"count": "42"}`; use `count="{{count}}"` to get a number)

**Use `:prop="{{binding}}"` to pass arrays and objects without polluting HTML attributes** β€” the `:` prefix causes the attribute to be stripped from the rendered HTML while still forwarding the resolved value (which can be an array or object) into the child element's rendering state. This is the recommended way to pass structured data to custom elements for use with `f-repeat` and similar directives.
**Use `:prop="{{binding}}"` to pass arrays and objects from state without polluting HTML attributes** β€” the `:` prefix causes the attribute to be stripped from the rendered HTML while still forwarding the resolved value (which can be an array or object) into the child element's rendering state. Alternatively, JSON array or object literals can be inlined directly as attribute values (e.g. `items='["a","b","c"]'`), which is useful when the data is static and does not come from parent state.

**`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.

Expand Down
6 changes: 6 additions & 0 deletions crates/microsoft-fast-build/src/directive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,12 @@ fn attribute_to_json_value(value: Option<&String>, root: &JsonValue, loop_vars:
let binding = v[2..v.len() - 2].trim();
return resolve_value(binding, root, loop_vars).unwrap_or(JsonValue::Null);
}
// Try parsing JSON array or object literals passed as attribute values
if v.starts_with('[') || v.starts_with('{') {
if let Ok(parsed) = crate::json::parse(v) {
return parsed;
}
}
JsonValue::String(v.clone())
}

Expand Down
43 changes: 43 additions & 0 deletions crates/microsoft-fast-build/tests/custom_elements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -565,3 +565,46 @@ fn test_root_elements_with_different_per_element_attrs() {
assert!(result.contains("earth"), "first element attr: {result}");
assert!(result.contains("mars"), "second element attr: {result}");
}

// ── JSON literals in attribute values ─────────────────────────────────────────

#[test]
fn test_custom_element_json_array_attr() {
let locator = make_locator(&[(
"item-list",
r#"<f-repeat value="{{item in items}}"><span>{{item}}</span></f-repeat>"#,
)]);
let result = render_template_with_locator(
r#"<item-list items='["a","b","c"]'></item-list>"#,
"{}",
&locator,
).unwrap();
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#"<f-repeat value="{{item in items}}"><span>{{item}}</span></f-repeat>"#,
)]);
let result = render_template_with_locator(
r#"<item-list items="[]"></item-list>"#,
"{}",
&locator,
).unwrap();
assert!(!result.contains("<span>"), "no items: {result}");
}

#[test]
fn test_custom_element_json_object_attr() {
let locator = make_locator(&[("my-card", r#"<div>{{config.title}}</div>"#)]);
let result = render_template_with_locator(
r#"<my-card config='{"title":"Hello"}'></my-card>"#,
"{}",
&locator,
).unwrap();
assert!(result.contains("Hello"), "rendered: {result}");
}
Loading