fix: improve nested CSS rule handling and add related tests (#1775)
* fix: improve nested CSS rule handling and add related tests * fix: enhance null safety for nested CSS rules and add related tests * Improve nested CSS rule handling and replayer handling Updated the fix message to include replayer handling of missing rules. --------- Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
5
.changeset/quiet-actors-mate.md
Normal file
5
.changeset/quiet-actors-mate.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"rrweb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: improve nested CSS rule handling and replayer handling of missing rules
|
||||||
@@ -555,6 +555,7 @@ function getNestedCSSRulePositions(rule: CSSRule): number[] {
|
|||||||
);
|
);
|
||||||
const index = rules.indexOf(childRule);
|
const index = rules.indexOf(childRule);
|
||||||
pos.unshift(index);
|
pos.unshift(index);
|
||||||
|
return recurse(childRule.parentRule, pos);
|
||||||
} else if (childRule.parentStyleSheet) {
|
} else if (childRule.parentStyleSheet) {
|
||||||
const rules = Array.from(childRule.parentStyleSheet.cssRules);
|
const rules = Array.from(childRule.parentStyleSheet.cssRules);
|
||||||
const index = rules.indexOf(childRule);
|
const index = rules.indexOf(childRule);
|
||||||
|
|||||||
@@ -1988,7 +1988,8 @@ export class Replayer {
|
|||||||
if (Array.isArray(nestedIndex)) {
|
if (Array.isArray(nestedIndex)) {
|
||||||
const { positions, index } = getPositionsAndIndex(nestedIndex);
|
const { positions, index } = getPositionsAndIndex(nestedIndex);
|
||||||
const nestedRule = getNestedRule(styleSheet.cssRules, positions);
|
const nestedRule = getNestedRule(styleSheet.cssRules, positions);
|
||||||
nestedRule.insertRule(rule, index);
|
// Null check: parent rule may not exist due to timing/ordering issues
|
||||||
|
nestedRule?.insertRule(rule, index);
|
||||||
} else {
|
} else {
|
||||||
const index =
|
const index =
|
||||||
nestedIndex === undefined
|
nestedIndex === undefined
|
||||||
@@ -2013,7 +2014,8 @@ export class Replayer {
|
|||||||
if (Array.isArray(nestedIndex)) {
|
if (Array.isArray(nestedIndex)) {
|
||||||
const { positions, index } = getPositionsAndIndex(nestedIndex);
|
const { positions, index } = getPositionsAndIndex(nestedIndex);
|
||||||
const nestedRule = getNestedRule(styleSheet.cssRules, positions);
|
const nestedRule = getNestedRule(styleSheet.cssRules, positions);
|
||||||
nestedRule.deleteRule(index || 0);
|
// Null check: parent rule may not exist due to timing/ordering issues
|
||||||
|
nestedRule?.deleteRule(index || 0);
|
||||||
} else {
|
} else {
|
||||||
styleSheet?.deleteRule(nestedIndex);
|
styleSheet?.deleteRule(nestedIndex);
|
||||||
}
|
}
|
||||||
@@ -2039,6 +2041,17 @@ export class Replayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a StyleDeclaration event (setProperty/removeProperty) to a stylesheet.
|
||||||
|
*
|
||||||
|
* Uses defensive null checks because the rule may not exist:
|
||||||
|
* - Timing issues: The rule was added by a previous StyleSheetRule event
|
||||||
|
* that hasn't been processed yet
|
||||||
|
* - Dynamic stylesheets: Constructed stylesheets or adopted stylesheets
|
||||||
|
* may not be fully synchronized
|
||||||
|
* - Nested rules: Rules inside @media/@supports require the parent rule
|
||||||
|
* to exist first
|
||||||
|
*/
|
||||||
private applyStyleDeclaration(
|
private applyStyleDeclaration(
|
||||||
data: styleDeclarationData,
|
data: styleDeclarationData,
|
||||||
styleSheet: CSSStyleSheet,
|
styleSheet: CSSStyleSheet,
|
||||||
@@ -2048,11 +2061,14 @@ export class Replayer {
|
|||||||
styleSheet.rules,
|
styleSheet.rules,
|
||||||
data.index,
|
data.index,
|
||||||
) as unknown as CSSStyleRule;
|
) as unknown as CSSStyleRule;
|
||||||
rule.style.setProperty(
|
// Null check: rule may not exist due to timing/ordering issues
|
||||||
data.set.property,
|
if (rule?.style) {
|
||||||
data.set.value,
|
rule.style.setProperty(
|
||||||
data.set.priority,
|
data.set.property,
|
||||||
);
|
data.set.value,
|
||||||
|
data.set.priority,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.remove) {
|
if (data.remove) {
|
||||||
@@ -2060,7 +2076,10 @@ export class Replayer {
|
|||||||
styleSheet.rules,
|
styleSheet.rules,
|
||||||
data.index,
|
data.index,
|
||||||
) as unknown as CSSStyleRule;
|
) as unknown as CSSStyleRule;
|
||||||
rule.style.removeProperty(data.remove.property);
|
// Null check: rule may not exist due to timing/ordering issues
|
||||||
|
if (rule?.style) {
|
||||||
|
rule.style.removeProperty(data.remove.property);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -420,18 +420,32 @@ export function hasShadowRoot<T extends Node | RRNode>(
|
|||||||
return Boolean(dom.shadowRoot(n as unknown as Element));
|
return Boolean(dom.shadowRoot(n as unknown as Element));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverses a CSSRuleList to find a nested rule at the given position.
|
||||||
|
*
|
||||||
|
* Returns null instead of throwing if the rule doesn't exist. This is important
|
||||||
|
* because during replay:
|
||||||
|
* - StyleDeclaration events may reference rules added dynamically that don't
|
||||||
|
* exist yet due to timing/ordering issues
|
||||||
|
* - StyleSheetRule events that create rules may not have been processed yet
|
||||||
|
* - Constructed/adopted stylesheets may not be fully synchronized
|
||||||
|
*
|
||||||
|
* @param rules - The CSSRuleList to traverse
|
||||||
|
* @param position - Array of indices, e.g., [0, 1, 0] for rules[0].cssRules[1].cssRules[0]
|
||||||
|
* @returns The nested rule, or null if not found
|
||||||
|
*/
|
||||||
export function getNestedRule(
|
export function getNestedRule(
|
||||||
rules: CSSRuleList,
|
rules: CSSRuleList,
|
||||||
position: number[],
|
position: number[],
|
||||||
): CSSGroupingRule {
|
): CSSGroupingRule | null {
|
||||||
const rule = rules[position[0]] as CSSGroupingRule;
|
const rule = rules?.[position[0]] as CSSGroupingRule | null;
|
||||||
|
if (!rule) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (position.length === 1) {
|
if (position.length === 1) {
|
||||||
return rule;
|
return rule;
|
||||||
} else {
|
} else {
|
||||||
return getNestedRule(
|
return getNestedRule(rule.cssRules, position.slice(1));
|
||||||
(rule.cssRules[position[1]] as CSSGroupingRule).cssRules,
|
|
||||||
position.slice(2),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
217
packages/rrweb/test/events/nested-style-declaration.ts
Normal file
217
packages/rrweb/test/events/nested-style-declaration.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { EventType, IncrementalSource } from '@rrweb/types';
|
||||||
|
import type { eventWithTime } from '@rrweb/types';
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test events for StyleDeclaration on nested CSS rules.
|
||||||
|
* This tests setProperty/removeProperty on rules inside @media blocks.
|
||||||
|
*/
|
||||||
|
const events: eventWithTime[] = [
|
||||||
|
{
|
||||||
|
type: EventType.Meta,
|
||||||
|
data: {
|
||||||
|
href: 'about:blank',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
timestamp: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: EventType.FullSnapshot,
|
||||||
|
data: {
|
||||||
|
node: {
|
||||||
|
type: 0,
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 1,
|
||||||
|
name: 'html',
|
||||||
|
publicId: '',
|
||||||
|
systemId: '',
|
||||||
|
id: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'html',
|
||||||
|
attributes: {},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'head',
|
||||||
|
attributes: {},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'style',
|
||||||
|
attributes: {
|
||||||
|
_cssText:
|
||||||
|
'@media all { .test-nested { background-color: blue; width: 100px; } .test-second { color: red; } } @supports (display: block) { @media all { .test-deep { background-color: teal; } } }',
|
||||||
|
},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
textContent: '',
|
||||||
|
isStyle: true,
|
||||||
|
id: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'body',
|
||||||
|
attributes: {},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
textContent: '\n ',
|
||||||
|
id: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'div',
|
||||||
|
attributes: {
|
||||||
|
class: 'test-nested',
|
||||||
|
},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
textContent: 'Nested rule test',
|
||||||
|
id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
textContent: '\n ',
|
||||||
|
id: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'div',
|
||||||
|
attributes: {
|
||||||
|
class: 'test-second',
|
||||||
|
},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
textContent: 'Second nested rule',
|
||||||
|
id: 13,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
textContent: '\n ',
|
||||||
|
id: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'div',
|
||||||
|
attributes: {
|
||||||
|
class: 'test-deep',
|
||||||
|
},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
textContent: 'Deep nested rule',
|
||||||
|
id: 16,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
initialOffset: {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: now + 100,
|
||||||
|
},
|
||||||
|
// setProperty on rule inside @media at index [0, 0]
|
||||||
|
{
|
||||||
|
type: EventType.IncrementalSnapshot,
|
||||||
|
data: {
|
||||||
|
source: IncrementalSource.StyleDeclaration,
|
||||||
|
id: 5,
|
||||||
|
set: {
|
||||||
|
property: 'background-color',
|
||||||
|
value: 'red',
|
||||||
|
priority: undefined,
|
||||||
|
},
|
||||||
|
index: [0, 0],
|
||||||
|
},
|
||||||
|
timestamp: now + 200,
|
||||||
|
},
|
||||||
|
// setProperty on second rule inside @media at index [0, 1]
|
||||||
|
{
|
||||||
|
type: EventType.IncrementalSnapshot,
|
||||||
|
data: {
|
||||||
|
source: IncrementalSource.StyleDeclaration,
|
||||||
|
id: 5,
|
||||||
|
set: {
|
||||||
|
property: 'font-weight',
|
||||||
|
value: 'bold',
|
||||||
|
priority: undefined,
|
||||||
|
},
|
||||||
|
index: [0, 1],
|
||||||
|
},
|
||||||
|
timestamp: now + 300,
|
||||||
|
},
|
||||||
|
// removeProperty on rule inside @media at index [0, 0]
|
||||||
|
{
|
||||||
|
type: EventType.IncrementalSnapshot,
|
||||||
|
data: {
|
||||||
|
source: IncrementalSource.StyleDeclaration,
|
||||||
|
id: 5,
|
||||||
|
remove: {
|
||||||
|
property: 'width',
|
||||||
|
},
|
||||||
|
index: [0, 0],
|
||||||
|
},
|
||||||
|
timestamp: now + 400,
|
||||||
|
},
|
||||||
|
// setProperty on deeply nested rule inside @supports > @media at index [1, 0, 0]
|
||||||
|
{
|
||||||
|
type: EventType.IncrementalSnapshot,
|
||||||
|
data: {
|
||||||
|
source: IncrementalSource.StyleDeclaration,
|
||||||
|
id: 5,
|
||||||
|
set: {
|
||||||
|
property: 'background-color',
|
||||||
|
value: 'purple',
|
||||||
|
priority: undefined,
|
||||||
|
},
|
||||||
|
index: [1, 0, 0],
|
||||||
|
},
|
||||||
|
timestamp: now + 500,
|
||||||
|
},
|
||||||
|
// removeProperty on deeply nested rule
|
||||||
|
{
|
||||||
|
type: EventType.IncrementalSnapshot,
|
||||||
|
data: {
|
||||||
|
source: IncrementalSource.StyleDeclaration,
|
||||||
|
id: 5,
|
||||||
|
remove: {
|
||||||
|
property: 'background-color',
|
||||||
|
},
|
||||||
|
index: [1, 0, 0],
|
||||||
|
},
|
||||||
|
timestamp: now + 600,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default events;
|
||||||
153
packages/rrweb/test/events/style-declaration-missing-rule.ts
Normal file
153
packages/rrweb/test/events/style-declaration-missing-rule.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { EventType, IncrementalSource } from '@rrweb/types';
|
||||||
|
import type { eventWithTime } from '@rrweb/types';
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test events for StyleDeclaration events that reference non-existent rules.
|
||||||
|
*
|
||||||
|
* This tests that the replayer gracefully handles:
|
||||||
|
* 1. StyleDeclaration events with invalid indices (rule doesn't exist)
|
||||||
|
* 2. StyleDeclaration events referencing nested rules in empty grouping rules
|
||||||
|
*
|
||||||
|
* These scenarios can occur due to:
|
||||||
|
* - Timing issues during recording/replay
|
||||||
|
* - Event ordering where StyleDeclaration arrives before StyleSheetRule
|
||||||
|
* - Dynamic stylesheets that aren't fully synchronized
|
||||||
|
*/
|
||||||
|
const events: eventWithTime[] = [
|
||||||
|
{
|
||||||
|
type: EventType.Meta,
|
||||||
|
data: {
|
||||||
|
href: 'about:blank',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
timestamp: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: EventType.FullSnapshot,
|
||||||
|
data: {
|
||||||
|
node: {
|
||||||
|
type: 0,
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 1,
|
||||||
|
name: 'html',
|
||||||
|
publicId: '',
|
||||||
|
systemId: '',
|
||||||
|
id: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'html',
|
||||||
|
attributes: {},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'head',
|
||||||
|
attributes: {},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'style',
|
||||||
|
attributes: {
|
||||||
|
// Only one rule at index 0, but we'll try to access index 5
|
||||||
|
_cssText: '.existing { color: blue; }',
|
||||||
|
},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
textContent: '',
|
||||||
|
isStyle: true,
|
||||||
|
id: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'body',
|
||||||
|
attributes: {},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
tagName: 'div',
|
||||||
|
attributes: {
|
||||||
|
class: 'existing',
|
||||||
|
},
|
||||||
|
childNodes: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
textContent: 'Test content',
|
||||||
|
id: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
initialOffset: {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: now + 100,
|
||||||
|
},
|
||||||
|
// setProperty on a rule that doesn't exist (index 5, but only index 0 exists)
|
||||||
|
{
|
||||||
|
type: EventType.IncrementalSnapshot,
|
||||||
|
data: {
|
||||||
|
source: IncrementalSource.StyleDeclaration,
|
||||||
|
id: 5,
|
||||||
|
set: {
|
||||||
|
property: 'background-color',
|
||||||
|
value: 'red',
|
||||||
|
priority: undefined,
|
||||||
|
},
|
||||||
|
index: [5], // This index doesn't exist!
|
||||||
|
},
|
||||||
|
timestamp: now + 200,
|
||||||
|
},
|
||||||
|
// removeProperty on a rule that doesn't exist
|
||||||
|
{
|
||||||
|
type: EventType.IncrementalSnapshot,
|
||||||
|
data: {
|
||||||
|
source: IncrementalSource.StyleDeclaration,
|
||||||
|
id: 5,
|
||||||
|
remove: {
|
||||||
|
property: 'color',
|
||||||
|
},
|
||||||
|
index: [99], // This index doesn't exist!
|
||||||
|
},
|
||||||
|
timestamp: now + 300,
|
||||||
|
},
|
||||||
|
// setProperty on nested rule that doesn't exist [0, 5]
|
||||||
|
// (rule 0 exists but it's not a grouping rule with nested rules)
|
||||||
|
{
|
||||||
|
type: EventType.IncrementalSnapshot,
|
||||||
|
data: {
|
||||||
|
source: IncrementalSource.StyleDeclaration,
|
||||||
|
id: 5,
|
||||||
|
set: {
|
||||||
|
property: 'font-size',
|
||||||
|
value: '20px',
|
||||||
|
priority: undefined,
|
||||||
|
},
|
||||||
|
index: [0, 5], // Tries to access nested rule that doesn't exist
|
||||||
|
},
|
||||||
|
timestamp: now + 400,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default events;
|
||||||
@@ -25,6 +25,8 @@ import StyleSheetTextMutation from './events/style-sheet-text-mutation';
|
|||||||
import canvasInIframe from './events/canvas-in-iframe';
|
import canvasInIframe from './events/canvas-in-iframe';
|
||||||
import adoptedStyleSheet from './events/adopted-style-sheet';
|
import adoptedStyleSheet from './events/adopted-style-sheet';
|
||||||
import adoptedStyleSheetModification from './events/adopted-style-sheet-modification';
|
import adoptedStyleSheetModification from './events/adopted-style-sheet-modification';
|
||||||
|
import nestedStyleDeclarationEvents from './events/nested-style-declaration';
|
||||||
|
import styleDeclarationMissingRuleEvents from './events/style-declaration-missing-rule';
|
||||||
import documentReplacementEvents from './events/document-replacement';
|
import documentReplacementEvents from './events/document-replacement';
|
||||||
import hoverInIframeShadowDom from './events/iframe-shadowdom-hover';
|
import hoverInIframeShadowDom from './events/iframe-shadowdom-hover';
|
||||||
import customElementDefineClass from './events/custom-element-define-class';
|
import customElementDefineClass from './events/custom-element-define-class';
|
||||||
@@ -1064,6 +1066,126 @@ describe('replayer', function () {
|
|||||||
await check600ms();
|
await check600ms();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can replay StyleDeclaration events on nested CSS rules inside @media', async () => {
|
||||||
|
await page.evaluate(`
|
||||||
|
events = ${JSON.stringify(nestedStyleDeclarationEvents)};
|
||||||
|
const { Replayer } = rrweb;
|
||||||
|
var replayer = new Replayer(events, { showDebug: true });
|
||||||
|
replayer.pause(0);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// At 250ms, setProperty on [0, 0] should change background-color to red
|
||||||
|
const bgColorAfterSet = await page.evaluate(`
|
||||||
|
replayer.pause(250);
|
||||||
|
const doc1 = replayer.iframe.contentDocument;
|
||||||
|
const styleElement1 = doc1.querySelector('head style');
|
||||||
|
const sheet1 = styleElement1?.sheet;
|
||||||
|
if (!sheet1 || sheet1.cssRules.length === 0) 'no sheet';
|
||||||
|
const mediaRule1 = sheet1.cssRules[0];
|
||||||
|
if (!mediaRule1 || !mediaRule1.cssRules) 'no media rule';
|
||||||
|
const nestedRule1 = mediaRule1.cssRules[0];
|
||||||
|
nestedRule1?.style?.backgroundColor || 'no bg color';
|
||||||
|
`);
|
||||||
|
expect(bgColorAfterSet).toBe('red');
|
||||||
|
|
||||||
|
// At 350ms, setProperty on [0, 1] should add font-weight: bold
|
||||||
|
const fontWeightAfterSet = await page.evaluate(`
|
||||||
|
replayer.pause(350);
|
||||||
|
const doc2 = replayer.iframe.contentDocument;
|
||||||
|
const styleElement2 = doc2.querySelector('head style');
|
||||||
|
const sheet2 = styleElement2?.sheet;
|
||||||
|
if (!sheet2) 'no sheet';
|
||||||
|
const mediaRule2 = sheet2.cssRules[0];
|
||||||
|
const secondRule2 = mediaRule2?.cssRules?.[1];
|
||||||
|
secondRule2?.style?.fontWeight || 'no font weight';
|
||||||
|
`);
|
||||||
|
expect(fontWeightAfterSet).toBe('bold');
|
||||||
|
|
||||||
|
// At 450ms, removeProperty on [0, 0] should remove width
|
||||||
|
const widthAfterRemove = await page.evaluate(`
|
||||||
|
replayer.pause(450);
|
||||||
|
const doc3 = replayer.iframe.contentDocument;
|
||||||
|
const styleElement3 = doc3.querySelector('head style');
|
||||||
|
const sheet3 = styleElement3?.sheet;
|
||||||
|
if (!sheet3) 'has width';
|
||||||
|
const mediaRule3 = sheet3.cssRules[0];
|
||||||
|
const nestedRule3 = mediaRule3?.cssRules?.[0];
|
||||||
|
nestedRule3?.style?.width || '';
|
||||||
|
`);
|
||||||
|
expect(widthAfterRemove).toBe('');
|
||||||
|
|
||||||
|
// At 550ms, setProperty on deeply nested [1, 0, 0] should change background-color to purple
|
||||||
|
const deepBgColorAfterSet = await page.evaluate(`
|
||||||
|
replayer.pause(550);
|
||||||
|
const doc4 = replayer.iframe.contentDocument;
|
||||||
|
const styleElement4 = doc4.querySelector('head style');
|
||||||
|
const sheet4 = styleElement4?.sheet;
|
||||||
|
if (!sheet4 || sheet4.cssRules.length < 2) 'no sheet';
|
||||||
|
const supportsRule4 = sheet4.cssRules[1];
|
||||||
|
const mediaRule4 = supportsRule4?.cssRules?.[0];
|
||||||
|
const deepRule4 = mediaRule4?.cssRules?.[0];
|
||||||
|
deepRule4?.style?.backgroundColor || 'no bg color';
|
||||||
|
`);
|
||||||
|
expect(deepBgColorAfterSet).toBe('purple');
|
||||||
|
|
||||||
|
// At 650ms, removeProperty on [1, 0, 0] should remove background-color
|
||||||
|
const deepBgColorAfterRemove = await page.evaluate(`
|
||||||
|
replayer.pause(650);
|
||||||
|
const doc5 = replayer.iframe.contentDocument;
|
||||||
|
const styleElement5 = doc5.querySelector('head style');
|
||||||
|
const sheet5 = styleElement5?.sheet;
|
||||||
|
if (!sheet5 || sheet5.cssRules.length < 2) 'has bg';
|
||||||
|
const supportsRule5 = sheet5.cssRules[1];
|
||||||
|
const mediaRule5 = supportsRule5?.cssRules?.[0];
|
||||||
|
const deepRule5 = mediaRule5?.cssRules?.[0];
|
||||||
|
deepRule5?.style?.backgroundColor || '';
|
||||||
|
`);
|
||||||
|
expect(deepBgColorAfterRemove).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not crash when StyleDeclaration references non-existent rules', async () => {
|
||||||
|
/**
|
||||||
|
* This test verifies that the replayer gracefully handles StyleDeclaration
|
||||||
|
* events that reference rules which don't exist in the stylesheet.
|
||||||
|
*
|
||||||
|
* This can happen due to:
|
||||||
|
* - Timing issues where StyleDeclaration arrives before StyleSheetRule
|
||||||
|
* - Dynamic stylesheets that aren't fully synchronized
|
||||||
|
* - Event ordering issues during recording
|
||||||
|
*
|
||||||
|
* The replayer should silently skip these instead of crashing.
|
||||||
|
*/
|
||||||
|
await page.evaluate(
|
||||||
|
`events = ${JSON.stringify(styleDeclarationMissingRuleEvents)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not throw any errors
|
||||||
|
const result = await page.evaluate(`
|
||||||
|
try {
|
||||||
|
const { Replayer } = rrweb;
|
||||||
|
const replayer = new Replayer(events, { showDebug: true });
|
||||||
|
replayer.pause(500); // After all StyleDeclaration events
|
||||||
|
'success';
|
||||||
|
} catch (e) {
|
||||||
|
'error: ' + e.message;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(result).toBe('success');
|
||||||
|
|
||||||
|
// Verify the existing rule still works (wasn't corrupted)
|
||||||
|
const existingRuleColor = await page.evaluate(`
|
||||||
|
const { Replayer } = rrweb;
|
||||||
|
const replayer = new Replayer(events);
|
||||||
|
replayer.pause(500);
|
||||||
|
const doc = replayer.iframe.contentDocument;
|
||||||
|
const style = doc.querySelector('head style');
|
||||||
|
const sheet = style?.sheet;
|
||||||
|
const rule = sheet?.cssRules?.[0];
|
||||||
|
rule?.style?.color || 'no color';
|
||||||
|
`);
|
||||||
|
expect(existingRuleColor).toBe('blue');
|
||||||
|
});
|
||||||
|
|
||||||
it('should replay document replacement events without warnings or errors', async () => {
|
it('should replay document replacement events without warnings or errors', async () => {
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
`events = ${JSON.stringify(documentReplacementEvents)}`,
|
`events = ${JSON.stringify(documentReplacementEvents)}`,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
inDom,
|
inDom,
|
||||||
shadowHostInDom,
|
shadowHostInDom,
|
||||||
getShadowHost,
|
getShadowHost,
|
||||||
|
getNestedRule,
|
||||||
|
getPositionsAndIndex,
|
||||||
} from '../src/utils';
|
} from '../src/utils';
|
||||||
|
|
||||||
describe('Utilities for other modules', () => {
|
describe('Utilities for other modules', () => {
|
||||||
@@ -143,4 +145,196 @@ describe('Utilities for other modules', () => {
|
|||||||
expect(inDom(a.childNodes[0])).toBeTruthy();
|
expect(inDom(a.childNodes[0])).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getNestedRule()', () => {
|
||||||
|
it('should return the rule at position [0] for a top-level rule', () => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet!;
|
||||||
|
sheet.insertRule('.test { color: red; }', 0);
|
||||||
|
|
||||||
|
const rule = getNestedRule(sheet.cssRules, [0]);
|
||||||
|
expect(rule).toBe(sheet.cssRules[0]);
|
||||||
|
expect((rule as CSSStyleRule).selectorText).toBe('.test');
|
||||||
|
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return nested rule inside @media at position [0, 0]', () => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet!;
|
||||||
|
sheet.insertRule(
|
||||||
|
'@media (min-width: 1px) { .nested { color: blue; } }',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaRule = sheet.cssRules[0] as CSSMediaRule;
|
||||||
|
const nestedRule = getNestedRule(sheet.cssRules, [0, 0]);
|
||||||
|
|
||||||
|
expect(nestedRule).toBe(mediaRule.cssRules[0]);
|
||||||
|
expect((nestedRule as CSSStyleRule).selectorText).toBe('.nested');
|
||||||
|
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct rule for multiple rules inside @media', () => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet!;
|
||||||
|
sheet.insertRule(
|
||||||
|
'@media (min-width: 1px) { .first { color: red; } .second { color: blue; } }',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaRule = sheet.cssRules[0] as CSSMediaRule;
|
||||||
|
|
||||||
|
const firstRule = getNestedRule(sheet.cssRules, [0, 0]);
|
||||||
|
expect(firstRule).toBe(mediaRule.cssRules[0]);
|
||||||
|
expect((firstRule as CSSStyleRule).selectorText).toBe('.first');
|
||||||
|
|
||||||
|
const secondRule = getNestedRule(sheet.cssRules, [0, 1]);
|
||||||
|
expect(secondRule).toBe(mediaRule.cssRules[1]);
|
||||||
|
expect((secondRule as CSSStyleRule).selectorText).toBe('.second');
|
||||||
|
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deeply nested rules (@supports > @media > rule)', () => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet!;
|
||||||
|
sheet.insertRule(
|
||||||
|
'@supports (display: flex) { @media (min-width: 1px) { .deep { color: green; } } }',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const supportsRule = sheet.cssRules[0] as CSSSupportsRule;
|
||||||
|
const mediaRule = supportsRule.cssRules[0] as CSSMediaRule;
|
||||||
|
const deepRule = mediaRule.cssRules[0] as CSSStyleRule;
|
||||||
|
|
||||||
|
const result = getNestedRule(sheet.cssRules, [0, 0, 0]);
|
||||||
|
expect(result).toBe(deepRule);
|
||||||
|
expect((result as CSSStyleRule).selectorText).toBe('.deep');
|
||||||
|
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple top-level grouping rules', () => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet!;
|
||||||
|
sheet.insertRule(
|
||||||
|
'@media (min-width: 1px) { .media-rule { color: red; } }',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
sheet.insertRule(
|
||||||
|
'@supports (display: grid) { .supports-rule { color: blue; } }',
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rule inside first @media
|
||||||
|
const mediaNestedRule = getNestedRule(sheet.cssRules, [0, 0]);
|
||||||
|
expect((mediaNestedRule as CSSStyleRule).selectorText).toBe(
|
||||||
|
'.media-rule',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rule inside second @supports
|
||||||
|
const supportsNestedRule = getNestedRule(sheet.cssRules, [1, 0]);
|
||||||
|
expect((supportsNestedRule as CSSStyleRule).selectorText).toBe(
|
||||||
|
'.supports-rule',
|
||||||
|
);
|
||||||
|
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPositionsAndIndex()', () => {
|
||||||
|
it('should split single element array into empty positions and index', () => {
|
||||||
|
const result = getPositionsAndIndex([5]);
|
||||||
|
expect(result.positions).toEqual([]);
|
||||||
|
expect(result.index).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split two element array correctly', () => {
|
||||||
|
const result = getPositionsAndIndex([0, 3]);
|
||||||
|
expect(result.positions).toEqual([0]);
|
||||||
|
expect(result.index).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split three element array correctly', () => {
|
||||||
|
const result = getPositionsAndIndex([1, 2, 3]);
|
||||||
|
expect(result.positions).toEqual([1, 2]);
|
||||||
|
expect(result.index).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNestedRule() null safety', () => {
|
||||||
|
/**
|
||||||
|
* These tests verify that getNestedRule returns null instead of crashing
|
||||||
|
* when the requested rule doesn't exist. This is important because:
|
||||||
|
* 1. StyleDeclaration events may reference rules that were added dynamically
|
||||||
|
* but don't exist yet during replay due to timing issues
|
||||||
|
* 2. Event ordering may cause StyleDeclaration events to arrive before
|
||||||
|
* the corresponding StyleSheetRule events that create the rules
|
||||||
|
* 3. Constructed/adopted stylesheets may not be fully synchronized
|
||||||
|
*/
|
||||||
|
|
||||||
|
it('should return null for invalid top-level index', () => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet!;
|
||||||
|
sheet.insertRule('.test { color: red; }', 0);
|
||||||
|
|
||||||
|
// Index 5 doesn't exist (only index 0 exists)
|
||||||
|
const result = getNestedRule(sheet.cssRules, [5]);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid nested index', () => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet!;
|
||||||
|
sheet.insertRule(
|
||||||
|
'@media (min-width: 1px) { .nested { color: blue; } }',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// [0, 5] - media rule exists at 0, but no rule at index 5 inside it
|
||||||
|
const result = getNestedRule(sheet.cssRules, [0, 5]);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for deeply nested invalid index', () => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet!;
|
||||||
|
sheet.insertRule(
|
||||||
|
'@supports (display: flex) { @media (min-width: 1px) { .deep { color: green; } } }',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// [0, 0, 99] - supports and media exist, but no rule at index 99
|
||||||
|
const result = getNestedRule(sheet.cssRules, [0, 0, 99]);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when rules list is empty', () => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const sheet = style.sheet!;
|
||||||
|
// Don't add any rules - empty stylesheet
|
||||||
|
|
||||||
|
const result = getNestedRule(sheet.cssRules, [0]);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
|
||||||
|
document.head.removeChild(style);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user