You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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. 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.
70
75
71
76
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.
72
77
@@ -205,20 +210,91 @@ A custom element is any opening tag whose name contains a hyphen, excluding `f-w
205
210
- Numeric string → `Number(f64)`
206
211
-`"{{binding}}"` → resolve from parent state (property binding with optional rename)
207
212
- 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.
209
214
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:
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.
217
223
218
224
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).
219
225
220
226
---
221
227
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)
`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
+
222
298
## The Locator — `locator.rs`
223
299
224
300
`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.
291
367
292
368
**`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`.
293
369
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
+
294
376
**`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.
295
377
296
378
**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.
Copy file name to clipboardExpand all lines: crates/microsoft-fast-build/README.md
+68-3Lines changed: 68 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -108,7 +108,15 @@ Right-hand operands can be string literals (`'foo'`), boolean literals (`true`/`
108
108
</ul>
109
109
```
110
110
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-repeatvalue="{{row in rows}}">
115
+
<trdata-index="{{$index}}">
116
+
<td>{{row.name}}</td>
117
+
</tr>
118
+
</f-repeat>
119
+
```
112
120
113
121
### Nesting
114
122
@@ -186,25 +194,82 @@ The last form is a **property binding with renaming**: `foo="{{bar}}"` resolves
186
194
187
195
### Output Format
188
196
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:
190
198
191
199
```html
192
200
<!-- Input -->
193
201
<my-buttonlabel="Submit">light DOM</my-button>
194
202
195
203
<!-- Output -->
196
204
<my-buttonlabel="Submit">
197
-
<templateshadowrootmode="open">
205
+
<templateshadowrootmode="open"shadowroot="open">
198
206
<button>Submit</button>
199
207
</template>
200
208
light DOM
201
209
</my-button>
202
210
```
203
211
212
+
-`shadowroot="open"` — legacy declarative shadow DOM attribute for broader browser compatibility.
213
+
204
214
Custom elements that have no matching template in the locator are passed through verbatim.
205
215
206
216
---
207
217
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:
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:
`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
+
208
273
## Error Handling
209
274
210
275
All render functions return `Result<String, RenderError>`. `RenderError` is an enum:
0 commit comments