Skip to content

Commit 8932372

Browse files
authored
feat(microsoft-fast-build): add hydration markers to Rust SSR renderer (#7350)
## Summary This PR adds **hydration markers** to the `microsoft-fast-build` Rust SSR crate so that the FAST client runtime can efficiently locate and patch DOM nodes during hydration without a full diff. ## What's changed ### Hydration output (custom elements) Custom elements now render with the attributes and template format required by the FAST runtime: ```html <my-button label="Submit" defer-hydration needs-hydration> <template shadowrootmode="open" shadowroot="open"> <button data-fe-c-0-1>Submit</button> </template> </my-button> ``` ### Content binding markers Each `{{expr}}` / `{{{expr}}}` text binding inside a shadow template is wrapped in HTML comments. The marker name is formed as `<expression>-<binding-index>`, making markers human-readable. ### Attribute binding markers (compact format) Elements with `{{expr}}` or single-brace `{expr}` attribute bindings receive a compact marker: ```html <input type="checkbox" data-fe-c-0-1> ``` ### Directive markers - `<f-when>` — outer scope emits `when-<idx>` named markers; truthy body renders in a child scope - `<f-repeat>` — outer scope emits `repeat-<idx>` named markers; each item wrapped in `fe-repeat` start/end comments with a fresh binding scope - `$index` is available inside repeat templates ## Tests 18 new integration tests in `tests/hydration.rs` covering all marker types, directive combinations, nested custom elements, and edge cases. All 136 tests pass. ## Next steps - npm/Node.js wrapper for use in server-side JS environments - CLI binary with builds for Linux, macOS, and Windows
1 parent ed6ae66 commit 8932372

10 files changed

Lines changed: 1000 additions & 117 deletions

File tree

crates/microsoft-fast-build/DESIGN.md

Lines changed: 97 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ render_template(template, state_str)
1818
renderer::render(template, root) ─────────────────────────────────────┐
1919
│ │
2020
▼ │
21-
node::render_node(template, root, loop_vars, locator) │
21+
node::render_node(template, root, loop_vars, locator, hydration?) │
22+
│ │
23+
├─ [hydration mode] scan HTML opening tags for attr bindings │
24+
│ └─ inject data-fe-c-{n}-{count} compact markers │
2225
│ │
2326
├─ scan forward: next_directive() │
2427
│ │ │
2528
│ ├─ TripleBrace → content::render_triple_brace() │
2629
│ ├─ DoubleBrace → content::render_double_brace() │
2730
│ ├─ When → directive::render_when() ───────────┘ (recurse)
2831
│ ├─ Repeat → directive::render_repeat() ──────────┘ (recurse per item)
29-
│ └─ CustomElement→ directive::render_custom_element() ──┘ (recurse with child state)
32+
│ └─ CustomElement→ directive::render_custom_element() ──┘ (recurse with fresh HydrationScope)
3033
3134
└─ Append literal prefix + resolved chunk → output string
3235
```
@@ -39,12 +42,13 @@ render_template(template, state_str)
3942
|--------|------|
4043
| `lib.rs` | Public API surface — four `pub fn`s and three `pub use` re-exports |
4144
| `renderer.rs` | Thin entry points converting the public API into `render_node` calls |
42-
| `node.rs` | The main rendering loop — scans for the next directive and dispatches |
45+
| `node.rs` | The main rendering loop — scans for directives, handles attribute bindings in hydration mode |
4346
| `directive.rs` | `Directive` enum, `next_directive` scanner, and all directive renderers |
4447
| `content.rs` | `{{expr}}` and `{{{expr}}}` binding renderers, `html_escape` |
45-
| `attribute.rs` | Low-level HTML/attribute string parsing utilities |
48+
| `attribute.rs` | Low-level HTML/attribute string parsing utilities + hydration attribute helpers |
4649
| `context.rs` | State value resolution: dot-path access, loop-variable scoping |
4750
| `expression.rs` | Boolean expression evaluator for `<f-when value="{{…}}">` |
51+
| `hydration.rs` | `HydrationScope` — binding index tracking and named marker generation per template scope |
4852
| `json.rs` | Hand-rolled JSON parser producing `JsonValue` |
4953
| `locator.rs` | `Locator` struct — maps element names to template strings; glob scanner |
5054
| `error.rs` | `RenderError` enum with `Display` impl and helpers |
@@ -53,20 +57,21 @@ render_template(template, state_str)
5357

5458
## The rendering loop — `node.rs`
5559

56-
`render_node` is the core of the crate. It is called recursively for nested directives and custom element templates.
60+
`render_node` is the core of the crate. It is called recursively for nested directives and custom element templates. Its signature:
5761

5862
```
59-
fn render_node(template, root, loop_vars, locator) → Result<String>
63+
fn render_node(template, root, loop_vars, locator, hydration: Option<&mut HydrationScope>) → Result<String>
6064
```
6165

6266
The loop works like a cursor:
6367

64-
1. Call `next_directive(template, pos, locator)` to find the earliest interesting position ahead.
65-
2. If nothing is found, append `template[pos..]` to output and break.
66-
3. Otherwise, append the literal text from `pos` up to the directive's start.
67-
4. Dispatch the directive to the appropriate handler (returns `(chunk, next_pos)`).
68-
5. Append `chunk` and advance `pos` to `next_pos`.
69-
6. Repeat.
68+
1. **Hydration tag scan** (when `hydration` is `Some`): before each directive, scan for any plain HTML opening tags in the literal region that precede the directive position. For each such tag, detect `{{expr}}` and `{expr}` attribute bindings, allocate binding indices, resolve `{{expr}}` values, and inject a compact `data-fe-c-{start}-{count}` attribute. This step advances `pos` past each processed tag so those tags' `{{expr}}` bindings are not re-encountered as content directives.
69+
2. Call `next_directive(template, pos, locator)` to find the earliest interesting position ahead.
70+
3. If nothing is found, append `template[pos..]` to output and break.
71+
4. Otherwise, append the literal text from `pos` up to the directive's start.
72+
5. Dispatch the directive to the appropriate handler (returns `(chunk, next_pos)`). In hydration mode, content bindings (`{{expr}}`, `{{{expr}}}`) are wrapped in `<!--fe-b$$start$$N$$UUID$$fe-b-->VALUE<!--fe-b$$end$$...-->` markers.
73+
6. Append `chunk` and advance `pos` to `next_pos`.
74+
7. Repeat.
7075

7176
All handlers return `Result<(String, usize), RenderError>`. The `usize` is the byte position in the original template that the handler consumed up to — the cursor's next position.
7277

@@ -205,20 +210,91 @@ A custom element is any opening tag whose name contains a hyphen, excluding `f-w
205210
- Numeric string → `Number(f64)`
206211
- `"{{binding}}"` → resolve from parent state (property binding with optional rename)
207212
- Anything else → `String`
208-
5. **Render the shadow template** by calling `render_node` recursively with the child state as root. The `Locator` is threaded through so nested custom elements are expanded too.
213+
5. **Render the shadow template** by calling `render_node` recursively with the child state as root and a **fresh `HydrationScope`** (always active). The `Locator` is threaded through so nested custom elements are expanded too.
209214
6. **Extract light DOM children** via `extract_directive_content` (reuses the same nesting-aware scanner as directives).
210-
7. **Emit Declarative Shadow DOM**:
215+
7. **Emit Declarative Shadow DOM** with hydration attributes:
211216
```html
212217
<my-button label="Hi">
213-
<template shadowrootmode="open">[shadow DOM]</template>
218+
<template shadowrootmode="open" shadowroot="open">[shadow DOM]</template>
214219
[light DOM children]
215220
</my-button>
216221
```
222+
When the element itself 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.
217223

218224
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).
219225

