Skip to content

Refactor: Consolidate Native Hydrate Path#179

Merged
jlukic merged 20 commits intomainfrom
chore/refactor-hydrate
May 5, 2026
Merged

Refactor: Consolidate Native Hydrate Path#179
jlukic merged 20 commits intomainfrom
chore/refactor-hydrate

Conversation

@jlukic
Copy link
Copy Markdown
Member

@jlukic jlukic commented May 4, 2026

This PR is designed to simplify hydration by abstracting helpers used during hydration, adding constants where they were necessary, and creating a specialized helper hydrateInto that blocks can use to avoid repeated code.

This also modifies code comments to be more precise, and consolidates logic for coordinating each blocks on server and client where logic was reproduced, as well as various minor fixes to improve legibility, coherence and simplicity for hydration.

Risk

5/10 - renderer is the hot path for everything, but the logic is preserved generally just refactored.

How to Test

Tests handle a lot of coverage but the real confirmation is visual tests across code components in docs, src, examples.

jlukic added 9 commits May 4, 2026 16:31
Five small wins bundled — drift hazards, dead state, unnamed magic.

- Drop dead `inheritsData` field from native Renderer + ServerRenderer (lit-only contract)
- Import shared `MARKER_VERSION` in component base instead of redeclaring
- Drop `oldKeys` allocation in each.reconcile — read `r.key` inline
- Name `MAIN_BRANCH_INDEX` constant for the {#if}-main-body sentinel
- Refresh stale bench-hydrate comment referencing removed `hasHydrated` flag
`/sui-block:` was hand-typed across 5 sites. parseServerMeta lived in
renderer.js but the matching write was inline at 5 sites in server.js,
keeping the format coordinated by comment.

- Export BLOCK_CLOSE_PREFIX, parseServerMeta, formatBlockClose from build-html-string.js
- formatBlockClose owns the wire format including reserved branchIndex meta
- Replace `/sui-block:` literals with the constant (renderer.js, each.js, reactive-data.js)
- server.js write sites use formatBlockClose
`hydrateInnerContent` and `hydrateMarkers` took positional arguments
that callers immediately reshaped. Standardize on options.

- Renderer.hydrateMarkers({ root, entries, data, scope })
- Renderer.hydrateInnerContent({ ownedNodes, innerAST, data, scope })
- Update callers in component/base.js, blocks/template.js, define-block.js wrapper
`getItemID`, `getEachData`, item-marker prefix, and key encode/decode
were duplicated between `server.js` and `blocks/each.js` with a
keep-in-sync comment as the only enforcement. The wire format that
bridges SSR and client hydration is now sourced from one module.

- New `engines/native/shared/each.js` exporting SUI_ITEM_MARKER,
  getItemID, getEachData, encodeItemKey, decodeItemKey
- `server.js` and `blocks/each.js` import from shared
- `blocks/each.js` also imports BLOCK_MARKER + BLOCK_CLOSE_PREFIX
  for its server-group walker (was inlining literals)
`bindAttribute` ran `parts.find(p => p.markerID !== undefined)` per
binding setup to locate the entry it should read classification from.
The first dynamic-part marker is already known at parse time —
populateAttributeBindings now stores it on the binding entry.

- attributeBinding gains `firstMarkerID` field
- bindAttribute reads `entries[firstMarkerID]` directly (no closure)
- bindAttributeExpression / bindMarkers / hydrateAttributes thread
  firstMarkerID through
Five walkers across renderer.js, each.js, reactive-data.js declared the
same prefix checks for block-open / block-close / expression / raw-text
markers inline. Promote the predicates and ID parsers next to the
constants in build-html-string.js.

- Export isBlockOpen / isBlockClose / isExpressionMarker / isRawTextMarker
- Export parseBlockOpenID / parseExpressionID / parseRawTextID
- Walkers consume the helpers; literal startsWith calls drop
- BLOCK_MARKER / BLOCK_CLOSE_PREFIX / COMMENT_MARKER / RAW_TEXT_MARKER
  imports trim off renderer.js (now reach via the helpers)
Most elements in a typical template carry zero `__sui` attributes. The
empty-array-then-iterate pattern allocated per element regardless. Lazy
init via `??=` skips the alloc on the common case while preserving the
defensive collect-then-iterate semantics — bindAttributeExpression calls
element.removeAttribute for property/event bindings, so live iteration
over NamedNodeMap is not safe.
Five blocks (conditional, rerender, sample, template-snippet,
each-elseContent) plus template-subtemplate all hand-rolled the same
post-hydrate fragment-build + region.anchor.after dance. Subtemplate's
copy was 22 lines reaching into a different renderer's internals.

Renderer.hydrateInto unifies the contract — creates a child scope
(opt-out via asChild: false for snippet/subtemplate flat-scope cases),
hydrates region.ownedNodes, reattaches via fragment, and sets
region.endAnchor so attachToRenderRoot's last-node lookup is consistent
across render and hydrate paths.

- Renderer.hydrateInto({ region, innerAST, data, scope, asChild })
- Bag exposes hydrateInto wrapper alongside hydrateInnerContent
- Replace 5 hydrate-and-attach sites in conditional / rerender / each /
  template (snippet + subtemplate)
- Update sample.js documentary to show hydrateInto as the canonical pattern
`renderEach` allocated a fresh `new ExpressionEvaluator(...)` per item
plus a save/swap dance to scope the data field. Same in `renderTemplate`
snippet branch and `renderSubtemplate`'s plain-AST path.

The evaluator's `data` field is dead in server-side use — every lookup
goes through `lookupExpressionValue(expr, data)` with explicit data, and
`evaluateJavascript` resets `jsContext` per call from the passed data.
Helpers are reference-shared between any per-call evaluator and the
parent so reusing the parent is identical.

For a 1000-item SSR list this drops 1000 Proxy + handler allocations
plus the swap dance per item. ~1ms saved per render at that scale.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
semantic-next Ready Ready Preview, Comment May 5, 2026 3:55am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
mcp Ignored Ignored Preview May 5, 2026 3:55am

Request Review

@github-actions github-actions Bot added the Component Component Rendering label May 4, 2026
@semantic-performance-bot
Copy link
Copy Markdown

semantic-performance-bot Bot commented May 4, 2026

⚪ No Meaningful Change for a3bdd6b on Benchmark Suite 📊

Base: main · Action: #25355965351 · Raw: bench-report.json

Refactor: Consolidate Native Hydrate Path

Note

This PR did not move any measured metrics.

✅ 0 faster · ❌ 0 slower · 🔍 6 unsure · ⚪ 26 no change


⚪ No Change (26)

Metrics where this PR measured within ±2% of main — no meaningful performance change detected.

metric Change
add-20 0.0% – +0.1%
bulk-add-500 -0.4% – +1.0%
clear-completed-250 -0.6% – +1.3%
create-10k -0.1% – +0.7%
create-1k -1.6% – -0.3%
edit-cycle-5 -0.4% – +0.1%
edit-start-10 -1.2% – +1.1%
filter-cycle-20 -0.3% – +1.2%
hydrate-each-100 -0.5% – +0.7%
hydrate-each-100-mount -0.2% – +1.0%
hydrate-helper-100-mount -0.4% – +0.9%
remove-10-middle -0.8% – +1.0%
remove-first-10 -0.8% – +1.3%
remove-last-10 -0.3% – +0.1%
remove-middle-10 -0.1% – +0.6%
remove-row-front-20 -0.6% – +0.6%
remove-row-middle-20 +0.1% – +1.6%
replace-1k -1.2% – -0.1%
select-40 +0.1% – +1.4%
swap-rows-20 +0.0% – +1.0%
toggle-10 -0.2% – +0.5%
toggle-all-20 -0.0% – +0.2%
toggle-first-10 -0.3% – +0.1%
toggle-last-10 -0.7% – +0.0%
toggle-middle-10 -0.3% – +0.3%
update-10th-10 -1.1% – +0.8%
🔍 Unsure (6)

Inconclusive (4)

The measured difference is small, and our sampling couldn't confidently place it above or below zero. Running more samples in a future run might settle these metrics.

metric Change Expected Noise
append-1k -4.1% – +0.5% ±2%
clear-10k -3.1% – +3.3% ±1%
remove-5-back +1.4% – +6.0% ±2%
remove-row-back-10 -2.2% – +0.1% ±1%

Too Fast to Measure Precisely (2)

On benches this short, system jitter (scheduling, GC, JIT) masks sub-4% changes; larger deltas still resolve cleanly.

metric Change Test Time Expected Noise
hydrate-helper-100-state-change -12.8% – +8.7% ~5ms ±29%
remove-5-front -1.1% – +2.3% ~88ms ±2%

Sample size: 50 · Resolution floor: ±2% · Timeout: 3min · Wall-clock: 16m05s

…leanup

- Drop redundant `firstMarkerID` field on `attributeBinding` and the
  threaded parameter through `bindAttribute` / `bindAttributeExpression`.
  parts.find runs once per binding setup (not in the reactive loop), so
  the precompute was unjustified scaffolding.
- Refresh stale "first data change" comment in server.js renderEach —
  adoption is eager since f013d4f.
- Reorder build-html-string.js so `RAW_TEXT_MARKER` declares above its
  predicate. Group all marker constants up top.
- Trim restated/over-explained comments across build-html-string.js,
  shared/each.js, blocks/each.js, blocks/template.js, renderer.js,
  bench-hydrate.js. Bar: would the comment ship in Vite/Svelte source?
jlukic added 2 commits May 4, 2026 17:47
…omment trim

- attributeBinding shape narrows to { parts } — rawAttrName had only one
  reader (reconstructed inline at the consumer from the data-sui-bind
  payload itself), markerIDs had no readers post-firstMarkerID revert.
- Marker ID parsers use `+str` instead of `parseInt(str)`. Compiler-emitted
  inputs are digit-only after a fixed prefix, so semantics match. ~2-3×
  faster, drops the no-radix linter concern.
- Comment trims at four clear sites (formatBlockClose, hydrateInto trailing
  default sentence, server.js per-item key marker, server.js plain-AST
  branch). Bench-hydrate file header refreshed to describe the eager-mount
  shape (was still describing pre-#175 lazy adoption).
- author-pull-requests skill: don't leak iteration history into commit
  subjects — branch commits feed code-review subagents and round-numbering
  biases their read.
…tr sweep

- populateAttributeBindings doc was describing the pre-narrowed
  `{ rawAttrName, parts, markerIDs }` shape; refresh to match the
  actual `{ parts }` it now stores. Drop the unused first capture
  group from ATTR_WITH_MARKER_RE since rawAttrName has no readers.
- Convert remaining parseInt calls on marker ID parsing paths to
  unary plus (renderer.js data-sui-bind entryId, parseAttributeParts
  marker capture). Same digit-only inputs, same downstream isNaN
  gates — consistent with the earlier sweep.
- Drop comment that restated ATTR_MARKER_PREFIX symbol name.
- Trim bench-hydrate inline narration and hydrateInto caller list.
- DocumentFragment.append accepts variadic nodes — three manual loops
  collapse to one-liners. hydrateInnerContent's container build, the
  hydrateInto re-attach, and adoptServerItems' per-item assembly all
  use the spread form now.
- DynamicRegion.placeEndAnchor consolidates the boundary-sentinel logic
  that setContent and hydrateInto were both running. setContent calls it
  after attaching its fragment; hydrateInto calls it after the post-hydrate
  re-attach. Future changes to the sentinel contract land in one place.
- Drop the misplaced asChild paragraph from sample.js's hydrate doc — the
  sample body doesn't pass asChild, so the explanation belongs only at
  hydrateInto's docblock where the param actually exists. Reword stale
  BLOCK_CLOSE_PREFIX comment that contradicted formatBlockClose's actual
  output. Trim past-tense narration on each.js elseScope and renderer.js
  ownedNodes-rebuild comments.
Surgical revert of the oldKeys-drop change from 6435278. The bench
flagged a possible regression on remove-5-back across recent iterations
and reconcile is the only remove-path code touched on this branch.
Reading r.key directly per iteration (instead of from a cached string
array) was theorized to cost a property dereference per access; this
restores the cached array shape so the bench can speak.

Reverts only the oldKeys portion — keeps the four other items from that
commit (inheritsData drop, MARKER_VERSION import, MAIN_BRANCH_INDEX,
bench comment refresh).
jlukic added 3 commits May 4, 2026 22:55
…reuse

Three CR-driven cleanups:

- parseServerMeta tightens the `bN` matcher to a strict regex so future
  reserved comment-data prefixes (e.g. `block`, `bypass`) can't silently
  clobber branchIndex via the over-broad `startsWith('b')` test. Allows
  the `-1` sentinel emitted when no branch matches.
- The `attributeBinding` wrapper carried only `parts` after the round-2
  field drops. Storing parts directly on the entry as `attributeParts`
  removes one allocation per dynamic attribute at template-prepare time
  and one property hop at hydrate.
- DynamicRegion.clear() detaches endAnchor without nulling it. The Text
  node lives for the region's lifetime; placeEndAnchor reuses it on the
  next fill instead of allocating a fresh one per setContent cycle.
- build-html-string.js doc still pointed at the old `attributeBinding`
  field name post-rename to `attributeParts` — refresh the path.
- Drop block-depth narration in hydrateMarkers; the surrounding
  isBlockOpen/isBlockClose checks plus blockDepth++/-- are self-evident.
- Drop "Plain AST object — render directly" preamble in renderSubtemplate
  AST branch; the conditional and identifier carry it.
- Phrase the dynamic-region clear() endAnchor note as a forward-looking
  invariant rather than a contrast against prior behavior.
- renderer.js hydrateInto docblock read as JSDoc for asChild's default;
  param name + ternary body carry the same meaning. Score 75 to drop.
- template.js subtemplate-hydrate comment restated the call site
  (`self.currentInstance.renderer.{data,scope}`). Score 50.

The other in-flight comment-trim suggestions either kept non-obvious
WHY (template.js snippet line, renderer.js hydrateMarkers strip note),
or were already correctly trimmed in earlier commits. Manual
appendChild loops in extractRangeToFragment / createRecord were
correctly NOT converted (live-walk safety, single-endpoint inserts).
…oc updates

Cold-scorer pass produced 8 actionable findings; this commit lands all
of them.

Source:
- shared/each.js SUI_ITEM_MARKER now interpolates MARKER_VERSION instead
  of hardcoding 'v1' (drift hazard the file's own header warns about).
- dynamic-region.js placeEndAnchor doc trimmed; first sentence restated
  the method name. Kept the load-bearing strict-between-comparison WHY.
- each.js adoptServerItems comment finishes the WHY (sibling block
  markers can interleave between item boundaries).
- template.js snippet hydrate comment carries the structural reason for
  asChild=false: child scope would dispose with the next region.clear()
  and break args reactivity.
- bench-hydrate.js: drop stale "O(1) on main" / "lazy vs eager" inline
  comments that referenced the removed strategy classifier.

Skill docs:
- native-renderer.md: replace literal `1000` with `MAIN_BRANCH_INDEX`;
  refresh `attributeBinding: { parts, classification }` description to
  the current `attributeParts` shape.
- ssr-hydration.md: same field-name refresh.
- sample.js bag-count docstring said "9 keys" plus a list that omitted
  hydrateInto. Bag has 11 keys after this PR adds hydrateInto.
- DATA_SUI_BIND comment listed `?attr` boolean prefix; server never
  emits `?` — boolean attributes go through tagBindings as the bare
  attribute name and are dispatched via classification.type at hydrate.
@jlukic jlukic merged commit e3608cf into main May 5, 2026
7 of 9 checks passed
@jlukic jlukic deleted the chore/refactor-hydrate branch May 5, 2026 03:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Component Component Rendering Docs Modifies documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant