@percy/dom 1.32.6-beta.2 → 1.32.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/bundle.js +59 -64
  2. package/package.json +3 -3
package/dist/bundle.js CHANGED
@@ -359,7 +359,13 @@
359
359
  // Mark elements that are to be serialized later with a data attribute.
360
360
  // Custom elements with ElementInternals or closed shadow roots also get
361
361
  // stamped so the post-clone state-fallback can locate their clones.
362
- if (['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style', 'dialog'].includes((_domElement$tagName = domElement.tagName) === null || _domElement$tagName === void 0 ? void 0 : _domElement$tagName.toLowerCase()) || isCustomElement(domElement)) {
362
+ let tagName = (_domElement$tagName = domElement.tagName) === null || _domElement$tagName === void 0 ? void 0 : _domElement$tagName.toLowerCase();
363
+ // Stylesheet <link>s are stamped so the pseudo-class serializer can locate
364
+ // the sheet's clone element and anchor its rewritten interactive-state rules
365
+ // immediately after it — preserving the sheet's original cascade position
366
+ // instead of appending at the end of <head> (PER-10077).
367
+ let isStylesheetLink = tagName === 'link' && /(^|\s)stylesheet(\s|$)/i.test(domElement.getAttribute('rel') || '');
368
+ if (['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style', 'dialog'].includes(tagName) || isStylesheetLink || isCustomElement(domElement)) {
363
369
  if (!domElement.getAttribute('data-percy-element-id')) {
364
370
  domElement.setAttribute('data-percy-element-id', uid());
365
371
  }
@@ -752,16 +758,12 @@
752
758
  const POPOVER_OPEN_ATTR = 'data-percy-popover-open';
753
759
  const FOCUS_ATTR = 'data-percy-focus';
754
760
  const FOCUS_WITHIN_ATTR = 'data-percy-focus-within';
755
- const CHECKED_ATTR = 'data-percy-checked';
756
- const DISABLED_ATTR = 'data-percy-disabled';
757
761
  const HOVER_ATTR = 'data-percy-hover';
758
762
  const ACTIVE_ATTR = 'data-percy-active';
759
- const ALL_INTERACTIVE_PSEUDO = [':focus', ':focus-within', ':checked', ':disabled', ':hover', ':active'];
763
+ const ALL_INTERACTIVE_PSEUDO = [':focus', ':focus-within', ':hover', ':active'];
760
764
  const PSEUDO_TO_ATTR = {
761
765
  ':focus': '[data-percy-focus]',
762
766
  ':focus-within': '[data-percy-focus-within]',
763
- ':checked': '[data-percy-checked]',
764
- ':disabled': '[data-percy-disabled]',
765
767
  ':hover': '[data-percy-hover]',
766
768
  ':active': '[data-percy-active]'
767
769
  };
@@ -769,7 +771,7 @@
769
771
  // Boundary regex per pseudo-class. Lookahead `(?![-\w])` prevents :focus
770
772
  // from matching the start of :focus-within / :focus-visible. Order matters:
771
773
  // longer pseudos (:focus-within) are listed first so they win over :focus.
772
- 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]];
774
+ const PSEUDO_RES = [[':focus-within', /:focus-within(?![-\w])/g], [':focus', /:focus(?![-\w])/g], [':hover', /:hover(?![-\w])/g], [':active', /:active(?![-\w])/g]];
773
775
  function selectorContainsPseudo(selectorText, pseudoList) {
774
776
  return pseudoList.some(pc => {
775
777
  const re = PSEUDO_RES.find(([p]) => p === pc)[1];
@@ -830,30 +832,6 @@
830
832
  stampOnce(ctx, focused, FOCUS_ATTR, 'true');
831
833
  markFocusWithinAncestors(ctx, focused);
832
834
  }
833
-
834
- // Single walk of ctx.dom + shadow roots collecting BOTH :checked and
835
- // :disabled in one pass. Previously two separate queryShadowAll calls
836
- // each walked the tree and re-traversed every [data-percy-shadow-host]
837
- // — on pages with many shadow hosts this doubled the per-snapshot
838
- // walk cost. Also tracks which states were observed so the CSS rule
839
- // extractor can skip work for selectors that have no matched elements.
840
- ctx._stampedInteractive = ctx._stampedInteractive || new Set();
841
- // walkShadowDOM only invokes the visitor with Document/Element/ShadowRoot
842
- // scopes — each has querySelectorAll, so no defensive guard is needed here.
843
- walkShadowDOM(ctx.dom, scope => {
844
- try {
845
- for (const el of scope.querySelectorAll(':checked')) {
846
- stampOnce(ctx, el, CHECKED_ATTR, 'true');
847
- ctx._stampedInteractive.add('checked');
848
- }
849
- } catch (e) {/* selector unsupported in this scope */}
850
- try {
851
- for (const el of scope.querySelectorAll(':disabled')) {
852
- stampOnce(ctx, el, DISABLED_ATTR, 'true');
853
- ctx._stampedInteractive.add('disabled');
854
- }
855
- } catch (e) {/* selector unsupported in this scope */}
856
- });
857
835
  }
858
836
 
859
837
  // Walk the LIVE document and every shadow root (open, or closed via the CDP
@@ -871,8 +849,8 @@
871
849
  }