220226
---
221227

228+
## Hydration markers — `hydration.rs`
229+
230+
When rendering a custom element's shadow DOM, the renderer tracks **binding indices** and emits **named markers** so the FAST client runtime can efficiently locate and patch DOM nodes during hydration.
231+
232+
### Scopes
233+
234+
A **template scope** is a `HydrationScope` that carries a single field:
235+
- `binding_idx: usize` — the next binding index to allocate (increments for each binding in this scope)
236+
237+
Scope boundaries are:
238+
| Context | Scope |
239+
|---|---|
240+
| Custom element shadow template | Fresh `HydrationScope` (binding_idx = 0) |
241+
| `f-when` truthy body | Child scope via `hy.child()` (binding_idx reset to 0) |
242+
| `f-repeat` item template | Fresh `HydrationScope` per item (binding_idx reset to 0) |
243+
244+
Scopes carry no numeric ID. Marker names are derived from the binding context (see below) and are therefore self-describing.
245+
246+
### Content binding markers
247+
248+
When `hydration` is `Some`, `render_node` wraps each `{{expr}}` / `{{{expr}}}` result:
249+
250+
```
251+
<!--fe-b$$start$$N$$<expr>-N$$fe-b-->VALUE<!--fe-b$$end$$N$$<expr>-N$$fe-b-->
252+
```
253+
254+
`N` is the current `binding_idx` from the scope; `<expr>` is the expression text (e.g. `title`, `item.name`, `$index`). So `{{title}}` at index 0 produces marker name `title-0`, and `{{item.name}}` at index 2 produces `item.name-2`.
255+
256+
### Attribute binding markers (compact format)
257+
258+
Plain HTML opening tags in the literal regions are scanned by `attribute::find_next_plain_html_tag` **before** `next_directive` processes them. For each tag that has `{{expr}}` (double-brace) or `{expr}` (single-brace) attribute values:
259+
260+
1. `count_tag_attribute_bindings` counts both types.
261+
2. The current `binding_idx` is recorded as `start`, and advanced by the total count.
262+
3. `resolve_attribute_bindings_in_tag` substitutes `{{expr}}` attribute values with their resolved HTML-escaped values; `{expr}` single-brace values are left unchanged (they are client-side bindings).
263+
4. `inject_compact_marker` inserts `data-fe-c-{start}-{count}` before the closing `>` of the tag.
264+
265+
This atomic tag processing ensures that the `{{expr}}` attribute values are never seen as content directives by the main loop — `pos` advances past the entire tag before the directive scanner runs again.
266+
267+
### `f-when` markers
268+
269+
```
270+
<!--fe-b$$start$$N$$when-N$$fe-b-->
271+
[inner content in child scope, or empty if falsy]
272+
<!--fe-b$$end$$N$$when-N$$fe-b-->
273+
```
274+
275+
`N` is allocated from the outer (parent) scope's `binding_idx`. The marker name is `when-N`.
276+
277+
### `f-repeat` markers
278+
279+
```
280+
<!--fe-b$$start$$N$$repeat-N$$fe-b-->
281+
<!--fe-repeat$$start$$0$$fe-repeat-->
282+
[item 0 rendered with fresh binding_idx = 0]
283+
<!--fe-repeat$$end$$0$$fe-repeat-->
284+
<!--fe-repeat$$start$$1$$fe-repeat-->
285+
[item 1 rendered with fresh binding_idx = 0]
286+
<!--fe-repeat$$end$$1$$fe-repeat-->
287+
<!--fe-b$$end$$N$$repeat-N$$fe-b-->
288+
```
289+
290+
The outer markers use `repeat-N` where `N` is the binding index in the parent scope. Each item gets its own fresh `HydrationScope`, so per-item content bindings are named after their expressions (e.g. `item-0`, `item.name-1`).
291+
292+
### `$index` in `f-repeat`
293+
294+
Each iteration pushes `("$index", JsonValue::Number(i as f64))` into `loop_vars`, making `{{$index}}` available in repeat templates in both hydration and non-hydration modes.
295+
296+
---
297+
222298
## The Locator — `locator.rs`
223299

