Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(dom): correct and optimize the client DOM renderer#787

Merged
harlan-zw merged 12 commits into
mainfrom
fix/dom-renderer-client-state
Jun 30, 2026
Merged

fix(dom): correct and optimize the client DOM renderer#787
harlan-zw merged 12 commits into
mainfrom
fix/dom-renderer-client-state

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

❓ Type of change

  • πŸ“– Documentation
  • 🐞 Bug fix
  • πŸ‘Œ Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

πŸ“š Description

The client DOM head renderer had several correctness bugs once the head re-rendered or the target document changed:

  • Leaked event listeners. Handlers were re-added on every patch instead of replaced, so repeated renders stacked duplicate listeners, and they were never removed when a tag (or a bodyAttrs/window handler) was removed.
  • Stale state across documents. DOM state was cached on head._dom and reused even when rendering into a different document, carrying stale element references and listeners into the new document.
  • Lost mutations. Changes made inside dom:rendered / onRendered hooks were dropped instead of applied, and a re-entrant render triggered from dom:beforeRender could clobber the in-flight pass.

This binds each handler once and tracks it as a disposable side effect keyed by identity, rebuilds DOM state when the document instance changes, guards re-entrant renders, and applies hook-driven mutations in a follow-up pass. Adds regression tests for event-handler replacement/removal, document switching, render reentrancy and the onRendered follow-up render.

Review follow-ups

  • SSR cleanup baseline is no longer single-use. createDomState previously deleted entry._o after seeding the class-removal baseline. Because the document-switch path can build state more than once, a second pre-rendered document could no longer strip stale htmlAttrs/bodyAttrs classes. _o is now kept intact and only read.
  • Reused elements clear dropped props/content. trackCtx only applied truthy values and only registered cleanup for *Attrs. A reused (keyed) element that dropped textContent/innerHTML/class/an attribute kept the old DOM. Element content and attributes are now tracked too, with value-guarded clears so SSR-adopted or externally mutated content is never touched.

Both follow-ups have regression tests (state.test.ts baseline-across-documents, dom.test.ts reused-element clearing).

Performance & size

Two follow-up commits optimise the renderer the fixes touch, with no behaviour change (all 826 unhead tests + the Vue events test pass):

  • Fewer per-render allocations (5ab4a5c9): hand the cleanup pool to _p by reference instead of spreading state._s into a fresh object every re-render, and reuse the previous render's cleanup closure for stable keys (the pattern trackEvent already uses) instead of reallocating ~one closure per tracked prop each frame. Cuts GC pressure under sustained re-rendering.
  • Smaller event tracking (61e90387): store the listener side-effect as a tuple, hoist the repeated `data-${k}`, and drop a fast-path marker re-assert that can never fire once _l holds the listener. βˆ’32 B gz on the minimal client bundle; the observable data-on* markers are unchanged.

Out of scope (by design): deeper resolveTags-level caching and the core hook/dedupe/normalize perf belong with #794, which already rewrites those files. The opt-in event-renderer split (~308 B gz) would be a breaking API change and was left out.

Benchmark

Micro-benchmark of the re-render hot path (one head, ~12 tags incl. htmlAttrs/bodyAttrs with classes + a bodyAttrs event handler, forced dirty re-render each iteration; jsdom, vitest bench):

renderer ops/sec mean
this branch ~68k ~14.6 Β΅s
main (baseline) ~85k ~11.7 Β΅s

main is ~1.25x faster on this path, the expected cost of correctness: it stays fast only by re-binding listeners every render and skipping cleanup tracking, the exact leaks/staleness this PR fixes. The wall-clock gap is dominated by that listener/attribute-cleanup work, not allocations, so the perf commits above target memory churn and code size rather than this number. Absolute delta is ~3 Β΅s per full re-render of a 12-tag head.

Summary by CodeRabbit

  • Bug Fixes

    • Ensure DOM head rendering targets the correct Document, reruns when needed for pending updates, and safely clears dirty/re-entrancy state after rendering.
    • Improve isolation across multiple document instances and ensure SSR baseline attribute/class cleanup is replayed correctly.
    • Refine DOM patching and event handling: deterministic replacement/removal for script onload and bodyAttrs window listeners, and correct clearing for reused keyed elements (including attribute/class/style revert behavior).
  • Tests

    • Added coverage for re-entrancy and dom:beforeRender/dom:rendered lifecycle, multi-document isolation, keyed diffing, and DOM event handler replacement/removal.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7c65e460-1bd5-4e00-bf4b-721ccf022d49

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between ba1d574 and 11933ae.

