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": "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"
}
8 changes: 4 additions & 4 deletions crates/microsoft-fast-build/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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<value>)` 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`.
Expand Down Expand Up @@ -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).

Expand Down
32 changes: 19 additions & 13 deletions crates/microsoft-fast-build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}}<my-header></my-header><my-app></my-app>"#,
r#"{{heading}}<my-header></my-header><my-app planet="earth"></my-app>"#,
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
Expand All @@ -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
<!-- Entry HTML β€” my-parent receives full root state; label="Hello" rendered, list stripped -->
<my-parent label="{{title}}" list="{{items}}"></my-parent>
<!-- Entry HTML β€” my-parent gets root state + its own attrs; label="Hello" rendered, list stripped -->
<my-parent label="{{title}}" list="{{items}}" planet="earth"></my-parent>

<!-- my-parent's template β€” my-child receives attr-based state; label is rendered -->
<!-- my-parent's template β€” my-child receives attr-based state only; label is rendered -->
<my-child label="{{heading}}" :items="{{items}}"></my-child>
```

Expand Down
60 changes: 34 additions & 26 deletions crates/microsoft-fast-build/src/directive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<std::collections::HashMap<String, JsonValue>>,
) -> 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")
Expand Down Expand Up @@ -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);

Expand Down
32 changes: 26 additions & 6 deletions crates/microsoft-fast-build/src/hydration.rs
Original file line number Diff line number Diff line change
@@ -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
/// `<!--fe-b$$start$$0$$1-when-0$$fe-b-->` instead of `<!--fe-b$$start$$0$$when-0$$fe-b-->`.
/// 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 {
Expand All @@ -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!("<!--fe-b$$start$${}$${}$$fe-b-->", idx, name)
format!("<!--fe-b$$start$${}$${}{}$$fe-b-->", idx, self.scope_prefix, name)
}

pub fn end_marker(&self, idx: usize, name: &str) -> String {
format!("<!--fe-b$$end$${}$${}$$fe-b-->", idx, name)
format!("<!--fe-b$$end$${}$${}{}$$fe-b-->", idx, self.scope_prefix, name)
}
}
Loading
Loading