Skip to content

Commit 4eabc23

Browse files
authored
feat: propagate shadowroot attributes (#7504)
# Pull Request ## 📖 Description - Propagates `shadowroot*` attributes from `f-template` onto rendered declarative shadow DOM `<template>` elements. - Preserves the existing default `shadowrootmode="open"` and `shadowroot="open"` behavior when no mode is provided. - Updates FAST build configuration/docs and adds Rust/Node coverage for explicit, boolean, escaped, and extra shadowroot attributes. ## 👩‍💻 Reviewer Notes Please focus review on the shadowroot attribute normalization/escaping path shared between the Rust renderer and the JS CLI configuration bridge. ## 📑 Test Plan Passed: - `git diff --check` - `cargo test --manifest-path crates/microsoft-fast-build/Cargo.toml` - `npm run build -w @microsoft/fast-build` - `npm run test:node -w @microsoft/fast-build` - `npm run build` - `npm run biome:check` - `npm run checkchange` ## ✅ Checklist ### General - [ ] I have included a change request file using `$ npm run change` - [x] I have added tests for my changes. - [x] I have tested my changes. - [x] I have updated the project documentation to reflect my changes. - [x] I have read the [CONTRIBUTING](https://github.com/microsoft/fast/blob/main/CONTRIBUTING.md) documentation and followed the [standards](https://github.com/microsoft/fast/blob/main/CODE_OF_CONDUCT.md#our-standards) for this project.
1 parent aa76170 commit 4eabc23

26 files changed

Lines changed: 674 additions & 89 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: propagate shadowroot attributes",
4+
"packageName": "@microsoft/fast-build",
5+
"email": "7559015+janechu@users.noreply.github.com",
6+
"dependentChangeType": "none"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "feat: propagate shadowroot attributes",
4+
"packageName": "@microsoft/fast-html",
5+
"email": "7559015+janechu@users.noreply.github.com",
6+
"dependentChangeType": "none"
7+
}

crates/microsoft-fast-build/DESIGN.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ A custom element is any opening tag whose name contains a hyphen, excluding `f-w
255255
[light DOM children]
256256
</my-button>
257257
```
258+
The `<template>` receives any `shadowroot*` attributes declared on the source `<f-template>`. The renderer normalizes `shadowrootmode` and legacy `shadowroot` for compatibility: when neither has a non-empty value, it emits `shadowrootmode="open" shadowroot="open"`; when exactly one has a non-empty value, that value is mirrored to the other; when both have explicit non-empty values, both are preserved as authored, even if they conflict.
259+
258260
When a nested element has attribute bindings (`{{expr}}` or `{expr}` values) and is being rendered inside another element's shadow (i.e., `parent_hydration` is `Some`), those bindings are counted, `data-fe-c-{start}-{count}` is added to the element's opening tag, and the binding indices are allocated from the parent scope.
259261

260262
Note: `is_entry` controls only opening-tag attribute handling. Child state is always built using the current root state as a base with per-element attributes overlaid on top, regardless of the `is_entry` flag.
@@ -438,16 +440,17 @@ For each glob pattern:
438440
6. `<f-template>` elements missing a `name` attribute emit a warning to stderr and are ignored.
439441
7. If two `<f-template>` elements across different files share the same name → `RenderError::DuplicateTemplate`.
440442

441-
### `<f-template>` parsing (`parse_f_templates`, `extract_attr_value`, `extract_template_content`)
443+
### `<f-template>` parsing (`parse_f_templates`, `parse_element_attributes`, `extract_template_content`)
442444

443445
`parse_f_templates(html)` scans for `<f-template` occurrences using `str::find` in a loop. For each match:
444446
- Verifies the character after `<f-template` is not alphanumeric or `-` to avoid matching `<f-templateX>`.
445447
- Extracts the attribute string between `<f-template` and `>`.
446-
- Calls `extract_attr_value(attrs, "name")` to get the name (supports both `"` and `'` quoting).
448+
- Uses `parse_element_attributes` to get the `name` attribute value (supports both `"` and `'` quoting).
449+
- Collects all unique `shadowroot`-prefixed attributes from the `<f-template>` opening tag, preserving boolean attributes as `None` and lowercasing attribute names.
447450
- Extracts the inner HTML between `>` and `</f-template>`.
448451
- Calls `extract_template_content` on the inner HTML to get the content inside the `<template>` element.
449452