872
850
 
873
851
  // Auto-detect open native popovers page-wide, INCLUDING inside shadow roots.
874
- // `:popover-open` is an unambiguous serialize-time state (like :checked /
875
- // :disabled), so it is stamped automatically rather than only on
852
+ // `:popover-open` is an unambiguous serialize-time state, so it is
853
+ // stamped automatically rather than only on
876
854
  // pseudoClassEnabledElements. The renderer's popover-element-helper already
877
855
  // re-opens any [popover][data-percy-popover-open] across shadow boundaries;
878
856
  // without this stamp a popover open at snapshot time renders hidden via the
@@ -919,8 +897,8 @@
919
897
  }
920
898
 
921
899
  // Configured elements get :hover/:active stamped unconditionally — opting in
922
- // IS the request to capture those forced states. :focus/:checked/:disabled
923
- // are already covered by the page-wide markInteractiveStates pass.
900
+ // IS the request to capture those forced states. :focus is already
901
+ // covered by the page-wide markInteractiveStates pass.
924
902
  function markElementInteractiveStates(ctx, element) {
925
903
  stampOnce(ctx, element, HOVER_ATTR, 'true');
926
904
  stampOnce(ctx, element, ACTIVE_ATTR, 'true');
@@ -1118,22 +1096,11 @@
1118
1096
  }
