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
refactor: simplify hydration markers to data-free sequential format (#7474)
# Pull Request
## 📖 Description
Replace the verbose, index-embedded hydration marker system with a compact, data-free sequential format. This is a **breaking change** to the SSR output format.
**Why:** The old markers embedded binding indices and scope IDs into HTML comments (73+ chars per pair) and required regex parsing on the client. Since both the SSR template compiler and client hydration walker traverse the DOM in identical DFS order, these embedded indices are redundant — a simple sequential pointer suffices.
**What changed:**
- **Content binding markers**:
Old: `fe-b$$start$$<factoryIndex>$$<scopeId>$$fe-b` / `fe-b$$end$$...`
New: `fe:b` / `fe:/b`
- **Repeat item markers**:
Old: `fe-repeat$$start$$<itemIndex>$$fe-repeat` / `fe-repeat$$end$$...`
New: `fe:r` / `fe:/r`
- **Element boundary markers**:
Old: `fe-eb$$start$$<elementId>$$fe-eb` / `fe-eb$$end$$...`
New: `fe:e` / `fe:/e`
- **Attribute binding markers**:
Old: three formats — `data-fe-b="0 1 2"` (space-separated indices), `data-fe-b-0` (enumerated), `data-fe-c-0-3` (compact start+count)
New: single format — `data-fe="N"` (binding count only)
- **Parsing**: Six regex patterns → string equality checks
- **Pairing**: ID-based start/end matching → balanced depth counting
- **Walker**: Index-based factory lookup → sequential factory pointer
- **Rust SSR**: `HydrationScope` simplified — removed `scope_prefix`, `child()`, parameterized marker methods
- **Benchmarks**: Replaced hardcoded `render.ts` functions with WASM-based SSR via `@microsoft/fast-build`, ensuring benchmark output always matches the actual crate
## 👩💻 Reviewer Notes
- The core architectural change is in `target-builder.ts` — the hydration walker now advances a `factoryPointer` sequentially rather than parsing indices from comment data.
- `repeat.ts` hydration walks backward from `items.length - 1` using `fe:/r`/`fe:r` end/start markers with balanced depth counting. Empty repeat items (adjacent markers with no content) are handled explicitly.
- Rust `HydrationScope` methods `repeat_start_marker()`/`repeat_end_marker()` are currently unused — repeat markers are emitted as string literals in `render_repeat_items`. These could be removed or wired in as a follow-up.
- Element boundary markers (`fe:e`/`fe:/e`) are supported on the TS client side but not emitted by the Rust crate — DSD (`<template shadowrootmode="open">`) provides natural isolation. Both start and end boundary markers are cleared after hydration.
- All markers now use the `fe:` namespace prefix, consistent with the `data-fe` attribute naming.
- `parseAttributeBindingCount()` validates strictly (digit-only regex, positive integer) and throws `FAST.error(Message.invalidHydrationAttributeMarker)` for malformed values.
- Content binding and element boundary paths throw `HydrationTargetElementError` when factories are exhausted or end markers are missing.
- Repeat hydration validates both underflow (fewer markers than items) and overflow (more markers than items).
## 📑 Test Plan
- All Rust unit tests pass (`cargo test` in `crates/microsoft-fast-build`)
- All 852 Playwright tests pass across Chromium, Firefox, and WebKit
- Test fixtures regenerated via `npm run build:fixtures -w @microsoft/fast-html` using rebuilt WASM binary
- Verified no old marker format remains in fixture HTML or WASM binary via `grep`/`strings`
- Rust test assertions use `matches().count()` for multi-occurrence markers to verify exact counts
## ✅ Checklist
### General
- [x] I have included a change request file using `$ npm run change`
- [ ] I have added tests for my changes.
- [x] I have tested my changes.
- [x] I have updated the project documentation to reflect my changes.
- [x] I have read the [CONTRIBUTING](https://github.com/microsoft/fast/blob/main/CONTRIBUTING.md) documentation and followed the [standards](https://github.com/microsoft/fast/blob/main/CODE_OF_CONDUCT.md#our-standards) for this project.
## ⏭ Next Steps
- Add focused Playwright hydration tests for the repeat marker scanning, depth counting, and empty-item edge cases.
- Consider removing unused `repeat_start_marker()`/`repeat_end_marker()` methods from `HydrationScope` in Rust, or wiring them into `render_repeat_items` instead of hardcoded strings.
- Consider whether the Rust crate should emit `fe:e`/`fe:/e` element boundary markers for non-DSD scenarios.
- Evaluate viewport-deferred hydration (see `PROPOSAL_VIEWPORT_DEFERRED_HYDRATION.md`) as a higher-impact optimization for reducing TTI.
"comment": "Simplify hydration markers to data-free sequential format (fe:b, fe:/b, fe:r, fe:/r, fe:e, fe:/e). Replace regex parsing with string equality checks. Single data-fe attribute replaces three old formats. Breaking change: SSR output format changed.",
The `is_entry` flag distinguishes two rendering contexts for **opening-tag attribute handling**:
70
70
71
-
-**`is_entry: true`** — the template is the top-level entry HTML. Custom elements found at this level (root custom elements) have their opening-tag `{{binding}}` attributes resolved to primitive values (stripping non-primitives). No `data-fe-c` marker is added.
72
-
-**`is_entry: false`** — the template is a shadow template, a directive body, or a repeat item. Custom elements found here have their `{{binding}}` attributes resolved and a `data-fe-c` compact marker injected when inside a parent hydration scope.
71
+
-**`is_entry: true`** — the template is the top-level entry HTML. Custom elements found at this level (root custom elements) have their opening-tag `{{binding}}` attributes resolved to primitive values (stripping non-primitives). No `data-fe` marker is added.
72
+
-**`is_entry: false`** — the template is a shadow template, a directive body, or a repeat item. Custom elements found here have their `{{binding}}` attributes resolved and a `data-fe` marker injected when inside a parent hydration scope.
73
73
74
74
**Child state** is built the same way regardless of `is_entry`: the current root state is always used as a base, with per-element attributes overlaid on top. This ensures all unbound state keys propagate through to every descendant custom element automatically.
75
75
76
76
`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`.
77
77
78
78
The loop works like a cursor:
79
79
80
-
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.
80
+
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 `data-fe="N"` attribute. This step advances `pos` past each processed tag so those tags' `{{expr}}` bindings are not re-encountered as content directives.
81
81
2. Call `next_directive(template, pos, locator)` to find the earliest interesting position ahead.
82
82
3. If nothing is found, append `template[pos..]` to output and break.
83
83
4. Otherwise, append the literal text from `pos` up to the directive's start.
84
-
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.
84
+
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.
85
85
6. Append `chunk` and advance `pos` to `next_pos`.
86
86
7. Repeat.
87
87
@@ -118,7 +118,7 @@ The skip logic lives in `attribute::find_single_brace` and `attribute::skip_sing
118
118
119
119
### Attribute directive stripping in Declarative Shadow DOM
120
120
121
-
Attribute directives — `f-ref="{expr}"`, `f-slotted="{expr}"`, `f-children="{expr}"` — use single-brace syntax and are **client-side only**. When rendering a custom element's shadow DOM template (where hydration is always active), `attribute::strip_client_only_attrs` removes these attributes from the output HTML, exactly as it removes `@event` and `:property` bindings. The `data-fe-c` compact binding count still includes them so the FAST runtime allocates the correct binding slots.
121
+
Attribute directives — `f-ref="{expr}"`, `f-slotted="{expr}"`, `f-children="{expr}"` — use single-brace syntax and are **client-side only**. When rendering a custom element's shadow DOM template (where hydration is always active), `attribute::strip_client_only_attrs` removes these attributes from the output HTML, exactly as it removes `@event` and `:property` bindings. The `data-fe` binding count still includes them so the FAST runtime allocates the correct binding slots.
122
122
123
123
---
124
124
@@ -239,17 +239,17 @@ A custom element is any opening tag whose name contains a hyphen, excluding `f-w
239
239
-**Non-primitives** (`Array`, `Object`, `Null`) — stripped. Arrays and objects cannot be meaningfully represented as HTML attribute values; the state is available directly in the element's template via state propagation. Because of this, same-name non-primitive bindings like `list="{{list}}"` are redundant in entry HTML and can be omitted — state propagation provides the value automatically.
240
240
-**Static attributes** (no binding syntax, e.g. `id="main"`) — passed through unchanged.
241
241
-**Client-only attrs** (`@event`, `:prop`, attribute directives) — stripped as usual.
242
-
- No `data-fe-c` marker is added — root elements at entry level have no parent hydration scope.
243
-
-**Nested custom elements** (`is_entry: false`): `strip_client_only_attrs` removes client-only attrs after binding resolution. If the element carries `{{expr}}` or `{expr}` attribute bindings and is inside a parent hydration scope, those bindings are counted and `data-fe-c-{start}-{count}` is injected.
244
-
8.**Strip client-only binding attributes** (`@attr` event bindings, `:attr` property bindings, and `f-ref`/`f-slotted`/`f-children` attribute directives) from all tags inside the rendered shadow template. `:attr` bindings contribute to child state in step 4 but are still removed from the rendered HTML — they are resolved by the FAST client runtime. The `data-fe-c` binding count is preserved — these bindings are still counted so the FAST client runtime allocates the correct number of binding slots.
242
+
- No `data-fe` marker is added — root elements at entry level have no parent hydration scope.
243
+
-**Nested custom elements** (`is_entry: false`): `strip_client_only_attrs` removes client-only attrs after binding resolution. If the element carries `{{expr}}` or `{expr}` attribute bindings and is inside a parent hydration scope, those bindings are counted and `data-fe="N"` is injected.
244
+
8.**Strip client-only binding attributes** (`@attr` event bindings, `:attr` property bindings, and `f-ref`/`f-slotted`/`f-children` attribute directives) from all tags inside the rendered shadow template. `:attr` bindings contribute to child state in step 4 but are still removed from the rendered HTML — they are resolved by the FAST client runtime. The `data-fe` binding count is preserved — these bindings are still counted so the FAST client runtime allocates the correct number of binding slots.
245
245
9.**Emit Declarative Shadow DOM** with hydration attributes:
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.
252
+
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="N"` is added to the element's opening tag, and the binding indices are allocated from the parent scope.
253
253
254
254
Note: `is_entry` controls only opening-tag attribute handling. Child state is always built using the current root state as a base with per-element attributes overlaid on top, regardless of the `is_entry` flag.
255
255
@@ -313,19 +313,19 @@ Scope boundaries are:
313
313
|`f-when` truthy body | Child scope via `hy.child()` (binding_idx reset to 0) |
314
314
|`f-repeat` item template | Fresh `HydrationScope` per item (binding_idx reset to 0) |
315
315
316
-
Scopes carry no numeric ID. Marker names are derived from the binding context (see below) and are therefore self-describing.
316
+
Scopes carry no numeric ID. Markers are data-free sequential strings matched by string equality; pairing uses balanced depth counting.
317
317
318
318
### Content binding markers
319
319
320
320
When `hydration` is `Some`, `render_node` wraps each `{{expr}}` / `{{{expr}}}` result:
`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`.
326
+
Markers carry no binding index or expression name. They are paired by balanced depth counting — each `<!--fe:b-->` increments a counter and each `<!--fe:/b-->` decrements it; when the counter returns to zero the pair is complete.
327
327
328
-
### Attribute binding markers (compact format)
328
+
### Attribute binding markers
329
329
330
330
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:
331
331
@@ -335,7 +335,7 @@ Plain HTML opening tags in the literal regions are scanned by `attribute::find_n
335
335
-`?attr="{{expr}}"` — **boolean binding**: `expr` is evaluated as a boolean. If truthy, the bare attribute name (without `?`) is emitted; if falsy, the attribute is omitted entirely. The `extract_bool_attr_prefix` helper detects this pattern by checking whether the output accumulated so far ends with `?name="`.
336
336
-`attr="{{expr}}"` — **value binding**: `expr` is resolved to a string and HTML-escaped.
337
337
-`attr="{expr}"` — **single-brace binding**: left unchanged (client-side only).
338
-
4.`inject_compact_marker` inserts `data-fe-c-{start}-{count}` before the closing `>` of the tag.
338
+
4.`inject_marker` inserts `data-fe="N"` (where `N` is the binding count) before the closing `>` of the tag.
339
339
340
340
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.
`N` is allocated from the outer (parent) scope's `binding_idx`. The marker name is `when-N`.
391
+
The binding index `N` is allocated from the outer (parent) scope's `binding_idx`. The markers are data-free and paired by balanced depth counting.
392
392
393
393
### `f-repeat` markers
394
394
395
395
```
396
-
<!--fe-b$$start$$N$$repeat-N$$fe-b-->
397
-
<!--fe-repeat$$start$$0$$fe-repeat-->
396
+
<!--fe:b-->
397
+
<!--fe:r-->
398
398
[item 0 rendered with fresh binding_idx = 0]
399
-
<!--fe-repeat$$end$$0$$fe-repeat-->
400
-
<!--fe-repeat$$start$$1$$fe-repeat-->
399
+
<!--fe:/r-->
400
+
<!--fe:r-->
401
401
[item 1 rendered with fresh binding_idx = 0]
402
-
<!--fe-repeat$$end$$1$$fe-repeat-->
403
-
<!--fe-b$$end$$N$$repeat-N$$fe-b-->
402
+
<!--fe:/r-->
403
+
<!--fe:/b-->
404
404
```
405
405
406
-
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`).
406
+
The outer `<!--fe:b-->` / `<!--fe:/b-->` markers wrap the entire repeat directive. Each item is wrapped in `<!--fe:r-->` / `<!--fe:/r-->` markers. All markers are data-free; pairing uses balanced depth counting. Each item gets its own fresh `HydrationScope`, so per-item content bindings restart at index 0.
407
407
408
408
### `$index` in `f-repeat`
409
409
@@ -542,9 +542,9 @@ A hand-rolled recursive-descent parser. No external crates.
542
542
543
543
**`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`.
544
544
545
-
**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.
545
+
**Named hydration markers.**Markers are data-free fixed strings (`<!--fe:b-->`, `<!--fe:/b-->`, `<!--fe:r-->`, `<!--fe:/r-->`) matched by string equality — no regex parsing required. Pairing uses balanced depth counting rather than ID matching. `HydrationScope` needs only `binding_idx` — no `Rc`, no `scope_id`, no `ScopeGen`.
546
546
547
-
**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.
547
+
**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.
548
548
549
549
**`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.
0 commit comments