Skip to content

fix(browser): flattenShadowDom emitted nested shadow CSS twice - #171

Merged
harper-joseph merged 1 commit into
mainfrom
fix/flatten-nested-reprefix
Sep 17, 2026
Merged

harper-joseph merged 1 commit into
mainfrom
fix/flatten-nested-reprefix

Conversation

@harper-joseph

@harper-joseph harper-joseph commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Asked whether flattenShadowDom could be made less bulky. It can — 100 KB, 48% of the injected
CSS
, 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 shadow
tree. When the ancestor is flattened, its sr.styleSheets therefore includes the descendant's
already-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) over sr.childNodes), and the generated sheet is a grandchild — so the
original stays as well. The rules end up in the document twice, at different prefix depths:

[data-sh="s16"] [data-sh="s15"] [data-sh="s14"] [data-sh="s13"] [data-sh="s12"] [data-sh="s11"] .cls

…and the chain is duplicated inside each :where(:not(…)) argument list too.

Measured, interleaved, control reproduced to 1 byte

before after
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 1

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:

paint marks   3800 vs 3800        page height 11048px vs 11048px
diffPaint     lost 0, gained 0    (control old-vs-old: lost 0, gained 0)
bv-rnr__      2,027 in the HTML both ways; 2,002 in the live DOM both ways
cssRules      1704 -> 1291        (the 413 redundant copies)

The 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.

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 main after #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.ts merged cleanly.

Worth knowing for context

The other things looked at in the same pass, so they are not re-proposed:

  • A shorter scope token is not a lever — it is already terse and unquoted, and after this fix the
    prefixes are 6.5 KB.
  • Deduping identical sheets across hosts was measured previously at 196 B.
  • pruneUnmatchedCss already runs after flatten, so the remaining CSS matches real elements.
  • minifyInlineCss consistently adds bytes (+33.8K / +18.7K / +3.8K over three interleaved
    pairs) for Chromium-identical output — a separate candidate for removal.

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist 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.

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.

@harper-joseph

Copy link
Copy Markdown
Contributor Author

Heads up from #170, which just merged: main is now at browser 1.28.0 and this branch stamps 1.27.1, so as it stands merging it would take main's version backwards — the same footgun as #59, where an already-released number landed on main and the release cut then failed on an existing tag.

Two things needed on a rebase:

  1. Re-stamp to 1.28.1 (npm version 1.28.1 --workspace @harperfast/prerender-browser --no-git-tag-version). v1.28.0 is already cut and its tarball is published, so 1.27.1 is no longer available.
  2. Expect a textual conflict on packages/browser/package.json and package-lock.json — both PRs are version bumps. Nothing else overlaps: feat(browser): one browser context per job, wiped between variants (v1.28.0) #170 touched ManagedBrowser.ts, Worker.ts, config.ts and a new variantContext.ts, and never renderer.ts, so there is no logical conflict with the flattenShadowDom fix.

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>
@harper-joseph
harper-joseph force-pushed the fix/flatten-nested-reprefix branch from 3159f8b to 72a4ffa Compare September 17, 2026 18:43
@harper-joseph

Copy link
Copy Markdown
Contributor Author

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 renderer.ts merged cleanly — #170 never touched it, so there is no logical overlap with the flattenShadowDom fix.

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):

arm      pageBytes   injectedCSS   prefixBytes   multiPrefix   bvHTML   bvDOM
old      1,470,903       209,727        37,381      392/1084    2,027    2002
NEW      1,370,617       109,599         6,563       0/692      2,027    2002
old2     1,470,742       209,727        37,381      392/1084    2,027    2002

−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.

@harper-joseph
harper-joseph merged commit 824b23f into main Sep 17, 2026
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