224300
`Locator` is a `HashMap<String, String>` mapping element names to their template HTML strings.
@@ -291,6 +367,12 @@ A hand-rolled recursive-descent parser. No external crates.
291367

292368
**`Option<&Locator>` threading.** The locator is an optional parameter on all internal functions. Passing `None` disables custom element expansion entirely, preserving backward compatibility for callers that use `render` / `render_template`.
293369

370+
**`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`.
371+
372+
**Named hydration markers.** Marker names are derived from the binding context: content bindings use `<expr>-<idx>` (e.g. `title-0`, `item.name-2`), f-when uses `when-<idx>`, and f-repeat uses `repeat-<idx>`. This makes markers human-readable and self-describing without a shared ID counter. `HydrationScope` needs only `binding_idx` — no `Rc`, no `scope_id`, no `ScopeGen`. The scheme differs from the FAST HTML package which uses random alphanumeric UUIDs, but the structure is equivalent.
373+
374+
**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-c` 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.
375+
294376
**`Result` throughout.** All render functions return `Result<_, RenderError>`. Errors propagate via `?` without any silent failures. The one deliberate choice: missing state keys return `RenderError::MissingState` rather than an empty string, so template bugs surface early.
295377

296378
**Left-to-right, first-match scanning.** Directives are found by searching for their literal opening strings. The earliest position wins. This is O(n×d) where n is the template length and d is the number of directive types — acceptable for the template sizes this crate targets.

crates/microsoft-fast-build/README.md

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,15 @@ Right-hand operands can be string literals (`'foo'`), boolean literals (`true`/`
108108
</ul>
109109
```
110110

111-
Inside `<f-repeat>`, `{{item}}` resolves to the current loop variable. Other bindings fall back to the root state (e.g. `{{title}}` above).
111+
Inside `<f-repeat>`, `{{item}}` resolves to the current loop variable and `{{$index}}` resolves to the 0-based iteration index. Other bindings fall back to the root state (e.g. `{{title}}` above).
112+
113+
```html
114+
<f-repeat value="{{row in rows}}">
115+
<tr data-index="{{$index}}">
116+
<td>{{row.name}}</td>
117+
</tr>
118+
</f-repeat>
119+
```
112120

113121
### Nesting
114122

@@ -186,25 +194,82 @@ The last form is a **property binding with renaming**: `foo="{{bar}}"` resolves
186194

187195
### Output Format
188196

189-
The renderer wraps the rendered template in Declarative Shadow DOM:
197+
The renderer wraps the rendered template in Declarative Shadow DOM and adds the hydration attributes required by the FAST client runtime:
190198

191199
```html
192200
<!-- Input -->
193201
<my-button label="Submit">light DOM</my-button>
194202

