Repository navigation
fix(dom): make interactive-state serialization opt-in, default off (PER-10588) #2399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a3ae26d
2914b28
e6d5d30
60de756
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,14 @@ | |
|
|
||
| // Serializes pseudo-class state into Percy's clone via two paths: | ||
| // | ||
| // 1. Auto-detect path (every snapshot). For :focus / :focus-within we | ||
| // Both paths below are gated on ctx.pseudoClassSerialization, which is off | ||
| // unless the snapshot sets enablePseudoClassSerialization or configures | ||
| // pseudoClassEnabledElements. Open-popover stamping and custom element | ||
| // :state() rewriting are NOT gated — the renderer requires | ||
| // data-percy-popover-open, and :state() is rewritten in place so it cannot | ||
| // reorder the cascade. | ||
| // | ||
| // 1. Auto-detect path (when enabled). 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 | ||
|
|
@@ -79,6 +86,47 @@ function selectorContainsPseudo(selectorText, pseudoList) { | |
| }); | ||
| } | ||
|
|
||
| function splitSelectorList(selectorText) { | ||
| const parts = []; | ||
| let depth = 0; | ||
| let quote = null; | ||
| let start = 0; | ||
|
|
||
| for (let i = 0; i < selectorText.length; i++) { | ||
| const char = selectorText[i]; | ||
|
|
||
| if (quote) { | ||
| if (char === '\\') i++; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] Backslash escapes are only honored inside quotes This skip lives in the Suggestion: hoist the escape skip out of the quote branch so Reviewer: stack-code-reviewer |
||
| else if (char === quote) quote = null; | ||
| continue; | ||
| } | ||
|
|
||
| if (char === '"' || char === "'") quote = char; | ||
| else if (char === '(' || char === '[') depth++; | ||
| else if (char === ')' || char === ']') depth--; | ||
| else if (char === ',' && depth === 0) { | ||
| parts.push(selectorText.slice(start, i)); | ||
| start = i + 1; | ||
| } | ||
| } | ||
|
|
||
| parts.push(selectorText.slice(start)); | ||
| return parts; | ||
| } | ||
|
|
||
| function rewritePseudoSelectorList(selectorText, pseudoList) { | ||
| const pseudoParts = []; | ||
|
|
||
| for (const part of splitSelectorList(selectorText)) { | ||
| const trimmed = part.trim(); | ||
| if (trimmed && selectorContainsPseudo(trimmed, pseudoList)) { | ||
| pseudoParts.push(rewritePseudoSelector(trimmed)); | ||
| } | ||
| } | ||
|
|
||
| return pseudoParts.length ? pseudoParts.join(', ') : null; | ||
| } | ||
|
|
||
| export function rewritePseudoSelector(selectorText) { | ||
| let out = selectorText; | ||
| for (const [pseudo, re] of PSEUDO_RES) out = out.replace(re, PSEUDO_TO_ATTR[pseudo]); | ||
|
|
@@ -288,8 +336,9 @@ export function getElementsToProcess(ctx, config, markWithId = false) { | |
| // the data-attributes are copied through to the clone via cloneNode. | ||
| export function markPseudoClassElements(ctx, config) { | ||
| ctx._liveMutations = []; | ||
| markInteractiveStates(ctx); | ||
| markOpenPopovers(ctx); | ||
| if (!ctx.pseudoClassSerialization) return; | ||
| markInteractiveStates(ctx); | ||
| if (config) getElementsToProcess(ctx, config, true); | ||
| } | ||
|
|
||
|
|
@@ -426,9 +475,9 @@ function extractPseudoClassRules(ctx) { | |
| // interactive pseudo. Skips most rules on most stylesheets without | ||
| // touching the regex bank. | ||
| if (!rule.selectorText.includes(':')) continue; | ||
| if (!selectorContainsPseudo(rule.selectorText, ALL_INTERACTIVE_PSEUDO)) continue; | ||
|
|
||
| const rewrittenSelector = rewritePseudoSelector(rule.selectorText); | ||
| const rewrittenSelector = rewritePseudoSelectorList(rule.selectorText, ALL_INTERACTIVE_PSEUDO); | ||
| if (!rewrittenSelector) continue; | ||
|
|
||
| const cssText = `${rewrittenSelector} { ${rule.style.cssText} }`; | ||
| const wrapped = rule.wrapper ? `${rule.wrapper} { ${cssText} }` : cssText; | ||
|
|
@@ -490,10 +539,11 @@ function injectAtSheetPosition(scopeRoot, sheet, styleElement, fallbackTarget) { | |
| } | ||
|
|
||
| export function serializePseudoClasses(ctx) { | ||
| // 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). | ||
| if (!ctx.pseudoClassSerialization) return; | ||
|
|
||
| // Auto-detect path rewrites `:focus`/`:focus-within` rules whether or not | ||
| // a pseudoClassEnabledElements list was configured (and regardless of | ||
| // whether that list matched anything on this page). | ||
| extractPseudoClassRules(ctx); | ||
|
|
||
| if (!ctx.pseudoClassEnabledElements) return; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Low] An empty
pseudoClassEnabledElementsobject silently re-enables the page-wide pathAny object is truthy here, so
pseudoClassEnabledElements: {}— valid against the schema — turns the full auto-detect path back on, including the rule-copying behind the four regressions this PR is narrowing exposure to. Same for a config whose selector lists are all empty: the user's intent there is closer to "nothing configured" than "enable everything".Suggestion: treat a config with no non-empty member list as absent (check that at least one recognized key holds a non-empty array). A one-line test with
{}would pin the intended behavior either way.Reviewer: orchestrator (aggregation pass)