Skip to content
Open
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": "minor",
"comment": "Add a markers_v2 option for emitting FAST Element 2.x indexed hydration markers.",
"packageName": "@microsoft/fast-build",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add an opt-in markers_v2 reader for FAST Element 2.x indexed hydration markers.",
"packageName": "@microsoft/fast-element",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
18 changes: 9 additions & 9 deletions crates/microsoft-fast-build/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ render_template(template, state_str)
| `content.rs` | `{{expr}}` and `{{{expr}}}` binding renderers, `html_escape` |
| `attribute.rs` | Low-level HTML/attribute string parsing utilities + hydration attribute helpers; `strip_client_only_attrs` (shadow-DOM tags and nested element opening tags) |
| `attribute_lookup.rs` | Static lookup tables mapping ARIA and HTML attribute names to their DOM property names |
| `config.rs` | `RenderConfig` struct and `AttributeNameStrategy` enum β€” rendering configuration options |
| `config.rs` | `RenderConfig` struct, `AttributeNameStrategy` enum, and `markers_v2` option β€” rendering configuration options |
| `context.rs` | State value resolution: dot-path access, loop-variable scoping |
| `expression.rs` | Boolean expression evaluator for `<f-when value="{{…}}">` |
| `hydration.rs` | `HydrationScope` β€” binding index tracking and data-free marker generation per template scope |
| `hydration.rs` | `HydrationScope` β€” binding index tracking and v3 data-free or opt-in v2 indexed marker generation per template scope |
| `json.rs` | Hand-rolled JSON parser producing `JsonValue` |
| `locator.rs` | `Locator` struct β€” maps element names to template strings; glob scanner; `<f-template>` parser. Stored template bodies are first run through `escape_code_sample_elements` so `{`/`}` characters and the angle brackets of FAST directive tags (`<f-when>`, `<f-repeat>`) inside `<code>` elements are entity-escaped and therefore not interpreted as binding delimiters or directives. Also captures the inner `<template>` element's attributes as **host attributes** for propagation onto the rendered host element opening tag. |
| `code_escape.rs` | `escape_code_sample_elements` β€” auto-escape preprocessor used by both `renderer.rs` and `locator.rs`. Walks the HTML and, inside every `<code>` element (including nested ones and attribute values of descendants), replaces `{` β†’ `&#123;` and `}` β†’ `&#125;` so that binding-like syntax in code samples renders literally. Additionally rewrites the `<` / `>` of every FAST directive tag (`<f-when>`, `</f-when>`, `<f-repeat>`, `</f-repeat>`) it finds inside `<code>` as `&lt;` / `&gt;`, so authors can write directives literally without manual entity escaping; tag-name matching is case-insensitive. Real HTML elements (`<button>`) and custom elements (`<my-widget>`) inside `<code>` keep their angle brackets and continue to render as live DOM elements. The brace half of the escape mirrors the JavaScript-side `escapeBracesInCodeElements` in `@microsoft/fast-html`; the directive-tag angle escape is server-only because the DOM serializer re-encodes `<`/`>` in text content so the client never sees a raw directive tag inside `<code>`. Modeled on Microsoft WebUI's `webui-press` markdown renderer, which auto-escapes the same characters inside code spans and code fences |
Expand Down Expand Up @@ -83,7 +83,7 @@ The loop works like a cursor:
2. Call `next_directive(template, pos, locator)` to find the earliest interesting position ahead.
3. If nothing is found, append `template[pos..]` to output and break.
4. Otherwise, append the literal text from `pos` up to the directive's start.
5. Dispatch the directive to the appropriate handler (returns `(chunk, next_pos)`). In hydration mode, content bindings (`{{expr}}`, `{{{expr}}}`) are wrapped in `<!--fe:b-->VALUE<!--fe:/b-->` markers.
5. Dispatch the directive to the appropriate handler (returns `(chunk, next_pos)`). In hydration mode, content bindings (`{{expr}}`, `{{{expr}}}`) are wrapped in the configured v3 data-free or v2 indexed markers.
6. Append `chunk` and advance `pos` to `next_pos`.
7. Repeat.

Expand Down Expand Up @@ -374,10 +374,10 @@ All render functions accept `config: Option<&RenderConfig>` as their last parame
- `render_entry_template_stream_with_locator(template, state_str, locator, config)`
- `render_entry_template_stream_with_locator_without_state(template, locator, config)`

