From b149cf31ed28cac7b6627972b423d29723524d87 Mon Sep 17 00:00:00 2001 From: Alailson <35277996+alailsonko@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:49:52 +0100 Subject: [PATCH] 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 --- .changeset/quiet-actors-mate.md | 5 + packages/rrweb/src/record/observer.ts | 1 + packages/rrweb/src/replay/index.ts | 35 ++- packages/rrweb/src/utils.ts | 26 ++- .../test/events/nested-style-declaration.ts | 217 ++++++++++++++++++ .../events/style-declaration-missing-rule.ts | 153 ++++++++++++ packages/rrweb/test/replayer.test.ts | 122 ++++++++++ packages/rrweb/test/util.test.ts | 194 ++++++++++++++++ 8 files changed, 739 insertions(+), 14 deletions(-) create mode 100644 .changeset/quiet-actors-mate.md create mode 100644 packages/rrweb/test/events/nested-style-declaration.ts create mode 100644 packages/rrweb/test/events/style-declaration-missing-rule.ts diff --git a/.changeset/quiet-actors-mate.md b/.changeset/quiet-actors-mate.md new file mode 100644 index 00000000..b63f45b5 --- /dev/null +++ b/.changeset/quiet-actors-mate.md @@ -0,0 +1,5 @@ +--- +"rrweb": patch +--- + +fix: improve nested CSS rule handling and replayer handling of missing rules diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 8326d796..62d4fc92 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -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); diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 5f1101cc..5ee1175b 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -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); + } } } diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index ef05dca5..d2b6c657 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -420,18 +420,32 @@ export function hasShadowRoot( 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)); } } diff --git a/packages/rrweb/test/events/nested-style-declaration.ts b/packages/rrweb/test/events/nested-style-declaration.ts new file mode 100644 index 00000000..0361a5d6 --- /dev/null +++ b/packages/rrweb/test/events/nested-style-declaration.ts @@ -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; diff --git a/packages/rrweb/test/events/style-declaration-missing-rule.ts b/packages/rrweb/test/events/style-declaration-missing-rule.ts new file mode 100644 index 00000000..0bee8164 --- /dev/null +++ b/packages/rrweb/test/events/style-declaration-missing-rule.ts @@ -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; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index c38ec356..b51079a0 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -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)}`, diff --git a/packages/rrweb/test/util.test.ts b/packages/rrweb/test/util.test.ts index fda0030b..7e3a33c9 100644 --- a/packages/rrweb/test/util.test.ts +++ b/packages/rrweb/test/util.test.ts @@ -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); + }); + }); });