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": "minor",
"comment": "feat: convert dataset.X attribute names to data-X in SSR renderer",
"packageName": "@microsoft/fast-build",
Comment on lines +2 to +4
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change file description looks inverted relative to the PR behavior: it says β€œconvert dataset.X attribute names to data-X”, but the PR maps data-* attributes into nested dataset.* state for SSR. Update the comment string so release notes accurately describe the actual change.

Copilot uses AI. Check for mistakes.
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "patch"
}
15 changes: 15 additions & 0 deletions crates/microsoft-fast-build/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ A custom element is any opening tag whose name contains a hyphen, excluding `f-w
- Numeric string β†’ `Number(f64)`
- `"{{binding}}"` β†’ resolve from parent state (property binding with optional rename)
- Anything else β†’ `String`
- `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.
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:
Expand Down Expand Up @@ -268,6 +269,20 @@ Plain HTML opening tags in the literal regions are scanned by `attribute::find_n

This atomic tag processing ensures that the `{{expr}}` attribute values are never seen as content directives by the main loop β€” `pos` advances past the entire tag before the directive scanner runs again.

### Dataset bindings β€” `attribute::data_attr_to_dataset_key`

FAST elements follow the [MDN `HTMLElement.dataset`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset) convention: a camelCase property (e.g. `dateOfBirth`) corresponds to a kebab-case `data-*` HTML attribute (e.g. `data-date-of-birth`).

When building child state for a custom element (step 4 of `render_custom_element`), any attribute whose name starts with `data-` is routed into a nested `"dataset"` object rather than a top-level key:

```
data-date-of-birth="1990-01-01" β†’ state["dataset"]["dateOfBirth"] = "1990-01-01"
```

`attribute::data_attr_to_dataset_key` returns the full dot-notation path: `"data-date-of-birth"` β†’ `"dataset.dateOfBirth"`. The caller in `render_custom_element` splits on the first `.` (`"dataset"` / `"dateOfBirth"`) and inserts the value into the nested `"dataset"` map. Shadow templates can then use `{{dataset.dateOfBirth}}` which resolves via ordinary dot-notation (`state["dataset"]["dateOfBirth"]`).

The `dataset.` portion of the binding expression is nothing special to `resolve_value` β€” it is plain two-level dot-notation that traverses the nested `"dataset"` object built by the attribute mapper.

### `f-when` markers

```
Expand Down
40 changes: 40 additions & 0 deletions crates/microsoft-fast-build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,42 @@ The `?attr="{{expr}}"` syntax is a FAST convention for conditionally rendering a

The `data-fe-c` compact marker is still emitted so the FAST client runtime knows to reconnect the binding during hydration.

### Dataset Attribute Bindings β€” `dataset.propertyName`

FAST elements follow the [MDN `HTMLElement.dataset`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset) convention: camelCase property names (e.g. `dateOfBirth`) correspond to kebab-case `data-*` HTML attributes (e.g. `data-date-of-birth`).

#### Passing `data-*` attributes to custom elements

When a custom element receives `data-*` attributes, the renderer groups them under a nested `"dataset"` key in the child state so that `{{dataset.X}}` bindings in the shadow template resolve naturally:

```html
<!-- Entry HTML -->
<test-el data-date-of-birth="{{dob}}"></test-el>

<!-- Shadow template of test-el -->
<div data-date-of-birth="{{dataset.dateOfBirth}}"></div>
```

With parent state `{"dob": "1990-01-01"}`, the shadow template receives child state `{"dataset": {"dateOfBirth": "1990-01-01"}}` and renders:

```html
<div data-date-of-birth="1990-01-01" data-fe-c-0-1></div>
```

The `data-*` β†’ `dataset.*` mapping uses the same camelCase conversion as the browser: `data-date-of-birth` β†’ `dataset.dateOfBirth`.

#### Using `{{dataset.X}}` bindings

`{{dataset.X}}` is ordinary dot-notation: it reads `state["dataset"]["X"]`. The `dataset` key must be present in state, either from a `data-*` attribute on the enclosing custom element or from a state object you supply directly:

```html
<!-- Works when state = {"dataset": {"name": "Alice"}} -->
<span>{{dataset.name}}</span>

<!-- Works in f-when -->
<f-when value="{{dataset.active}}">Active</f-when>
```

### Conditional Rendering β€” `<f-when>`

