Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions packages/core/test/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -924,10 +924,11 @@ describe('utils', () => {
expect(isMetadataIP(undefined)).toBeNull();
});

it('falls back to the raw value for a malformed IPv6 connected address', () => {
// A colon-bearing address that is not a valid IPv6 literal makes the
// URL-based canonicalization throw; canonicalHost catches and returns the
// input unchanged, and a non-metadata value is then allowed through.
it('allows a colon-containing address that is not a valid IPv6 literal', () => {
// canonicalHost wraps any colon-containing host in new URL('http://[..]/')
// to normalize IPv6. A malformed address (colon present but not valid
// IPv6) makes the URL parser throw; the catch falls back to the bare
// host, which is not a metadata target, so the connection is allowed.
expect(isMetadataIP('gg::1')).toBeNull();
});
});
Expand Down
10 changes: 9 additions & 1 deletion packages/dom/src/prepare-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@ export function markElement(domElement, disableShadowDOM, forceShadowAsLightDOM)
// Mark elements that are to be serialized later with a data attribute.
// Custom elements with ElementInternals or closed shadow roots also get
// stamped so the post-clone state-fallback can locate their clones.
let tagName = domElement.tagName?.toLowerCase();
// Stylesheet <link>s are stamped so the pseudo-class serializer can locate
// the sheet's clone element and anchor its rewritten interactive-state rules
// immediately after it — preserving the sheet's original cascade position
// instead of appending at the end of <head> (PER-10077).
let isStylesheetLink = tagName === 'link' &&
/(^|\s)stylesheet(\s|$)/i.test(domElement.getAttribute('rel') || '');
if (
['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style', 'dialog'].includes(domElement.tagName?.toLowerCase()) ||
['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style', 'dialog'].includes(tagName) ||
isStylesheetLink ||
isCustomElement(domElement)
) {
if (!domElement.getAttribute('data-percy-element-id')) {
Expand Down
135 changes: 64 additions & 71 deletions packages/dom/src/serialize-pseudo-classes.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,29 @@

// Serializes pseudo-class state into Percy's clone via two paths:
//
// 1. Auto-detect path (every snapshot). For :focus / :focus-within /
// :checked / :disabled we stamp the live DOM with the corresponding
// data-percy-* attribute and rewrite matching CSS rules to use those
// attribute selectors. :focus-within stamps the focused element's
// ancestor chain across shadow boundaries.
// 1. Auto-detect path (every snapshot). For :focus / :focus-within we
// stamp the live DOM with the corresponding data-percy-* attribute
// and rewrite matching CSS rules to use those attribute selectors.
// :focus-within stamps the focused element's ancestor chain across
// shadow boundaries.
//
// Each rewritten rule is injected into a <style> placed immediately
// AFTER its source stylesheet's clone element, so the copy keeps the
// sheet's original cascade rank. Appending every copy at the end of
// <head> (the pre-PER-10077 behavior) flipped cascade ties — same
// specificity/importance, later source order wins — against equal-
// specificity rules from later sheets that were not copied. Angular
// Material's pervasive `:not(:disabled)` rules recolored buttons
// page-wide that way; anchoring the copy at the sheet's position keeps
// later sheets winning their ties exactly as the live page does.
//
// :checked and :disabled are intentionally NOT handled here — those
// states serialize natively (`disabled` is a reflected content
// attribute the renderer recomputes, including `<fieldset disabled>`
// descendants, and serialize-inputs syncs checked/selected properties
// to attributes on the clone), so their pseudo-classes match in the
// renderer without any rewriting. Copying their rules would be pure
// redundancy, so they stay off the rewrite path entirely.
//
// 2. Configured-element path (`pseudoClassEnabledElements` config). User
// opts in elements by id/className/xpath/selector. We snapshot all
Expand All @@ -31,18 +49,14 @@ const PSEUDO_ELEMENT_MARKER_ATTR = 'data-percy-pseudo-element-id';
const POPOVER_OPEN_ATTR = 'data-percy-popover-open';
const FOCUS_ATTR = 'data-percy-focus';
const FOCUS_WITHIN_ATTR = 'data-percy-focus-within';
const CHECKED_ATTR = 'data-percy-checked';
const DISABLED_ATTR = 'data-percy-disabled';
const HOVER_ATTR = 'data-percy-hover';
const ACTIVE_ATTR = 'data-percy-active';

const ALL_INTERACTIVE_PSEUDO = [':focus', ':focus-within', ':checked', ':disabled', ':hover', ':active'];
const ALL_INTERACTIVE_PSEUDO = [':focus', ':focus-within', ':hover', ':active'];

const PSEUDO_TO_ATTR = {
':focus': '[data-percy-focus]',
':focus-within': '[data-percy-focus-within]',
':checked': '[data-percy-checked]',
':disabled': '[data-percy-disabled]',
':hover': '[data-percy-hover]',
':active': '[data-percy-active]'
};
Expand All @@ -53,8 +67,6 @@ const PSEUDO_TO_ATTR = {
const PSEUDO_RES = [
[':focus-within', /:focus-within(?![-\w])/g],
[':focus', /:focus(?![-\w])/g],
[':checked', /:checked(?![-\w])/g],
[':disabled', /:disabled(?![-\w])/g],
[':hover', /:hover(?![-\w])/g],
[':active', /:active(?![-\w])/g]
];
Expand Down Expand Up @@ -120,30 +132,6 @@ function markInteractiveStates(ctx) {
stampOnce(ctx, focused, FOCUS_ATTR, 'true');
markFocusWithinAncestors(ctx, focused);
}

// Single walk of ctx.dom + shadow roots collecting BOTH :checked and
// :disabled in one pass. Previously two separate queryShadowAll calls
// each walked the tree and re-traversed every [data-percy-shadow-host]
// — on pages with many shadow hosts this doubled the per-snapshot
// walk cost. Also tracks which states were observed so the CSS rule
// extractor can skip work for selectors that have no matched elements.
ctx._stampedInteractive = ctx._stampedInteractive || new Set();
// walkShadowDOM only invokes the visitor with Document/Element/ShadowRoot
// scopes — each has querySelectorAll, so no defensive guard is needed here.
walkShadowDOM(ctx.dom, scope => {
try {
for (const el of scope.querySelectorAll(':checked')) {
stampOnce(ctx, el, CHECKED_ATTR, 'true');
ctx._stampedInteractive.add('checked');
}
} catch (e) { /* selector unsupported in this scope */ }
try {
for (const el of scope.querySelectorAll(':disabled')) {
stampOnce(ctx, el, DISABLED_ATTR, 'true');
ctx._stampedInteractive.add('disabled');
}
} catch (e) { /* selector unsupported in this scope */ }
});
}

// Walk the LIVE document and every shadow root (open, or closed via the CDP
Expand All @@ -161,8 +149,8 @@ function eachScopeIncludingShadow(root, visit) {
}

// Auto-detect open native popovers page-wide, INCLUDING inside shadow roots.
// `:popover-open` is an unambiguous serialize-time state (like :checked /
// :disabled), so it is stamped automatically rather than only on
// `:popover-open` is an unambiguous serialize-time state, so it is
// stamped automatically rather than only on
// pseudoClassEnabledElements. The renderer's popover-element-helper already
// re-opens any [popover][data-percy-popover-open] across shadow boundaries;
// without this stamp a popover open at snapshot time renders hidden via the
Expand Down Expand Up @@ -212,8 +200,8 @@ function stampPseudoElementId(ctx, element) {
}

// Configured elements get :hover/:active stamped unconditionally — opting in
// IS the request to capture those forced states. :focus/:checked/:disabled
// are already covered by the page-wide markInteractiveStates pass.
// IS the request to capture those forced states. :focus is already
// covered by the page-wide markInteractiveStates pass.
function markElementInteractiveStates(ctx, element) {
stampOnce(ctx, element, HOVER_ATTR, 'true');
stampOnce(ctx, element, ACTIVE_ATTR, 'true');
Expand Down Expand Up @@ -417,25 +405,11 @@ function collectStyleSheets(doc) {

function extractPseudoClassRules(ctx) {
const sheetEntries = collectStyleSheets(ctx.dom);
const rulesByOwner = new Map();

// Short-circuit per pseudo: when markInteractiveStates ran first (the
// production path via markPseudoClassElements), `ctx._stampedInteractive`
// records which interactive states were actually observed. Rules for
// `:checked` / `:disabled` selectors that found no live elements can't
// match anything in the clone after rewriting, so we drop them from the
// filter and avoid the rewrite cost. When the marker is absent (unit
// tests that call serializePseudoClasses directly), fall back to the
// full pseudo list to preserve prior behavior. `:focus`, `:focus-within`,
// `:hover`, `:active` are kept regardless either way.
const activePseudos = ctx._stampedInteractive
? ALL_INTERACTIVE_PSEUDO.filter(p => {
if (p === ':checked') return ctx._stampedInteractive.has('checked');
if (p === ':disabled') return ctx._stampedInteractive.has('disabled');
return true;
})
: ALL_INTERACTIVE_PSEUDO;

// Collect rewritten rules PER SOURCE SHEET (not merged per owner scope) so
// each sheet's copies can be anchored back at that sheet's cascade position.
// Discovery order is document order, which is also the order we inject in.
const perSheet = [];
for (const { sheet, owner } of sheetEntries) {
let rules;
try {
Expand All @@ -446,26 +420,29 @@ function extractPseudoClassRules(ctx) {
}
if (!rules) continue;

let rewrittenRules = null;
for (const rule of walkCSSRules(rules)) {
// Cheapest possible filter: a selector with no `:` can't contain any
// interactive pseudo. Skips most rules on most stylesheets without
// touching the regex bank.
if (!rule.selectorText.includes(':')) continue;
if (!selectorContainsPseudo(rule.selectorText, activePseudos)) continue;
if (!selectorContainsPseudo(rule.selectorText, ALL_INTERACTIVE_PSEUDO)) continue;

const rewrittenSelector = rewritePseudoSelector(rule.selectorText);

const cssText = `${rewrittenSelector} { ${rule.style.cssText} }`;
const wrapped = rule.wrapper ? `${rule.wrapper} { ${cssText} }` : cssText;
if (!rulesByOwner.has(owner)) rulesByOwner.set(owner, []);
rulesByOwner.get(owner).push(wrapped);
(rewrittenRules ||= []).push(wrapped);
}
if (rewrittenRules) perSheet.push({ sheet, owner, rewrittenRules });
}

// Build a percyId → cloneEl index once for shadow-host injection — only
// when there is at least one non-null owner in the collected rules.
if (!perSheet.length) return;

// Build a percyId → cloneEl index once for shadow-host injection — only when
// at least one collected sheet lives inside a shadow root.
let cloneByPercyId = null;
for (const owner of rulesByOwner.keys()) {
for (const { owner } of perSheet) {
if (owner !== null) {
cloneByPercyId = new Map();
for (const el of ctx.clone.querySelectorAll('[data-percy-element-id]')) {
Expand All @@ -475,7 +452,7 @@ function extractPseudoClassRules(ctx) {
}
}

for (const [owner, rewrittenRules] of rulesByOwner) {
for (const { sheet, owner, rewrittenRules } of perSheet) {
const styleElement = ctx.clone.createElement
? ctx.clone.createElement('style')
: ctx.dom.createElement('style');
Expand All @@ -484,20 +461,36 @@ function extractPseudoClassRules(ctx) {
styleElement.textContent = rewrittenRules.join('\n');

if (owner === null) {
const head = ctx.clone.head || ctx.clone.querySelector('head');
if (head) head.appendChild(styleElement);
injectAtSheetPosition(ctx.clone, sheet, styleElement, ctx.clone.head || ctx.clone.querySelector('head'));
} else {
const percyId = owner.getAttribute('data-percy-element-id');
const cloneHost = cloneByPercyId.get(percyId);
const cloneHost = cloneByPercyId.get(owner.getAttribute('data-percy-element-id'));
if (cloneHost && cloneHost.shadowRoot) {
cloneHost.shadowRoot.appendChild(styleElement);
injectAtSheetPosition(cloneHost.shadowRoot, sheet, styleElement, cloneHost.shadowRoot);
}
}
}
}

// Insert an interactive-states <style> immediately AFTER its source sheet's
// clone element so the copy keeps the sheet's original source-order rank —
// later stylesheets still win equal-specificity ties, exactly as on the live
// page (PER-10077). The sheet's ownerNode (<style> / <link rel=stylesheet>) is
// stamped with a data-percy-element-id, which serialize-cssom preserves on any
// node it rebuilds in place. Falls back to appending at the scope's end when
// the sheet has no locatable clone anchor.
function injectAtSheetPosition(scopeRoot, sheet, styleElement, fallbackTarget) {
const anchorId = sheet.ownerNode.getAttribute('data-percy-element-id');
const anchor = anchorId ? scopeRoot.querySelector(`[data-percy-element-id="${anchorId}"]`) : null;
const anchorParent = anchor ? anchor.parentNode : null;
if (anchorParent) {
anchorParent.insertBefore(styleElement, anchor.nextSibling);
} else if (fallbackTarget) {
fallbackTarget.appendChild(styleElement);
}
}

export function serializePseudoClasses(ctx) {
// Auto-detect path runs unconditionally so `:focus`/`:checked`/`:disabled`
// Auto-detect path runs unconditionally so `:focus`/`:focus-within`
// rules are rewritten regardless of whether the user configured a
// pseudoClassEnabledElements list (and regardless of whether that list
// matched anything on this page).
Expand Down
60 changes: 50 additions & 10 deletions packages/dom/test/serialize-dom.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -748,43 +748,83 @@ describe('serializeDOM', () => {
});

describe('interactive state CSS capture', () => {
it('marks checked inputs with data-percy-checked', () => {
// :checked and :disabled serialize natively — `disabled` is a reflected
// content attribute and serialize-inputs syncs checked/selected to
// attributes on the clone. They are neither stamped nor rewritten;
// copying their rules to the end of <head> flipped !important cascade
// ties against equal-specificity rules from later stylesheets (PER-10077).
it('does not mark checked inputs with data-percy-checked', () => {
withExample('<input type="checkbox" id="cb" checked>', { withShadow: false });
let result = serializeDOM();
expect(result.html).toContain('data-percy-checked="true"');
expect(result.html).not.toContain('data-percy-checked');
});

it('marks disabled inputs with data-percy-disabled', () => {
it('does not mark disabled inputs with data-percy-disabled', () => {
withExample('<input type="text" id="dis" disabled>', { withShadow: false });
let result = serializeDOM();
expect(result.html).toContain('data-percy-disabled="true"');
expect(result.html).not.toContain('data-percy-disabled');
});

it('extracts :checked CSS rules and injects as attribute selectors', () => {
it('does not copy or rewrite :checked CSS rules', () => {
withExample('<label><input type="checkbox" checked><span>text</span></label>', { withShadow: false });
// Add a CSS rule with :checked
let style = document.createElement('style');
style.textContent = 'input:checked + span { color: green; }';
document.head.appendChild(style);

let result = serializeDOM();
expect(result.html).toContain('data-percy-interactive-states');
expect(result.html).toContain('[data-percy-checked]');
expect(result.html).not.toContain('[data-percy-checked]');

style.remove();
});

it('extracts :disabled CSS rules', () => {
it('does not copy or rewrite :disabled CSS rules (PER-10077)', () => {
withExample('<input type="text" disabled>', { withShadow: false });
let style = document.createElement('style');
style.textContent = 'input:disabled { opacity: 0.5; }';
document.head.appendChild(style);

let result = serializeDOM();
expect(result.html).toContain('[data-percy-disabled]');
expect(result.html).not.toContain('[data-percy-disabled]');

style.remove();
});

it('injects a rewritten :hover copy after its source sheet, not at end of head (PER-10077)', () => {
// The copy must keep its source sheet's cascade rank: it sits AFTER the
// sheet it came from but BEFORE any later sheet, so a later equal-
// specificity rule still wins the tie exactly as in the live browser.
withExample(
'<style>.cbtn:hover { color: red }</style>' +
'<style>.cbtn.later { color: blue }</style>' +
'<button class="cbtn later">go</button>',
{ withShadow: false }
);
let html = serializeDOM().html;
let sourceIdx = html.indexOf('.cbtn:hover');
let copyIdx = html.indexOf('[data-percy-hover]');
let laterIdx = html.indexOf('.cbtn.later');
expect(copyIdx).toBeGreaterThan(-1);
expect(copyIdx).toBeGreaterThan(sourceIdx); // after its own sheet
expect(copyIdx).toBeLessThan(laterIdx); // before the later sheet
});
});

describe('stylesheet <link> stamping (PER-10077 cascade anchoring)', () => {
it('stamps only stylesheet <link>s with a percy element id', () => {
// The pseudo-class serializer anchors its rewritten rules after a sheet's
// clone element, found via data-percy-element-id — so stylesheet links
// need one. Non-stylesheet links (preload, icon, …) do not.
withExample(
'<link rel="stylesheet" href="data:text/css,.lx{color:red}">' +
'<link rel="preload" as="style" href="data:text/css,.ly{color:red}">' +
'<link href="data:text/css,.lz{color:red}">',
{ withShadow: false }
);
let $ = parseDOM(serializeDOM().html, 'plain');
expect($('link[rel="stylesheet"]')[0].getAttribute('data-percy-element-id')).toBeTruthy();
expect($('link[rel="preload"]')[0].getAttribute('data-percy-element-id')).toBeNull();
expect($('link:not([rel])')[0].getAttribute('data-percy-element-id')).toBeNull();
});
});

describe(':state() CSS rewriting', () => {
Expand Down
Loading
Loading