From a3ae26de49fb4b2de2d1bbfa1312bdaa6220282a Mon Sep 17 00:00:00 2001 From: Pranav Zinzurde Date: Tue, 25 Aug 2026 10:39:35 +0530 Subject: [PATCH 1/4] fix(dom): copy only pseudo-bearing members of a grouped selector (PER-10588) The interactive-states auto-detect path copies every CSS rule whose selector contains :focus/:focus-within/:hover/:active, rewrites the pseudo to a data-percy-* attribute, and re-injects the copy. It copied the rule's ENTIRE selector list, including members with no pseudo-class at all. Those members already match from their own stylesheet, so the copy adds nothing -- but it re-ranks them last in the cascade, and any equal-specificity rule from later in the same sheet that was NOT copied then loses purely on source order. A customer's AEM site hit this hard. One 82-selector rule .dbk-basic-box ... [class*="__title"], ... , .dbk-basic-box .dbk-basic-box__inner .dbk-readmore__trigger:hover { color: #004b6f } carries exactly one :hover member, so all 82 selectors were re-injected. The later same-sheet rule that paints those headings white on colored cards (.dbk-basic-box--blue ... { color: #fff }, identical specificity, no pseudo) lost the tie -- every heading on a blue/green/yellow card rendered dark blue on a dark background, illegible. PER-10077's per-sheet anchoring does not help here: both rules live in the same stylesheet, so anchoring the copy after that sheet still places it after the rule it beats. Verified against the customer's captured DOM + real CSS: on master the serialized page renders the headings rgb(0,75,111); with this change it renders rgb(255,255,255), matching the live page, while the :hover member is still copied. Split the selector list on top-level commas only (commas inside :is()/:not(), attribute values and quoted strings are not separators), keep just the members that carry an interactive pseudo, and rewrite those. The split doubles as the pseudo filter, so rules with a `:` but no interactive pseudo (::before, :root, :nth-child) skip as before. Side effect: the injected ' + bodyHTML); + ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + markPseudoClassElements(ctx, null); + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; + serializePseudoClasses(ctx); + let el = ctx.clone.querySelector('style[data-percy-interactive-states]'); + return el ? el.textContent : null; + } + + it('copies only the pseudo-bearing members of a grouped selector', () => { + let css = serializeWith( + '.card .title, .card .trigger:hover { color: rgb(0, 75, 111) }' + + '.card--blue .title { color: rgb(255, 255, 255) }', + '
t' + + 'go
' + ); + expect(css).toContain('.card .trigger[data-percy-hover]'); + expect(css).not.toContain('.card .title'); + }); + + it('does not split commas nested in :is()/:not() or attribute values', () => { + let css = serializeWith( + ':is(.a, .b) .lnk:focus, [data-k="x,y"] .lnk:focus { color: red }', + '
a
' + ); + expect(css).toContain(':is(.a, .b) .lnk[data-percy-focus]'); + expect(css).toContain('[data-k="x,y"] .lnk[data-percy-focus]'); + }); + + it('keeps every member when they all carry a pseudo', () => { + let css = serializeWith( + '.p:focus, .q:focus { color: red }', + '
pq
' + ); + expect(css).toContain('.p[data-percy-focus]'); + expect(css).toContain('.q[data-percy-focus]'); + }); + }); + describe('walkCSSRules charset / import / etc with no selectorText', () => { it('skips rules that have neither nested cssRules nor a selector', () => { // @charset has no cssRules and no selectorText — exercises the From 2914b285aead4f3aaaf8e0ce71916bb226d1b3dd Mon Sep 17 00:00:00 2001 From: Pranav Zinzurde Date: Tue, 25 Aug 2026 21:12:44 +0530 Subject: [PATCH 2/4] feat(dom): make interactive-state serialization opt-in, default off (PER-10588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive-state auto-detect path has shipped four regressions in three months, all of the same shape: it copies customer CSS, rewrites a pseudo-class to a data-percy-* attribute, and re-injects the copy, which reorders the cascade for rules nobody asked it to touch. PER-9775 (#2324) nested selectors leaked page-wide via a bare `&` PER-9836 pseudoClassEnabledElements froze layout/transitions PER-10077 (#2342) :checked/:disabled copies recolored Angular Material buttons across 70+ snapshots PER-10588 one :hover member of an 82-selector rule repainted every colored-card heading on an AEM site Adoption does not justify that risk. On the PER-10588 customer's captured DOM the injected block was 289KB with 629 [data-percy-hover] selectors and ZERO data-percy-* attributes stamped on any element — nothing it emitted could match. The feature contributed no correct behavior and all of the breakage. Gate both paths behind a new snapshot option, enablePseudoClassSerialization (default false; snake_case enable_pseudo_class_serialization also accepted). Configuring pseudoClassEnabledElements is itself an opt-in and turns the feature on without the flag, so no existing opt-in user changes behavior. The predicate is computed once in serializeDOM and carried as ctx.pseudoClassSerialization — one auditable switch, not a check per call site. No pseudo-class logic is removed. Deliberately NOT gated, both verified against percy-renderer: - Open-popover stamping. src/script/popover-element-helper.js reopens [popover][data-percy-popover-open]; without the stamp an open popover renders hidden behind the UA [popover]:not(:popover-open) rule. - Custom element :state() rewriting. serialize-custom-states rewrites ' + + ''; + + it('is off by default — no rewritten copy and no state stamps', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM().html; + expect(html).not.toContain('data-percy-interactive-states'); + expect(html).not.toContain('[data-percy-hover]'); + expect(html).not.toContain('data-percy-focus'); + }); + + it('is on when enablePseudoClassSerialization is set', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM({ enablePseudoClassSerialization: true }).html; + expect(html).toContain('data-percy-interactive-states'); + expect(html).toContain('[data-percy-hover]'); + }); + + it('accepts the snake_case option name', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM({ enable_pseudo_class_serialization: true }).html; + expect(html).toContain('[data-percy-hover]'); + }); + + it('is on when pseudoClassEnabledElements is configured, without the flag', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM({ pseudoClassEnabledElements: { id: ['gbtn'] } }).html; + expect(html).toContain('[data-percy-hover]'); + expect(html).toContain('data-percy-pseudo-element-id'); + }); + + it('stays on for configured elements even when the flag is materialized false', () => { + withExample(HOVER_PAGE, { withShadow: false }); + let html = serializeDOM({ + enablePseudoClassSerialization: false, + pseudoClassEnabledElements: { id: ['gbtn'] } + }).html; + expect(html).toContain('[data-percy-hover]'); + }); + + it('still stamps open popovers while disabled — the renderer requires it', () => { + withExample( + '
hi
', + { withShadow: false } + ); + let popover = document.getElementById('gpop'); + if (typeof popover.showPopover !== 'function') return; + popover.showPopover(); + + let html = serializeDOM().html; + expect(html).not.toContain('data-percy-interactive-states'); + expect(html).toContain('data-percy-popover-open'); + }); + + it('still rewrites custom element :state() while disabled', () => { + withExample( + '
', + { withShadow: false } + ); + let html = serializeDOM().html; + expect(html).not.toContain('data-percy-interactive-states'); + expect(html).toContain('[data-percy-custom-state~="checked"]'); + }); + + it('leaves no data-percy-* state attributes on the live DOM either way', () => { + withExample(HOVER_PAGE, { withShadow: false }); + serializeDOM({ enablePseudoClassSerialization: true }); + expect(document.querySelector('[data-percy-hover]')).toBeNull(); + expect(document.querySelector('[data-percy-focus]')).toBeNull(); + }); + }); + describe('stylesheet stamping (PER-10077 cascade anchoring)', () => { it('stamps only stylesheet s with a percy element id', () => { // The pseudo-class serializer anchors its rewritten rules after a sheet's diff --git a/packages/dom/test/serialize-pseudo-classes.test.js b/packages/dom/test/serialize-pseudo-classes.test.js index 445c5504a..82a3780e8 100644 --- a/packages/dom/test/serialize-pseudo-classes.test.js +++ b/packages/dom/test/serialize-pseudo-classes.test.js @@ -26,7 +26,8 @@ describe('serialize-pseudo-classes', () => { beforeEach(() => { ctx = { dom: document, - warnings: new Set() + warnings: new Set(), + pseudoClassSerialization: true }; withExample('
'); ctx.clone = document.implementation.createHTMLDocument('Clone'); @@ -179,7 +180,7 @@ describe('serialize-pseudo-classes', () => { // We'll temporarily patch getElementsToProcess to expose markWithId withExample('
'); const el = document.getElementById('foo'); - const ctx2 = { dom: document, warnings: new Set() }; + const ctx2 = { dom: document, warnings: new Set(), pseudoClassSerialization: true }; getElementsToProcess(ctx2, { id: ['foo'] }); expect(el.hasAttribute('data-percy-pseudo-element-id')).toBe(false); }); @@ -191,7 +192,7 @@ describe('serialize-pseudo-classes', () => { }); it('throws an error if config is null', () => { - const ctx2 = { dom: document, warnings: new Set() }; + const ctx2 = { dom: document, warnings: new Set(), pseudoClassSerialization: true }; expect(() => { getElementsToProcess(ctx2, null); }).toThrow(); @@ -476,7 +477,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -531,7 +533,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -562,7 +565,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -679,7 +683,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -709,6 +714,7 @@ describe('serialize-pseudo-classes', () => { resources: new Set(), hints: new Set(), shadowRootElements: [], + pseudoClassSerialization: true, pseudoClassEnabledElements: { id: ['has-test'] } }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method @@ -738,7 +744,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -763,7 +770,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -793,7 +801,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -836,7 +845,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // Do NOT copy DOM to clone - so the clone element won't be found // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method @@ -873,7 +883,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -908,7 +919,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = '
test
'; @@ -930,7 +942,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -956,7 +969,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -976,7 +990,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1004,7 +1019,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // Blur any active element to ensure nothing is focused document.activeElement?.blur(); @@ -1025,7 +1041,7 @@ describe('serialize-pseudo-classes', () => { let el = document.getElementById('has-percy-id'); el.setAttribute('data-percy-element-id', '_focus_branch_test'); withMockedFocus(el, () => { - ctx = { dom: document, warnings: new Set() }; + ctx = { dom: document, warnings: new Set(), pseudoClassSerialization: true }; markPseudoClassElements(ctx, { id: ['has-percy-id'] }); }); expect(el.hasAttribute('data-percy-focus')).toBe(true); @@ -1078,7 +1094,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1135,7 +1152,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1179,7 +1197,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1234,7 +1253,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1281,7 +1301,7 @@ describe('serialize-pseudo-classes', () => { Object.defineProperty(shadow, 'activeElement', { get: () => deepInput, configurable: true }); Object.defineProperty(document, 'activeElement', { get: () => host, configurable: true }); try { - ctx = { dom: document, warnings: new Set() }; + ctx = { dom: document, warnings: new Set(), pseudoClassSerialization: true }; markPseudoClassElements(ctx, null); // The traversal should reach deepInput and stamp [data-percy-focus] expect(deepInput.hasAttribute('data-percy-focus')).toBe(true); @@ -1325,7 +1345,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = '
'; @@ -1371,7 +1392,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // Clone host exists but WITHOUT a shadow root attached // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method @@ -1447,7 +1469,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1485,6 +1508,7 @@ describe('serialize-pseudo-classes', () => { resources: new Set(), hints: new Set(), shadowRootElements: [], + pseudoClassSerialization: true, pseudoClassEnabledElements: { id: ['cm3-btn'] } }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method @@ -1509,7 +1533,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1566,7 +1591,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1595,7 +1621,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = document.body.innerHTML; @@ -1631,7 +1658,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method ctx.clone.body.innerHTML = ctx.dom.body.innerHTML; @@ -1661,7 +1689,8 @@ describe('serialize-pseudo-classes', () => { cache: new Map(), resources: new Set(), hints: new Set(), - shadowRootElements: [] + shadowRootElements: [], + pseudoClassSerialization: true }; // Clone intentionally does NOT mirror the source
x
">'; + document.body.appendChild($container); + + await when(() => { + let $frame = $container.querySelector('#frame-pseudo'); + assert($frame?.contentDocument?.querySelector('.ibtn'), '#frame-pseudo did not load in time'); + return $frame; + }, 5000); + }, 0); + + afterEach(() => { + $container?.remove(); + $container = null; + }); + + it('does not serialize iframe interactive states by default', () => { + let inner = srcdocOfFrame(); + expect(inner).not.toContain('data-percy-interactive-states'); + expect(inner).not.toContain('data-percy-hover'); + }); + + it('serializes iframe interactive states when the flag is enabled', () => { + let inner = srcdocOfFrame({ enablePseudoClassSerialization: true }); + expect(inner).toContain('data-percy-interactive-states'); + expect(inner).toContain('[data-percy-hover]'); + }); + + it('serializes iframe interactive states via the snake_case alias', () => { + let inner = srcdocOfFrame({ enable_pseudo_class_serialization: true }); + expect(inner).toContain('[data-percy-hover]'); + }); + + it('serializes iframe interactive states when pseudoClassEnabledElements is configured', () => { + let inner = srcdocOfFrame({ pseudoClassEnabledElements: { selectors: ['.ibtn'] } }); + expect(inner).toContain('[data-percy-hover]'); + }); +}); From 60de756fcdb083742d74b9f8bdacf09fba073907 Mon Sep 17 00:00:00 2001 From: Pranav Zinzurde Date: Thu, 27 Aug 2026 16:18:18 +0530 Subject: [PATCH 4/4] test(core): add enablePseudoClassSerialization to the snapshot defaults assertion (PER-10588) Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/test/percy.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 7da647883..bb62dc6f4 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -89,7 +89,8 @@ describe('Percy', () => { ignoreCanvasSerializationErrors: false, ignoreStyleSheetSerializationErrors: false, forceShadowAsLightDOM: false, - ignoreIframeSelectors: [] + ignoreIframeSelectors: [], + enablePseudoClassSerialization: false }); });