πŸ“’ Files selected for processing (5)
  • packages/unhead/src/client/renderDOMHead.ts
  • packages/unhead/test/unit/client/dom.test.ts
  • packages/unhead/test/unit/client/eventHandlers.test.ts
  • packages/unhead/test/unit/client/onRendered.test.ts
  • packages/unhead/test/unit/client/state.test.ts
 _________________________________________
< Make it work, make it right, make it πŸ₯•. >
 -----------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
πŸ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dom-renderer-client-state

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

πŸ“¦ Bundle Size

⚠️ 6 bundles grew · net +2.5 kB gz

Bundle Gzipped Ξ”
Client (Minimal) 4.7 kB β†’ 5.1 kB πŸ”΄ +0.4 kB (+8.7%)
Client (Full) 7.9 kB β†’ 8.4 kB πŸ”΄ +0.4 kB (+5.4%)
Vue Client (Minimal) 5.2 kB β†’ 5.6 kB πŸ”΄ +0.4 kB (+8.1%)
Vue Client (Full) 8.7 kB β†’ 9.1 kB πŸ”΄ +0.4 kB (+4.8%)
React Client (Minimal) 5.1 kB β†’ 5.5 kB πŸ”΄ +0.4 kB (+8.0%)
React Client (Full) 8.5 kB β†’ 8.9 kB πŸ”΄ +0.4 kB (+4.8%)
All bundles (12)
Bundle Gzipped Raw
Core
Client (Minimal) 5.1 kB 12.7 kB πŸ”΄
Client (Full) 8.4 kB 21.6 kB πŸ”΄
Server (Minimal) 4.5 kB 11.4 kB βœ…
Vue
Vue Client (Minimal) 5.6 kB 13.8 kB πŸ”΄
Vue Client (Full) 9.1 kB 23.5 kB πŸ”΄
Vue Server (Minimal) 5 kB 12.3 kB βœ…
React
React Client (Minimal) 5.5 kB 13.7 kB πŸ”΄
React Client (Full) 8.9 kB 23.1 kB πŸ”΄
React Server (Minimal) 4.8 kB 12 kB βœ…
Schema.org
Schema.org (Minimal) 9.5 kB 26.3 kB βœ…
Schema.org Imports 0.1 kB 0.1 kB βœ…
Schema.org Vue Meta 0.4 kB 0.8 kB βœ…

⚑ Performance (directional)

⚠️ 1 slower · past the per-metric noise gate

Benchmark base β†’ PR Ξ”
SSR allocated / render 318.1 KiB β†’ 336.1 KiB πŸ”΄ +18.1 KiB (+5.7%)
All benchmarks (6)
Benchmark PR Ξ” RME
SSR render (CPU) 0.459 ms ~ noise Β±9.2%
SSR render (wall) 0.294 ms ~ noise Β±4.8%
SSR allocated / render 336.1 KiB πŸ”΄ +18.1 KiB (+5.7%) β€”
CSR DOM mutations / nav 38 ~ noise β€”
CSR re-render (CPU) 0.843 ms ~ noise Β±4.7%
CSR re-render (wall) 0.475 ms ~ noise Β±2.1%

Baseline: main @ e740464 Β· 2026-06-27 Β· gzipped is the headline size metric Β· perf is directional (shared-runner, gated)

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▢️ Resume reviews
  • πŸ” Trigger review
πŸ“ Walkthrough

Walkthrough

The DOM head renderer now tracks the active document, pending entries, and cleanup state. It replaces event listeners and tracked DOM bindings, reuses keyed elements across passes, and reruns rendering when hooks or pending work leave the head dirty.

Changes

DOM head render lifecycle

Layer / File(s) Summary
Render state and document gating
packages/unhead/src/client/renderDOMHead.ts, packages/unhead/test/unit/client/state.test.ts
renderDOMHead now tracks the active document, pending entries, and cleanup callbacks; the state tests cover separate-document rendering and SSR baseline replay.
Event listeners and tracked cleanup
packages/unhead/src/client/renderDOMHead.ts, packages/unhead/test/unit/client/eventHandlers.test.ts
trackEvent now replaces or removes listeners and keeps data-* markers in sync; tests cover script onload and bodyAttrs onresize replacement and cleanup.
Render completion and reruns
packages/unhead/src/client/renderDOMHead.ts, packages/unhead/test/unit/client/dom.test.ts, packages/unhead/test/unit/client/onRendered.test.ts
The render loop now applies title and DOM updates, fires dom:rendered, reruns when hooks mutate head state, and preserves keyed element reuse behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Head
  participant renderDOMHead
  participant Hook as dom:beforeRender / dom:rendered
  participant Document
  Head->>renderDOMHead: renderDOMHead(head, options)
  renderDOMHead->>Hook: emit render hooks
  Hook-->>renderDOMHead: mutate head entries
  renderDOMHead->>Document: apply title, nodes, listeners
  renderDOMHead->>Hook: emit dom:rendered
  Hook-->>renderDOMHead: mutate head entries
  renderDOMHead->>renderDOMHead: rerun when head.dirty or pending entries exist
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • unjs/unhead#712: Shares the dom:rendered lifecycle that onRendered callbacks use to schedule follow-up render passes.

