Skip to content

Commit e3608cf

Browse files
authored
Refactor: Consolidate Native Hydrate Path (#179)
1 parent 51e699f commit e3608cf

20 files changed

Lines changed: 342 additions & 408 deletions

File tree

ai/skills/authoring/ssr-hydration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ hydrate(prototypeTemplate) {
356356
357357
Two passes over the server-rendered DOM:
358358
359-
**Pass 1: Attribute bindings** — The server stamps `data-sui-bind="attr=N,..."` on every element with dynamic bindings (Plan 04 fast path). The client walks `[data-sui-bind]` elements at top level and wires Reactions directly via `entries[id].attributeBinding`. Block-owned elements (inside `{#if}`/`{#each}`/etc., tracked via `blockDepth` from comment markers) are skipped — block handlers recurse into their own contents via `hydrateInnerContent`. A legacy fallback (parallel ref-DOM walker) exists for older SSR output without `data-sui-bind`.
359+
**Pass 1: Attribute bindings** — The server stamps `data-sui-bind="attr=N,..."` on every element with dynamic bindings (Plan 04 fast path). The client walks `[data-sui-bind]` elements at top level and wires Reactions directly via `entries[id].attributeParts`. Block-owned elements (inside `{#if}`/`{#each}`/etc., tracked via `blockDepth` from comment markers) are skipped — block handlers recurse into their own contents via `hydrateInnerContent`.
360360
361361
**Pass 2: Comment markers** — A TreeWalker finds `<!--sui:v1:N-->` (text expressions) and `<!--sui-block:v1:N-->` (block directives) at top level. `blockDepth` tracking skips inner markers; block handlers process their own children.
362362

ai/skills/contributing/author-pull-requests.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ After the prefix:
7272
- **Concrete verbs.** Make / Move / Swap / Group / Add / Remove. Avoid Relocate / Re-anchor / Configure (formal/abstract). Avoid Drop (SQL connotations).
7373
- **Title is the primary change only.** Secondary work goes in body bullets — never `X and Y` titles.
7474
- **Don't lead with `BREAKING:` even for breaking changes.** The Risk score + changelog automation already signal the breaking nature. Use the prefix that describes the change *kind*`Refactor:`, `Perf:`, `Feat:` — and let the Risk/changelog do the warning work.
75+
- **Don't leak iteration history into commit subjects** — no "Round 2 fixes," "post-review cleanup," "second pass," or similar. Even with squash-on-merge protecting `main`, branch commits are read by code-review subagents working on the PR, and round-numbering subtly biases them toward assuming earlier rounds caught the obvious issues. State what changed, not which review pass produced it. Same applies inside multi-commit branches that humans bisect.
7576

7677
### Worked examples (real PRs from this project)
7778

ai/skills/contributing/code-review.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ Launch all 6 in parallel. Always use **Opus** — never Sonnet or Haiku for revi
3232

3333
Each agent runs `gh pr diff {number}` and examines the changes through its lens.
3434

35+
**Don't leak round number or iteration history to lens agents.** Each round's prompt should read as a first review against the current PR diff. Hints like "round 3", "round-2 fixes", "after iteration", or "previous reviewers already covered X" subtly bias the agent toward assuming earlier rounds caught the obvious issues — they read with less rigor and less skepticism. Frame every round as a cold first read of the current state. The agent has no way to verify what previous rounds covered, so the bias is unfalsifiable from its end.
36+
37+
Same applies to scorers: don't tell a scoring agent "this finding came up in round 3" — strip iteration context before passing the finding.
38+
3539
**Lens agent output format:** Each lens agent returns a structured list of findings. For each issue:
3640
- **File path and line number(s)**
3741
- **What's wrong** — one sentence

ai/skills/contributing/native-renderer.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ Without recovery, hook throws propagate so failures are loud — the browser log
273273
274274
| Block | AST type | Notes |
275275
|---|---|---|
276-
| `conditional.js` | `if` | Linear branch match. `matchIndex 1000` = main body, ≥0 = branches. Server stamps `serverMeta.branchIndex` on the closing marker; mismatch triggers a re-render with a dev warning (env guards `isClient`/`isServer` exempted). |
276+
| `conditional.js` | `if` | Linear branch match. `matchIndex` = `MAIN_BRANCH_INDEX` (1000) for main body, ≥0 for branches. Server stamps `serverMeta.branchIndex` on the closing marker; mismatch triggers a re-render with a dev warning (env guards `isClient`/`isServer` exempted). |
277277
| `each.js` | `each` | Keyed reconciliation with per-item `Signal` + Proxy + per-item start/end text-node markers. `WeakSet itemContextProxies` replaces the old `__isItemProxy` flag; `template.js` checks via `isItemContext()` import. Snapshot-based dirty detection avoids full deep-equality on the steady-state path. |
278278
| `async.js` | `async` | Three states (loading / success / error). Generation counter discards stale promise resolutions. Recovery wraps sync throws and dispatches into `errorContent` via the `error` hook. |
279279
| `rerender.js` | `rerender` | Both `{#rerender expr}` and `{#guard key}` compile to the same node type. Guard wraps tracking in `Reaction.guard` (deep-equality gate); rerender uses plain `lookupExpression`. |
@@ -310,7 +310,7 @@ Collects all DOM nodes between the opening `sui-block:v1:N` and matching `/sui-b
310310
311311
### Why `data-sui-bind` matters
312312
313-
Profiling identified a ~648 ms `template.innerHTML = htmlString` reparse per 100-card page in the legacy hydration path. The fast path skips that entirely — entries already carry `attributeBinding: { parts, classification }` from `buildHTMLString`. The reference DOM is now a lazy getter on the cache entry, only built when the legacy path runs (rolling-upgrade tolerance).
313+
Profiling identified a ~648 ms `template.innerHTML = htmlString` reparse per 100-card page in the legacy hydration path. The fast path skips that entirely — entries already carry `attributeParts` (the parsed alternating static/marker segments) from `buildHTMLString`, and the classification is on the entry itself.
314314
315315
---
316316

packages/component/bench/tachometer/bench-hydrate.js

Lines changed: 25 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,17 @@ import { defineComponent } from '@semantic-ui/component';
22
import { ServerRenderer } from '@semantic-ui/renderer';
33

44
/*
5-
Hydrate-each — gates hydration cost across two distinct windows:
6-
7-
1. `hydrate-each-100-mount` — work paid by the hydrate microtask itself,
8-
before any data mutation. Sensitive to designs that move per-item
9-
wiring earlier (e.g. eager `adoptServerItems` in `each.hydrate`).
10-
2. `hydrate-each-100` — first-data-change adoption: server-emitted
11-
`<!--sui-item:v1:KEY-->` markers let `adoptServerItems` reuse DOM
12-
and call `hydrateInnerContent` 100×. Sensitive to silent regressions
13-
in the AST→string/entries cache (`renderer.js buildStringCache`),
14-
which on a miss reverts to a full `buildHTMLStringPure` per item.
15-
16-
Both windows require Declarative Shadow DOM to be parsed — `innerHTML`
17-
does not process `<template shadowrootmode>`, only `setHTMLUnsafe` does.
5+
Hydrate-each — two windows:
6+
7+
1. `hydrate-each-100-mount` — DSD parse + connectedCallback + the hydrate
8+
microtask (eager `adoptServerItems`) + post-hydrate rAF. Per-item
9+
wiring cost lives here.
10+
2. `hydrate-each-100` — post-mount items mutation. Items dependency wakes,
11+
`each.update` reconciles same keys, hits same-ref path. Mostly the
12+
reconcile walk + Phase 3 snapshot diff.
13+
14+
Both windows require Declarative Shadow DOM — `setHTMLUnsafe` processes
15+
`<template shadowrootmode>`, plain `innerHTML` does not.
1816
*/
1917

2018
defineComponent({
@@ -90,12 +88,10 @@ const startMark = (name) => `${name}-start`;
9088
+ the hydrate microtask)
9189
*******************************/
9290

93-
// Measured: parsing the DSD via setHTMLUnsafe (which actually attaches
94-
// the shadow root, unlike innerHTML), connectedCallback, the hydrate
95-
// microtask scheduled inside it, and the post-hydrate rAF that strips
96-
// data-sui-bind markers. The `each` block's hydrate hook stays O(1) on
97-
// main — it just registers a dep on the items signal. Per-item DOM is
98-
// already correct from SSR; no mutation triggered.
91+
// Measured: DSD parse via setHTMLUnsafe (innerHTML doesn't attach the
92+
// shadow root), connectedCallback, the hydrate microtask, and the
93+
// post-hydrate rAF that strips data-sui-bind. Per-item Reactions wire
94+
// here; subsequent updates exercise the already-wired graph.
9995
const itemsForMount = makeItems(1000);
10096
const dsdHTMLForMount = ssrList(itemsForMount);
10197
performance.mark(startMark('hydrate-each-100-mount'));
@@ -107,15 +103,11 @@ const elForMutate = container.firstElementChild;
107103

108104
/*******************************
109105
Hydrate Each-100
110-
(first-data-change
111-
adoption: 100× per-item
112-
hydrateInnerContent)
106+
(post-mount items mutation)
113107
*******************************/
114108

115-
// Measured: setItems with a fresh array reference whose item keys
116-
// match the SSR'd markers — Reaction fires, update sees hasHydrated,
117-
// adoptServerItems reuses the server DOM and calls hydrateInnerContent
118-
// 100× (one cachedBuildHTMLString call per item).
109+
// Fresh array reference, same keys as SSR markers — exercises the items
110+
// dependency wake + per-Signal equality gate without DOM work.
119111
performance.mark(startMark('hydrate-each-100'));
120112
elForMutate.component.setItems(itemsForMount.slice());
121113
await flush();
@@ -175,11 +167,9 @@ function ssrHelperList(items) {
175167
+ `</bench-hydrate-helper>`;
176168
}
177169

178-
// Same mount window shape as above, but with a per-item attribute that
179-
// calls a helper closing over external `state.activeID`. Surfaces the
180-
// cost difference between strategies that keep hydrate O(1) and
181-
// strategies that wire per-item Reactions on hydrate to register
182-
// external-signal deps eagerly.
170+
// Same mount-window shape as above, but with a per-item attribute that
171+
// calls a helper closing over external `state.activeID`. Sensitive to
172+
// regressions in per-item Reaction wiring at hydrate time.
183173
const helperItems = makeItems(1000);
184174
const dsdHTMLForHelper = ssrHelperList(helperItems);
185175
performance.mark(startMark('hydrate-helper-100-mount'));
@@ -194,10 +184,10 @@ const elHelper = container.firstElementChild;
194184
(state change after mount)
195185
*******************************/
196186

197-
// Measured: cost of mutating a state signal that per-item helpers read.
198-
// On a hydrate path that wires per-item Reactions, this fires 100
199-
// helper invocations + setAttribute calls. On a hydrate path that
200-
// defers wiring, this is silent — fast number, broken UX.
187+
// Mutating a state signal that per-item helpers close over fires 100
188+
// helper invocations + setAttribute calls. Confirms per-item Reactions
189+
// wired at hydrate are reactive to external state, not just to
190+
// itemSignal mutations.
201191
performance.mark(startMark('hydrate-helper-100-state-change'));
202192
elHelper.component.setActive('id-50');
203193
await flush();

packages/component/src/engines/native/base.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { $ } from '@semantic-ui/query';
2-
const MARKER_VERSION = 'v1';
2+
import { MARKER_VERSION } from '@semantic-ui/renderer';
33
import { adoptStylesheet, isFunction, isServer, kebabToCamel } from '@semantic-ui/utils';
44

55
import {
@@ -142,12 +142,12 @@ class WebComponentBase extends HTMLElementBase {
142142
const entries = prototypeTemplate._hydrationEntries;
143143

144144
// Wire reactive bindings to existing server-rendered DOM
145-
this.template.renderer.hydrateMarkers(
146-
this.shadowRoot,
145+
this.template.renderer.hydrateMarkers({
146+
root: this.shadowRoot,
147147
entries,
148-
this.template.renderer.data,
149-
this.template.renderer.scope,
150-
);
148+
data: this.template.renderer.data,
149+
scope: this.template.renderer.scope,
150+
});
151151

152152
this.template.isHydrating = false;
153153
this.template.markRendered();

packages/renderer/src/build-html-string.js

Lines changed: 70 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,83 @@
66
Pure function: all dependencies passed in, no instance state.
77
*/
88

9-
// Marker for expression placeholders in attribute values
109
export const ATTR_MARKER_PREFIX = '__sui';
1110
export const ATTR_MARKER_SUFFIX = '__';
1211

13-
// Versioned markers — client checks version on hydration,
14-
// falls back to full render on mismatch
12+
// Versioned so client can detect server/client mismatches at hydrate time.
1513
export const MARKER_VERSION = 'v1';
1614

17-
// Marker for text-position expressions (comment nodes)
1815
export const COMMENT_MARKER = `sui:${MARKER_VERSION}:`;
19-
20-
// Marker for block-level directive positions
2116
export const BLOCK_MARKER = `sui-block:${MARKER_VERSION}:`;
22-
23-
// Marker for raw text element content (script, style, textarea, title)
2417
export const RAW_TEXT_MARKER = `sui-rawtext:${MARKER_VERSION}:`;
2518

26-
// Attribute stamped by the server on elements with dynamic bindings so the
27-
// client can wire Reactions without reconstructing a reference DOM from the
28-
// AST (eliminates the parallel-TreeWalker dance in hydrateAttributes).
29-
// Encoding: `attr=N[,attr=N]*` where N is the first-entry ID for that
30-
// attribute. Name prefixes: `.prop` property, `@event` event, `?attr`
31-
// boolean. Entries[N].attributeBinding carries full parts + classification,
32-
// so the data-sui-bind string stays minimal and all static/multi-expression
33-
// metadata lives on the prototype-cached entries array.
19+
// Prefix only — full close marker is `/sui-block:v1:N[:bX]`. Version match
20+
// is enforced at the open marker so `startsWith` callers compare without it.
21+
export const BLOCK_CLOSE_PREFIX = '/sui-block:';
22+
23+
// Sentinel for {#if} main-body in branchIndex serialization — branches
24+
// array indexes from 0 for {:elseif}/{:else}, so 1000 reserves a value
25+
// above any plausible branch count.
26+
export const MAIN_BRANCH_INDEX = 1000;
27+
28+
// Stamped on elements with dynamic bindings so the client can wire
29+
// Reactions without a reference-DOM walk. Encoding: `attr=N[,attr=N]*`
30+
// where N is the first-entry ID. Name prefixes: `.prop` for property
31+
// bindings, `@event` for event listeners; boolean attrs use the bare
32+
// name (classification.type === 'boolean' on the entry).
3433
export const DATA_SUI_BIND = 'data-sui-bind';
3534

35+
// Closing-marker payload. `branchIndex` is the only reserved suffix.
36+
export function formatBlockClose(id, meta) {
37+
let s = `${BLOCK_CLOSE_PREFIX}${MARKER_VERSION}:${id}`;
38+
if (meta && meta.branchIndex !== undefined) {
39+
s += `:b${meta.branchIndex}`;
40+
}
41+
return s;
42+
}
43+
44+
// `bN` (signed integer) → branchIndex. Strict digit suffix so future
45+
// reserved prefixes like `block`/`bypass` don't accidentally clobber.
46+
// Allows the `-1` sentinel emitted when no conditional branch matched.
47+
const META_BRANCH_RE = /^b(-?\d+)$/;
48+
export function parseServerMeta(commentData, target) {
49+
for (const part of commentData.split(':')) {
50+
const m = META_BRANCH_RE.exec(part);
51+
if (m) {
52+
target.branchIndex = +m[1];
53+
}
54+
}
55+
}
56+
57+
export function isBlockOpen(commentData) {
58+
return commentData.startsWith(BLOCK_MARKER);
59+
}
60+
export function isBlockClose(commentData) {
61+
return commentData.startsWith(BLOCK_CLOSE_PREFIX);
62+
}
63+
export function isExpressionMarker(commentData) {
64+
return commentData.startsWith(COMMENT_MARKER);
65+
}
66+
export function isRawTextMarker(commentData) {
67+
return commentData.startsWith(RAW_TEXT_MARKER);
68+
}
69+
export function parseBlockOpenID(commentData) {
70+
return +commentData.slice(BLOCK_MARKER.length);
71+
}
72+
export function parseExpressionID(commentData) {
73+
return +commentData.slice(COMMENT_MARKER.length);
74+
}
75+
export function parseRawTextID(commentData) {
76+
return +commentData.slice(RAW_TEXT_MARKER.length);
77+
}
78+
3679
// Compiled once — `lastIndex` is reset manually per use so the regex can
3780
// be reused across parse calls without cross-talk.
3881
const ATTR_MARKER_RE = new RegExp(`${ATTR_MARKER_PREFIX}(\\d+)${ATTR_MARKER_SUFFIX}`, 'g');
3982

4083
// Split an attribute value like `card __sui0__` or `foo __sui1__ bar __sui2__`
41-
// into static/dynamic parts. Used by both render-path binding and hydrate
42-
// lookup via entries[id].attributeBinding.
84+
// into static/dynamic parts. Used by render-path binding and the hydrate
85+
// lookup via entries[id].attributeParts.
4386
export function parseAttributeParts(attrValue) {
4487
const parts = [];
4588
const markerIDs = [];
@@ -50,7 +93,7 @@ export function parseAttributeParts(attrValue) {
5093
if (match.index > lastIndex) {
5194
parts.push({ static: attrValue.slice(lastIndex, match.index) });
5295
}
53-
const markerID = parseInt(match[1]);
96+
const markerID = +match[1];
5497
parts.push({ markerID });
5598
markerIDs.push(markerID);
5699
lastIndex = ATTR_MARKER_RE.lastIndex;
@@ -241,41 +284,22 @@ export function buildHTMLString(ast, { snippets = {}, isSVG: initialSVG = false
241284
return { htmlString, entries, snippets };
242285
}
243286

244-
// After the AST walk completes, scan the emitted htmlString for every
245-
// attribute value that contains one or more `__sui{id}__` markers and
246-
// attach an `attributeBinding` record to the FIRST entry in that
247-
// attribute's marker set.
248-
//
249-
// attributeBinding = { rawAttrName, parts, markerIDs }
250-
// rawAttrName: attribute as written in the template, with optional
251-
// `.` / `@` prefix for property/event bindings. Boolean
252-
// and regular attributes share the plain name — callers
253-
// disambiguate via `entries[id].classification.type`.
254-
// parts: output of parseAttributeParts; alternating
255-
// `{static}` and `{markerID}` entries covering the whole
256-
// attribute value (including statics between/around
257-
// multiple markers).
258-
// markerIDs: entry IDs for every expression inside this attribute,
259-
// in document order. First ID is the "representative"
260-
// that `data-sui-bind` on the server references.
261-
//
262-
// Subsequent entries in markerIDs (the non-first ones for a
263-
// multi-expression attribute) are reachable via the first entry's
264-
// attributeBinding. The hydration path processes each attribute once
265-
// via the first entry; bindAttribute handles the multi-expression
266-
// reaction internally using the full parts array.
267-
const ATTR_WITH_MARKER_RE = /\s([.@]?[\w:-]+)\s*=\s*(?:"([^"]*__sui\d+__[^"]*)"|([^\s"'>]*__sui\d+__[^\s"'>]*))/g;
287+
// Attach `attributeParts` to the first entry of each attribute whose value
288+
// carries one or more markers. `parts` covers the full value (alternating
289+
// static/dynamic segments) so bindAttribute can reconstruct the
290+
// interpolation in one pass. The hydration path resolves the binding via
291+
// `data-sui-bind` → first entry → entry.attributeParts.
292+
const ATTR_WITH_MARKER_RE = /\s(?:[.@]?[\w:-]+)\s*=\s*(?:"([^"]*__sui\d+__[^"]*)"|([^\s"'>]*__sui\d+__[^\s"'>]*))/g;
268293

269294
function populateAttributeBindings(htmlString, entries) {
270295
ATTR_WITH_MARKER_RE.lastIndex = 0;
271296
let match;
272297
while ((match = ATTR_WITH_MARKER_RE.exec(htmlString)) !== null) {
273-
const rawAttrName = match[1];
274-
const attrValue = match[2] !== undefined ? match[2] : match[3];
298+
const attrValue = match[1] !== undefined ? match[1] : match[2];
275299
const { parts, markerIDs } = parseAttributeParts(attrValue);
276300
if (markerIDs.length === 0) { continue; }
277301
const entry = entries[markerIDs[0]];
278302
if (!entry) { continue; }
279-
entry.attributeBinding = { rawAttrName, parts, markerIDs };
303+
entry.attributeParts = parts;
280304
}
281305
}

0 commit comments

Comments
 (0)