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 4 commits 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 4 commits 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:

aryanku-dev and others added 3 commits July 27, 2026 10:16
…aIP (PER-10077)

The @percy/core 100% coverage gate was failing on utils.js — the
canonicalHost() catch branch (return bare when new URL('http://[host]/')
throws for a colon-containing, non-IPv6 host) had no deterministic test,
so coverage of that line depended on a flaky spec and CI stayed red on
this PR even though all specs pass.

Add an isMetadataIP('gg::1') case: the colon routes it into the IPv6
normalization branch, the invalid literal makes new URL throw, and the
catch falls back to the bare host (not a metadata target -> null).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ascade position (PER-10077)

The interactive-states auto-detect copies every CSS rule matching :focus/
:focus-within/:hover/:active into a <style> and injected it at the END of
<head>. That moved the copy to the back of the cascade, so it won
equal-specificity !important ties it should have lost — the root cause of
PER-10077 (Angular Material :not(:disabled) rules recolored buttons).

The prior fix only stopped copying :checked/:disabled (which serialize
natively), sidestepping the customer's trigger but leaving the same
end-of-head hazard for :focus/:hover/:active on any page.

Complete fix: group rewritten rules PER SOURCE SHEET and insert each
sheet's <style> immediately AFTER that sheet's clone element instead of at
end-of-head, so the copy keeps the sheet's original source-order rank and
later stylesheets still win their ties — exactly as the live page renders.

- serialize-pseudo-classes.js: per-sheet grouping + injectAtSheetPosition()
  anchors the copy after the sheet's clone element (found via the ownerNode's
  data-percy-element-id); falls back to end-of-head when no anchor exists.
- prepare-dom.js: stamp <link rel=stylesheet> with data-percy-element-id so
  external sheets (the customer's real structure) can be anchored too.
- :checked/:disabled stay on native serialization (already complete).

Tests: per-sheet injection; cascade-position (unit + end-to-end) asserting
the copy sits after its sheet and before the later sheet; stamped-sheet
fallback; and link-stamping (stylesheet stamped, preload/no-rel not). Full
@percy/dom suite passes in Chrome; prepare-dom 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants