Repository navigation
fix(browser): flattenShadowDom emitted nested shadow CSS twice - #171
Conversation
There was a problem hiding this comment.
Code Review
This pull request bumps the version of @harperfast/prerender-browser to 1.27.1 and fixes an issue where nested shadow roots were being scoped multiple times, leading to duplicated and excessively chained CSS selectors. It introduces a WeakSet to track generated style elements and skip them in subsequent ancestor passes. A comprehensive test has also been added to verify this behavior. There are no review comments to address.
|
Heads up from #170, which just merged: Two things needed on a rebase:
Sorry for the ordering — no signal on this branch said it was mid-flight when #170 went in. |
Removes 100KB — 48% of the injected CSS — from a review-heavy product page,
with byte-identical rendering.
A generated `<style>` is appended to its HOST, and that host sits inside the
ancestor's shadow tree. So when the ancestor is flattened, its
`sr.styleSheets` includes the descendant's already-scoped sheet and
re-serializes every rule with the ancestor's token on the front. The drop-guard
that is supposed to prevent re-emission only checks DIRECT children of the
shadow root (`captured.has(node)` over `sr.childNodes`), and the generated
sheet is a grandchild — so the original stays as well and the rules end up in
the document twice, at different prefix depths.
Measured on a real PDP, interleaved with a control that reproduced to 1 byte:
page bytes 1,470,903 -> 1,370,617 -100,286 (-6.8%)
injected CSS 209,727 -> 109,599 -100,128 (-47.7%)
scope-prefix bytes 37,381 -> 6,563
selectors carrying an ancestor chain 392/1084 -> 0/692
max chain depth 6 (`[data-sh="s16"] … [data-sh="s11"] .cls`) -> 1
Rendering is unchanged, which is the point — this is duplicate CSS, not CSS
doing work. Both snapshots loaded at the real URL with subresources resolving:
paint marks 3800 vs 3800, page height 11048px vs 11048px
diffPaint: lost 0, gained 0 (control: lost 0, gained 0)
bv-rnr__ nodes in the HTML 2,027 in both; 2,002 in the live DOM in both
cssRules parsed 1704 -> 1291
Fix: track the `<style>` elements we generate and skip them when serializing an
ancestor's sheets — they are already scoped. They are deliberately NOT marked
captured, so they travel into the light DOM with the rest of the subtree.
The regression test builds three nested shadow roots and asserts each rule is
emitted exactly once and no selector carries more than one scope token. Verified
to FAIL against the unfixed renderer ("lvl2 rule emitted exactly once").
252/252 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3159f8b to
72a4ffa
Compare
|
Done — rebased onto `main` at 4e84795 and re-stamped 1.28.1 (`72a4ffa`). Confirmed v1.28.0 is cut and its tarball published, so 1.27.1 was indeed gone. Your read on the conflicts was exactly right: the only textual conflicts were the two version fields, and Since the base moved, I re-measured the whole thing on v1.28.0 rather than leaving the pre-rebase numbers in the body (which is now updated in place): −100,286 bytes (−6.8%), injected CSS still halved, control drift 161 bytes — so #170 did not disturb the result. 263/263 tests, lint and format clean. No apology needed on the ordering; nothing on the branch signalled it was mid-flight. |
Asked whether
flattenShadowDomcould be made less bulky. It can — 100 KB, 48% of the injectedCSS, and it is a bug rather than a tradeoff. Rendering is byte-identical.
The bug
A generated
<style>is appended to its host, and that host sits inside the ancestor's shadowtree. When the ancestor is flattened, its
sr.styleSheetstherefore includes the descendant'salready-scoped sheet, and re-serializes every rule with the ancestor's token on the front.
The drop-guard meant to prevent re-emission only checks direct children of the shadow root
(
captured.has(node)oversr.childNodes), and the generated sheet is a grandchild — so theoriginal stays as well. The rules end up in the document twice, at different prefix depths:
…and the chain is duplicated inside each
:where(:not(…))argument list too.Measured, interleaved, control reproduced to 1 byte
Rendering is unchanged, which is the whole point
This is duplicate CSS, not CSS doing work. Both snapshots loaded at the real URL with subresources
resolving through the bypass token:
The fix
Track the
<style>elements we generate and skip them when serializing an ancestor's sheets — theyare already scoped. They are deliberately not marked captured, so they travel into the light DOM
with the rest of the subtree.
Test
Three nested shadow roots; asserts each rule is emitted exactly once and no selector carries more
than one scope token. Verified to fail against the unfixed renderer (
lvl2 rule emitted exactly once) — a regression test that passes on the buggy code would be worth nothing.263/263 pass; lint and format clean. Rebased onto
mainafter #170 (v1.28.0); this is v1.28.1, and every number above was re-measured on that base — the only conflicts were the version fields,renderer.tsmerged cleanly.Worth knowing for context
The other things looked at in the same pass, so they are not re-proposed:
prefixes are 6.5 KB.
pruneUnmatchedCssalready runs after flatten, so the remaining CSS matches real elements.minifyInlineCssconsistently adds bytes (+33.8K / +18.7K / +3.8K over three interleavedpairs) for Chromium-identical output — a separate candidate for removal.
🤖 Generated with Claude Code