diff --git a/change/@microsoft-fast-html-when-00615379-81a9-4dd0-86af-bd600a8ab155.json b/change/@microsoft-fast-html-when-00615379-81a9-4dd0-86af-bd600a8ab155.json
new file mode 100644
index 00000000000..a1e216b54fc
--- /dev/null
+++ b/change/@microsoft-fast-html-when-00615379-81a9-4dd0-86af-bd600a8ab155.json
@@ -0,0 +1,7 @@
+{
+ "type": "none",
+ "comment": "chore(fast-html): use @microsoft/fast-build to build when fixture",
+ "packageName": "@microsoft/fast-html",
+ "email": "7559015+janechu@users.noreply.github.com",
+ "dependentChangeType": "none"
+}
diff --git a/crates/microsoft-fast-build/DESIGN.md b/crates/microsoft-fast-build/DESIGN.md
index b32b852dded..14e72d49449 100644
--- a/crates/microsoft-fast-build/DESIGN.md
+++ b/crates/microsoft-fast-build/DESIGN.md
@@ -66,7 +66,7 @@ fn render_node(template, root, loop_vars, locator, hydration: Option<&mut Hydrat
The `is_entry` flag distinguishes two rendering contexts:
-- **`is_entry: true`** — the template is the top-level entry HTML. Custom elements found at this level (root custom elements) receive the **full root state** as their child rendering context.
+- **`is_entry: true`** — the template is the top-level entry HTML. Custom elements found at this level (root custom elements) receive the **full root state merged with their own attribute-derived state** as their child rendering context. Root state provides app-level context; per-element attributes overlay on top.
- **`is_entry: false`** — the template is a shadow template, a directive body, or a repeat item. Custom elements found here receive **attribute-based child state** as usual.
`renderer::render_entry_with_locator` sets `is_entry: true`; `renderer::render_with_locator` sets `is_entry: false`. All recursive calls from `render_when`, `render_repeat_items`, and `render_custom_element` (for shadow templates) always use `is_entry: false`.
@@ -217,8 +217,8 @@ A custom element is any opening tag whose name contains a hyphen, excluding `f-w
2. **Detect self-closing** — if the character before `>` (ignoring whitespace) is `/`, the element is self-closing. The output always uses non-self-closing form.
3. **Parse attributes** — `parse_element_attributes` walks the opening tag string and extracts `(name, Option)` pairs.
4. **Build child state**:
- - **Root custom elements** (`is_entry: true`) — the child state is the **complete root state** (`root.clone()`). This mirrors the runtime behaviour: root elements receive application state rather than per-instance attribute state. All top-level state keys are available directly in their templates.
- - **Nested custom elements** (`is_entry: false`) — the child state is built from the element's HTML attributes:
+ - **Root custom elements** (`is_entry: true`) — the child state starts with the **complete root state** as a base, then each HTML attribute on the element is resolved and overlaid on top (attribute-derived values take precedence). This ensures app-level context keys (e.g. `error`, `showProgress`) are available alongside per-element attribute state (e.g. `planet="earth"`, `vara="3"`, boolean `show`).
+ - **Nested custom elements** (`is_entry: false`) — the child state is built entirely from the element's HTML attributes:
- Attributes starting with `@` (event handlers) or named `f-ref`, `f-slotted`, `f-children` (attribute directives) are **skipped** — all are resolved entirely by the FAST client runtime and have no meaning in server-side rendering state.
- Attributes starting with `:` (property bindings) are **stripped from rendered HTML** but their resolved value **is added to the child state** under the lowercased property name (without the `:` prefix). This lets structured data (arrays, objects) be passed to the SSR template without appearing as a visible HTML attribute.
- **HTML attribute keys are lowercased** — HTML attribute names are case-insensitive and browsers always store them lowercase. `isEnabled` becomes `isenabled`; hyphens are preserved: `selected-user-id` stays `selected-user-id`.
@@ -246,7 +246,7 @@ A custom element is any opening tag whose name contains a hyphen, excluding `f-w
```
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.
-Note: `is_entry` controls both child state and opening-tag attribute handling. Root elements (`is_entry: true`) resolve primitive `{{binding}}` attributes to their values and strip non-primitive ones.
+Note: `is_entry` controls both child state and opening-tag attribute handling. Root elements (`is_entry: true`) merge root state with attribute-derived state for their template, and resolve primitive `{{binding}}` attributes to their values (stripping non-primitive ones) in the rendered HTML.
If a custom element has no matching template, it is left in place by `next_directive` (which only returns `CustomElement` for tags in the locator).
diff --git a/crates/microsoft-fast-build/README.md b/crates/microsoft-fast-build/README.md
index ccb731cdd7b..fa724f45dfe 100644
--- a/crates/microsoft-fast-build/README.md
+++ b/crates/microsoft-fast-build/README.md
@@ -273,34 +273,40 @@ let html = render_template_with_locator(
)?;
```
-### Rendering entry HTML (root custom elements receive full state)
+### Rendering entry HTML (root custom elements receive merged state)
-When rendering the top-level **entry HTML** of a page, use `render_entry_with_locator` or `render_entry_template_with_locator`. Custom elements found at this level are treated as **root elements** and receive the complete root state rather than building their child state from HTML attributes.
+When rendering the top-level **entry HTML** of a page, use `render_entry_with_locator` or `render_entry_template_with_locator`. Custom elements found at this level are treated as **root elements** and receive the complete root state **merged with their own HTML attribute-derived state**. Attribute-derived values take precedence over root state for overlapping keys.
-This mirrors the FAST runtime behaviour: root-level components access application state (e.g. via `$fastController.context`) rather than per-instance attribute state.
+This gives root elements access to both app-level context (from the shared root state) and their own per-element attribute values (e.g. `planet="earth"`, `vara="3"`, boolean `show`), without requiring each attribute to be present in the state JSON.
```rust
use microsoft_fast_build::{render_entry_with_locator, render_entry_template_with_locator, Locator};
let locator = Locator::from_patterns(&["./components/**/*.html"])?;
-// Root custom elements (my-header, my-app) receive the full root state.
-// Their templates can reference any key in the state JSON directly.
+// Root custom elements (my-header, my-app) receive the full root state merged with
+// any per-element attributes. Their templates can reference any key in the state JSON directly.
let html = render_entry_template_with_locator(
- r#"{{heading}}"#,
+ r#"{{heading}}"#,
r#"{"heading": "Hello", "user": "Alice", "items": [{"name": "Item 1"}]}"#,
&locator,
)?;
// my-header's template can use {{user}}, {{heading}}, etc. directly.
-// my-app's template can use {{items}}, etc. directly.
+// my-app's template can use {{items}}, {{planet}}, etc. directly.
```
#### Attribute handling on root elements
-Because root custom elements receive the full root state, `{{binding}}` attributes on root elements are **resolved** rather than forwarded via child state as they would be for nested elements:
+Root custom elements receive a **merged child state**: the full root state as a base, with the element's own HTML attributes overlaid on top. This means:
+
+- Per-element attributes (e.g. `planet="earth"`, `vara="3"`, boolean `show`) are available in the template alongside root state keys.
+- `{{binding}}` attributes resolve their value from root state and add it to the child state under the (lowercased) attribute name.
+- Attribute-derived values take precedence over root state keys when the same key appears in both.
+
+`{{binding}}` attributes on root elements are also **resolved in the rendered HTML output** rather than forwarded:
- **Primitive bindings** (`string`, `number`, `boolean`) — resolved and rendered with the resolved value. e.g. `text="{{message}}"` → `text="Hello world"`.
-- **Non-primitive bindings** (`array`, `object`, `null`) — stripped. These cannot be represented as HTML attribute values; the state is available directly in the element's template.
+- **Non-primitive bindings** (`array`, `object`, `null`) — stripped from the HTML output (the state is still available in the element's template via the merged child state).
- **Static attributes** (no binding syntax) — passed through unchanged.
```html
@@ -317,15 +323,15 @@ Nested custom elements inside shadow templates continue to use attribute-based c
| Context | Child state source | `{{binding}}` attrs in rendered HTML |
|---|---|---|
-| Root element in entry HTML (via `render_entry_*`) | Full root state | Resolved — primitives kept, non-primitives stripped |
+| Root element in entry HTML (via `render_entry_*`) | Root state merged with element's own attrs | Resolved — primitives kept, non-primitives stripped |
| Nested element inside a shadow template | Attributes on the element tag | Rendered (resolved) |
| Element inside `f-repeat` or `f-when` (at any level) | Attributes on the element tag | Rendered (resolved) |
```html
-
-
+
+
-
+
```
diff --git a/crates/microsoft-fast-build/src/directive.rs b/crates/microsoft-fast-build/src/directive.rs
index b477519b451..c05be58fb77 100644
--- a/crates/microsoft-fast-build/src/directive.rs
+++ b/crates/microsoft-fast-build/src/directive.rs
@@ -97,7 +97,7 @@ pub fn render_when(
let start = hy.start_marker(idx, &name);
let end = hy.end_marker(idx, &name);
let inner_content = if evaluate(&expr, root, loop_vars) {
- let mut child_scope = hy.child();
+ let mut child_scope = hy.child(idx);
render_node(&inner, root, loop_vars, locator, Some(&mut child_scope), false)?
} else {
String::new()
@@ -251,36 +251,32 @@ pub fn render_custom_element(
open_tag_content[..open_tag_content.len() - 1].to_string()
};
- // Build child state.
+ // Build the child state used to render this element's shadow template.
//
- // **Entry custom elements** — those marked with `is_entry` — receive the **full root
- // state** as their child rendering state. This mirrors the runtime behaviour: entry
- // elements receive state from the application (e.g. via `$fastController.context`)
- // rather than from HTML attributes, so all top-level state keys are available directly
- // in their templates.
+ // **Entry custom elements** (`is_entry == true`) receive the full root state merged
+ // with their own HTML attribute-derived state. The root state provides app-level
+ // context (e.g. `error`, `showProgress`), while per-element attributes (e.g.
+ // `planet="earth"`, `vara="3"`, boolean `show`) overlay on top so templates that
+ // compare against per-element values resolve correctly. Attribute-derived values take
+ // precedence over root state for overlapping keys.
//
- // All other custom elements (`is_entry == false`) receive state built from their HTML
- // attributes as usual (`:prop` forwarded typed, regular attrs lowercased, `data-*`
- // grouped). This includes elements rendered inside `f-when`/`f-repeat` bodies, even
- // when they are not under a parent hydration scope.
- //
- // We avoid cloning `root` for the entry case by using an `Option` that is `None` for
- // entry elements (falling back to `root` via `unwrap_or`) and `Some(owned)` for
- // attribute-derived nested state.
- let nested_child_state = if is_entry {
- None
- } else {
- // Nested element: build child state from the element's HTML attributes.
- // `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.
+ // **Nested custom elements** (`is_entry == false`) receive state built solely from
+ // their HTML attributes (`:prop` forwarded typed, regular attrs lowercased, `data-*`
+ // grouped). This includes elements rendered inside `f-when`/`f-repeat` bodies.
+ fn build_attr_state(
+ open_tag_content: &str,
+ root: &JsonValue,
+ loop_vars: &[(String, JsonValue)],
+ base: Option>,
+ ) -> JsonValue {
let attrs = parse_element_attributes(open_tag_content);
- let mut state_map = std::collections::HashMap::new();
+ let mut state_map = base.unwrap_or_default();
for (attr_name, value) in &attrs {
// Skip @event handlers — they are client-side only and have no meaning in SSR state.
- // Also skip f-ref, f-slotted, and f-children attribute directives — all are resolved
- // entirely by the FAST client runtime.
+ // Also skip f-ref, f-slotted, f-children, and ?boolean-binding directives — all are
+ // resolved entirely by the FAST client runtime.
if attr_name.starts_with('@')
+ || attr_name.starts_with('?')
|| attr_name.eq_ignore_ascii_case("f-ref")
|| attr_name.eq_ignore_ascii_case("f-slotted")
|| attr_name.eq_ignore_ascii_case("f-children")
@@ -315,7 +311,19 @@ pub fn render_custom_element(
state_map.insert(key, json_val);
}
}
- Some(JsonValue::Object(state_map))
+ JsonValue::Object(state_map)
+ }
+
+ let nested_child_state = if is_entry {
+ // Start with the full root state, then overlay per-element attribute values.
+ let base = if let JsonValue::Object(ref root_map) = root {
+ root_map.clone()
+ } else {
+ std::collections::HashMap::new()
+ };
+ Some(build_attr_state(open_tag_content, root, loop_vars, Some(base)))
+ } else {
+ Some(build_attr_state(open_tag_content, root, loop_vars, None))
};
let child_root = nested_child_state.as_ref().unwrap_or(root);
diff --git a/crates/microsoft-fast-build/src/hydration.rs b/crates/microsoft-fast-build/src/hydration.rs
index 979b7692ace..bc9c0b7354f 100644
--- a/crates/microsoft-fast-build/src/hydration.rs
+++ b/crates/microsoft-fast-build/src/hydration.rs
@@ -1,17 +1,34 @@
/// Hydration state for one template scope.
/// Each custom-element shadow, f-when body, and f-repeat item template gets its own scope.
+///
+/// `scope_prefix` is prepended to marker names so that nested f-when bodies produce names
+/// that are unique within the full shadow DOM. For example, a f-when body whose parent
+/// allocated index 1 gets `scope_prefix = "1-"`, yielding inner markers like
+/// `` instead of ``.
+/// This prevents the FAST client runtime from confusing an inner end-marker with the
+/// outer end-marker when both would otherwise share the same `id` field.
pub struct HydrationScope {
pub binding_idx: usize,
+ scope_prefix: String,
}
impl HydrationScope {
pub fn new() -> Self {
- Self { binding_idx: 0 }
+ Self {
+ binding_idx: 0,
+ scope_prefix: String::new(),
+ }
}
- /// Create a child scope with a fresh binding counter.
- pub fn child(&self) -> Self {
- Self { binding_idx: 0 }
+ /// Create a child scope for the body of an f-when directive.
+ /// `parent_idx` is the index allocated by the parent scope for the f-when itself;
+ /// it is used as a path segment in the scope prefix to guarantee globally-unique
+ /// marker names across all nesting levels within a single shadow DOM.
+ pub fn child(&self, parent_idx: usize) -> Self {
+ Self {
+ binding_idx: 0,
+ scope_prefix: format!("{}{}-", self.scope_prefix, parent_idx),
+ }
}
pub fn next_binding(&mut self) -> usize {
@@ -24,11 +41,14 @@ impl HydrationScope {
/// - Content bindings: `name` = the expression (e.g. `"text"` for `{{text}}`)
/// - f-when: `name` = `"when-{idx}"`
/// - f-repeat: `name` = `"repeat-{idx}"`
+ ///
+ /// The `scope_prefix` is prepended to `name` to produce a globally-unique marker id
+ /// when directives are nested (e.g. f-when inside f-when).
pub fn start_marker(&self, idx: usize, name: &str) -> String {
- format!("", idx, name)
+ format!("", idx, self.scope_prefix, name)
}
pub fn end_marker(&self, idx: usize, name: &str) -> String {
- format!("", idx, name)
+ format!("", idx, self.scope_prefix, name)
}
}
diff --git a/crates/microsoft-fast-build/tests/custom_elements.rs b/crates/microsoft-fast-build/tests/custom_elements.rs
index 11d35b746c4..a6c0df08c06 100644
--- a/crates/microsoft-fast-build/tests/custom_elements.rs
+++ b/crates/microsoft-fast-build/tests/custom_elements.rs
@@ -494,3 +494,74 @@ fn test_custom_element_object_from_colon_binding() {
assert!(!result.contains(":config"), "colon attr not in HTML: {result}");
assert!(result.contains("Hello"), "rendered: {result}");
}
+
+// ── entry-level custom element merged state ────────────────────────────────────
+
+#[test]
+fn test_root_element_per_element_attr_available_in_template() {
+ // A per-element static attribute (e.g. planet="earth") should be available as a
+ // template binding key in the entry element's shadow template, even if that key
+ // is not present in the shared root state.json.
+ let locator = make_locator(&[("planet-el", r#"{{planet}}"#)]);
+ let result = render_entry_template_with_locator(
+ r#""#,
+ r#"{"shared":"yes"}"#,
+ &locator,
+ ).unwrap();
+ assert!(result.contains("earth"), "per-element attr available in template: {result}");
+}
+
+#[test]
+fn test_root_element_attr_overrides_root_state_key() {
+ // When an entry element carries a static attribute whose key matches a root state
+ // key, the attribute-derived value should take precedence in the child template.
+ let locator = make_locator(&[("my-el", r#"{{color}}"#)]);
+ let result = render_entry_template_with_locator(
+ r#""#,
+ r#"{"color":"red"}"#,
+ &locator,
+ ).unwrap();
+ assert!(result.contains("blue"), "attr value overrides root state: {result}");
+ assert!(!result.contains("red"), "root state key not used when attr present: {result}");
+}
+
+#[test]
+fn test_root_element_root_state_available_alongside_attr() {
+ // Root state keys that are NOT shadowed by an attribute should still be accessible
+ // in the entry element's template alongside per-element attr values.
+ let locator = make_locator(&[("my-el", r#"{{planet}} {{shared}}"#)]);
+ let result = render_entry_template_with_locator(
+ r#""#,
+ r#"{"shared":"context"}"#,
+ &locator,
+ ).unwrap();
+ assert!(result.contains("mars"), "per-element attr rendered: {result}");
+ assert!(result.contains("context"), "root state key still accessible: {result}");
+}
+
+#[test]
+fn test_root_element_boolean_attr_available_in_template() {
+ // A boolean (no-value) attribute on an entry element should be available as
+ // true in the child template.
+ let locator = make_locator(&[("my-el", r#"visible"#)]);
+ let result = render_entry_template_with_locator(
+ r#""#,
+ r#"{}"#,
+ &locator,
+ ).unwrap();
+ assert!(result.contains("visible"), "boolean attr resolves to true in template: {result}");
+}
+
+#[test]
+fn test_root_elements_with_different_per_element_attrs() {
+ // Multiple root elements in the same entry HTML each have their own attribute
+ // values available in their respective templates.
+ let locator = make_locator(&[("planet-el", r#"{{planet}}"#)]);
+ let result = render_entry_template_with_locator(
+ r#""#,
+ r#"{"shared":"yes"}"#,
+ &locator,
+ ).unwrap();
+ assert!(result.contains("earth"), "first element attr: {result}");
+ assert!(result.contains("mars"), "second element attr: {result}");
+}
diff --git a/crates/microsoft-fast-build/tests/hydration.rs b/crates/microsoft-fast-build/tests/hydration.rs
index 483426c7439..63fca1af77f 100644
--- a/crates/microsoft-fast-build/tests/hydration.rs
+++ b/crates/microsoft-fast-build/tests/hydration.rs
@@ -332,8 +332,8 @@ fn test_hydration_f_repeat_with_inner_when() {
assert!(result.contains(""), "item start: {result}");
// Item scope: f-when is binding 0, name = "when-0"
assert!(result.contains(""), "when start: {result}");
- // When body scope: {{item.text}} is binding 0, name = "item.text-0"
- assert!(result.contains("Foo"),
+ // When body scope: {{item.text}} is binding 0 inside when-0, name = "0-item.text-0"
+ assert!(result.contains("Foo"),
"text binding: {result}");
}
diff --git a/packages/fast-html/scripts/build-fixtures.js b/packages/fast-html/scripts/build-fixtures.js
index 412d2103160..dcfd8ec9186 100644
--- a/packages/fast-html/scripts/build-fixtures.js
+++ b/packages/fast-html/scripts/build-fixtures.js
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
// Builds test fixtures using @microsoft/fast-build. Add fixture names here
// incrementally as each one is verified to work with the fast-build CLI.
-const fixtures = ["attribute", "binding", "event", "ref", "slotted"];
+const fixtures = ["attribute", "binding", "event", "ref", "slotted", "when"];
const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
diff --git a/packages/fast-html/test/fixtures/when/entry.html b/packages/fast-html/test/fixtures/when/entry.html
new file mode 100644
index 00000000000..0340a4430e9
--- /dev/null
+++ b/packages/fast-html/test/fixtures/when/entry.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/fast-html/test/fixtures/when/index.html b/packages/fast-html/test/fixtures/when/index.html
index 7c4a26d631a..4323e9cd2b4 100644
--- a/packages/fast-html/test/fixtures/when/index.html
+++ b/packages/fast-html/test/fixtures/when/index.html
@@ -1,160 +1,130 @@
-
+
-
-
- Hello worldplutomars
-
-
- Hello world
-
-
- Hello pluto
-
-
- Hello mars
-
+ Hello world
+ Hello pluto
+ Hello mars
-
- Hello world
-
-
- Hello world
-
-
-
-
+ Hello world
+
-
- Hello world
-
-
- Hello world
-
-
-
-
+ Hello world
+
-
- Equals 3
-
-
- Equals 3
-
-
-
-
+ Equals 3
+
-
- Not equals 3
-
-
- Not equals 3
-
-
-
-
+ Not equals 3
+
-
- Two and Over
-
-
- Two and Over
-
-
-
-
+ Two and Over
+
-
- Over two
-
-
- Over two
-
-
-
-
+ Over two
+
-
- Two and Under
-
-
- Two and Under
-
-
-
-
+ Two and Under
+
-
- Under two
-
-
- Under two
-
-
-
-
+ Under two
+
-
- This or That
-
-
- This or That
-
-
-
-
+ This or That
+
-
- This and That
-
-
- This and That
-
-
-
-
-
+ This and That
+
-
-
-
-
{{strings.errorMessage}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+ Hello worldplutomars
+
+
+
+ Hello world
+
+
+
+ Hello world
+
+
+
+ Equals 3
+
+
+
+ Not equals 3
+
+
+
+ Two and Over
+
+
+
+ Over two
+
+
+
+ Two and Under
+
+
+
+ Under two
+
+
+
+ This or That
+
+
+
+ This and That
+
+
+
+
+
+