The streaming APIs return `Result<Vec<String>, RenderError>` and otherwise use the same configuration and state semantics as their non-streaming equivalents. The `*_without_state` APIs render with an empty object root state. In WASM, the state parameter is optional for `render`, `render_with_templates`, and `render_entry_with_templates`; omitted state also uses an empty object. The template-rendering WASM exports accept an `attribute_name_strategy` string parameter (`""`, `"none"`, or `"camelCase"`), and `render_entry_with_templates` accepts an optional fifth `stream` boolean:
The streaming APIs return `Result<Vec<String>, RenderError>` and otherwise use the same configuration and state semantics as their non-streaming equivalents. The `*_without_state` APIs render with an empty object root state. In WASM, the state parameter is optional for `render`, `render_with_templates`, and `render_entry_with_templates`; omitted state also uses an empty object. The template-rendering WASM exports accept an `attribute_name_strategy` string parameter (`""`, `"none"`, or `"camelCase"`) and an optional `markers_v2` boolean. `render_entry_with_templates` also accepts an optional `stream` boolean:

- `render_with_templates(entry, templates_json, state?, attribute_name_strategy?)`
- `render_entry_with_templates(entry, templates_json, state?, attribute_name_strategy?, stream?)`
- `render_with_templates(entry, templates_json, state?, attribute_name_strategy?, markers_v2?)`
- `render_entry_with_templates(entry, templates_json, state?, attribute_name_strategy?, stream?, markers_v2?)`

---

Expand Down Expand Up @@ -542,8 +542,8 @@ Hand-rolled in `glob_match` β†’ `match_segments` β†’ `match_segment` β†’ `match_
| Export | Signature | Description |
|--------|-----------|-------------|
| `render` | `(entry: &str, state?: string) β†’ String` | Render a template with no custom elements; omitted state is `{}` |
| `render_with_templates` | `(entry: &str, templates_json: &str, state?: string, attribute_name_strategy?: string) β†’ String` | Render a template with a pre-built `{name: content}` templates map using non-entry semantics; omitted state is `{}` |
| `render_entry_with_templates` | `(entry: &str, templates_json: &str, state?: string, attribute_name_strategy?: string, stream?: bool) β†’ String` | Render top-level entry HTML with a pre-built `{name: content}` templates map; omitted state is `{}`. When `stream` is `true`, returns a JSON array string of stream chunks instead of HTML. |
| `render_with_templates` | `(entry: &str, templates_json: &str, state?: string, attribute_name_strategy?: string, markers_v2?: bool) β†’ String` | Render a template with a pre-built `{name: content}` templates map using non-entry semantics; omitted state is `{}`. `markers_v2` selects indexed FAST Element 2.x hydration markers. |
| `render_entry_with_templates` | `(entry: &str, templates_json: &str, state?: string, attribute_name_strategy?: string, stream?: bool, markers_v2?: bool) β†’ String` | Render top-level entry HTML with a pre-built `{name: content}` templates map; omitted state is `{}`. When `stream` is `true`, returns a JSON array string of stream chunks instead of HTML. `markers_v2` selects indexed FAST Element 2.x hydration markers. |
| `parse_f_templates` | `(html: &str) β†’ String` | Parse `<f-template>` elements and return a JSON array |

### `parse_f_templates`
Expand Down Expand Up @@ -649,7 +649,7 @@ A hand-rolled recursive-descent parser. No external crates.

**`Option<&mut HydrationScope>` threading.** The hydration context is an optional mutable parameter on `render_node` and all directive renderers. Passing `None` disables all hydration marker emission and keeps non-custom-element rendering identical to the pre-hydration behaviour. The public API always passes `None` at the top level; hydration is only activated inside `render_custom_element`.

**Data-free hydration markers.** Comment markers carry only a type indicator and start/end flag (`<!--fe:b-->`, `<!--fe:/b-->`, `<!--fe:r-->`, `<!--fe:/r-->`). The FAST client pairs them by balanced depth counting and derives factory-to-node mappings from DFS traversal order. `HydrationScope` only tracks `binding_idx` for attribute binding counts; it does not carry marker names or scope IDs.
**Hydration marker formats.** Comment markers are data-free by default (`<!--fe:b-->`, `<!--fe:/b-->`, `<!--fe:r-->`, `<!--fe:/r-->`). When `RenderConfig.markers_v2` is enabled, `HydrationScope` emits FAST Element 2.x indexed comment markers and `data-fe-b-N` / `data-fe-c-START-COUNT` attribute markers instead.

**Atomic tag processing for attribute bindings.** When a plain HTML opening tag in the literal region contains `{{expr}}` attribute values, those values are resolved and `data-fe` is injected into the tag as a whole before `next_directive` ever sees them. This prevents the `{{expr}}` inside attributes from being mistaken for content bindings. The cost is that `next_directive` is called once extra per tag iteration, but tags are short and rare enough that this has no meaningful performance impact.

Expand Down
14 changes: 11 additions & 3 deletions crates/microsoft-fast-build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,8 @@ The `camelCase` strategy only applies to "plain" attributes. It does **not** aff
use microsoft_fast_build::{RenderConfig, AttributeNameStrategy, render_template_with_locator};

let config = RenderConfig::new()
.with_attribute_name_strategy(AttributeNameStrategy::CamelCase);
.with_attribute_name_strategy(AttributeNameStrategy::CamelCase)
.with_markers_v2(true);

let result = render_template_with_locator(
r#"<my-el foo-bar="hello"></my-el>"#,
Expand All @@ -554,19 +555,21 @@ const html = render_entry_with_templates(
templatesJson,
stateJson,
"camelCase", // or "none"
false // optional stream flag
false, // optional stream flag
true // optional markers_v2 flag
);

const chunksJson = render_entry_with_templates(
entry,
templatesJson,
stateJson,
"camelCase",
true,
true
);
```