```html
Expand Down Expand Up @@ -239,6 +275,10 @@ Attributes on a custom element become the state passed to its template:
| `label="Click me"` | `{"label": "Click me"}` |
| `count="42"` | `{"count": 42}` |
| `foo="{{bar}}"` | `{"foo": <value of bar from parent state>}` |
| `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>}}` |

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

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

Expand Down
29 changes: 29 additions & 0 deletions crates/microsoft-fast-build/src/attribute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,35 @@ pub fn count_tag_attribute_bindings(tag: &str) -> (usize, usize) {
(db, sb)
}

// ── Data attribute helpers ────────────────────────────────────────────────────

/// Convert a kebab-case string to camelCase.
/// "date-of-birth" β†’ "dateOfBirth", "name" β†’ "name"
fn kebab_to_camel(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut next_upper = false;
for ch in s.chars() {
if ch == '-' {
next_upper = true;
} else if next_upper {
result.push(ch.to_ascii_uppercase());
next_upper = false;
} else {
result.push(ch);
}
}
result
}

/// Convert a `data-kebab-case` HTML attribute name to its full dot-notation
/// dataset path, following the MDN HTMLElement.dataset naming convention.
/// Returns `None` for names that do not start with `data-`.
///
/// Examples: `"data-date-of-birth"` β†’ `"dataset.dateOfBirth"`, `"data-name"` β†’ `"dataset.name"`
pub fn data_attr_to_dataset_key(name: &str) -> Option<String> {
name.strip_prefix("data-").map(|rest| format!("dataset.{}", kebab_to_camel(rest)))
Comment on lines +246 to +250
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data_attr_to_dataset_key returns Some("dataset.") for the attribute name "data-", which then produces an empty property name when inserted into the child state. It would be safer to return None (or otherwise reject) when the suffix after data- is empty to avoid creating state.dataset[""] entries.

Suggested change
/// Returns `None` for names that do not start with `data-`.
///
/// Examples: `"data-date-of-birth"` β†’ `"dataset.dateOfBirth"`, `"data-name"` β†’ `"dataset.name"`
pub fn data_attr_to_dataset_key(name: &str) -> Option<String> {
name.strip_prefix("data-").map(|rest| format!("dataset.{}", kebab_to_camel(rest)))
/// Returns `None` for names that do not start with `data-` or have an empty suffix.
///
/// Examples: `"data-date-of-birth"` β†’ `"dataset.dateOfBirth"`, `"data-name"` β†’ `"dataset.name"`
pub fn data_attr_to_dataset_key(name: &str) -> Option<String> {
name.strip_prefix("data-")
.filter(|rest| !rest.is_empty())
.map(|rest| format!("dataset.{}", kebab_to_camel(rest)))

Copilot uses AI. Check for mistakes.
}

/// Resolve `{{expr}}` in attribute values of an opening tag, leaving `{expr}`
/// single-brace values and all other content unchanged.
/// Handles `?attr="{{expr}}"` boolean bindings: evaluates `expr` as a boolean and
Expand Down
5 changes: 5 additions & 0 deletions crates/microsoft-fast-build/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ use crate::json::JsonValue;

/// Resolve a binding expression against root state and loop variables.
/// Loop vars are checked innermost-first (rev); falls back to root state.
///
/// Dot-notation paths traverse nested objects: `dataset.dateOfBirth` accesses
/// `state["dataset"]["dateOfBirth"]`. When a custom element receives `data-*`
/// HTML attributes, the renderer stores them in the child state under a nested
/// `"dataset"` key so that `{{dataset.X}}` bindings work naturally.
pub fn resolve_value(expr: &str, root: &JsonValue, loop_vars: &[(String, JsonValue)]) -> Option<JsonValue> {
let expr = expr.trim();
for (var_name, value) in loop_vars.iter().rev() {
Expand Down
17 changes: 16 additions & 1 deletion crates/microsoft-fast-build/src/directive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::attribute::{
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,
data_attr_to_dataset_key,
};
use crate::error::{RenderError, template_context};
use crate::node::render_node;
Expand Down Expand Up @@ -249,11 +250,25 @@ pub fn render_custom_element(
};

// Parse attributes and build child state.
// `data-*` attributes are stored using the full dot-notation path returned by
// `data_attr_to_dataset_key` (e.g. `"dataset.dateOfBirth"`), split on the first
// `.` to build a nested state object so `{{dataset.X}}` bindings resolve correctly.
let attrs = parse_element_attributes(open_tag_content);
let mut state_map = std::collections::HashMap::new();
for (attr_name, value) in &attrs {
let json_val = attribute_to_json_value(value.as_ref(), root, loop_vars);
state_map.insert(attr_name.clone(), json_val);
if let Some(path) = data_attr_to_dataset_key(attr_name) {
if let Some((group, prop)) = path.split_once('.') {
let group_val = state_map
.entry(group.to_string())
.or_insert_with(|| JsonValue::Object(std::collections::HashMap::new()));
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If an element has a non-data-* attribute named dataset (or the state map already contains a dataset key) and it's not a JSON object, subsequent data-* attributes will be silently dropped because the if let JsonValue::Object(...) branch won’t run. Consider handling this conflict deterministically (e.g., overwrite dataset with an object, or preserve the existing value under another key and still insert the data-* entries).

Suggested change
.or_insert_with(|| JsonValue::Object(std::collections::HashMap::new()));
.or_insert_with(|| JsonValue::Object(std::collections::HashMap::new()));
if !matches!(group_val, JsonValue::Object(_)) {
*group_val = JsonValue::Object(std::collections::HashMap::new());
}

Copilot uses AI. Check for mistakes.
if let JsonValue::Object(ref mut map) = group_val {
map.insert(prop.to_string(), json_val);
}
}
} else {
state_map.insert(attr_name.clone(), json_val);
}
}
let child_root = JsonValue::Object(state_map);

Expand Down
128 changes: 128 additions & 0 deletions crates/microsoft-fast-build/tests/dataset_attributes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
mod common;
use common::{ok, make_locator};
use microsoft_fast_build::{render_with_locator, JsonValue};
use std::collections::HashMap;

fn state(entries: Vec<(&str, JsonValue)>) -> JsonValue {
JsonValue::Object(entries.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}

fn str_val(s: &str) -> JsonValue { JsonValue::String(s.to_string()) }
fn bool_val(b: bool) -> JsonValue { JsonValue::Bool(b) }

fn empty() -> JsonValue { JsonValue::Object(HashMap::new()) }

// ── Content bindings β€” dataset.X reads state["dataset"]["X"] ─────────────────

/// `{{dataset.name}}` resolves from nested state `{"dataset": {"name": "Alice"}}`.
#[test]
fn test_dataset_content_binding() {
assert_eq!(
ok(r#"<p>{{dataset.name}}</p>"#, r#"{"dataset": {"name": "Alice"}}"#),
r#"<p>Alice</p>"#,
);
}

/// Multi-level camelCase: `{{dataset.dateOfBirth}}` reads `state.dataset.dateOfBirth`.
#[test]
fn test_dataset_content_binding_multi_word() {
assert_eq!(
ok(
r#"{{dataset.dateOfBirth}}"#,
r#"{"dataset": {"dateOfBirth": "1990-01-01"}}"#,
),
"1990-01-01",
);
}

// ── Custom element: data-* attributes populate the dataset state key ──────────

/// `data-date-of-birth` on a custom element becomes `state.dataset.dateOfBirth`
/// in the shadow template.
#[test]
fn test_data_attr_maps_to_dataset_state() {
let locator = make_locator(&[(
"test-el",
r#"<div data-date-of-birth="{{dataset.dateOfBirth}}"></div>"#,
)]);
let result = render_with_locator(
r#"<test-el data-date-of-birth="1990-01-01"></test-el>"#,
&empty(),
&locator,
).unwrap();
assert!(result.contains(r#"data-date-of-birth="1990-01-01""#), "resolved: {result}");
}

/// `data-date-of-birth="{{dob}}"` β€” value from parent binding β€” reaches the
/// shadow template as `{{dataset.dateOfBirth}}`.
#[test]
fn test_data_attr_from_parent_binding() {
let locator = make_locator(&[(
"test-el",
r#"<span>{{dataset.dateOfBirth}}</span>"#,
)]);
let root = state(vec![("dob", str_val("1990-01-01"))]);
let result = render_with_locator(
r#"<test-el data-date-of-birth="{{dob}}"></test-el>"#,
&root,
&locator,
).unwrap();
assert!(result.contains("1990-01-01"), "content binding: {result}");
}

/// Multiple `data-*` attributes are all grouped under `dataset`.
#[test]
fn test_multiple_data_attrs_in_dataset() {
let locator = make_locator(&[(
"test-el",
r#"<p>{{dataset.firstName}} {{dataset.lastName}}</p>"#,
)]);
let result = render_with_locator(
r#"<test-el data-first-name="Ada" data-last-name="Lovelace"></test-el>"#,
&empty(),
&locator,
).unwrap();
assert!(result.contains("Ada"), "firstName: {result}");
assert!(result.contains("Lovelace"), "lastName: {result}");
}

/// Non-`data-*` attributes are still accessible as top-level state keys.
#[test]
fn test_non_data_attrs_remain_top_level() {
let locator = make_locator(&[(
"test-el",
r#"<div class="{{cls}}" data-id="{{dataset.id}}"></div>"#,
)]);
let result = render_with_locator(
r#"<test-el cls="card" data-id="42"></test-el>"#,
&empty(),
&locator,
).unwrap();
assert!(result.contains(r#"class="card""#), "cls: {result}");
assert!(result.contains(r#"data-id="42""#), "dataset.id: {result}");
}

// ── dataset.X in f-when ───────────────────────────────────────────────────────

/// `<f-when value="{{dataset.active}}">` evaluates `state.dataset.active`.
#[test]
fn test_dataset_in_f_when_true() {
assert_eq!(
ok(
r#"<f-when value="{{dataset.active}}">yes</f-when>"#,
r#"{"dataset": {"active": true}}"#,
),
"yes",
);
}

#[test]
fn test_dataset_in_f_when_false() {
assert_eq!(
ok(
r#"<f-when value="{{dataset.active}}">yes</f-when>"#,
r#"{"dataset": {"active": false}}"#,
),
"",
);
}
Loading