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);
|
||||
pos.unshift(index);
|
||||
return recurse(childRule.parentRule, pos);
|
||||
} else if (childRule.parentStyleSheet) {
|
||||
const rules = Array.from(childRule.parentStyleSheet.cssRules);
|
||||
const index = rules.indexOf(childRule);
|
||||
|
||||
@@ -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,11 +2061,14 @@ export class Replayer {
|
||||
styleSheet.rules,
|
||||
data.index,
|
||||
) as unknown as CSSStyleRule;
|
||||
rule.style.setProperty(
|
||||
data.set.property,
|
||||
data.set.value,
|
||||
data.set.priority,
|
||||
);
|
||||
// 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) {
|
||||
@@ -2060,7 +2076,10 @@ export class Replayer {
|
||||
styleSheet.rules,
|
||||
data.index,
|
||||
) 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 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)}`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user