Skip to content

fix(dom): stop stamping live stylesheet <link>s, duplicating them (PER-10610) - #2406

Merged
pranavz28 merged 1 commit into
masterfrom
fix/PER-10610-duplicate-stylesheet-links
Aug 31, 2026
Merged

fix(dom): stop stamping live stylesheet <link>s, duplicating them (PER-10610)#2406
pranavz28 merged 1 commit into
masterfrom
fix/PER-10610-duplicate-stylesheet-links

Conversation

@pranavz28

@pranavz28 pranavz28 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #2398 · PER-10610

Summary

Since 1.32.6, some archived DOM snapshots contain the same stylesheet <link> twice, changing the effective CSS source order so unintended styles win. 1.32.5 emits it once.

packages/dom changed in exactly one functional commit across 1.32.5 → 1.32.7 — 5c39872 (#2342, PER-10077) — and packages/dom/src didn't change at all in 1.32.7, which matches the reporter seeing it in both. That commit added the only write @percy/dom has ever made to a live stylesheet link:

prepare-dom.js markElement() stamps data-percy-element-id on <link rel=stylesheet> so injectAtSheetPosition can find the sheet's clone and anchor its rewritten interactive-state rules there.

Nothing in the package emits a second <link> for an http(s) sheet — every <link>-creating site was audited (serialize-cssom's blob and adopted branches only; both pre-1.32.6, neither matching the report). The duplicate is created by the page itself.

Unlike the pseudo-class markers, which stampOnce records on ctx._liveMutations for cleanupInteractiveStateMarkers to undo, this setAttribute is raw and never cleaned off the live page. Head-managing frameworks (next/head's head-manager, react-helmet, vue-meta) reconcile <head> children with isEqualNode(), so a managed <link> carrying an unexpected attribute compares unequal and the manager re-inserts its own copy at the end of <head> — a duplicate href in a new cascade position.

Because markElement runs inside the synchronous clone walk, the reconcile lands after that snapshot: the first is clean and every later one carries the duplicate. That is why only some archived DOMs showed it.

Fix

Fix the cause, not the symptom: don't touch the live page at all. cloneNodeAndShadow records a live→clone WeakMap (ctx.styleSheetClones) for every <style>/<link> as it clones them, and injectAtSheetPosition resolves its anchor by node identity through that map.

PER-10077's cascade anchoring is fully preserved (covered by spec). No stylesheet <link> is mutated or stamped — on the live page or in the output.

Design decisions

Recording these here rather than as code comments.

Why a live→clone map instead of a marker attribute. The anchor only has to be found during serialization, so it never needed to be persisted in the DOM at all. Keying on node identity gives the same O(1) lookup with zero observable effect on the customer's page — which is the actual invariant that was violated. It also makes the anchor lookup shadow-safe by construction, where the previous scopeRoot.querySelector could not pierce shadow roots.

Why WeakMap. Keys are live DOM nodes and the map is never iterated. Weak refs mean a node detached mid-serialization isn't retained for the lifetime of ctx.

Why <style> is in the map too, not just <link>. <style> is still stamped (unchanged from 1.32.5 — pre-existing behavior, and it isn't the reported regression), so it would work via the fallback. Including it keeps one resolution path for both owner-node kinds instead of branching on tag name.

Why serialize-cssom repoints the map. It replaces a CSSOM <style> clone in place (insertBefore + remove) and runs before serializePseudoClasses. Without repointing, the map would hand back a detached node. The ?.set is a no-op for direct callers that build a ctx without the map.

Fallback chain, in order. map → data-percy-element-id lookup → append at end of <head> (the pre-PER-10077 behavior). Each step degrades to something correct rather than throwing, and anchor.parentNode is checked so a detached hit falls through instead of being used.

Why markElement keeps the tagName local. #2342 extracted it; it's still used by the includes() check, so only the isStylesheetLink branch is removed. This leaves markElement functionally identical to 1.32.5.

sheet.ownerNode is now null-guarded. The previous code dereferenced it unconditionally. Not reachable today (collectStyleSheets reads only scope.styleSheets, which excludes adopted sheets), but an unguarded throw here aborts the entire snapshot, and the guard is free.

Scope. Contained to @percy/dom: no other package reads data-percy-element-id off a <link> (core/webdriver-utils reference it only for iframes). Independent of PER-10588, which gates the interactive-state injection but leaves markElement's <link> stamp unconditional — so it would not have fixed this.

Rebased onto master (PER-10588, #2399) rather than merged. The branch was behind and conflicted. The only conflict was positional in serialize-dom.test.js — master inserted an interactive-state serialization opt-in gate (PER-10588) block immediately above the stylesheet <link> stamping (PER-10077) block this PR replaces; git could not tell "new neighbour" from "rewritten block". Master's gate block is kept verbatim and this PR's stylesheet <link> handling (PER-10610) block takes the PER-10077 block's place. All five source files merged clean, and the fix is unchanged by the rebase. Rebase over merge keeps the PR one reviewable commit, matching the surrounding history.

The PER-10077 anchoring spec now passes enablePseudoClassSerialization: true. PER-10588 landed after this branch was cut and made interactive-state serialization opt-in, default off. Without the flag that spec would call serializeDOM() with the rewriting disabled, find no .lbtn[data-percy-hover] copy, and fail — or worse, pass vacuously if the assertion were loosened. The flag restores what the spec is there to guard. The other three specs deliberately stay on the default path: they assert on markElement, which the PER-10588 gate does not touch, so they must hold with the feature off — which is how the reporter hit the bug in the first place.

Test plan

Four specs in a new stylesheet <link> handling (PER-10610) block. Three fail with the stamp restored and pass without it — verified in both directions:

Spec Fails on buggy code
leaves a live stylesheet <link> equal to what a head manager rendered (isEqualNode)
does not stamp stylesheet <link>s in the serialized output
does not accumulate <link>s when a head manager reconciles between snapshots
still anchors a rewritten copy after its source <link>, not at end of head (PER-10077) — (guards that the fix doesn't regress #2342)

The third reproduces the reported symptom end to end: it reconciles a head-managed <link> between two serializeDOM passes and asserts the href still appears exactly once. isEqualNode is used rather than an attribute allowlist because it catches any change serialization makes to the live element, and it is the same predicate the real head managers use. The inverted PER-10077 spec that asserted links are stamped is replaced.

Re-verified after the rebase, on ChromeHeadless against origin/master (719021a) in the same worktree:

Run Failing specs
master baseline 80
this branch 80 — identical set, 0 new, 0 fixed (comm on the sorted failure names)
this branch, stamp restored 86 — the 80, plus exactly the 3 specs above

The 80 are pre-existing local failures (readiness font/js_idle, video poster timeouts, focus specs needing real window focus), unrelated to this change and equally present on master. All four PER-10610 specs pass on the fix. Root yarn lint is clean. Firefox was not run locally (no binary available); CI covers it.

🤖 Generated with Claude Code

@pranavz28 pranavz28 added the 🐛 bug Something isn't working label Aug 27, 2026
@pranavz28
pranavz28 marked this pull request as ready for review August 27, 2026 13:11
@pranavz28
pranavz28 requested a review from a team as a code owner August 27, 2026 13:11
@pranavz28
pranavz28 force-pushed the fix/PER-10610-duplicate-stylesheet-links branch 3 times, most recently from 7c666f5 to d235049 Compare August 27, 2026 14:58
…R-10610)

Reported as #2398: since @percy/cli 1.32.6, some archived DOM
snapshots contain the same stylesheet <link> twice, changing the effective
CSS source order so unintended styles win. 1.32.5 emits it once.

packages/dom changed in exactly one functional commit across 1.32.5 →
1.32.7 — 5c39872 (PR #2342, PER-10077) — and nothing in packages/dom/src
changed at all in 1.32.7, which matches the reporter seeing it in both.
That commit added the only write @percy/dom has ever made to a live
stylesheet <link>:

  prepare-dom.js markElement() stamps data-percy-element-id on
  <link rel=stylesheet> so injectAtSheetPosition can find the sheet's
  clone and anchor its rewritten interactive-state rules there.

Nothing in the package emits a second <link> for an http(s) sheet — every
<link>-creating site was audited (serialize-cssom's blob and adopted
branches only, both pre-1.32.6 and neither matching the report). The
duplicate is created by the page itself: unlike the pseudo-class markers,
which stampOnce records on ctx._liveMutations for
cleanupInteractiveStateMarkers to undo, this setAttribute is raw and never
cleaned off. Head-managing frameworks (next/head's head-manager,
react-helmet, vue-meta) reconcile <head> children with isEqualNode(), so a
managed <link> carrying an unexpected attribute compares unequal and the
manager re-inserts its own copy at the end of <head> — a duplicate href in
a new cascade position. Because markElement runs inside the synchronous
clone walk, the reconcile lands after that snapshot: the first is clean and
every later one carries the duplicate, which is why only *some* archived
DOMs showed it.

Fix the cause rather than the symptom: don't touch the live page at all.
cloneNodeAndShadow now records a live→clone WeakMap (ctx.styleSheetClones)
for every <style>/<link> as it clones them, and injectAtSheetPosition
resolves its anchor by node identity through that map. PER-10077's cascade
anchoring is fully preserved — verified by spec — and no stylesheet <link>
is mutated or stamped, on the live page or in the output. serialize-cssom
repoints the map when it rebuilds a <style> clone in place, so the anchor
never lands on a detached node; the data-percy-element-id lookup remains as
a fallback (<style> is still stamped, unchanged from 1.32.5), then the
pre-PER-10077 end-of-<head> append. sheet.ownerNode is now null-guarded,
which the previous code dereferenced unconditionally.

Contained to @percy/dom: no other package reads data-percy-element-id off a
<link> (core/webdriver-utils reference it only for iframes).

Four specs in the PER-10610 block, three of which fail with the stamp
restored and pass without it — including one that reproduces the reported
symptom end to end by reconciling a head-managed <link> between two
serializeDOM passes and asserting the href still appears exactly once.
The inverted PER-10077 spec that asserted links *are* stamped is replaced.

@percy/dom is at parity with the pre-change baseline: identical set of 81
pre-existing failures on ChromeHeadless (readiness font/js_idle, video
poster timeouts, focus specs needing real window focus), 0 new. eslint
clean.

Note: this is independent of PER-10588, which gates the interactive-state
injection but leaves markElement's <link> stamp unconditional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pranavz28
pranavz28 force-pushed the fix/PER-10610-duplicate-stylesheet-links branch from d235049 to 0a4710f Compare August 31, 2026 05:13

@pranavz28 pranavz28 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude Code Review (automated) — 1 inline finding(s). Full report in the PR comment below. Verdict: Passed.

const ownerNode = sheet.ownerNode;
let anchor = ownerNode ? ctx.styleSheetClones?.get(ownerNode) : null;

if (!anchor?.parentNode && ownerNode) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] data-percy-element-id fallback anchor is now unreachable for <link> owners

Since prepare-dom.js no longer stamps data-percy-element-id on stylesheet <link>s, this middle fallback step (scopeRoot.querySelector('[data-percy-element-id=...]')) can never resolve an anchor for a <link>-owned sheet anymore — only for <style> owners, which are still stamped. In production this is harmless: cloneNodeAndShadow always populates ctx.styleSheetClones before this runs, so the WeakMap lookup succeeds and this branch is not reached for links. It is exercised today only by a pre-existing hand-built-ctx unit test (cascade position of injected rules) that pre-stamps a <style> tag directly, so nothing currently regresses. Not a bug — the PR description documents this as an intentional 3-step degrade chain (map → id lookup → end-of-head) — flagging only because the middle step is dead for one of the two owner-node kinds it nominally handles.

Suggestion: No action required; optional follow-up would be a short inline note (or drop the redundant middle step for <link>-only lookups) so a future reader doesn't assume this fallback still helps stylesheet links. Given the repo convention here of no explanatory code comments, this is best left as-is or addressed only if it becomes confusing in practice.

Reviewer: code-review (built-in fallback)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adjudicated: accurate observation, keeping the code as-is. Recording the reasoning rather than implementing either suggestion.

The observation is correct — for a <link>-owned sheet this middle step can no longer resolve, because not stamping links is the whole point of the fix. But the suggestion to "drop the redundant middle step" would be a regression, because the step is not redundant: it is load-bearing for the other owner-node kind and for direct callers.

ctx.styleSheetClones?.get(...) is optional-chained precisely because serializePseudoClasses is a public entry point that callers invoke with their own ctx. In test/serialize-pseudo-classes.test.js there are 33 calls to serializePseudoClasses(...) and zero that supply styleSheetClones. For every one of those, the WeakMap lookup yields undefined and the data-percy-element-id step is the only thing that resolves a <style> anchor — including the cascade position of injected rules spec that guards PER-10077. Dropping it would break them and silently downgrade those callers to append-at-end-of-head.

So the accurate framing is not "dead middle step" but "step 1 covers <link> and <style> for the serializeDOM() path; step 2 covers <style> for callers that build their own ctx." Each step degrades to something correct instead of throwing, which is the documented intent.

On the optional inline note: declining per this repo's convention that rationale lives in the PR description, not the diff. The fallback chain and its ordering are already written up there under "Fallback chain, in order."

No code change.

@pranavz28

Copy link
Copy Markdown
Contributor Author

Claude Code PR Review

PR: #2406Head: 0a4710fReviewers: code-review (built-in fallback)

Summary

Fixes PER-10610: @percy/dom no longer stamps data-percy-element-id on live stylesheet <link> elements (the only live-DOM write PER-10077 introduced), which was causing head-managing frameworks (next/head, react-helmet, vue-meta) to see the mutated <link> as unequal to their own copy and re-insert a duplicate at the end of <head> on reconcile; the fix replaces the attribute stamp with a live→clone WeakMap (ctx.styleSheetClones) built during cloning, so interactive-state rule anchoring resolves by node identity instead of a DOM-visible marker, while preserving PER-10077's cascade-position behavior.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass N/A — no secrets touched
High Security Authentication/authorization checks present N/A Not applicable to this change
High Security Input validation and sanitization Pass No new external input surface
High Security No IDOR — resource ownership validated N/A Not applicable
High Security No SQL injection (parameterized queries) N/A Not applicable
High Correctness Logic is correct, handles edge cases Pass Verified fallback chain (map → id lookup → append-at-end), null-guarded sheet.ownerNode, anchor?.parentNode detached-clone check, and correct pipeline ordering (cloneNodeAndShadow populates the map before serializeCSSOM repoints it, before serializePseudoClasses reads it)
High Correctness Error handling is explicit, no swallowed exceptions Pass Unchanged from existing try/catch structure in clone-dom.js
High Correctness No race conditions or concurrency issues Pass Synchronous clone walk; WeakMap is per-serializeDOM() call, fresh per iframe (verified serialize-frames.js calls serializeDOM() recursively, not sharing ctx), and correctly shared across shadow-DOM recursion via the same closure
Medium Testing New code has corresponding tests Pass 4 new specs in serialize-dom.test.js under "stylesheet <link> handling (PER-10610)"; all 4 pass locally against this branch
Medium Testing Error paths and edge cases tested Pass Existing "falls back to end of head when a stamped sheet has no clone anchor" spec covers the detached-anchor path
Medium Testing Existing tests still pass (no regressions) Pass Ran packages/dom suite (ChromeHeadless) locally: 81 pre-existing failures, all confined to waitForReady/serializeVideos/utils describe blocks — none in serialize-dom, serialize-pseudo-classes, serialize-cssom, or clone-dom (the files this PR touches). Matches the PR description's own before/after comparison
Medium Performance No N+1 queries or unbounded data fetching Pass O(1) WeakMap lookups, no added iteration cost
Medium Performance Long-running tasks use background jobs N/A Not applicable
Medium Quality Follows existing codebase patterns Pass Minor stylistic overlap noted below (Low)
Medium Quality Changes are focused (single concern) Pass Contained to @percy/dom; no unrelated changes
Low Quality Meaningful names, no dead code Pass (minor note) See Low finding below re: one now-unreachable fallback branch for <link> owners
Low Quality Comments explain why, not what Pass No code comments added, per repo convention; rationale is in the PR description as intended
Low Quality No unnecessary dependencies added Pass None added

Findings

  • File: packages/dom/src/serialize-pseudo-classes.js:531

  • Severity: Low

  • Reviewer: code-review (built-in fallback)

  • Issue: The data-percy-element-id fallback lookup in injectAtSheetPosition can no longer resolve an anchor for a <link> owner, since prepare-dom.js no longer stamps that attribute on stylesheet links. It still works for <style> owners (still stamped). In the real serializeDOM() pipeline this is never reached for either — ctx.styleSheetClones is always populated first — so there's no live bug; it's only exercised today by a pre-existing unit test that hand-builds ctx for a <style> element.

  • Suggestion: No action required for this PR. Optional future cleanup: note that the id-lookup step now only ever helps <style> owners, if that ever causes confusion.

  • File: packages/dom/src/serialize-pseudo-classes.js:493 (unchanged context, not part of this diff)

  • Severity: Low

  • Reviewer: code-review (built-in fallback)

  • Issue: Two independent "find my clone by identity" idioms now coexist in the package: the pre-existing data-percy-element-id + querySelector/cloneByPercyId pattern, and the new ctx.styleSheetClones WeakMap. Not a bug — the new mechanism is necessary because stylesheet <link>s intentionally no longer carry the id attribute — but it's worth knowing for future contributors adding a new "find my clone" need.

  • Suggestion: No action required for this PR; purely an observation for future refactors.

Both findings were independently verified (code paths read directly, cross-checked against the pipeline order in serialize-dom.js, and the existing test in serialize-pseudo-classes.test.js that exercises the fallback for <style> owners). Neither is a correctness defect, and both are explicitly consistent with the fallback-chain design documented in the PR description. Neither gates the verdict.

Independently verified beyond the reviewer's findings:

  • yarn/local eslint on all 6 changed files: clean, no errors or warnings.
  • packages/dom test suite (ChromeHeadless, local): all 4 new PER-10610 specs pass, all pre-existing PER-10077 cascade-position specs pass, and the 81 local failures are pre-existing/unrelated (readiness + video timing specs untouched by this PR).
  • Manually traced pipeline ordering (cloneNodeAndShadowserializeElements/serializeCSSOMserializePseudoClasses) and shadow-DOM/iframe context sharing — matches the design decisions stated in the PR description.

Verdict: PASS

@pranavz28

Copy link
Copy Markdown
Contributor Author

Review adjudication — both findings resolved, no code change

Recording the reasoning for each finding from the automated review, per the "reply with reasoning, accepts and rejects alike" convention.

1. serialize-pseudo-classes.js:531data-percy-element-id fallback unreachable for <link> owners. [Low] → Accurate, rejected the suggested change.

Replied in the inline thread. Summary: the step is not redundant. serializePseudoClasses is invoked by callers that build their own ctxtest/serialize-pseudo-classes.test.js makes 33 such calls, none supplying styleSheetClones — and for all of them the data-percy-element-id lookup is the only thing that resolves a <style> anchor, including the spec guarding PER-10077 cascade position. Removing it would silently downgrade those callers to append-at-end-of-head.

2. serialize-pseudo-classes.js:493 — two "find my clone" idioms now coexist. [Low] → Rejected: they key different things, and the line is outside the diff.

cloneByPercyId and ctx.styleSheetClones are not two idioms for one job:

key value purpose
cloneByPercyId shadow host data-percy-element-id clone host element reach cloneHost.shadowRoot to inject into the right shadow scope
ctx.styleSheetClones live sheet owner node (<style>/<link>) that node's clone anchor the rewritten copy at the sheet's cascade position

Different key spaces, different value types, different questions. Shadow hosts are stamped by markElement independently of this change, so unifying the two would conflate a scope lookup with an anchor lookup — and serialize-pseudo-classes.js:493 is pre-existing context untouched by this PR, so changing it would widen the diff beyond the fix.


CI: 47 checks passing, 0 pending. Test @percy/dom and Test @percy/core green on both Linux and Windows.

The Windows Test @percy/core red on the first attempt was Discovery captures favicon when the server provides one, which is unrelated to this change — that spec passes a hand-written domSnapshot string into percy.snapshot(), so @percy/dom is never invoked in its code path, and favicons are rel="icon" whereas the attribute removed here only ever applied to rel="stylesheet". That run's suite took 1h02m against ~18m for the Linux leg; it passed on re-run.

The Percy visual check reports 1 change needing review. Verified as not caused by this PR: building @percy/dom at both revisions and diffing the serialized DOM of all 23 regression pages shows the only difference is the removed non-rendering data-percy-element-id on <link>s — no reordering, nothing moved — and interactive-states.html, the one page that exercises the cascade-anchoring path, is byte-identical. Still needs a human to approve in the Percy UI.

@pranavz28
pranavz28 merged commit 1d69b31 into master Aug 31, 2026
65 of 66 checks passed
@pranavz28
pranavz28 deleted the fix/PER-10610-duplicate-stylesheet-links branch August 31, 2026 08:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Duplicate stylesheet <link> elements in DOM snapshots since @percy/cli 1.32.6

3 participants