Use `render_with_templates` for the original non-entry template-rendering semantics; use `render_entry_with_templates` for top-level entry HTML rendering. Passing `true` as the optional fifth argument switches `render_entry_with_templates` to stream mode and returns a JSON array string of HTML chunks; omitted or `false` returns normal HTML.
Use `render_with_templates` for the original non-entry template-rendering semantics; use `render_entry_with_templates` for top-level entry HTML rendering. Passing `true` as the optional fifth argument switches `render_entry_with_templates` to stream mode and returns a JSON array string of HTML chunks; omitted or `false` returns normal HTML. Passing `true` as the optional sixth `markers_v2` argument emits FAST Element 2.x indexed hydration markers.

Passing `"none"` or `""` as the strategy uses the default behaviour.

Expand All @@ -576,6 +579,11 @@ Passing `"none"` or `""` as the strategy uses the default behaviour.

When a custom element's shadow template is rendered, the renderer emits **hydration markers** so the FAST client runtime can efficiently locate and patch DOM nodes without a full diff.

FAST Element 3.x data-free markers are emitted by default. Set
`RenderConfig::with_markers_v2(true)` in Rust, or pass the `markers_v2` WASM/CLI
option, to emit FAST Element 2.x indexed markers for a client configured with
`enableHydration({ markers: markers_v2 })`.

### Content binding markers

Each `{{expr}}` or `{{{expr}}}` text binding is wrapped in data-free HTML comment markers:
Expand Down
7 changes: 3 additions & 4 deletions crates/microsoft-fast-build/src/attribute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,9 @@ pub(crate) fn strip_client_only_attrs(tag: &str) -> String {
out
}