1119
1097
  function extractPseudoClassRules(ctx) {
1120
1098
  const sheetEntries = collectStyleSheets(ctx.dom);
1121
- const rulesByOwner = new Map();
1122
-
1123
- // Short-circuit per pseudo: when markInteractiveStates ran first (the
1124
- // production path via markPseudoClassElements), `ctx._stampedInteractive`
1125
- // records which interactive states were actually observed. Rules for
1126
- // `:checked` / `:disabled` selectors that found no live elements can't
1127
- // match anything in the clone after rewriting, so we drop them from the
1128
- // filter and avoid the rewrite cost. When the marker is absent (unit
1129
- // tests that call serializePseudoClasses directly), fall back to the
1130
- // full pseudo list to preserve prior behavior. `:focus`, `:focus-within`,
1131
- // `:hover`, `:active` are kept regardless either way.
1132
- const activePseudos = ctx._stampedInteractive ? ALL_INTERACTIVE_PSEUDO.filter(p => {
1133
- if (p === ':checked') return ctx._stampedInteractive.has('checked');
1134
- if (p === ':disabled') return ctx._stampedInteractive.has('disabled');
1135
- return true;
1136
- }) : ALL_INTERACTIVE_PSEUDO;
1099
+
1100
+ // Collect rewritten rules PER SOURCE SHEET (not merged per owner scope) so
1101
+ // each sheet's copies can be anchored back at that sheet's cascade position.
1102
+ // Discovery order is document order, which is also the order we inject in.
1103
+ const perSheet = [];
1137
1104
  for (const {
1138
1105
  sheet,
1139
1106
  owner
@@ -1146,24 +1113,32 @@
1146
1113
  continue;
1147
1114
  }
1148
1115
  if (!rules) continue;
1116
+ let rewrittenRules = null;
1149
1117
  for (const rule of walkCSSRules(rules)) {
1150
1118
  // Cheapest possible filter: a selector with no `:` can't contain any
1151
1119
  // interactive pseudo. Skips most rules on most stylesheets without
1152
1120
  // touching the regex bank.
1153
1121
  if (!rule.selectorText.includes(':')) continue;
1154
- if (!selectorContainsPseudo(rule.selectorText, activePseudos)) continue;
1122
+ if (!selectorContainsPseudo(rule.selectorText, ALL_INTERACTIVE_PSEUDO)) continue;
1155
1123
  const rewrittenSelector = rewritePseudoSelector(rule.selectorText);
1156
1124
  const cssText = `${rewrittenSelector} { ${rule.style.cssText} }`;
1157
1125
  const wrapped = rule.wrapper ? `${rule.wrapper} { ${cssText} }` : cssText;
1158
- if (!rulesByOwner.has(owner)) rulesByOwner.set(owner, []);
1159
- rulesByOwner.get(owner).push(wrapped);
1126
+ (rewrittenRules || (rewrittenRules = [])).push(wrapped);
1160
1127
  }
1128
+ if (rewrittenRules) perSheet.push({
1129
+ sheet,
1130
+ owner,
1131
+ rewrittenRules
1132
+ });
1161
1133
  }
1134
+ if (!perSheet.length) return;
1162
1135
 
1163
- // Build a percyId → cloneEl index once for shadow-host injection — only
1164
- // when there is at least one non-null owner in the collected rules.
1136
+ // Build a percyId → cloneEl index once for shadow-host injection — only when
1137
+ // at least one collected sheet lives inside a shadow root.
1165
1138
  let cloneByPercyId = null;
1166
- for (const owner of rulesByOwner.keys()) {
1139
+ for (const {
1140
+ owner
1141
+ } of perSheet) {
1167
1142
  if (owner !== null) {
1168
1143
  cloneByPercyId = new Map();
1169
1144
  for (const el of ctx.clone.querySelectorAll('[data-percy-element-id]')) {
@@ -1172,25 +1147,45 @@
1172
1147
  break;
1173
1148
  }
1174
1149
  }
1175
- for (const [owner, rewrittenRules] of rulesByOwner) {
1150
+ for (const {
1151
+ sheet,
1152
+ owner,
1153
+ rewrittenRules
1154
+ } of perSheet) {
1176
1155
  const styleElement = ctx.clone.createElement ? ctx.clone.createElement('style') : ctx.dom.createElement('style');
1177
1156
  styleElement.setAttribute('data-percy-interactive-states', 'true');
1178
1157
  // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method
1179
1158
  styleElement.textContent = rewrittenRules.join('\n');
1180
1159
  if (owner === null) {
1181
- const head = ctx.clone.head || ctx.clone.querySelector('head');
1182
- if (head) head.appendChild(styleElement);
1160
+ injectAtSheetPosition(ctx.clone, sheet, styleElement, ctx.clone.head || ctx.clone.querySelector('head'));
1183
1161
  } else {
1184
- const percyId = owner.getAttribute('data-percy-element-id');
1185
- const cloneHost = cloneByPercyId.get(percyId);
1162
+ const cloneHost = cloneByPercyId.get(owner.getAttribute('data-percy-element-id'));
1186
1163
  if (cloneHost && cloneHost.shadowRoot) {
1187
- cloneHost.shadowRoot.appendChild(styleElement);
1164
+ injectAtSheetPosition(cloneHost.shadowRoot, sheet, styleElement, cloneHost.shadowRoot);
1188
1165
  }
1189
1166
  }
1190
1167
  }
1191
1168
  }
1169
+
1170
+ // Insert an interactive-states <style> immediately AFTER its source sheet's
1171
+ // clone element so the copy keeps the sheet's original source-order rank —
1172
+ // later stylesheets still win equal-specificity ties, exactly as on the live
1173
+ // page (PER-10077). The sheet's ownerNode (<style> / <link rel=stylesheet>) is
1174
+ // stamped with a data-percy-element-id, which serialize-cssom preserves on any
1175
+ // node it rebuilds in place. Falls back to appending at the scope's end when
1176
+ // the sheet has no locatable clone anchor.
1177
+ function injectAtSheetPosition(scopeRoot, sheet, styleElement, fallbackTarget) {
1178
+ const anchorId = sheet.ownerNode.getAttribute('data-percy-element-id');
1179
+ const anchor = anchorId ? scopeRoot.querySelector(`[data-percy-element-id="${anchorId}"]`) : null;
1180
+ const anchorParent = anchor ? anchor.parentNode : null;
1181
+ if (anchorParent) {
1182
+ anchorParent.insertBefore(styleElement, anchor.nextSibling);
1183
+ } else if (fallbackTarget) {
1184
+ fallbackTarget.appendChild(styleElement);
1185
+ }
1186
+ }
1192
1187
  function serializePseudoClasses(ctx) {
1193
- // Auto-detect path runs unconditionally so `:focus`/`:checked`/`:disabled`
1188
+ // Auto-detect path runs unconditionally so `:focus`/`:focus-within`
1194
1189
  // rules are rewritten regardless of whether the user configured a
1195
1190
  // pseudoClassEnabledElements list (and regardless of whether that list
1196
1191
  // matched anything on this page).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/dom",
3
- "version": "1.32.6-beta.2",
3
+ "version": "1.32.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "publishConfig": {
11
11
  "access": "public",
12
- "tag": "beta"
12
+ "tag": "latest"
13
13
  },
14
14
  "main": "dist/bundle.js",
15
15
  "browser": "dist/bundle.js",
@@ -35,5 +35,5 @@
35
35
  "devDependencies": {
36
36
  "interactor.js": "^2.0.0-beta.10"
37
37
  },
38
- "gitHead": "11bfa3bf3daebe5ae0b07999054be412e9995a02"
38
+ "gitHead": "e7dd935acf71273fd045ccceda6d1a37db8e4600"
39
39
  }