Skip to content

fix(dom): stop rewriting :checked/:disabled rules — cascade-order corruption recolors Angular Material buttons (PER-10077)#2342

Open
prklm10 wants to merge 1 commit into
masterfrom
fix/PER-10077-disabled-checked-cascade
Open

fix(dom): stop rewriting :checked/:disabled rules — cascade-order corruption recolors Angular Material buttons (PER-10077)#2342
prklm10 wants to merge 1 commit into
masterfrom
fix/PER-10077-disabled-checked-cascade

Conversation

@prklm10

@prklm10 prklm10 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10077 — AON customer, 70+ visual diffs.

Root cause

packages/dom/src/serialize-pseudo-classes.js (interactive-states auto-detect, shipped in the 1.31.8 → 1.31.14 window via PER-7292 / #2177):

  1. markInteractiveStates stamped every live :disabled/:checked element with data-percy-disabled/data-percy-checked.
  2. extractPseudoClassRules copied every CSS rule whose selector contains :disabled/:checked, rewrote the pseudo to the attribute selector, and appended the copy in a <style data-percy-interactive-states> at the end of <head>.

The copy flips cascade ties. In the customer's app:

  • sheet 1 (styles-*.css): .mat-mdc-raised-button:not(:disabled) { background-color: var(--mat-button-protected-container-color, white) !important } → resolves #36495a on mat-primary buttons
  • sheet 2 (rpcc-styles-*.css, later): .mat-mdc-raised-button.mat-primary { background-color: #1b98e0 !important } → blue

Both are !important with equal specificity (0,2,0), so later source order wins → blue (matches live Chrome and the 1.31.8 baseline). Percy copied rule 1 (contains :disabled) to the end of head — rule 2 has no pseudo-class so it was not copied — making the copy the last equal-specificity !important declaration → buttons rendered #36495a instead of #1b98e0.

Reproduced on snapshot 2792517378 / 2792516808 of build 51680903: loading the serialized DOM in headless Chrome and deleting only the injected <style data-percy-interactive-states> flips the Execute buttons from rgb(54,73,90) (broken head screenshot) back to rgb(27,152,224) (baseline screenshot).

Fix

Drop :checked and :disabled from the auto-detect stamp + rewrite entirely. Both states serialize natively, so the rewrite added no coverage:

  • disabled is a reflected content attribute — always present in the serialized HTML (including <fieldset disabled> ancestors), so :disabled matches in the renderer as-is.
  • checked/selected properties are synced to attributes on the clone by serialize-inputs.js, so :checked matches as-is.

:focus/:focus-within (genuinely not serializable) and opt-in :hover/:active are unchanged. Their rewritten copies only ever match explicitly stamped elements, but note the same end-of-head reordering hazard exists in principle for :not(:focus)-style rules — preserving cascade position for injected copies would be the complete fix and can be a follow-up.

Testing

  • yarn workspace @percy/dom test — 900 tests pass
  • New regression tests:
    • serialize-pseudo-classes.test.js: 'cascade safety for :checked / :disabled rules (PER-10077)' — two-sheet equal-specificity !important scenario asserts no :not(:disabled) copy is injected; rewritePseudoSelector expectations flipped to assert :checked/:disabled pass through unchanged
    • serialize-dom.test.js: end-to-end asserts no data-percy-checked/data-percy-disabled stamping or rule rewriting
  • Mechanics tests that used :checked as a vehicle (@media wrapper preservation, multi-sheet owner grouping, createElement/head fallbacks) switched to :hover, which still exercises the same paths

🤖 Generated with Claude Code

…ade order (PER-10077)

The interactive-states auto-detect path (PER-7292) copied every CSS rule
containing :checked/:disabled, rewrote the pseudo to a data-percy-*
attribute selector, and appended the copy at the end of <head>. The copy
flips cascade ties: any equal-specificity rule from a later stylesheet
that was NOT copied (no pseudo-class in its selector) loses to the
re-injected copy purely on source order. Angular Material apps hit this
hard — its pervasive .mat-mdc-raised-button:not(:disabled) rules were
re-injected last and overrode customer theme overrides, recoloring
buttons across 70+ snapshots.

Both states serialize natively: disabled is a reflected content
attribute, and serialize-inputs syncs checked/selected properties to
attributes on the clone — so :checked/:disabled match in the renderer
without any rewriting. Drop them from the auto-detect stamp + rewrite;
keep :focus/:focus-within (not serializable) and :hover/:active
(opt-in via pseudoClassEnabledElements).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@prklm10
prklm10 requested a review from a team as a code owner July 17, 2026 04:28
@prklm10

prklm10 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Minimal repro + end-to-end verification (also saved in percy-ops tickets/PER-10077/repro/):

3-file page mirroring the customer's cascade: sheet 1 has .mat-btn:not(:disabled) { background-color: var(--btn-container-color, white) !important }, sheet 2 (later) sets the token to #36495a and overrides .mat-btn.primary { background-color: #1b98e0 !important }. Equal specificity, both !important → later sheet wins → button is blue in any real browser. A disabled button is also on the page (required — the serializer only copies :disabled rules when a :disabled element exists).

Local A/B (serialize with a given @percy/dom bundle, reload serialized HTML with page JS disabled, measure computed style):

bundle live button serialized button
@percy/dom 1.32.4 (published) rgb(27,152,224) rgb(54,73,90) — bug
this PR's build rgb(27,152,224) rgb(27,152,224) — correct

Real Percy builds — same page, same CLI, only the dom bundle swapped:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant