fix(dom): correct and optimize the client DOM renderer#787
Conversation
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... βοΈ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: π Files selected for processing (5)
β¨ Finishing Touchesπ Generate docstrings
π§ͺ Generate unit tests (beta)
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. Comment |
π¦ Bundle Size
All bundles (12)
β‘ Performance (directional)
All benchmarks (6)
Baseline: main @ e740464 Β· 2026-06-27 Β· gzipped is the headline size metric Β· perf is directional (shared-runner, gated) |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
π WalkthroughWalkthroughThe 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. ChangesDOM head render lifecycle
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
Estimated code review effortπ― 4 (Complex) | β±οΈ ~60 minutes Possibly related PRs
Poem
π₯ Pre-merge checks | β 4 | β 1β Failed checks (1 warning)
β Passed checks (4 passed)
β¨ Finishing Touchesπ§ͺ Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
π Files selected for processing (5)
packages/unhead/src/client/renderDOMHead.tspackages/unhead/test/unit/client/dom.test.tspackages/unhead/test/unit/client/eventHandlers.test.tspackages/unhead/test/unit/client/onRendered.test.tspackages/unhead/test/unit/client/state.test.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.
|
Both review findings addressed in dae1f2c:
|
There was a problem hiding this comment.
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
π Files selected for processing (3)
packages/unhead/src/client/renderDOMHead.tspackages/unhead/test/unit/client/dom.test.tspackages/unhead/test/unit/client/state.test.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.
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.
|
Added a regression test ( |
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.
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.
β¦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.
β Type of change
π Description
The client DOM head renderer had several correctness bugs once the head re-rendered or the target
documentchanged:bodyAttrs/window handler) was removed.head._domand reused even when rendering into a differentdocument, carrying stale element references and listeners into the new document.dom:rendered/onRenderedhooks were dropped instead of applied, and a re-entrant render triggered fromdom:beforeRendercould 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
onRenderedfollow-up render.Review follow-ups
createDomStatepreviously deletedentry._oafter 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 stalehtmlAttrs/bodyAttrsclasses._ois now kept intact and only read.trackCtxonly applied truthy values and only registered cleanup for*Attrs. A reused (keyed) element that droppedtextContent/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.tsbaseline-across-documents,dom.test.tsreused-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):
5ab4a5c9): hand the cleanup pool to_pby reference instead of spreadingstate._sinto a fresh object every re-render, and reuse the previous render's cleanup closure for stable keys (the patterntrackEventalready uses) instead of reallocating ~one closure per tracked prop each frame. Cuts GC pressure under sustained re-rendering.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_lholds the listener. β32 B gz on the minimal client bundle; the observabledata-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/bodyAttrswith classes + abodyAttrsevent handler, forced dirty re-render each iteration; jsdom, vitestbench):main(baseline)mainis ~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
Document, reruns when needed for pending updates, and safely clears dirty/re-entrancy state after rendering.onloadandbodyAttrswindow listeners, and correct clearing for reused keyed elements (including attribute/class/style revert behavior).Tests
dom:beforeRender/dom:renderedlifecycle, multi-document isolation, keyed diffing, and DOM event handler replacement/removal.