Poem

I hopped through the head with a whisker and grin,
πŸ‡ new listeners swapped while old ones tucked in.
When hooks made a ripple, I hopped once more,
and tidy DOM updates came back to the fore.
Thump-thumpβ€”fresh render, then off I go!

πŸš₯ Pre-merge checks | βœ… 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
βœ… Passed checks (4 passed)
Check name Status Explanation
Title check βœ… Passed The title is concise, conventional, and accurately summarizes the main client DOM renderer fix.
Description check βœ… Passed The description is detailed and covers the bug fix, rationale, and tests, though it omits the linked issue section.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dom-renderer-client-state

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/unhead/src/client/renderDOMHead.ts`:
- Around line 71-85: The per-document state builder in renderDOMHead is
consuming entry._o too early by deleting it after registering the cleanup
callbacks. Keep entry._o intact in the createDomState()/head.entries.values()
flow so the SSR baseline can be reused across document switches, and only read
from it when generating state for each document without mutating the shared
entry data.
- Around line 153-195: The DOM reuse path in renderDOMHead’s trackCtx only
applies new truthy values, so removed props and cleared content are never
reflected on reused elements. Update trackCtx to explicitly clear stale state on
reused nodes when tag.textContent, tag.innerHTML, or props like src/class/style
are removed or emptied, and ensure the cleanup registration covers non-Attrs
tags as well as attribute-only tags. Use the existing track, trackEvent, and
renderState._e logic in trackCtx to locate where to add removal handling for
missing values.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7c65e460-1bd5-4e00-bf4b-721ccf022d49

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between ba1d574 and 11933ae.

πŸ“’ Files selected for processing (5)
  • packages/unhead/src/client/renderDOMHead.ts
  • packages/unhead/test/unit/client/dom.test.ts
  • packages/unhead/test/unit/client/eventHandlers.test.ts
  • packages/unhead/test/unit/client/onRendered.test.ts
  • packages/unhead/test/unit/client/state.test.ts

Comment thread packages/unhead/src/client/renderDOMHead.ts
Comment thread packages/unhead/src/client/renderDOMHead.ts
…ed elements

Addresses two review findings on the DOM renderer:

- createDomState no longer deletes entry._o, so the SSR class-removal
  baseline can be replayed when the same head renders into a second
  pre-rendered document (previously single-use).
- trackCtx now tracks content and attributes for reused element tags, not
  just *Attrs, so dropping textContent/innerHTML/class/an attribute from a
  reused (keyed) element clears the stale DOM instead of leaving it behind.
  Clears are value-guarded so SSR-adopted/foreign content is never touched.

Adds regression tests for both.
@harlan-zw

Copy link
Copy Markdown
Collaborator Author

Both review findings addressed in dae1f2c:

  • _o consumed too early β€” fixed. createDomState no longer deletes entry._o, so the SSR class-removal baseline is replayable when the same head renders into another pre-rendered document. Regression test added (state.test.ts β†’ replays the SSR class-removal baseline across documents).
  • Reused elements never clear removed props/content β€” fixed. trackCtx now tracks content and attributes for reused element tags (not just *Attrs), so dropping textContent/innerHTML/class/an attribute clears the stale DOM. Clears are value-guarded so SSR-adopted content is untouched. Regression test added (dom.test.ts β†’ clears dropped attributes and content when a keyed element is reused).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/unhead/src/client/renderDOMHead.ts`:
- Around line 161-178: The content cleanup in renderDOMHead should make
textContent and innerHTML scopes mutually exclusive for the same tag id. In
renderDOMHead, when applying text or html content, remove any opposite pending
cleanup from _p before tracking the new one so a later pass cannot clear freshly
rendered content; also ensure switching from html to text always assigns
textContent even if the string matches, so old markup is fully replaced. Use the
existing track(id, 'text'|'html', ...) flow and the content handling logic
around tag.textContent and tag.innerHTML to locate the fix.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2708f725-ee42-4f88-9659-1c0dafb63e8d

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 11933ae and dae1f2c.

