feat(microsoft-fast-build): map data-* attributes to nested dataset state#7398
feat(microsoft-fast-build): map data-* attributes to nested dataset state#7398
Conversation
Convert binding paths that start with 'dataset.' (e.g. dataset.dateOfBirth) to their camelCase property names (e.g. dateOfBirth) following the MDN HTMLElement.dataset naming convention. This ensures that: - Schema root property is the camelCase property name (not 'dataset') - pathResolver accesses element.dateOfBirth (Observable via @attr) - Reactivity works when the backing data-* attribute changes via @attr Adds: - datasetCamelToAttribute(camelCase): converts 'dateOfBirth' -> 'data-date-of-birth' - test fixture: test/fixtures/dataset/ - unit tests for datasetCamelToAttribute conversion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add '#### Dataset bindings' section to README.md explaining the dataset.X → camelCase conversion, @attr usage, and the exported datasetCamelToAttribute helper - Create DESIGN.md documenting the binding resolution pipeline, the dataset conversion rationale, and hydration marker formats Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This reverts commit c3e9f8a.
This reverts commit 171e42c.
…erty access" This reverts commit 97429cd.
Following the MDN HTMLElement.dataset naming convention, attribute names that start with `dataset.` are now converted to `data-*` kebab-case equivalents in the SSR renderer output. - dataset.dateOfBirth -> data-date-of-birth - dataset.createdAt -> data-created-at - ?dataset.active -> ?data-active (boolean binding form) Conversion is applied in: - resolve_attribute_bindings_in_tag (tags with bindings) - process_hydration_tags zero-binding branch (static tags in shadows) - build_element_open_tag zero-binding branch (custom element opening tags) Adds normalize_dataset_attribute_names and dataset_name_to_data_attr helpers in attribute.rs. New integration tests in tests/dataset_attributes.rs cover static, bound, nested-state, mixed, multi-attribute, and custom-element-opening-tag scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t_attribute_names Split the monolithic function into three focused private helpers: - advance_past_tag_name — moves i past <tagname - convert_dataset_attr_name — converts dataset.X / ?dataset.X names - advance_past_attr_value — skips quoted or unquoted attribute values normalize_dataset_attribute_names is now a clean loop that delegates each concern to the appropriate helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…_value
The feature was misimplemented as attribute-name conversion. The correct
behaviour is: when a binding expression inside {{}} starts with dataset.,
the prefix is stripped before state lookup.
{{dataset.dateOfBirth}} resolves state.dateOfBirth (not state.dataset.dateOfBirth).
Templates use data-date-of-birth as the HTML attribute name directly:
<div data-date-of-birth="{{dataset.dateOfBirth}}"></div>
Remove the normalize_dataset_attribute_names, dataset_name_to_data_attr,
advance_past_tag_name, convert_dataset_attr_name, advance_past_attr_value,
and camel_to_kebab functions from attribute.rs; revert node.rs and
directive.rs to their pre-feature state; move the sole change to
context::resolve_value.
Replace all dataset_attributes.rs tests with correct cases:
- content binding with dataset. prefix
- attribute binding in shadow template
- multiple dataset bindings
- f-when with dataset. expression
- plain data-* attr passes through unchanged
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n custom elements
When building child state for a custom element, data-* HTML attributes are
now grouped under a nested "dataset" key using kebab-to-camelCase conversion:
data-date-of-birth="1990-01-01" → state["dataset"]["dateOfBirth"] = "1990-01-01"
Shadow templates access dataset values with ordinary dot-notation bindings:
{{dataset.dateOfBirth}} → state["dataset"]["dateOfBirth"]
Revert the resolve_value strip_prefix change from the previous commit;
dot-notation already traverses the nested dataset object correctly.
Add data_attr_to_dataset_key and kebab_to_camel helpers in attribute.rs.
Update directive.rs to route data-* attrs into the dataset sub-map.
Update tests to use correct state structure {"dataset": {...}} and cover
the full flow: static attrs, parent bindings, multiple dataset attrs,
f-when with dataset, and non-data-* attrs remaining top-level.
Update README (Attribute → State Mapping table, Dataset Attribute Bindings
section) and DESIGN.md (custom element step 4, dataset bindings section).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… dot-notation path data_attr_to_dataset_key now returns the complete path "dataset.dateOfBirth" instead of just the camelCase key "dateOfBirth". The caller in render_custom_element splits on the first '.' to insert the value into the nested state map, so no external knowledge of the "dataset" grouping key is needed. Update docs: attribute.rs doc comment, DESIGN.md step 4 and dataset section, README Attribute → State Mapping note. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the @microsoft/fast-build SSR renderer so data-* attributes on custom elements are grouped into a nested dataset object in the child state, enabling natural {{dataset.fooBar}} bindings that match HTMLElement.dataset conventions.
Changes:
- Add
attribute::data_attr_to_dataset_key(andkebab_to_camel) to convertdata-*names todataset.*dot-paths. - Update
render_custom_elementto routedata-*attributes into a nesteddatasetstate object. - Add integration tests and documentation describing the new mapping behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/microsoft-fast-build/src/attribute.rs | Introduces data-* → dataset.* conversion helper. |
| crates/microsoft-fast-build/src/directive.rs | Builds nested dataset child state when rendering custom elements. |
| crates/microsoft-fast-build/src/context.rs | Documents nested dot-notation access (incl. dataset.*). |
| crates/microsoft-fast-build/tests/dataset_attributes.rs | Adds integration tests validating dataset bindings and custom element propagation. |
| crates/microsoft-fast-build/README.md | Documents dataset attribute/state mapping and usage. |
| crates/microsoft-fast-build/DESIGN.md | Updates design notes for data-* routing into dataset. |
| change/@microsoft-fast-build-a7be27dd-c99a-4485-8ba1-5b3490b7f55e.json | Adds release change entry for this feature. |
| 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())); |
There was a problem hiding this comment.
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).
| .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()); | |
| } |
| /// 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))) |
There was a problem hiding this comment.
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.
| /// 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))) |
| "type": "minor", | ||
| "comment": "feat: convert dataset.X attribute names to data-X in SSR renderer", | ||
| "packageName": "@microsoft/fast-build", |
There was a problem hiding this comment.
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.
…only bindings from SSR output (#7396) ## Summary Fixes how the `microsoft-fast-build` Rust SSR crate handles custom element attributes when building child rendering state and producing declarative shadow DOM HTML. Rebased on top of #7398 (`data-*` → dataset mapping). ### Attribute → child state key rules | Binding form | Child state key | Rationale | |---|---|---| | `isEnabled="..."` | `isenabled` | HTML attrs are case-insensitive; browsers lowercase | | `selected-user-id="42"` | `selected-user-id` | Hyphens preserved | | `foo="{{bar}}"` | `foo` (resolved from parent state) | Standard double-brace binding | | `data-date-of-birth="..."` | `{"dataset": {"dateOfBirth": ...}}` | Handled by #7398 | | `:myProp="{{expr}}"` | *(skipped)* | Client-side only — FAST runtime sets JS properties | | `@click="{fn()}"` | *(skipped)* | Client-side only | ### Attribute value rules - **Boolean attributes** (no value, e.g. `disabled`) → `true` in child state - **All string attributes** → stored as strings — `count="42"` is `"42"`, not the number `42` - **Typed values** must be passed via `{{binding}}` expressions so the resolved `JsonValue` flows from parent state ### Client-only bindings — stripped from HTML, skipped from state Both `@event` and `:property` bindings are **removed from all rendered HTML** (outer element open tag and all tags inside the declarative shadow DOM) and are **not added to the child element's rendering scope**. The `data-fe-c` binding count is preserved so the FAST runtime still allocates the correct number of binding slots. ### Changes - `directive.rs`: `@` and `:` prefixed attrs both skipped when building child state; all HTML attribute keys lowercased; `attribute_to_json_value` simplified (strings only, no numeric/boolean coercion for literals); dataset logic from #7398 preserved - `attribute.rs`: `strip_client_only_attrs` removes `@attr` and `:attr` from any opening tag; refactored to use `parse_element_attributes` + `read_tag_name` - `node.rs`: `strip_client_only_attrs` wired into hydration tag scan so shadow template HTML is also cleaned - Tests updated; `test_custom_element_colon_property_binding_skipped` verifies property bindings are stripped from HTML and not passed to child scope; stale test names fixed - DESIGN.md and README.md updated to document the full attribute handling rules alongside the dataset mapping from #7398
Pull Request
📖 Description
When a custom element receives
data-*HTML attributes, the SSR renderer now groups them under a nested"dataset"key in the child state, following the MDNHTMLElement.datasetnaming convention.How it works
attribute::data_attr_to_dataset_keyconverts adata-*attribute name to the full dot-notation state path:The caller in
render_custom_elementsplits on." to insert into the nested state map, so shadow templates can use{{dataset.X}}` via ordinary dot-notation:Attribute → State Mapping
data-date-of-birth="1990-01-01"{"dataset": {"dateOfBirth": "1990-01-01"}}data-date-of-birth="{{dob}}"{"dataset": {"dateOfBirth": <value of dob>}}label="Hi"{"label": "Hi"}(non-data-*unchanged)Change surface
attribute.rs:data_attr_to_dataset_keyreturns the full path"dataset.dateOfBirth"; privatekebab_to_camelhelperdirective.rs: child state builder splits the dot-notation path on.and inserts into a nested"dataset"mapcontext.rs: no change — dot-notation already handles nested access📑 Test Plan
8 integration tests in
crates/microsoft-fast-build/tests/dataset_attributes.rs:{{dataset.name}}content binding readsstate["dataset"]["name"]dataset.dateOfBirth)data-*attribute on custom element →{{dataset.X}}resolves in shadowdata-*value from parent binding (data-date-of-birth="{{dob}}")data-*attrs all grouped underdatasetdata-*attrs remain as top-level state keys<f-when value="{{dataset.active}}">truthy and falsyAll 109 existing Rust tests continue to pass.
✅ Checklist
General
$ npm run change