From 0a4710f678f3f771035790c53abc5fa667bd90ef Mon Sep 17 00:00:00 2001 From: Pranav Zinzurde Date: Thu, 27 Aug 2026 20:25:49 +0530 Subject: [PATCH] fix(dom): stop stamping live stylesheet s, duplicating them (PER-10610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as percy/cli#2398: since @percy/cli 1.32.6, some archived DOM snapshots contain the same stylesheet 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 — 5c39872a (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 : prepare-dom.js markElement() stamps data-percy-element-id on so injectAtSheetPosition can find the sheet's clone and anchor its rewritten interactive-state rules there. Nothing in the package emits a second for an http(s) sheet — every -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 children with isEqualNode(), so a managed carrying an unexpected attribute compares unequal and the manager re-inserts its own copy at the end of — 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 ' + + '', + { withShadow: false } + ); + let link = document.querySelector('link[href="base/test/assets/hover-anchor.css"]'); + await new Promise((resolve, reject) => { + if (link.sheet) return resolve(); + link.addEventListener('load', resolve, { once: true }); + link.addEventListener('error', () => reject(new Error('hover-anchor.css failed to load')), { once: true }); + }); + + let html = serializeDOM({ enablePseudoClassSerialization: true }).html; + let sourceIdx = html.indexOf('hover-anchor.css'); + let copyIdx = html.indexOf('.lbtn[data-percy-hover]'); + let laterIdx = html.indexOf('.lbtn.later'); + expect(copyIdx).toBeGreaterThan(-1); + expect(copyIdx).toBeGreaterThan(sourceIdx); + expect(copyIdx).toBeLessThan(laterIdx); + }); }); describe(':state() CSS rewriting', () => {