Skip to content

feat(microsoft-fast-build): map data-* attributes to nested dataset state#7398

Merged
janechu merged 12 commits intomainfrom
users/janechu/treat-data-attributes-separately
Apr 6, 2026
Merged

feat(microsoft-fast-build): map data-* attributes to nested dataset state#7398
janechu merged 12 commits intomainfrom
users/janechu/treat-data-attributes-separately

Conversation

@janechu
Copy link
Copy Markdown
Collaborator

@janechu janechu commented Apr 6, 2026

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 MDN HTMLElement.dataset naming convention.

How it works

attribute::data_attr_to_dataset_key converts a data-* attribute name to the full dot-notation state path:

data-date-of-birth  →  "dataset.dateOfBirth"
data-name           →  "dataset.name"

The caller in render_custom_element splits on ." to insert into the nested state map, so shadow templates can use {{dataset.X}}` via ordinary dot-notation:

<test-el data-date-of-birth="{{dob}}"></test-el>

<div data-date-of-birth="{{dataset.dateOfBirth}}"></div>

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

Attribute → State Mapping

Attribute Child state
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_key returns the full path "dataset.dateOfBirth"; private kebab_to_camel helper
  • directive.rs: child state builder splits the dot-notation path on . and inserts into a nested "dataset" map
  • context.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 reads state["dataset"]["name"]
  • Multi-word camelCase (dataset.dateOfBirth)
  • Static data-* attribute on custom element → {{dataset.X}} resolves in shadow
  • data-* value from parent binding (data-date-of-birth="{{dob}}")
  • Multiple data-* attrs all grouped under dataset
  • Non-data-* attrs remain as top-level state keys
  • <f-when value="{{dataset.active}}"> truthy and falsy

All 109 existing Rust tests continue to pass.

✅ Checklist

General

  • I have included a change request file using $ npm run change
  • I have added tests for my changes.
  • I have tested my changes.
  • I have updated the project documentation to reflect my changes.
  • I have read the CONTRIBUTING documentation and followed the standards for this project.

janechu and others added 8 commits April 6, 2026 14:29
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 171e42c.
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>
@janechu janechu changed the title feat(fast-html): treat dataset.X bindings separately per MDN naming convention feat(microsoft-fast-build): convert dataset.X attribute names to data-* in SSR renderer Apr 6, 2026
janechu and others added 2 commits April 6, 2026 15:16
…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>
@janechu janechu changed the title feat(microsoft-fast-build): convert dataset.X attribute names to data-* in SSR renderer feat(microsoft-fast-build): support dataset.X prefix in binding expressions Apr 6, 2026
…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>
@janechu janechu changed the title feat(microsoft-fast-build): support dataset.X prefix in binding expressions feat(microsoft-fast-build): map data-* attributes to nested dataset state Apr 6, 2026
… 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>
@janechu janechu marked this pull request as ready for review April 6, 2026 22:50
@janechu janechu requested a review from Copilot April 6, 2026 22:50
@janechu janechu merged commit c6fb02e into main Apr 6, 2026
17 of 18 checks passed
@janechu janechu deleted the users/janechu/treat-data-attributes-separately branch April 6, 2026 22:53
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 (and kebab_to_camel) to convert data-* names to dataset.* dot-paths.
  • Update render_custom_element to route data-* attributes into a nested dataset state 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()));
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.
Comment on lines +246 to +250
/// 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)))
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.
Comment on lines +2 to +4
"type": "minor",
"comment": "feat: convert dataset.X attribute names to data-X in SSR renderer",
"packageName": "@microsoft/fast-build",
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.
janechu added a commit that referenced this pull request Apr 6, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants