From 793ff43ed182a81c08efa5438fd839a457025edf Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] Fixes #690 (#701) Add nested css recording for safari --- packages/rrweb/src/record/observer.ts | 134 +++++++++++++++++--------- packages/rrweb/test/record.test.ts | 17 +++- 2 files changed, 107 insertions(+), 44 deletions(-) diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index a97e114f..c5d6c8da 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -60,7 +60,10 @@ type WindowWithAngularZone = Window & { export const mutationBuffers: MutationBuffer[] = []; -const isCSSGroupingRuleSupported = typeof CSSGroupingRule !== "undefined" +const isCSSGroupingRuleSupported = typeof CSSGroupingRule !== 'undefined'; +const isCSSMediaRuleSupported = typeof CSSMediaRule !== 'undefined'; +const isCSSSupportsRuleSupported = typeof CSSSupportsRule !== 'undefined'; +const isCSSConditionRuleSupported = typeof CSSConditionRule !== 'undefined'; function getEventTarget(event: Event): EventTarget | null { try { @@ -475,12 +478,32 @@ function initInputObserver( }; } +type GroupingCSSRule = + | CSSGroupingRule + | CSSMediaRule + | CSSSupportsRule + | CSSConditionRule; +type GroupingCSSRuleTypes = + | typeof CSSGroupingRule + | typeof CSSMediaRule + | typeof CSSSupportsRule + | typeof CSSConditionRule; + function getNestedCSSRulePositions(rule: CSSRule): number[] { const positions: number[] = []; function recurse(childRule: CSSRule, pos: number[]) { - if (isCSSGroupingRuleSupported && childRule.parentRule instanceof CSSGroupingRule) { + if ( + (isCSSGroupingRuleSupported && + childRule.parentRule instanceof CSSGroupingRule) || + (isCSSMediaRuleSupported && + childRule.parentRule instanceof CSSMediaRule) || + (isCSSSupportsRuleSupported && + childRule.parentRule instanceof CSSSupportsRule) || + (isCSSConditionRuleSupported && + childRule.parentRule instanceof CSSConditionRule) + ) { const rules = Array.from( - (childRule.parentRule as CSSGroupingRule).cssRules, + (childRule.parentRule as GroupingCSSRule).cssRules, ); const index = rules.indexOf(childRule); pos.unshift(index); @@ -522,53 +545,78 @@ function initStyleSheetObserver( return deleteRule.apply(this, arguments); }; - if (!isCSSGroupingRuleSupported) { - return () => { - CSSStyleSheet.prototype.insertRule = insertRule; - CSSStyleSheet.prototype.deleteRule = deleteRule; - }; + const supportedNestedCSSRuleTypes: { + [key: string]: GroupingCSSRuleTypes; + } = {}; + if (isCSSGroupingRuleSupported) { + supportedNestedCSSRuleTypes['CSSGroupingRule'] = CSSGroupingRule; + } else { + // Some browsers (Safari) don't support CSSGroupingRule + // https://caniuse.com/?search=cssgroupingrule + // fall back to monkey patching classes that would have inherited from CSSGroupingRule + + if (isCSSMediaRuleSupported) { + supportedNestedCSSRuleTypes['CSSMediaRule'] = CSSMediaRule; + } + if (isCSSConditionRuleSupported) { + supportedNestedCSSRuleTypes['CSSConditionRule'] = CSSConditionRule; + } + if (isCSSSupportsRuleSupported) { + supportedNestedCSSRuleTypes['CSSSupportsRule'] = CSSSupportsRule; + } } - const groupingInsertRule = CSSGroupingRule.prototype.insertRule; - CSSGroupingRule.prototype.insertRule = function ( - rule: string, - index?: number, - ) { - const id = mirror.getId(this.parentStyleSheet.ownerNode as INode); - if (id !== -1) { - cb({ - id, - adds: [ - { - rule, - index: [ - ...getNestedCSSRulePositions(this), - index || 0, // defaults to 0 - ], - }, - ], - }); - } - return groupingInsertRule.apply(this, arguments); - }; + const unmodifiedFunctions: { + [key: string]: { + insertRule: (rule: string, index?: number) => number; + deleteRule: (index: number) => void; + }; + } = {}; - const groupingDeleteRule = CSSGroupingRule.prototype.deleteRule; - CSSGroupingRule.prototype.deleteRule = function (index: number) { - const id = mirror.getId(this.parentStyleSheet.ownerNode as INode); - if (id !== -1) { - cb({ - id, - removes: [{ index: [...getNestedCSSRulePositions(this), index] }], - }); - } - return groupingDeleteRule.apply(this, arguments); - }; + Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => { + unmodifiedFunctions[typeKey] = { + insertRule: (type as GroupingCSSRuleTypes).prototype.insertRule, + deleteRule: (type as GroupingCSSRuleTypes).prototype.deleteRule, + }; + + type.prototype.insertRule = function (rule: string, index?: number) { + const id = mirror.getId(this.parentStyleSheet.ownerNode as INode); + if (id !== -1) { + cb({ + id, + adds: [ + { + rule, + index: [ + ...getNestedCSSRulePositions(this), + index || 0, // defaults to 0 + ], + }, + ], + }); + } + return unmodifiedFunctions[typeKey].insertRule.apply(this, arguments); + }; + + type.prototype.deleteRule = function (index: number) { + const id = mirror.getId(this.parentStyleSheet.ownerNode as INode); + if (id !== -1) { + cb({ + id, + removes: [{ index: [...getNestedCSSRulePositions(this), index] }], + }); + } + return unmodifiedFunctions[typeKey].deleteRule.apply(this, arguments); + }; + }); return () => { CSSStyleSheet.prototype.insertRule = insertRule; CSSStyleSheet.prototype.deleteRule = deleteRule; - CSSGroupingRule.prototype.insertRule = groupingInsertRule; - CSSGroupingRule.prototype.deleteRule = groupingDeleteRule; + Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => { + type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule; + type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule; + }); }; } diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 8dbbe5a7..a14c7cd1 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -251,7 +251,7 @@ describe('record', function (this: ISuite) { assertSnapshot(this.events, __filename, 'stylesheet-rules'); }); - it('captures nested stylesheet rules', async () => { + const captureNestedStylesheetRulesTest = async () => { await this.page.evaluate(() => { const { record } = ((window as unknown) as IWindow).rrweb; @@ -295,6 +295,21 @@ describe('record', function (this: ISuite) { expect(addRuleCount).to.equal(2); expect(removeRuleCount).to.equal(1); assertSnapshot(this.events, __filename, 'nested-stylesheet-rules'); + }; + it('captures nested stylesheet rules', captureNestedStylesheetRulesTest); + + describe('without CSSGroupingRule support', () => { + // Safari currently doesn't support CSSGroupingRule, let's test without that + // https://caniuse.com/?search=CSSGroupingRule + beforeEach(async () => { + await this.page.evaluate(() => { + /* @ts-ignore: override CSSGroupingRule */ + CSSGroupingRule = undefined; + }); + // load a fresh rrweb recorder without CSSGroupingRule + await this.page.evaluate(this.code); + }); + it('captures nested stylesheet rules', captureNestedStylesheetRulesTest); }); });