195203
<!-- Output -->
196204
<my-button label="Submit">
197-
<template shadowrootmode="open">
205+
<template shadowrootmode="open" shadowroot="open">
198206
<button>Submit</button>
199207
</template>
200208
light DOM
201209
</my-button>
202210
```
203211

212+
- `shadowroot="open"` — legacy declarative shadow DOM attribute for broader browser compatibility.
213+
204214
Custom elements that have no matching template in the locator are passed through verbatim.
205215

206216
---
207217

218+
## Hydration Markers
219+
220+
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.
221+
222+
### Content binding markers
223+
224+
Each `{{expr}}` or `{{{expr}}}` text binding is wrapped in HTML comments:
225+
226+
```html
227+
<!--fe-b$$start$$0$$title-0$$fe-b-->Hello world<!--fe-b$$end$$0$$title-0$$fe-b-->
228+
```
229+
230+
The `0` is the **binding index** (increments per binding within the current template scope) and `title-0` is the **marker name** — formed as `<expression>-<binding index>`. This makes markers human-readable and unique within a scope.
231+
232+
### Attribute binding markers (compact format)
233+
234+
Elements with `{{expr}}` attribute values or `{expr}` single-brace event/ref bindings receive a compact marker attribute:
235+
236+
```html
237+
<!-- Template: <input type="{{type}}" disabled> -->
238+
<input type="checkbox" disabled data-fe-c-0-1>
239+
240+
<!-- Template: <button @click="{handleClick()}">Label</button> -->
241+
<button @click="{handleClick()}" data-fe-c-0-1>Label</button>
242+
243+
<!-- Template: <my-el title="{{t}}" @click="{fn()}"> — 2 bindings -->
244+
<my-el title="Hello" @click="{fn()}" data-fe-c-0-2>
245+
```
246+
247+
`data-fe-c-{startIndex}-{count}``startIndex` is the binding index of the first attribute binding on the element; `count` is the total number of attribute bindings.
248+
249+
### Scope boundaries
250+
251+
Each custom element shadow, `<f-when>` body, and `<f-repeat>` item template gets its own scope with the binding index reset to 0. Scopes don't carry numeric IDs — marker names are self-describing.
252+
253+
### Directive markers
254+
255+
```html
256+
<!-- f-when at binding index 0 -->
257+
<!--fe-b$$start$$0$$when-0$$fe-b-->
258+
[inner content, or empty if condition is false]
259+
<!--fe-b$$end$$0$$when-0$$fe-b-->
260+
261+
<!-- f-repeat at binding index 0, 2 items -->
262+
<!--fe-b$$start$$0$$repeat-0$$fe-b-->
263+
<!--fe-repeat$$start$$0$$fe-repeat-->
264+
[item 0 — each binding named by its expression, e.g. item-0]
265+
<!--fe-repeat$$end$$0$$fe-repeat-->
266+
<!--fe-repeat$$start$$1$$fe-repeat-->
267+
[item 1 — binding index reset to 0 per item]
268+
<!--fe-repeat$$end$$1$$fe-repeat-->
269+
<!--fe-b$$end$$0$$repeat-0$$fe-b-->
270+
```
271+
272+
208273
## Error Handling
209274

210275
All render functions return `Result<String, RenderError>`. `RenderError` is an enum:

0 commit comments

Comments
 (0)