/// Insert `data-fe="N"` as an attribute just before the closing `>` or `/>`.
/// N is the count of attribute binding factories targeting this element.
pub(crate) fn inject_count_marker(tag: &str, count: usize) -> String {
let marker = format!(" data-fe=\"{}\"", count);
/// Insert a hydration marker attribute just before the closing `>` or `/>`.
pub(crate) fn inject_hydration_marker(tag: &str, marker: &str) -> String {
let marker = format!(" {}", marker);
let trimmed = tag.trim_end();
if trimmed.ends_with("/>") {
let base = trimmed[..trimmed.len() - 2].trim_end();
Expand Down
9 changes: 9 additions & 0 deletions crates/microsoft-fast-build/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub enum AttributeNameStrategy {
pub struct RenderConfig {
/// Strategy for mapping HTML attribute names to state property names.
pub attribute_name_strategy: AttributeNameStrategy,
/// Emit FAST Element 2.x indexed hydration markers instead of the
/// default FAST Element 3.x data-free markers.
pub markers_v2: bool,
}

impl RenderConfig {
Expand All @@ -37,4 +40,10 @@ impl RenderConfig {
self.attribute_name_strategy = strategy;
self
}

/// Configure whether FAST Element 2.x indexed hydration markers are emitted.
pub fn with_markers_v2(mut self, markers_v2: bool) -> Self {
self.markers_v2 = markers_v2;
self
}
}
28 changes: 15 additions & 13 deletions crates/microsoft-fast-build/src/directive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ pub fn render_when(
})?;

let output = if let Some(hy) = hydration {
hy.next_binding();
let start = hy.content_start_marker();
let end = hy.content_end_marker();
let index = hy.next_binding();
let start = hy.content_start_marker(index);
let end = hy.content_end_marker(index);
let inner_content = if evaluate(&expr, root, loop_vars) {
let mut child_scope = HydrationScope::new();
let mut child_scope = HydrationScope::new(config.markers_v2);
render_node(&inner, root, loop_vars, locator, Some(&mut child_scope), false, config)?
} else {
String::new()
Expand Down Expand Up @@ -169,17 +169,19 @@ fn render_repeat_items(
) -> Result<String, RenderError> {
match hydration {
Some(hy) => {
hy.next_binding();
let outer_start = hy.content_start_marker();
let outer_end = hy.content_end_marker();
let binding_index = hy.next_binding();
let outer_start = hy.content_start_marker(binding_index);
let outer_end = hy.content_end_marker(binding_index);
let mut parts: Vec<String> = Vec::with_capacity(items.len());
for (i, item) in items.iter().enumerate() {
let new_vars = build_loop_vars(loop_vars, var_name, item, i);
let mut item_scope = HydrationScope::new();
let mut item_scope = HydrationScope::new(config.markers_v2);
let rendered = render_node(inner, root, &new_vars, locator, Some(&mut item_scope), false, config)?;
parts.push(format!(
"<!--fe:r-->{}<!--fe:/r-->",
rendered
"{}{}{}",
hy.repeat_start_marker(i),
rendered,
hy.repeat_end_marker(i)
));
}
Ok(format!("{}{}{}", outer_start, parts.concat(), outer_end))
Expand Down Expand Up @@ -269,7 +271,7 @@ pub fn render_custom_element(
let child_root = child_root_owned.as_ref().unwrap_or(root);

// Render the shadow DOM template with a fresh hydration scope.
let mut shadow_scope = HydrationScope::new();
let mut shadow_scope = HydrationScope::new(config.markers_v2);
let element_template = locator.get_template(&tag_name).unwrap_or_default();
let rendered = render_node(element_template, child_root, &[], Some(locator), Some(&mut shadow_scope), false, config)?;
let shadowroot_attributes = build_shadowroot_template_attrs(locator.get_shadowroot_attributes(&tag_name));
Expand Down Expand Up @@ -686,8 +688,8 @@ pub(crate) fn build_element_open_tag(
let stripped = strip_client_only_attrs(&resolved);
match parent_hydration {
Some(hy) => {
hy.binding_idx += total_attr;
format!("{} data-fe=\"{}\">", stripped, total_attr)
let marker = hy.attribute_marker(total_attr);
format!("{} {}>", stripped, marker)
}
None => format!("{}>", stripped),
}
Expand Down
63 changes: 45 additions & 18 deletions crates/microsoft-fast-build/src/hydration.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
/// Hydration state for one template scope.
/// Each custom-element shadow, f-when body, and f-repeat item template gets its own scope.
///
/// Markers are data-free β€” they carry only a type indicator and start/end flag.
/// The sequential factory pointer on the client derives all factory-to-node mappings
/// from DFS traversal order. The `binding_idx` counter is used only to track how many
/// attribute bindings have been allocated for `data-fe="N"` markers.
/// FAST Element 3.x markers are data-free. FAST Element 2.x markers include
/// binding and repeat indices so older server output can be reproduced when requested.
pub struct HydrationScope {
pub binding_idx: usize,
markers_v2: bool,
}

impl HydrationScope {
pub fn new() -> Self {
Self { binding_idx: 0 }
pub fn new(markers_v2: bool) -> Self {
Self { binding_idx: 0, markers_v2 }
}

pub fn next_binding(&mut self) -> usize {
Expand All @@ -20,23 +19,51 @@ impl HydrationScope {
idx
}

/// Content binding start marker: `<!--fe:b-->`
pub fn content_start_marker(&self) -> &'static str {
"<!--fe:b-->"
pub fn attribute_marker(&mut self, count: usize) -> String {
debug_assert!(count > 0);
let start = self.binding_idx;
self.binding_idx += count;

if !self.markers_v2 {
return format!("data-fe=\"{}\"", count);
}

if count == 1 {
format!("data-fe-b-{}", start)
} else {
format!("data-fe-c-{}-{}", start, count)
}
}

pub fn content_start_marker(&self, index: usize) -> String {
if self.markers_v2 {
format!("<!--fe-b$$start$${}$$fast-build$$fe-b-->", index)
} else {
"<!--fe:b-->".to_string()
}
}

/// Content binding end marker: `<!--fe:/b-->`
pub fn content_end_marker(&self) -> &'static str {
"<!--fe:/b-->"
pub fn content_end_marker(&self, index: usize) -> String {
if self.markers_v2 {
format!("<!--fe-b$$end$${}$$fast-build$$fe-b-->", index)
} else {
"<!--fe:/b-->".to_string()
}
}

/// Repeat item start marker: `<!--fe:r-->`
pub fn repeat_start_marker(&self) -> &'static str {
"<!--fe:r-->"
pub fn repeat_start_marker(&self, index: usize) -> String {
if self.markers_v2 {
format!("<!--fe-repeat$$start$${}$$fe-repeat-->", index)
} else {
"<!--fe:r-->".to_string()
}
}

/// Repeat item end marker: `<!--fe:/r-->`
pub fn repeat_end_marker(&self) -> &'static str {
"<!--fe:/r-->"
pub fn repeat_end_marker(&self, index: usize) -> String {
if self.markers_v2 {
format!("<!--fe-repeat$$end$${}$$fe-repeat-->", index)
} else {
"<!--fe:/r-->".to_string()
}
}
}
Loading
Loading