πŸ“’ Files selected for processing (3)
  • packages/unhead/src/client/renderDOMHead.ts
  • packages/unhead/test/unit/client/dom.test.ts
  • packages/unhead/test/unit/client/state.test.ts

Comment thread packages/unhead/src/client/renderDOMHead.ts
- swap the side-effect cleanup pool by reference instead of spreading
  state._s into a fresh object every re-render (was O(tags x attrs)/render)
- reuse the previous render's cleanup closure for stable keys (same
  $el/attr/class), the pattern trackEvent already uses, instead of
  reallocating ~one closure per tracked prop each frame; content closures
  opt out via `fresh` since they capture a value that can change

Wall-clock on a 12-tag re-render microbench is unchanged (the cost there is
event tracking + attr cleanup work, not allocation); this reduces GC pressure
under sustained re-rendering. All 826 unhead tests pass.
- store the side-effect record as a tuple instead of an object literal
- hoist the repeated `data-${k}` template
- drop the fast-path data-attr re-assert: once _l holds the listener the
  marker is already set, so the check could never fire

Behavior-preserving (the observable data-on* markers are unchanged); -32 B gz
on the minimal client bundle. 826 unhead tests + vue events test pass.
@harlan-zw harlan-zw changed the title fix(dom): correct renderer event handlers, document switching and reentrancy fix(dom): correct and optimize the client DOM renderer Jun 26, 2026
The strict script type makes src/innerHTML mutually exclusive, so a
content-less reused inline script isn't expressible; cast the drop-everything
patch input that deliberately exercises the runtime clear path.
track() received the cleanup as an already-evaluated argument, so the arrow was
allocated at every call site every render even when the reuse path discarded it.
Move the closure literal to the right of `||` via reclaim(key), so a stable
re-render (same element/attr/class) reuses the prior cleanup and allocates no
closures. Correctness unchanged (60 DOM tests pass); DOM mutations per render are
identical to before.
Settles the review concern that a dropped content cleanup could clear freshly
rendered content when a keyed element switches between innerHTML and textContent.
The value-guarded cleanups already handle it: a switch leaves the new content
intact in both directions.
@harlan-zw

Copy link
Copy Markdown
Collaborator Author

Added a regression test (switches a reused element between innerHTML and textContent without leaking stale content) covering this. The value-guarded cleanups already handle it: when a keyed element switches html→text (or back), the dropped-content cleanup's guard (if ($el.innerHTML === html) / === text) sees the content no longer matches what it set, so it doesn't clobber the freshly rendered content. Verified in both directions; no code change needed.

Pins what this PR fixes vs what already worked, grounding the scope:
- update content / remove tag / drop attr on an unkeyed tag -> already correct
- drop inline content on a KEYED tag (stale JSON-LD structured data) -> fixed here
- drop a data-* attr on a KEYED tag -> fixed here

The bug only triggers behind an explicit key (which forces element reuse); without
one, dropping a value changes the dedupe identity and yields a fresh element.
harlan-zw added a commit that referenced this pull request Jun 27, 2026
Wall time is the noisiest metric (GC + scheduling the process doesn't control), so
it false-alarmed a 'slower' verdict on #787 while CPU and DOM-mutation count (the
reliable signals) both showed no change. Mark the wall benches informational: they
still appear in the details with their delta (shown neutrally as ℹ️), but they no
longer drive the verdict or the surfaced-changes table. CPU is the gated time
authority; alloc and mutation count keep their deterministic gates.
harlan-zw added a commit that referenced this pull request Jun 27, 2026
…ocess noise

The batch method allocated ~340KB x 60 = ~20MB per sample, overflowing young-gen
so a scavenge fired mid-measurement and undercounted by a GC-timing-dependent
amount (~4% cross-process). That false-flagged SSR alloc as a +5% regression on a
client-only PR (#787). Measure ONE render between forced GCs instead β€” it stays
under young-gen, so the heapUsed delta is the full allocation and is reproducible
to the byte across builds with identical code. Widen the alloc gate 2% -> 3% to
sit above the ~1-2% residual.
dupeKeyCounter Map -> plain object (string keys; Map.get/.set don't minify, bracket
access does) and drop the new Set([...Object.values]) dedupe in cleanupDomState
(_s and _p never share a function ref, so drain both directly). -12 B gz client,
fewer per-render allocations. The renderer's correctness machinery is otherwise
largely irreducible; micro-tweaks (helper extraction, hook-skip guards) measured
net-neutral or worse on gz, so left out.
@harlan-zw harlan-zw merged commit ace356f into main Jun 30, 2026
8 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Jul 1, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant