fix(dom): make interactive-state serialization opt-in, default off (PER-10588) - #2399
Conversation
…-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 <style> shrank 284KB -> 199KB on this page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…PER-10588) 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 <style> text in place, so it cannot reorder the cascade. The renderer consumes only shadow-host, injected, popover-open, dialog-modal, scrolltop/left and freeze-animation-logs attributes — none of the gated focus/hover/active stamps — so nothing downstream regresses. Verified end to end on the PER-10588 customer's real page and CSS: master 714,774 bytes, injected block, headings rgb(0,75,111) gated (default) 429,635 bytes, no block, headings white gated + flag 629,882 bytes, injected block, headings white @percy/dom is regression-clean against baseline (466 pass vs 459, same 40 pre-existing focus failures on a headless browser without window focus), plus 7 new specs covering default-off, both opt-in routes, the snake_case alias, and the two carve-outs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…PER-10588) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
00f0b37 to
e6d5d30
Compare
…ts assertion (PER-10588) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pranavz28
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 2 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| const char = selectorText[i]; | ||
|
|
||
| if (quote) { | ||
| if (char === '\\') i++; |
There was a problem hiding this comment.
[Medium] Backslash escapes are only honored inside quotes
This skip lives in the if (quote) branch, so a comma escaped outside a string is still treated as a list separator. \, is valid inside a CSS identifier, so a rule like .foo\,bar:hover, .baz splits mid-identifier and can emit a mangled selector into the injected <style>. Reachability is low — CSSOM usually re-escapes such identifiers when serializing selectorText — but the omission currently reads as an oversight rather than a decision.
Suggestion: hoist the escape skip out of the quote branch so \ consumes the next character in any state, and add a test with .foo\,bar:hover, .baz. If the case is genuinely unreachable via CSSOM, say so in a comment instead.
Reviewer: stack-code-reviewer
| enablePseudoClassSerialization = options?.enable_pseudo_class_serialization | ||
| } = options || {}; | ||
|
|
||
| const pseudoClassSerialization = !!(enablePseudoClassSerialization || pseudoClassEnabledElements); |
There was a problem hiding this comment.
[Low] An empty pseudoClassEnabledElements object silently re-enables the page-wide path
Any 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)
Claude Code PR ReviewPR: #2399 • Head: 60de756 • Reviewers: stack-code-reviewer SummaryMakes Review Table
Findings
Raised by other reviewers (not independently confirmed)None — no human comments or reviews on this PR at review time. Verification notes
Verdict: PASS — the gate is wired correctly and thoroughly tested; the four findings are non-blocking (two Medium polish items, two Low). |
Fixes PER-10588
Three commits, separable if you'd rather land them apart:
Why turn it off by default
The interactive-state auto-detect path has shipped four regressions in three months, all the same shape: it copies customer CSS, rewrites a pseudo-class to a
data-percy-*attribute, and re-injects the copy — reordering the cascade for rules nobody asked it to touch.&pseudoClassEnabledElementsfroze layout / transitions:checked/:disabledcopies recolored Angular Material buttons across 70+ snapshots:hovermember of an 82-selector rule repainted every colored-card heading on an AEM siteAdoption does not justify that risk. On the PER-10588 customer's captured DOM:
data-percy-hoverselectorsdata-percy-*state attributes actually stamped on elements: 0Not one rule it emitted could match anything. The feature contributed zero correct behavior and all of the breakage on that page.
What the gate does
New snapshot option
enablePseudoClassSerialization(boolean, defaultfalse;enable_pseudo_class_serializationaccepted for the SDK path). The predicate is computed once inserializeDOMand carried asctx.pseudoClassSerialization— one auditable switch rather than a check per call site:Configuring
pseudoClassEnabledElementsis itself an opt-in, so existing opt-in users are unaffected without touching their config.||(not??) is deliberate:PercyConfig.getDefaults()materializessnapshot.enablePseudoClassSerializationtofalse, so??would short-circuit on that materializedfalseand silently break users who opt in only viapseudoClassEnabledElements. Verified against the real schema —pseudoClassEnabledElementscarries nodefault, so it is never materialized — and covered by a test and a build using that exact production shape.No pseudo-class logic is removed — both paths are intact behind the flag.
What is deliberately NOT gated
Both verified against
percy-renderer:src/script/popover-element-helper.jsreopens[popover][data-percy-popover-open]; its own comment reads "CLI stamps data-percy-popover-open on the popover elements." Without the stamp an open popover renders hidden behind the UA[popover]:not(:popover-open){display:none}rule.:state()rewriting.serialize-custom-statesrewrites<style>text in place, so it cannot reorder the cascade — a different mechanism from the one causing these regressions.The renderer consumes only
shadow-host,injected,popover-open,dialog-modal,scrolltop/scrollleftandsend-freeze-animation-logs. It reads none of the gated focus/hover/active/pseudo-element-id stamps, so nothing downstream regresses.Commit 1 — the grouped-selector fix
Still needed: it makes the path correct for people who do opt in.
extractPseudoClassRules()copied a rule's entire selector list when any one member carried a pseudo-class. Inclientlib-site.csson the reported site:.dbk-basic-box … [class*="__title"]color:#004b6f… .dbk-readmore__trigger:hover.dbk-basic-box--blue … [class*="__title"]color:#fffBoth specificity
(0,6,0); live, B wins on source order. That one:hovermember dragged all 82 of A's selectors into the injected<style>→ A after B → headings dark-on-dark.PER-10077's per-sheet anchoring doesn't help — A and B share a sheet. Fix: split the selector list on top-level commas only (paren/bracket/quote aware) and copy only pseudo-bearing members.
Commit 3 — iframe propagation
serializeFramesrecurses viaserializeDOM({...})but forwarded neither pseudo-class option. Pre-PR that was invisible because the auto-detect path ran unconditionally, so iframe content was serialized. With the gate added in commit 2 and nothing forwarded, iframes got zero interactive-state serialization in every configuration — including an explicitenablePseudoClassSerialization: true.Measured on a host page with a
srcdociframe carrying a:hoverrule (injected bytes inside the iframe's serializedsrcdoc):pseudoClassEnabledElementsmasterThe fix forwards the already-computed parent gate, so the child inherits the decision rather than re-deriving it from raw options:
ctxcarries the computedpseudoClassSerialization, not the rawenablePseudoClassSerialization— forwarding the raw name readsundefinedand silently keeps iframes dark. Covered by 4 specs.Verification — scenario matrix
Ran the real
PercyDOM.serialize()from built bundles in headless Chrome over six purpose-built pages × five config variants ×mastervs this branch (60 combinations), then re-served each serialized document so relative stylesheet hrefs resolve exactly as the renderer sees them, and readgetComputedStyle().1. Grouped-selector cascade — the PER-10588 shape
82 grouped selectors declaring
color:#004b6f, exactly one carrying:hover; a later equal-specificity rule in the same sheet declaringcolor:#fff. Live page renders the headings white.master(all variants)rgb(0, 75, 111)❌rgb(0, 75, 111)✅rgb(255, 255, 255)✅rgb(0, 75, 111)✅rgb(255, 255, 255)✅rgb(255, 255, 255)✅pseudoClassEnabledElementsrgb(255, 255, 255)✅false+ elementsrgb(255, 255, 255)✅3,838 B → 147 B when opted in: only the one pseudo-bearing member is copied instead of all 82. Both the default and the opt-in path now match the live page.
2. Selector-list splitting integrity
Byte-for-byte output of the injected block,
mastervs branch:Same 5 hover selectors emitted either way; the only difference is the dropped non-pseudo
.other. Nothing is shredded.3. Gate routes — opt-in output is unchanged from today
Injected bytes on a page with real
:hover/:active/:focus/:focus-withinrules:masterenablePseudoClassSerialization: trueenable_pseudo_class_serialization: truepseudoClassEnabledElementsonlyfalse+pseudoClassEnabledElementsEvery opt-in route produces byte-identical output to
master. The last row is the||-vs-??case.4. Carve-outs hold with the feature off
masterdata-percy-popover-openstampsdisplayblockblockblock:state():state()rgb(31, 122, 77)rgb(31, 122, 77)rgb(31, 122, 77)5. Live DOM is left clean
data-percy-*stamps remaining on the live document after serialization: 0 in all 60 combinations. A dedicated probe also confirms 0 leaked<base>elements, 0 leftoverstyle[data-percy-interactive-states], and unchanged relative-URL resolution (new URL('assets/x.webp', document.baseURI)identical before and after) on both bundles, both variants.Verification — Percy builds
Project
test-pranav. Build #520 is the reference: the same six pages captured with themasterbundle, i.e. today's shipped behavior. #521–#523 run this branch against it, so every diff is exactly what this PR changes.masterbundle)grouped-cascade,interactive-statesenablePseudoClassSerialization: truegrouped-cascadeonlypseudoClassEnabledElementsgrouped-cascade,interactive-statesReading the results:
grouped-cascadeis the PER-10588 correction.interactive-statesis the intended consequence of turning the feature off — the forced:focusstyling is no longer injected.popover,custom-state,selector-integrityandiframe-hostare unchanged, which is the carve-out and no-collateral-damage evidence.grouped-cascade— the commit-1 correction.interactive-statesmatching the reference is the compatibility result that matters: an opt-in customer's interactive-state rendering is untouched by this PR.interactive-statesbecause it enables the configured-elements path, which the reference build did not have configured — expected, not a regression.Also validated directly against the config schema: accepts
true/false, materializes tofalse, and rejects a non-boolean withsnapshot.enablePseudoClassSerialization: must be a boolean, received a string.Testing
@percy/dom, Chrome, same command on both sides, run in a clean worktree rebased on currentmaster(3886c115):masterbaselineIdentical pre-existing failure set on both sides — focus-dependent specs that need a focused browser window; they fail the same way on unmodified
masterin this environment and pass in CI. No new failures.15 new specs:
:is(.a, .b)/[data-k="x,y"]; all-pseudo lists kept wholepseudoClassEnabledElementsalone; on for configured elements even when the flag is materializedfalse; popovers still stamped while disabled;:state()still rewritten while disabled; nodata-percy-*left on the live DOM either waypseudoClassEnabledElementsThe iframe specs build their iframe in their own container and remove it in
afterEachrather than going throughwithExample, which would create a second same-id iframe inside the shadow-DOM copy and perturb the shared karma document — that leaked a<base>into live iframe documents and broke the unrelatedloadAllSrcsetLinksspecs downstream.Firefox is not installed on the dev machine, so only the Chrome leg ran locally — CI covers the rest.
@percy/corewas not runnable locally. Its suite binds port 8000, which is held on this machine by a pre-existing listener (an ssh tunnel plus an unrelated node process). Unmodifiedmasterfails the same way — 342EADDRINUSEerrors onmasteragainst 964 on the branch, both runs swamped rather than comparable — so this is environmental, not a signal about the change. The core-side edits are a schema addition plus threading one option throughpage.jsand adebugPropline; they are covered by the config-schema validation above (acceptstrue/false, materializes tofalse, rejects non-boolean with the right message), by the four Percy builds — which exercise the wholeconfig -> snapshot options -> serializeDOMpath end to end through the real CLI — and by CI.Rollout
Snapshots currently distorted by the injected copies — not just this customer's — will diff once as they return to their true colors. That diff is the correction and should be approved to re-baseline. Worth a heads-up to CE ahead of the release, since it will land across multiple accounts at once.
Evidence — original report
4793907995, diff-ratio0.00227GET /api/v1/snapshots/2863001336/assets/head.html— 698,096 bytesGET /api/v1/snapshots/2784826354/assets/head.html— 411,417 bytes🤖 Generated with Claude Code