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:
Alailson
2026-02-06 10:49:52 +01:00
committed by GitHub
parent 3b8daa6034
commit b149cf31ed
8 changed files with 739 additions and 14 deletions

View File

@@ -0,0 +1,5 @@
---
"rrweb": patch
---
fix: improve nested CSS rule handling and replayer handling of missing rules

View File

@@ -555,6 +555,7 @@ function getNestedCSSRulePositions(rule: CSSRule): number[] {
);
const index = rules.indexOf(childRule);
pos.unshift(index);
return recurse(childRule.parentRule, pos);
} else if (childRule.parentStyleSheet) {
const rules = Array.from(childRule.parentStyleSheet.cssRules);
const index = rules.indexOf(childRule);

View File

@@ -1988,7 +1988,8 @@ export class Replayer {
if (Array.isArray(nestedIndex)) {
const { positions, index } = getPositionsAndIndex(nestedIndex);
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 {
const index =
nestedIndex === undefined
@@ -2013,7 +2014,8 @@ export class Replayer {
if (Array.isArray(nestedIndex)) {
const { positions, index } = getPositionsAndIndex(nestedIndex);
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 {
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(
data: styleDeclarationData,
styleSheet: CSSStyleSheet,
@@ -2048,21 +2061,27 @@ export class Replayer {
styleSheet.rules,
data.index,
) as unknown as CSSStyleRule;
// Null check: rule may not exist due to timing/ordering issues
if (rule?.style) {
rule.style.setProperty(
data.set.property,
data.set.value,
data.set.priority,
);
}
}
if (data.remove) {
const rule = getNestedRule(
styleSheet.rules,
data.index,
) as unknown as CSSStyleRule;
// Null check: rule may not exist due to timing/ordering issues
if (rule?.style) {
rule.style.removeProperty(data.remove.property);
}
}
}
private applyAdoptedStyleSheet(data: adoptedStyleSheetData) {
const targetHost = this.mirror.getNode(data.id);

View File

@@ -420,18 +420,32 @@ export function hasShadowRoot<T extends Node | RRNode>(
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(
rules: CSSRuleList,
position: number[],
): CSSGroupingRule {
const rule = rules[position[0]] as CSSGroupingRule;
): CSSGroupingRule | null {
const rule = rules?.[position[0]] as CSSGroupingRule | null;
if (!rule) {
return null;
}
if (position.length === 1) {
return rule;
} else {
return getNestedRule(
(rule.cssRules[position[1]] as CSSGroupingRule).cssRules,
position.slice(2),
);
return getNestedRule(rule.cssRules, position.slice(1));
}
}

View 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;

View 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;

View File

@@ -25,6 +25,8 @@ import StyleSheetTextMutation from './events/style-sheet-text-mutation';
import canvasInIframe from './events/canvas-in-iframe';
import adoptedStyleSheet from './events/adopted-style-sheet';
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 hoverInIframeShadowDom from './events/iframe-shadowdom-hover';
import customElementDefineClass from './events/custom-element-define-class';
@@ -1064,6 +1066,126 @@ describe('replayer', function () {
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 () => {
await page.evaluate(
`events = ${JSON.stringify(documentReplacementEvents)}`,

View File

@@ -7,6 +7,8 @@ import {
inDom,
shadowHostInDom,
getShadowHost,
getNestedRule,
getPositionsAndIndex,
} from '../src/utils';
describe('Utilities for other modules', () => {
@@ -143,4 +145,196 @@ describe('Utilities for other modules', () => {
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);
});
});
});