450-
Returns `Vec<(Option<String>, String)>` — pairs of (name, template content).
453+
Returns a `Vec<FTemplate>` containing the optional name, template content, and the collected shadowroot attributes. The locator stores those attributes with the template definition so custom-element rendering can apply them to the emitted Declarative Shadow DOM `<template>`.
451454

452455
### Glob matching
453456

@@ -477,12 +480,20 @@ Calls `locator::parse_f_templates` (the same function used by `Locator::from_pat
477480

478481
```json
479482
[
480-
{"name": "my-button", "content": "<button>{{label}}</button>"},
481-
{"name": null, "content": "<span>unnamed</span>"}
483+
{
484+
"name": "my-button",
485+
"content": "<button>{{label}}</button>",
486+
"shadowrootAttributes": [{"name": "shadowrootmode", "value": "closed"}]
487+
},
488+
{
489+
"name": null,
490+
"content": "<span>unnamed</span>",
491+
"shadowrootAttributes": []
492+
}
482493
]
483494
```
484495

485-
`name` is `null` when the `<f-template>` element has no `name` attribute. The `@microsoft/fast-build` CLI uses this export to parse HTML files without reimplementing the parsing logic in JavaScript.
496+
`name` is `null` when the `<f-template>` element has no `name` attribute. The `shadowrootAttributes` array preserves forwarded `shadowroot*` attributes as `{name, value}` metadata, using `null` for boolean attributes. The `@microsoft/fast-build` CLI uses this export to parse HTML files without reimplementing the parsing logic in JavaScript.
486497

487498
### `render_with_templates`
488499

crates/microsoft-fast-build/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,8 @@ A single file may contain multiple templates:
318318

319319
If an `<f-template>` element is missing a `name` attribute, a warning is emitted to stderr and the template is ignored.
320320

321+
Any `shadowroot*` attributes on `<f-template>` are copied to the rendered Declarative Shadow DOM `<template>`. The renderer normalizes `shadowrootmode` and legacy `shadowroot` for compatibility: when neither has a non-empty value, it emits `shadowrootmode="open" shadowroot="open"`; when exactly one has a non-empty value, that value is mirrored to the other; when both have explicit non-empty values, both are preserved as authored, even if they conflict.
322+
321323
### Rendering with a Locator
322324

323325
```rust
@@ -465,7 +467,9 @@ The renderer wraps the rendered template in Declarative Shadow DOM and adds the
465467
</my-button>
466468
```
467469

470+
- `shadowrootmode="open"` — the standard declarative shadow DOM mode emitted by default.
468471
- `shadowroot="open"` — legacy declarative shadow DOM attribute for broader browser compatibility.
472+
- `shadowroot*` attributes declared on the source `<f-template>` are forwarded to the output `<template>`; `shadowrootmode` and `shadowroot` default to `open` when neither has a non-empty value, mirror when exactly one has a non-empty value, and are preserved as authored when both have explicit non-empty values.
469473

470474
Custom elements that have no matching template in the locator are passed through verbatim.
471475

crates/microsoft-fast-build/src/directive.rs

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ fn parse_repeat_expr(expr: &str) -> Option<(String, String)> {
221221
///
222222
/// Output format:
223223
/// `<original-open-tag>
224-
/// <template shadowrootmode="open" shadowroot="open">{rendered}</template>
224+
/// <template {shadowroot-attrs}>{rendered}</template>
225225
/// {children}</{tag-name}>`
226226
///
227227
/// For self-closing elements (`<my-button />`), the element is emitted as non-self-closing.
@@ -355,6 +355,7 @@ pub fn render_custom_element(
355355
let mut shadow_scope = HydrationScope::new();
356356
let element_template = locator.get_template(&tag_name).unwrap_or_default();
357357
let rendered = render_node(element_template, child_root, &[], Some(locator), Some(&mut shadow_scope), false, config)?;
358+
let shadowroot_attributes = build_shadowroot_template_attrs(locator.get_shadowroot_attributes(&tag_name));
358359

359360
// Build the final opening tag, resolving {{expr}} attrs and injecting hydration attrs.
360361
let element_open = build_element_open_tag(&open_tag_base, open_tag_content, root, loop_vars, parent_hydration, is_entry);
@@ -371,13 +372,83 @@ pub fn render_custom_element(
371372
};
372373

373374
let output = format!(
374-
"{}<template shadowrootmode=\"open\" shadowroot=\"open\">{}</template>{}</{}>",
375-
element_open, rendered, children, tag_name
375+
"{}<template{}>{}</template>{}</{}>",
376+
element_open, shadowroot_attributes, rendered, children, tag_name
376377
);
377378

378379
Ok((output, after))
379380
}
380381

382+
fn build_shadowroot_template_attrs(attrs: &[(String, Option<String>)]) -> String {
383+
let mut mode: Option<Option<String>> = None;
384+
let mut legacy_shadowroot: Option<Option<String>> = None;
385+
let mut forwarded: Vec<(String, Option<String>)> = Vec::new();
386+
let mut seen_forwarded: Vec<String> = Vec::new();
387+
388+
for (name, value) in attrs {
389+
let normalized = name.to_lowercase();
390+
match normalized.as_str() {
391+
"shadowrootmode" => {
392+
if mode.is_none() {
393+
mode = Some(value.clone());
394+
}
395+
}
396+
"shadowroot" => {
397+
if legacy_shadowroot.is_none() {
398+
legacy_shadowroot = Some(value.clone());
399+
}
400+
}
401+
_ if normalized.starts_with("shadowroot") => {
402+
if !seen_forwarded.contains(&normalized) {
403+
seen_forwarded.push(normalized.clone());
404+
forwarded.push((normalized, value.clone()));
405+
}
406+
}
407+
_ => {}
408+
}
409+
}
410+
411+
let explicit_mode = explicit_shadowroot_attr_value(mode);
412+
let explicit_legacy_shadowroot = explicit_shadowroot_attr_value(legacy_shadowroot);
413+
let mode = explicit_mode
414+
.clone()
415+
.or_else(|| explicit_legacy_shadowroot.clone())
416+
.unwrap_or_else(|| "open".to_string());
417+
let legacy_shadowroot = explicit_legacy_shadowroot
418+
.or(explicit_mode)
419+
.unwrap_or_else(|| mode.clone());
420+
421+
let mut out = String::new();
422+
append_template_attr_value(&mut out, "shadowrootmode", &mode);
423+
append_template_attr_value(&mut out, "shadowroot", &legacy_shadowroot);
424+
for (name, value) in forwarded {
425+
append_template_attr(&mut out, &name, &value);
426+
}
427+
out
428+
}
429+
430+
fn explicit_shadowroot_attr_value(value: Option<Option<String>>) -> Option<String> {
431+
value.flatten().filter(|value| !value.is_empty())
432+
}
433+
434+
fn append_template_attr(out: &mut String, name: &str, value: &Option<String>) {
435+
match value {
436+
Some(value) => append_template_attr_value(out, name, value),
437+
None => {
438+
out.push(' ');
439+
out.push_str(name);
440+
}
441+
}
442+
}
443+
444+
fn append_template_attr_value(out: &mut String, name: &str, value: &str) {
445+
out.push(' ');
446+
out.push_str(name);
447+
out.push_str("=\"");
448+
out.push_str(&html_escape(value));
449+
out.push('"');
450+
}
451+
381452
fn attribute_to_json_value(value: Option<&String>, root: &JsonValue, loop_vars: &[(String, JsonValue)]) -> JsonValue {
382453
let v = match value {
383454
None => return JsonValue::Bool(true), // boolean attribute (no value)

0 commit comments

Comments
 (0)