diff --git a/.eslintrc.js b/.eslintrc.js index 2860afba..8dac0c23 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,6 +9,7 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:compat/recommended', ], parser: '@typescript-eslint/parser', parserOptions: { @@ -17,7 +18,7 @@ module.exports = { tsconfigRootDir: __dirname, project: ['./tsconfig.eslint.json', './packages/*/tsconfig.json'], }, - plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc', 'jest'], + plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc', 'jest', 'compat'], rules: { 'tsdoc/syntax': 'warn', }, diff --git a/.github/workflows/style-check.yml b/.github/workflows/style-check.yml index b028a636..50498ee9 100644 --- a/.github/workflows/style-check.yml +++ b/.github/workflows/style-check.yml @@ -27,7 +27,7 @@ jobs: run: yarn lint:report # Continue to the next step even if this fails continue-on-error: true - - name: Upload ESLint report + - name: Upload ESLint Report uses: actions/upload-artifact@v3 with: name: eslint_report.json @@ -50,7 +50,7 @@ jobs: report-json: 'eslint_report.json' prettier_check: - # In the forked PR, it's hard to format code and push to the branch directly. + # In the forked PR, it's hard to format code and push to the branch directly, so the action only check the format correctness. if: github.event_name != 'push' && github.event.pull_request.head.repo.full_name != 'rrweb-io/rrweb' runs-on: ubuntu-latest name: Format Check @@ -66,7 +66,7 @@ jobs: cache: 'yarn' - name: Install Dependencies run: yarn - - name: Prettify code + - name: Prettier Check run: yarn prettier --check '**/*.{ts,md}' prettier: @@ -86,9 +86,9 @@ jobs: cache: 'yarn' - name: Install Dependencies run: yarn - - name: Prettify code + - name: Prettify Code run: yarn prettier --write '**/*.{ts,md}' - - name: Commit changes + - name: Commit Changes uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: Apply formatting changes diff --git a/package.json b/package.json index 9a1d39b9..66ae51cc 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,10 @@ "@monorepo-utils/workspaces-to-typescript-project-references": "^2.8.2", "@typescript-eslint/eslint-plugin": "^5.25.0", "@typescript-eslint/parser": "^5.25.0", + "browserslist": "^4.21.4", "concurrently": "^7.1.0", "eslint": "^8.19.0", + "eslint-plugin-compat": "^4.0.2", "eslint-plugin-jest": "^26.5.3", "eslint-plugin-tsdoc": "^0.2.16", "lerna": "^4.0.0", @@ -44,5 +46,9 @@ }, "resolutions": { "**/jsdom/cssom": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz" - } + }, + "browserslist": [ + "defaults", + "not op_mini all" + ] } diff --git a/packages/rrdom-nodejs/package.json b/packages/rrdom-nodejs/package.json index 3a21b2c5..4a14308b 100644 --- a/packages/rrdom-nodejs/package.json +++ b/packages/rrdom-nodejs/package.json @@ -51,5 +51,8 @@ "nwsapi": "^2.2.0", "rrdom": "^0.1.5", "rrweb-snapshot": "^2.0.0-alpha.2" - } + }, + "browserslist": [ + "supports es6-class" + ] } diff --git a/packages/rrdom-nodejs/tsconfig.json b/packages/rrdom-nodejs/tsconfig.json index 4497eff2..0c2f1198 100644 --- a/packages/rrdom-nodejs/tsconfig.json +++ b/packages/rrdom-nodejs/tsconfig.json @@ -17,7 +17,12 @@ }, "compileOnSave": true, "exclude": ["test"], - "include": ["src", "test.d.ts", "../rrweb/src/record/workers/workers.d.ts"], + "include": [ + "src", + "test.d.ts", + "../rrweb/src/record/workers/workers.d.ts", + "../rrweb/src/record/constructable-stylesheets.d.ts" + ], "references": [ { "path": "../rrdom" diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 2e770527..1cb4dd80 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -4,6 +4,8 @@ import type { canvasEventWithTime, inputData, scrollData, + styleDeclarationData, + styleSheetRuleData, } from 'rrweb/src/types'; import type { IRRCDATASection, @@ -79,6 +81,10 @@ export type ReplayerHandler = { ) => void; applyInput: (data: inputData) => void; applyScroll: (data: scrollData, isSync: boolean) => void; + applyStyleSheetMutation: ( + data: styleDeclarationData | styleSheetRuleData, + styleSheet: CSSStyleSheet, + ) => void; }; export function diff( @@ -161,20 +167,25 @@ export function diff( } break; case 'STYLE': - applyVirtualStyleRulesToNode( - oldElement as HTMLStyleElement, - (newTree as RRStyleElement).rules, - ); + { + const styleSheet = (oldElement as HTMLStyleElement).sheet; + styleSheet && + (newTree as RRStyleElement).rules.forEach((data) => + replayer.applyStyleSheetMutation(data, styleSheet), + ); + } break; } if (newRRElement.shadowRoot) { if (!oldElement.shadowRoot) oldElement.attachShadow({ mode: 'open' }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const oldChildren = oldElement.shadowRoot!.childNodes; const newChildren = newRRElement.shadowRoot.childNodes; if (oldChildren.length > 0 || newChildren.length > 0) diffChildren( Array.from(oldChildren), newChildren, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion oldElement.shadowRoot!, replayer, rrnodeMirror, @@ -335,6 +346,7 @@ function diffChildren( } indexInOld = oldIdToIndex[rrnodeMirror.getId(newStartNode)]; if (indexInOld) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const nodeToMove = oldChildren[indexInOld]!; parentNode.insertBefore(nodeToMove, oldStartNode); diff(nodeToMove, newStartNode, replayer, rrnodeMirror); @@ -439,110 +451,3 @@ export function createOrGetNode( if (sn) domMirror.add(node, { ...sn }); return node; } - -export function getNestedRule( - rules: CSSRuleList, - position: number[], -): CSSGroupingRule { - const rule = rules[position[0]] as CSSGroupingRule; - if (position.length === 1) { - return rule; - } else { - return getNestedRule( - (rule.cssRules[position[1]] as CSSGroupingRule).cssRules, - position.slice(2), - ); - } -} - -export enum StyleRuleType { - Insert, - Remove, - Snapshot, - SetProperty, - RemoveProperty, -} -type InsertRule = { - cssText: string; - type: StyleRuleType.Insert; - index?: number | number[]; -}; -type RemoveRule = { - type: StyleRuleType.Remove; - index: number | number[]; -}; -type SetPropertyRule = { - type: StyleRuleType.SetProperty; - index: number[]; - property: string; - value: string | null; - priority: string | undefined; -}; -type RemovePropertyRule = { - type: StyleRuleType.RemoveProperty; - index: number[]; - property: string; -}; - -export type VirtualStyleRules = Array< - InsertRule | RemoveRule | SetPropertyRule | RemovePropertyRule ->; - -export function getPositionsAndIndex(nestedIndex: number[]) { - const positions = [...nestedIndex]; - const index = positions.pop(); - return { positions, index }; -} - -export function applyVirtualStyleRulesToNode( - styleNode: HTMLStyleElement, - virtualStyleRules: VirtualStyleRules, -) { - const sheet = styleNode.sheet!; - - virtualStyleRules.forEach((rule) => { - if (rule.type === StyleRuleType.Insert) { - try { - if (Array.isArray(rule.index)) { - const { positions, index } = getPositionsAndIndex(rule.index); - const nestedRule = getNestedRule(sheet.cssRules, positions); - nestedRule.insertRule(rule.cssText, index); - } else { - sheet.insertRule(rule.cssText, rule.index); - } - } catch (e) { - /** - * sometimes we may capture rules with browser prefix - * insert rule with prefixs in other browsers may cause Error - */ - } - } else if (rule.type === StyleRuleType.Remove) { - try { - if (Array.isArray(rule.index)) { - const { positions, index } = getPositionsAndIndex(rule.index); - const nestedRule = getNestedRule(sheet.cssRules, positions); - nestedRule.deleteRule(index || 0); - } else { - sheet.deleteRule(rule.index); - } - } catch (e) { - /** - * accessing styleSheet rules may cause SecurityError - * for specific access control settings - */ - } - } else if (rule.type === StyleRuleType.SetProperty) { - const nativeRule = (getNestedRule( - sheet.cssRules, - rule.index, - ) as unknown) as CSSStyleRule; - nativeRule.style.setProperty(rule.property, rule.value, rule.priority); - } else if (rule.type === StyleRuleType.RemoveProperty) { - const nativeRule = (getNestedRule( - sheet.cssRules, - rule.index, - ) as unknown) as CSSStyleRule; - nativeRule.style.removeProperty(rule.property); - } - }); -} diff --git a/packages/rrdom/src/index.ts b/packages/rrdom/src/index.ts index 0caefa8d..776427a1 100644 --- a/packages/rrdom/src/index.ts +++ b/packages/rrdom/src/index.ts @@ -12,8 +12,9 @@ import type { canvasEventWithTime, inputData, scrollData, + styleSheetRuleData, + styleDeclarationData, } from 'rrweb/src/types'; -import type { VirtualStyleRules } from './diff'; import { BaseRRNode as RRNode, BaseRRCDATASectionImpl, @@ -164,7 +165,7 @@ export class RRCanvasElement extends RRElement implements IRRElement { } export class RRStyleElement extends RRElement { - public rules: VirtualStyleRules = []; + public rules: (styleSheetRuleData | styleDeclarationData)[] = []; } export class RRIFrameElement extends RRElement { @@ -312,7 +313,8 @@ export function buildFromDom( } if (node.nodeName === 'IFRAME') { - walk((node as HTMLIFrameElement).contentDocument!, rrNode); + const iframeDoc = (node as HTMLIFrameElement).contentDocument; + iframeDoc && walk(iframeDoc, rrNode); } else if ( node.nodeType === NodeType.DOCUMENT_NODE || node.nodeType === NodeType.ELEMENT_NODE || @@ -323,6 +325,7 @@ export function buildFromDom( node.nodeType === NodeType.ELEMENT_NODE && (node as HTMLElement).shadowRoot ) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion walk((node as HTMLElement).shadowRoot!, rrNode); node.childNodes.forEach((childNode) => walk(childNode, rrNode)); } @@ -475,11 +478,5 @@ function walk(node: IRRNode, mirror: IMirror, blankSpace: string) { export { RRNode }; -export { - diff, - createOrGetNode, - StyleRuleType, - ReplayerHandler, - VirtualStyleRules, -} from './diff'; +export { diff, createOrGetNode, ReplayerHandler } from './diff'; export * from './document'; diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index 699b0aee..723b2a3e 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -2,14 +2,7 @@ * @jest-environment jsdom */ import { getDefaultSN, RRDocument, RRMediaElement } from '../src'; -import { - applyVirtualStyleRulesToNode, - createOrGetNode, - diff, - ReplayerHandler, - StyleRuleType, - VirtualStyleRules, -} from '../src/diff'; +import { createOrGetNode, diff, ReplayerHandler } from '../src/diff'; import { NodeType as RRNodeType, serializedNodeWithId, @@ -17,11 +10,14 @@ import { Mirror, } from 'rrweb-snapshot'; import type { IRRNode } from '../src/document'; -import { +import { Replayer } from 'rrweb'; +import type { canvasMutationData, - EventType, - IncrementalSource, + styleDeclarationData, + styleSheetRuleData, } from 'rrweb/src/types'; +import { EventType, IncrementalSource } from 'rrweb/src/types'; +import type { eventWithTime } from 'rrweb/typings/types'; const elementSn = { type: RRNodeType.Element, @@ -101,6 +97,7 @@ describe('diff algorithm for rrdom', () => { applyCanvas: () => {}, applyInput: () => {}, applyScroll: () => {}, + applyStyleSheetMutation: () => {}, }; }); @@ -162,12 +159,23 @@ describe('diff algorithm for rrdom', () => { document.documentElement.appendChild(element); const rrDocument = new RRDocument(); const rrStyle = rrDocument.createElement('style'); - rrStyle.rules = [ - { cssText: 'div{color: black;}', type: StyleRuleType.Insert, index: 0 }, - ]; + const styleData: styleSheetRuleData = { + source: IncrementalSource.StyleSheetRule, + adds: [ + { + rule: 'div{color: black;}', + index: 0, + }, + ], + }; + rrStyle.rules = [styleData]; + replayer.applyStyleSheetMutation = jest.fn(); diff(element, rrStyle, replayer); - expect(element.sheet!.cssRules.length).toEqual(1); - expect(element.sheet!.cssRules[0].cssText).toEqual('div {color: black;}'); + expect(replayer.applyStyleSheetMutation).toHaveBeenCalledTimes(1); + expect(replayer.applyStyleSheetMutation).toHaveBeenCalledWith( + styleData, + element.sheet, + ); }); it('should diff a canvas element', () => { @@ -1267,16 +1275,50 @@ describe('diff algorithm for rrdom', () => { }); describe('apply virtual style rules to node', () => { + beforeEach(() => { + const dummyReplayer = new Replayer(([ + { + type: EventType.DomContentLoaded, + timestamp: 0, + }, + { + type: EventType.Meta, + data: { + with: 1920, + height: 1080, + }, + timestamp: 0, + }, + ] as unknown) as eventWithTime[]); + replayer.applyStyleSheetMutation = ( + data: styleDeclarationData | styleSheetRuleData, + styleSheet: CSSStyleSheet, + ) => { + if (data.source === IncrementalSource.StyleSheetRule) + // Disable the ts check here because these two functions are private methods. + // @ts-ignore + dummyReplayer.applyStyleSheetRule(data, styleSheet); + else if (data.source === IncrementalSource.StyleDeclaration) + // @ts-ignore + dummyReplayer.applyStyleDeclaration(data, styleSheet); + }; + }); + it('should insert rule at index 0 in empty sheet', () => { document.write(''); const styleEl = document.getElementsByTagName('style')[0]; - const cssText = '.added-rule {border: 1px solid yellow;}'; - const virtualStyleRules: VirtualStyleRules = [ - { cssText, index: 0, type: StyleRuleType.Insert }, - ]; - applyVirtualStyleRulesToNode(styleEl, virtualStyleRules); + const styleRuleData: styleSheetRuleData = { + source: IncrementalSource.StyleSheetRule, + adds: [ + { + rule: cssText, + index: 0, + }, + ], + }; + replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!); expect(styleEl.sheet?.cssRules?.length).toEqual(1); expect(styleEl.sheet?.cssRules[0].cssText).toEqual(cssText); @@ -1292,10 +1334,16 @@ describe('diff algorithm for rrdom', () => { const styleEl = document.getElementsByTagName('style')[0]; const cssText = '.added-rule {border: 1px solid yellow;}'; - const virtualStyleRules: VirtualStyleRules = [ - { cssText, index: 0, type: StyleRuleType.Insert }, - ]; - applyVirtualStyleRulesToNode(styleEl, virtualStyleRules); + const styleRuleData: styleSheetRuleData = { + source: IncrementalSource.StyleSheetRule, + adds: [ + { + rule: cssText, + index: 0, + }, + ], + }; + replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!); expect(styleEl.sheet?.cssRules?.length).toEqual(3); expect(styleEl.sheet?.cssRules[0].cssText).toEqual(cssText); @@ -1310,10 +1358,15 @@ describe('diff algorithm for rrdom', () => { `); const styleEl = document.getElementsByTagName('style')[0]; - const virtualStyleRules: VirtualStyleRules = [ - { index: 0, type: StyleRuleType.Remove }, - ]; - applyVirtualStyleRulesToNode(styleEl, virtualStyleRules); + const styleRuleData: styleSheetRuleData = { + source: IncrementalSource.StyleSheetRule, + removes: [ + { + index: 0, + }, + ], + }; + replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!); expect(styleEl.sheet?.cssRules?.length).toEqual(1); expect(styleEl.sheet?.cssRules[0].cssText).toEqual('div {color: black;}'); @@ -1331,10 +1384,16 @@ describe('diff algorithm for rrdom', () => { const styleEl = document.getElementsByTagName('style')[0]; const cssText = '.added-rule {border: 1px solid yellow;}'; - const virtualStyleRules: VirtualStyleRules = [ - { cssText, index: [0, 0], type: StyleRuleType.Insert }, - ]; - applyVirtualStyleRulesToNode(styleEl, virtualStyleRules); + const styleRuleData: styleSheetRuleData = { + source: IncrementalSource.StyleSheetRule, + adds: [ + { + rule: cssText, + index: [0, 0], + }, + ], + }; + replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!); expect( (styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules?.length, @@ -1354,11 +1413,15 @@ describe('diff algorithm for rrdom', () => { `); const styleEl = document.getElementsByTagName('style')[0]; - - const virtualStyleRules: VirtualStyleRules = [ - { index: [0, 1], type: StyleRuleType.Remove }, - ]; - applyVirtualStyleRulesToNode(styleEl, virtualStyleRules); + const styleRuleData: styleSheetRuleData = { + source: IncrementalSource.StyleSheetRule, + removes: [ + { + index: [0, 1], + }, + ], + }; + replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!); expect( (styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules?.length, diff --git a/packages/rrdom/tsconfig.json b/packages/rrdom/tsconfig.json index 44af127b..4cf4a46f 100644 --- a/packages/rrdom/tsconfig.json +++ b/packages/rrdom/tsconfig.json @@ -22,5 +22,9 @@ ], "compileOnSave": true, "exclude": ["test"], - "include": ["src", "../rrweb/src/record/workers/workers.d.ts"] + "include": [ + "src", + "../rrweb/src/record/workers/workers.d.ts", + "../rrweb/src/record/constructable-stylesheets.d.ts" + ] } diff --git a/packages/rrweb-player/src/utils.ts b/packages/rrweb-player/src/utils.ts index 32f1faf5..bee0bde5 100644 --- a/packages/rrweb-player/src/utils.ts +++ b/packages/rrweb-player/src/utils.ts @@ -83,12 +83,18 @@ export function exitFullscreen(): Promise { } export function isFullscreen(): boolean { - return ( - document.fullscreen || - document.webkitIsFullScreen || - document.mozFullScreen || - document.msFullscreenElement - ); + let fullscreen = false; + [ + 'fullscreen', + 'webkitIsFullScreen', + 'mozFullScreen', + 'msFullscreenElement', + ].forEach((fullScreenAccessor) => { + if (fullScreenAccessor in document) { + fullscreen = fullscreen || Boolean(document[fullScreenAccessor]); + } + }); + return fullscreen; } export function onFullscreenChange(handler: () => unknown): () => void { diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index c5c317bf..6907802d 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -364,11 +364,9 @@ export function buildNodeWithSN( if (!node) { return null; } - if (n.rootId) { - console.assert( - (mirror.getNode(n.rootId) as Document) === doc, - 'Target document should have the same root id.', - ); + // If the snapshot is created by checkout, the rootId doesn't change but the iframe's document can be changed automatically when a new iframe element is created. + if (n.rootId && (mirror.getNode(n.rootId) as Document) !== doc) { + mirror.replace(n.rootId, doc); } // use target document as root document if (n.type === NodeType.Document) { diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index a947b073..1b42dd5b 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -19,6 +19,7 @@ import { isShadowRoot, maskInputValue, isNativeShadowDom, + getCssRulesString, } from './utils'; let _id = 1; @@ -47,31 +48,6 @@ function getValidTagName(element: HTMLElement): string { return processedTagName; } -function getCssRulesString(s: CSSStyleSheet): string | null { - try { - const rules = s.rules || s.cssRules; - return rules ? Array.from(rules).map(getCssRuleString).join('') : null; - } catch (error) { - return null; - } -} - -function getCssRuleString(rule: CSSRule): string { - let cssStringified = rule.cssText; - if (isCSSImportRule(rule)) { - try { - cssStringified = getCssRulesString(rule.styleSheet) || cssStringified; - } catch { - // ignore - } - } - return cssStringified; -} - -function isCSSImportRule(rule: CSSRule): rule is CSSImportRule { - return 'styleSheet' in rule; -} - function stringifyStyleSheet(sheet: CSSStyleSheet): string { return sheet.cssRules ? Array.from(sheet.cssRules) diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 1c151a1e..8f63e44f 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -24,6 +24,31 @@ export function isNativeShadowDom(shadowRoot: ShadowRoot) { return Object.prototype.toString.call(shadowRoot) === '[object ShadowRoot]'; } +export function getCssRulesString(s: CSSStyleSheet): string | null { + try { + const rules = s.rules || s.cssRules; + return rules ? Array.from(rules).map(getCssRuleString).join('') : null; + } catch (error) { + return null; + } +} + +export function getCssRuleString(rule: CSSRule): string { + let cssStringified = rule.cssText; + if (isCSSImportRule(rule)) { + try { + cssStringified = getCssRulesString(rule.styleSheet) || cssStringified; + } catch { + // ignore + } + } + return cssStringified; +} + +export function isCSSImportRule(rule: CSSRule): rule is CSSImportRule { + return 'styleSheet' in rule; +} + export class Mirror implements IMirror { private idNodeMap: idNodeMap = new Map(); private nodeMetaMap: nodeMetaMap = new WeakMap(); @@ -76,6 +101,11 @@ export class Mirror implements IMirror { } replace(id: number, n: Node) { + const oldNode = this.getNode(id); + if (oldNode) { + const meta = this.nodeMetaMap.get(oldNode); + if (meta) this.nodeMetaMap.set(n, meta); + } this.idNodeMap.set(id, n); } diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index c529dcaa..d4cfcff2 100644 --- a/packages/rrweb/package.json +++ b/packages/rrweb/package.json @@ -55,6 +55,7 @@ "@types/offscreencanvas": "^2019.6.4", "@types/prettier": "^2.3.2", "@types/puppeteer": "^5.4.4", + "construct-style-sheets-polyfill": "^3.1.0", "cross-env": "^5.2.0", "esbuild": "^0.14.38", "fast-mhtml": "^1.1.9", diff --git a/packages/rrweb/src/plugins/canvas-webrtc/simple-peer-light.d.ts b/packages/rrweb/src/plugins/canvas-webrtc/simple-peer-light.d.ts index 203aa564..9f7fc868 100644 --- a/packages/rrweb/src/plugins/canvas-webrtc/simple-peer-light.d.ts +++ b/packages/rrweb/src/plugins/canvas-webrtc/simple-peer-light.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ /// declare module 'simple-peer-light' { import * as stream from 'stream'; diff --git a/packages/rrweb/src/record/constructable-stylesheets.d.ts b/packages/rrweb/src/record/constructable-stylesheets.d.ts new file mode 100644 index 00000000..8545dfdf --- /dev/null +++ b/packages/rrweb/src/record/constructable-stylesheets.d.ts @@ -0,0 +1,10 @@ +// This informs the TS compiler about constructed stylesheets. +// It can be removed when this is fixed: https://github.com/Microsoft/TypeScript/issues/30022 +declare interface DocumentOrShadowRoot { + adoptedStyleSheets?: CSSStyleSheet[]; +} + +declare interface CSSStyleSheet { + replace?(text: string): Promise; + replaceSync?(text: string): void; +} diff --git a/packages/rrweb/src/record/iframe-manager.ts b/packages/rrweb/src/record/iframe-manager.ts index db2d85d4..ed2bef85 100644 --- a/packages/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/src/record/iframe-manager.ts @@ -1,13 +1,19 @@ import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; import type { mutationCallBack } from '../types'; +import type { StylesheetManager } from './stylesheet-manager'; export class IframeManager { private iframes: WeakMap = new WeakMap(); private mutationCb: mutationCallBack; private loadListener?: (iframeEl: HTMLIFrameElement) => unknown; + private stylesheetManager: StylesheetManager; - constructor(options: { mutationCb: mutationCallBack }) { + constructor(options: { + mutationCb: mutationCallBack; + stylesheetManager: StylesheetManager; + }) { this.mutationCb = options.mutationCb; + this.stylesheetManager = options.stylesheetManager; } public addIframe(iframeEl: HTMLIFrameElement) { @@ -37,5 +43,15 @@ export class IframeManager { isAttachIframe: true, }); this.loadListener?.(iframeEl); + + if ( + iframeEl.contentDocument && + iframeEl.contentDocument.adoptedStyleSheets && + iframeEl.contentDocument.adoptedStyleSheets.length > 0 + ) + this.stylesheetManager.adoptStyleSheets( + iframeEl.contentDocument.adoptedStyleSheets, + mirror.getId(iframeEl.contentDocument), + ); } } diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index cd996c65..1718a036 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -24,6 +24,7 @@ import { mutationCallbackParam, scrollCallback, canvasMutationParam, + adoptedStyleSheetParam, } from '../types'; import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; @@ -225,12 +226,25 @@ function record( }), ); - const iframeManager = new IframeManager({ - mutationCb: wrappedMutationEmit, - }); + const wrappedAdoptedStyleSheetEmit = (a: adoptedStyleSheetParam) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + ...a, + }, + }), + ); const stylesheetManager = new StylesheetManager({ mutationCb: wrappedMutationEmit, + adoptedStyleSheetCb: wrappedAdoptedStyleSheetEmit, + }); + + const iframeManager = new IframeManager({ + mutationCb: wrappedMutationEmit, + stylesheetManager: stylesheetManager, }); canvasManager = new CanvasManager({ @@ -282,6 +296,9 @@ function record( isCheckout, ); + // When we take a full snapshot, old tracked StyleSheets need to be removed. + stylesheetManager.reset(); + mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting const node = snapshot(document, { mirror, @@ -301,7 +318,7 @@ function record( iframeManager.addIframe(n as HTMLIFrameElement); } if (isSerializedStylesheet(n, mirror)) { - stylesheetManager.addStylesheet(n as HTMLLinkElement); + stylesheetManager.trackLinkElement(n as HTMLLinkElement); } if (hasShadowRoot(n)) { shadowDomManager.addShadowRoot(n.shadowRoot, document); @@ -312,7 +329,7 @@ function record( shadowDomManager.observeAttachShadow(iframe); }, onStylesheetLoad: (linkEl, childSn) => { - stylesheetManager.attachStylesheet(linkEl, childSn, mirror); + stylesheetManager.attachLinkElement(linkEl, childSn, mirror); }, keepIframeSrcFn, }); @@ -332,20 +349,27 @@ function record( ? window.pageXOffset : document?.documentElement.scrollLeft || document?.body?.parentElement?.scrollLeft || - document?.body.scrollLeft || + document?.body?.scrollLeft || 0, top: window.pageYOffset !== undefined ? window.pageYOffset : document?.documentElement.scrollTop || document?.body?.parentElement?.scrollTop || - document?.body.scrollTop || + document?.body?.scrollTop || 0, }, }, }), ); mutationBuffers.forEach((buf) => buf.unlock()); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror + + // Some old browsers don't support adoptedStyleSheets. + if (document.adoptedStyleSheets && document.adoptedStyleSheets.length > 0) + stylesheetManager.adoptStyleSheets( + document.adoptedStyleSheets, + mirror.getId(document), + ); }; try { diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 3a0b3afb..c852f3eb 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -311,7 +311,9 @@ export default class MutationBuffer { this.iframeManager.addIframe(currentN as HTMLIFrameElement); } if (isSerializedStylesheet(currentN, this.mirror)) { - this.stylesheetManager.addStylesheet(currentN as HTMLLinkElement); + this.stylesheetManager.trackLinkElement( + currentN as HTMLLinkElement, + ); } if (hasShadowRoot(n)) { this.shadowDomManager.addShadowRoot(n.shadowRoot, document); @@ -322,7 +324,7 @@ export default class MutationBuffer { this.shadowDomManager.observeAttachShadow(iframe); }, onStylesheetLoad: (link, childSn) => { - this.stylesheetManager.attachStylesheet(link, childSn, this.mirror); + this.stylesheetManager.attachLinkElement(link, childSn, this.mirror); }, }); if (sn) { diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 53bb35f1..6fb2edad 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -1,4 +1,4 @@ -import { MaskInputOptions, maskInputValue } from 'rrweb-snapshot'; +import { MaskInputOptions, maskInputValue, Mirror } from 'rrweb-snapshot'; import type { FontFaceSet } from 'css-font-loading-module'; import { throttle, @@ -9,6 +9,7 @@ import { isBlocked, isTouchEvent, patch, + StyleSheetMirror, } from '../utils'; import { mutationCallBack, @@ -499,8 +500,8 @@ function getNestedCSSRulePositions(rule: CSSRule): number[] { ); const index = rules.indexOf(childRule); pos.unshift(index); - } else { - const rules = Array.from(childRule.parentStyleSheet!.cssRules); + } else if (childRule.parentStyleSheet) { + const rules = Array.from(childRule.parentStyleSheet.cssRules); const index = rules.indexOf(childRule); pos.unshift(index); } @@ -509,8 +510,30 @@ function getNestedCSSRulePositions(rule: CSSRule): number[] { return recurse(rule, positions); } +/** + * For StyleSheets in Element, this function retrieves id of its host element. + * For adopted StyleSheets, this function retrieves its styleId from a styleMirror. + */ +function getIdAndStyleId( + sheet: CSSStyleSheet | undefined | null, + mirror: Mirror, + styleMirror: StyleSheetMirror, +): { + styleId?: number; + id?: number; +} { + let id, styleId; + if (!sheet) return {}; + if (sheet.ownerNode) id = mirror.getId(sheet.ownerNode as Node); + else styleId = styleMirror.getId(sheet); + return { + styleId, + id, + }; +} + function initStyleSheetObserver( - { styleSheetRuleCb, mirror }: observerParam, + { styleSheetRuleCb, mirror, stylesheetManager }: observerParam, { win }: { win: IWindow }, ): listenerHandler { // eslint-disable-next-line @typescript-eslint/unbound-method @@ -520,10 +543,16 @@ function initStyleSheetObserver( rule: string, index?: number, ) { - const id = mirror.getId(this.ownerNode as Node); - if (id !== -1) { + const { id, styleId } = getIdAndStyleId( + this, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, + styleId, adds: [{ rule, index }], }); } @@ -536,16 +565,72 @@ function initStyleSheetObserver( this: CSSStyleSheet, index: number, ) { - const id = mirror.getId(this.ownerNode as Node); - if (id !== -1) { + const { id, styleId } = getIdAndStyleId( + this, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, + styleId, removes: [{ index }], }); } return deleteRule.apply(this, [index]); }; + let replace: (text: string) => Promise; + if (win.CSSStyleSheet.prototype.replace) { + // eslint-disable-next-line @typescript-eslint/unbound-method + replace = win.CSSStyleSheet.prototype.replace; + win.CSSStyleSheet.prototype.replace = function ( + this: CSSStyleSheet, + text: string, + ) { + const { id, styleId } = getIdAndStyleId( + this, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleSheetRuleCb({ + id, + styleId, + replace: text, + }); + } + return replace.apply(this, [text]); + }; + } + + let replaceSync: (text: string) => void; + if (win.CSSStyleSheet.prototype.replaceSync) { + // eslint-disable-next-line @typescript-eslint/unbound-method + replaceSync = win.CSSStyleSheet.prototype.replaceSync; + win.CSSStyleSheet.prototype.replaceSync = function ( + this: CSSStyleSheet, + text: string, + ) { + const { id, styleId } = getIdAndStyleId( + this, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleSheetRuleCb({ + id, + styleId, + replaceSync: text, + }); + } + return replaceSync.apply(this, [text]); + }; + } + const supportedNestedCSSRuleTypes: { [key: string]: GroupingCSSRuleTypes; } = {}; @@ -582,12 +667,21 @@ function initStyleSheetObserver( deleteRule: type.prototype.deleteRule, }; - type.prototype.insertRule = function (rule: string, index?: number) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const id = mirror.getId(this.parentStyleSheet.ownerNode as Node); - if (id !== -1) { + type.prototype.insertRule = function ( + this: CSSGroupingRule, + rule: string, + index?: number, + ) { + const { id, styleId } = getIdAndStyleId( + this.parentStyleSheet, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, + styleId, adds: [ { rule, @@ -602,12 +696,20 @@ function initStyleSheetObserver( return unmodifiedFunctions[typeKey].insertRule.apply(this, [rule, index]); }; - type.prototype.deleteRule = function (index: number) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const id = mirror.getId(this.parentStyleSheet.ownerNode as Node); - if (id !== -1) { + type.prototype.deleteRule = function ( + this: CSSGroupingRule, + index: number, + ) { + const { id, styleId } = getIdAndStyleId( + this.parentStyleSheet, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, + styleId, removes: [ { index: [...getNestedCSSRulePositions(this as CSSRule), index] }, ], @@ -620,6 +722,8 @@ function initStyleSheetObserver( return () => { win.CSSStyleSheet.prototype.insertRule = insertRule; win.CSSStyleSheet.prototype.deleteRule = deleteRule; + replace && (win.CSSStyleSheet.prototype.replace = replace); + replaceSync && (win.CSSStyleSheet.prototype.replaceSync = replaceSync); Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => { type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule; type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule; @@ -627,8 +731,76 @@ function initStyleSheetObserver( }; } +export function initAdoptedStyleSheetObserver( + { + mirror, + stylesheetManager, + }: Pick, + host: Document | ShadowRoot, +): listenerHandler { + let hostId: number | null = null; + // host of adoptedStyleSheets is outermost document or IFrame's document + if (host.nodeName === '#document') hostId = mirror.getId(host); + // The host is a ShadowRoot. + else hostId = mirror.getId((host as ShadowRoot).host); + + const patchTarget = + host.nodeName === '#document' + ? (host as Document).defaultView?.Document + : host.ownerDocument?.defaultView?.ShadowRoot; + const originalPropertyDescriptor = Object.getOwnPropertyDescriptor( + patchTarget?.prototype, + 'adoptedStyleSheets', + ); + if ( + hostId === null || + hostId === -1 || + !patchTarget || + !originalPropertyDescriptor + ) + return () => { + // + }; + + // Patch adoptedStyleSheets by overriding the original one. + Object.defineProperty(host, 'adoptedStyleSheets', { + configurable: originalPropertyDescriptor.configurable, + enumerable: originalPropertyDescriptor.enumerable, + get(): CSSStyleSheet[] { + return originalPropertyDescriptor.get?.call(this) as CSSStyleSheet[]; + }, + set(sheets: CSSStyleSheet[]) { + const result = originalPropertyDescriptor.set?.call(this, sheets); + if (hostId !== null && hostId !== -1) { + try { + stylesheetManager.adoptStyleSheets(sheets, hostId); + } catch (e) { + // for safety + } + } + return result; + }, + }); + + return () => { + Object.defineProperty(host, 'adoptedStyleSheets', { + configurable: originalPropertyDescriptor.configurable, + enumerable: originalPropertyDescriptor.enumerable, + // eslint-disable-next-line @typescript-eslint/unbound-method + get: originalPropertyDescriptor.get, + // eslint-disable-next-line @typescript-eslint/unbound-method + set: originalPropertyDescriptor.set, + }); + }; +} + function initStyleDeclarationObserver( - { styleDeclarationCb, mirror, ignoreCSSAttributes }: observerParam, + { + styleDeclarationCb, + mirror, + ignoreCSSAttributes, + stylesheetManager, + }: observerParam, { win }: { win: IWindow }, ): listenerHandler { // eslint-disable-next-line @typescript-eslint/unbound-method @@ -643,15 +815,21 @@ function initStyleDeclarationObserver( if (ignoreCSSAttributes.has(property)) { return setProperty.apply(this, [property, value, priority]); } - const id = mirror.getId(this.parentRule?.parentStyleSheet?.ownerNode); - if (id !== -1) { + const { id, styleId } = getIdAndStyleId( + this.parentRule?.parentStyleSheet, + mirror, + stylesheetManager.styleMirror, + ); + if ((id && id !== -1) || (styleId && styleId !== -1)) { styleDeclarationCb({ id, + styleId, set: { property, value, priority, }, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion index: getNestedCSSRulePositions(this.parentRule!), }); } @@ -668,13 +846,19 @@ function initStyleDeclarationObserver( if (ignoreCSSAttributes.has(property)) { return removeProperty.apply(this, [property]); } - const id = mirror.getId(this.parentRule?.parentStyleSheet?.ownerNode); - if (id !== -1) { + const { id, styleId } = getIdAndStyleId( + this.parentRule?.parentStyleSheet, + mirror, + stylesheetManager.styleMirror, + ); + if ((id && id !== -1) || (styleId && styleId !== -1)) { styleDeclarationCb({ id, + styleId, remove: { property, }, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion index: getNestedCSSRulePositions(this.parentRule!), }); } @@ -939,6 +1123,7 @@ export function initObservers( const mediaInteractionHandler = initMediaInteractionObserver(o); const styleSheetObserver = initStyleSheetObserver(o, { win: currentWindow }); + const adoptedStyleSheetObserver = initAdoptedStyleSheetObserver(o, o.doc); const styleDeclarationObserver = initStyleDeclarationObserver(o, { win: currentWindow, }); @@ -967,6 +1152,7 @@ export function initObservers( inputHandler(); mediaInteractionHandler(); styleSheetObserver(); + adoptedStyleSheetObserver(); styleDeclarationObserver(); fontObserver(); selectionObserver(); diff --git a/packages/rrweb/src/record/observers/canvas/2d.ts b/packages/rrweb/src/record/observers/canvas/2d.ts index dd54e554..8de2aa77 100644 --- a/packages/rrweb/src/record/observers/canvas/2d.ts +++ b/packages/rrweb/src/record/observers/canvas/2d.ts @@ -14,7 +14,6 @@ export default function initCanvas2DMutationObserver( win: IWindow, blockClass: blockClass, blockSelector: string | null, - mirror: Mirror, ): listenerHandler { const handlers: listenerHandler[] = []; const props2D = Object.getOwnPropertyNames( diff --git a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts index 5a2cedb1..2974d2a4 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts @@ -243,7 +243,6 @@ export class CanvasManager { win, blockClass, blockSelector, - this.mirror, ); const canvasWebGL1and2Reset = initCanvasWebGLMutationObserver( diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index bfff380e..16f679c8 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -4,7 +4,11 @@ import type { MutationBufferParam, SamplingStrategy, } from '../types'; -import { initMutationObserver, initScrollObserver } from './observer'; +import { + initMutationObserver, + initScrollObserver, + initAdoptedStyleSheetObserver, +} from './observer'; import { patch } from '../utils'; import type { Mirror } from 'rrweb-snapshot'; import { isNativeShadowDom } from 'rrweb-snapshot'; @@ -76,6 +80,24 @@ export class ShadowDomManager { doc: (shadowRoot as unknown) as Document, mirror: this.mirror, }); + // Defer this to avoid adoptedStyleSheet events being created before the full snapshot is created or attachShadow action is recorded. + setTimeout(() => { + if ( + shadowRoot.adoptedStyleSheets && + shadowRoot.adoptedStyleSheets.length > 0 + ) + this.bypassOptions.stylesheetManager.adoptStyleSheets( + shadowRoot.adoptedStyleSheets, + this.mirror.getId(shadowRoot.host), + ); + initAdoptedStyleSheetObserver( + { + mirror: this.mirror, + stylesheetManager: this.bypassOptions.stylesheetManager, + }, + shadowRoot, + ); + }, 0); } /** diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index 01a69744..1a2f6dc1 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -1,29 +1,27 @@ import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; -import type { mutationCallBack } from '../types'; +import { getCssRuleString } from 'rrweb-snapshot'; +import type { + adoptedStyleSheetCallback, + adoptedStyleSheetParam, + mutationCallBack, +} from '../types'; +import { StyleSheetMirror } from '../utils'; export class StylesheetManager { - private trackedStylesheets: WeakSet = new WeakSet(); + private trackedLinkElements: WeakSet = new WeakSet(); private mutationCb: mutationCallBack; + private adoptedStyleSheetCb: adoptedStyleSheetCallback; + public styleMirror = new StyleSheetMirror(); - constructor(options: { mutationCb: mutationCallBack }) { + constructor(options: { + mutationCb: mutationCallBack; + adoptedStyleSheetCb: adoptedStyleSheetCallback; + }) { this.mutationCb = options.mutationCb; + this.adoptedStyleSheetCb = options.adoptedStyleSheetCb; } - public addStylesheet(linkEl: HTMLLinkElement) { - if (this.trackedStylesheets.has(linkEl)) return; - - this.trackedStylesheets.add(linkEl); - this.trackStylesheet(linkEl); - } - - // TODO: take snapshot on stylesheet reload by applying event listener - private trackStylesheet(linkEl: HTMLLinkElement) { - // linkEl.addEventListener('load', () => { - // // re-loaded, maybe take another snapshot? - // }); - } - - public attachStylesheet( + public attachLinkElement( linkEl: HTMLLinkElement, childSn: serializedNodeWithId, mirror: Mirror, @@ -40,6 +38,54 @@ export class StylesheetManager { texts: [], attributes: [], }); - this.addStylesheet(linkEl); + + this.trackLinkElement(linkEl); + } + + public trackLinkElement(linkEl: HTMLLinkElement) { + if (this.trackedLinkElements.has(linkEl)) return; + + this.trackedLinkElements.add(linkEl); + this.trackStylesheetInLinkElement(linkEl); + } + + public adoptStyleSheets(sheets: CSSStyleSheet[], hostId: number) { + if (sheets.length === 0) return; + const adoptedStyleSheetData: adoptedStyleSheetParam = { + id: hostId, + styleIds: [] as number[], + }; + const styles: NonNullable = []; + for (const sheet of sheets) { + let styleId; + if (!this.styleMirror.has(sheet)) { + styleId = this.styleMirror.add(sheet); + const rules = Array.from(sheet.rules || CSSRule); + styles.push({ + styleId, + rules: rules.map((r, index) => { + return { + rule: getCssRuleString(r), + index, + }; + }), + }); + } else styleId = this.styleMirror.getId(sheet); + adoptedStyleSheetData.styleIds.push(styleId); + } + if (styles.length > 0) adoptedStyleSheetData.styles = styles; + this.adoptedStyleSheetCb(adoptedStyleSheetData); + } + + public reset() { + this.styleMirror.reset(); + this.trackedLinkElements = new WeakSet(); + } + + // TODO: take snapshot on stylesheet reload by applying event listener + private trackStylesheetInLinkElement(linkEl: HTMLLinkElement) { + // linkEl.addEventListener('load', () => { + // // re-loaded, maybe take another snapshot? + // }); } } diff --git a/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts b/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts index 9b11641b..51e75dbe 100644 --- a/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts +++ b/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts @@ -29,14 +29,18 @@ async function getTransparentBlobFor( dataURLOptions: DataURLOptions, ): Promise { const id = `${width}-${height}`; - if (transparentBlobMap.has(id)) return transparentBlobMap.get(id)!; - const offscreen = new OffscreenCanvas(width, height); - offscreen.getContext('2d'); // creates rendering context for `converToBlob` - const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while - const arrayBuffer = await blob.arrayBuffer(); - const base64 = encode(arrayBuffer); // cpu intensive - transparentBlobMap.set(id, base64); - return base64; + if ('OffscreenCanvas' in globalThis) { + if (transparentBlobMap.has(id)) return transparentBlobMap.get(id)!; + const offscreen = new OffscreenCanvas(width, height); + offscreen.getContext('2d'); // creates rendering context for `converToBlob` + const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while + const arrayBuffer = await blob.arrayBuffer(); + const base64 = encode(arrayBuffer); // cpu intensive + transparentBlobMap.set(id, base64); + return base64; + } else { + return ''; + } } // `as any` because: https://github.com/Microsoft/TypeScript/issues/20595 @@ -44,41 +48,42 @@ const worker: ImageBitmapDataURLResponseWorker = self; // eslint-disable-next-line @typescript-eslint/no-misused-promises worker.onmessage = async function (e) { - if (!('OffscreenCanvas' in globalThis)) - return worker.postMessage({ id: e.data.id }); + if ('OffscreenCanvas' in globalThis) { + const { id, bitmap, width, height, dataURLOptions } = e.data; - const { id, bitmap, width, height, dataURLOptions } = e.data; + const transparentBase64 = getTransparentBlobFor( + width, + height, + dataURLOptions, + ); - const transparentBase64 = getTransparentBlobFor( - width, - height, - dataURLOptions, - ); + const offscreen = new OffscreenCanvas(width, height); + const ctx = offscreen.getContext('2d')!; - const offscreen = new OffscreenCanvas(width, height); - const ctx = offscreen.getContext('2d')!; + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while + const type = blob.type; + const arrayBuffer = await blob.arrayBuffer(); + const base64 = encode(arrayBuffer); // cpu intensive - ctx.drawImage(bitmap, 0, 0); - bitmap.close(); - const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while - const type = blob.type; - const arrayBuffer = await blob.arrayBuffer(); - const base64 = encode(arrayBuffer); // cpu intensive + // on first try we should check if canvas is transparent, + // no need to save it's contents in that case + if (!lastBlobMap.has(id) && (await transparentBase64) === base64) { + lastBlobMap.set(id, base64); + return worker.postMessage({ id }); + } - // on first try we should check if canvas is transparent, - // no need to save it's contents in that case - if (!lastBlobMap.has(id) && (await transparentBase64) === base64) { + if (lastBlobMap.get(id) === base64) return worker.postMessage({ id }); // unchanged + worker.postMessage({ + id, + type, + base64, + width, + height, + }); lastBlobMap.set(id, base64); - return worker.postMessage({ id }); + } else { + return worker.postMessage({ id: e.data.id }); } - - if (lastBlobMap.get(id) === base64) return worker.postMessage({ id }); // unchanged - worker.postMessage({ - id, - type, - base64, - width, - height, - }); - lastBlobMap.set(id, base64); }; diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 8e1948e3..ae33316b 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -9,7 +9,6 @@ import { } from 'rrweb-snapshot'; import { RRDocument, - StyleRuleType, createOrGetNode, buildFromNode, buildFromDom, @@ -25,7 +24,6 @@ import type { RRCanvasElement, ReplayerHandler, Mirror as RRDOMMirror, - VirtualStyleRules, } from 'rrdom'; import * as mittProxy from 'mitt'; import { polyfill as smoothscrollPolyfill } from './smoothscroll'; @@ -60,6 +58,9 @@ import { canvasMutationParam, canvasEventWithTime, selectionData, + styleSheetRuleData, + styleDeclarationData, + adoptedStyleSheetData, } from '../types'; import { polyfill, @@ -72,6 +73,7 @@ import { getNestedRule, getPositionsAndIndex, uniqueTextMutations, + StyleSheetMirror, } from '../utils'; import getInjectStyleRules from './styles/inject-style'; import './styles/style.css'; @@ -136,6 +138,9 @@ export class Replayer { private mirror: Mirror = createMirror(); + // Used to track StyleSheetObjects adopted on multiple document hosts. + private styleMirror: StyleSheetMirror = new StyleSheetMirror(); + private firstFullSnapshot: eventWithTime | true | null = null; private newDocumentQueue: addedNodeMutation[] = []; @@ -146,6 +151,15 @@ export class Replayer { // In the fast-forward mode, only the last selection data needs to be applied. private lastSelectionData: selectionData | null = null; + // In the fast-forward mode using VirtualDom optimization, all stylesheetRule, and styleDeclaration events on constructed StyleSheets will be delayed to get applied until the flush stage. + private constructedStyleMutations: ( + | styleSheetRuleData + | styleDeclarationData + )[] = []; + + // Similar to the reason for constructedStyleMutations. + private adoptedStyleSheets: adoptedStyleSheetData[] = []; + constructor( events: Array, config?: Partial, @@ -206,13 +220,23 @@ export class Replayer { }, applyInput: this.applyInput.bind(this), applyScroll: this.applyScroll.bind(this), + applyStyleSheetMutation: ( + data: styleDeclarationData | styleSheetRuleData, + styleSheet: CSSStyleSheet, + ) => { + if (data.source === IncrementalSource.StyleSheetRule) + this.applyStyleSheetRule(data, styleSheet); + else if (data.source === IncrementalSource.StyleDeclaration) + this.applyStyleDeclaration(data, styleSheet); + }, }; - diff( - this.iframe.contentDocument!, - this.virtualDom, - replayerHandler, - this.virtualDom.mirror, - ); + this.iframe.contentDocument && + diff( + this.iframe.contentDocument, + this.virtualDom, + replayerHandler, + this.virtualDom.mirror, + ); this.virtualDom.destroyTree(); this.usingVirtualDom = false; @@ -240,6 +264,16 @@ export class Replayer { } } } + + this.constructedStyleMutations.forEach((data) => { + this.applyStyleSheetMutation(data); + }); + this.constructedStyleMutations = []; + + this.adoptedStyleSheets.forEach((data) => { + this.applyAdoptedStyleSheet(data); + }); + this.adoptedStyleSheets = []; } if (this.mousePos) { @@ -260,6 +294,7 @@ export class Replayer { this.emitter.on(ReplayerEvents.PlayBack, () => { this.firstFullSnapshot = null; this.mirror.reset(); + this.styleMirror.reset(); }); const timer = new Timer([], { @@ -611,6 +646,7 @@ export class Replayer { } this.rebuildFullSnapshot(event, isSync); this.iframe.contentWindow?.scrollTo(event.data.initialOffset); + this.styleMirror.reset(); }; break; case EventType.IncrementalSnapshot: @@ -772,14 +808,13 @@ export class Replayer { getDefaultSN(styleEl, this.virtualDom.unserializedId), ); (documentElement as RRElement).insertBefore(styleEl, head as RRElement); - for (let idx = 0; idx < injectStylesRules.length; idx++) { - // push virtual styles - styleEl.rules.push({ - cssText: injectStylesRules[idx], - type: StyleRuleType.Insert, - index: idx, - }); - } + styleEl.rules.push({ + source: IncrementalSource.StyleSheetRule, + adds: injectStylesRules.map((cssText, index) => ({ + rule: cssText, + index, + })), + }); } else { const styleEl = document.createElement('style'); (documentElement as HTMLElement).insertBefore( @@ -1204,121 +1239,15 @@ export class Replayer { } break; } - case IncrementalSource.StyleSheetRule: { - if (this.usingVirtualDom) { - const target = this.virtualDom.mirror.getNode(d.id) as RRStyleElement; - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - const rules: VirtualStyleRules = target.rules; - d.adds?.forEach(({ rule, index: nestedIndex }) => - rules?.push({ - cssText: rule, - index: nestedIndex, - type: StyleRuleType.Insert, - }), - ); - d.removes?.forEach(({ index: nestedIndex }) => - rules?.push({ index: nestedIndex, type: StyleRuleType.Remove }), - ); - } else { - const target = this.mirror.getNode(d.id); - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - const styleSheet = (target as HTMLStyleElement).sheet!; - d.adds?.forEach(({ rule, index: nestedIndex }) => { - try { - if (Array.isArray(nestedIndex)) { - const { positions, index } = getPositionsAndIndex(nestedIndex); - const nestedRule = getNestedRule( - styleSheet.cssRules, - positions, - ); - nestedRule.insertRule(rule, index); - } else { - const index = - nestedIndex === undefined - ? undefined - : Math.min(nestedIndex, styleSheet.cssRules.length); - styleSheet.insertRule(rule, index); - } - } catch (e) { - /** - * sometimes we may capture rules with browser prefix - * insert rule with prefixs in other browsers may cause Error - */ - /** - * accessing styleSheet rules may cause SecurityError - * for specific access control settings - */ - } - }); - - d.removes?.forEach(({ index: nestedIndex }) => { - try { - if (Array.isArray(nestedIndex)) { - const { positions, index } = getPositionsAndIndex(nestedIndex); - const nestedRule = getNestedRule( - styleSheet.cssRules, - positions, - ); - nestedRule.deleteRule(index || 0); - } else { - styleSheet?.deleteRule(nestedIndex); - } - } catch (e) { - /** - * same as insertRule - */ - } - }); - } - break; - } + case IncrementalSource.StyleSheetRule: case IncrementalSource.StyleDeclaration: { if (this.usingVirtualDom) { - const target = this.virtualDom.mirror.getNode(d.id) as RRStyleElement; - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - const rules: VirtualStyleRules = target.rules; - d.set && - rules.push({ - type: StyleRuleType.SetProperty, - index: d.index, - ...d.set, - }); - d.remove && - rules.push({ - type: StyleRuleType.RemoveProperty, - index: d.index, - ...d.remove, - }); - } else { - const target = (this.mirror.getNode( - d.id, - ) as Node) as HTMLStyleElement; - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - const styleSheet = target.sheet!; - if (d.set) { - const rule = (getNestedRule( - styleSheet.rules, - d.index, - ) as unknown) as CSSStyleRule; - rule.style.setProperty(d.set.property, d.set.value, d.set.priority); - } - - if (d.remove) { - const rule = (getNestedRule( - styleSheet.rules, - d.index, - ) as unknown) as CSSStyleRule; - rule.style.removeProperty(d.remove.property); - } - } + if (d.styleId) this.constructedStyleMutations.push(d); + else if (d.id) + (this.virtualDom.mirror.getNode( + d.id, + ) as RRStyleElement | null)?.rules.push(d); + } else this.applyStyleSheetMutation(d); break; } case IncrementalSource.CanvasMutation: { @@ -1377,6 +1306,11 @@ export class Replayer { this.applySelection(d); break; } + case IncrementalSource.AdoptedStyleSheet: { + if (this.usingVirtualDom) this.adoptedStyleSheets.push(d); + else this.applyAdoptedStyleSheet(d); + break; + } default: } } @@ -1823,6 +1757,166 @@ export class Replayer { } } + private applyStyleSheetMutation( + data: styleDeclarationData | styleSheetRuleData, + ) { + let styleSheet: CSSStyleSheet | null = null; + if (data.styleId) styleSheet = this.styleMirror.getStyle(data.styleId); + else if (data.id) + styleSheet = + (this.mirror.getNode(data.id) as HTMLStyleElement)?.sheet || null; + if (!styleSheet) return; + if (data.source === IncrementalSource.StyleSheetRule) + this.applyStyleSheetRule(data, styleSheet); + else if (data.source === IncrementalSource.StyleDeclaration) + this.applyStyleDeclaration(data, styleSheet); + } + + private applyStyleSheetRule( + data: styleSheetRuleData, + styleSheet: CSSStyleSheet, + ) { + data.adds?.forEach(({ rule, index: nestedIndex }) => { + try { + if (Array.isArray(nestedIndex)) { + const { positions, index } = getPositionsAndIndex(nestedIndex); + const nestedRule = getNestedRule(styleSheet.cssRules, positions); + nestedRule.insertRule(rule, index); + } else { + const index = + nestedIndex === undefined + ? undefined + : Math.min(nestedIndex, styleSheet.cssRules.length); + styleSheet?.insertRule(rule, index); + } + } catch (e) { + /** + * sometimes we may capture rules with browser prefix + * insert rule with prefixs in other browsers may cause Error + */ + /** + * accessing styleSheet rules may cause SecurityError + * for specific access control settings + */ + } + }); + + data.removes?.forEach(({ index: nestedIndex }) => { + try { + if (Array.isArray(nestedIndex)) { + const { positions, index } = getPositionsAndIndex(nestedIndex); + const nestedRule = getNestedRule(styleSheet.cssRules, positions); + nestedRule.deleteRule(index || 0); + } else { + styleSheet?.deleteRule(nestedIndex); + } + } catch (e) { + /** + * same as insertRule + */ + } + }); + + if (data.replace) + try { + void styleSheet.replace?.(data.replace); + } catch (e) { + // for safety + } + + if (data.replaceSync) + try { + styleSheet.replaceSync?.(data.replaceSync); + } catch (e) { + // for safety + } + } + + private applyStyleDeclaration( + data: styleDeclarationData, + styleSheet: CSSStyleSheet, + ) { + if (data.set) { + const rule = (getNestedRule( + styleSheet.rules, + data.index, + ) as unknown) as CSSStyleRule; + 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; + rule.style.removeProperty(data.remove.property); + } + } + + private applyAdoptedStyleSheet(data: adoptedStyleSheetData) { + const targetHost = this.mirror.getNode(data.id); + if (!targetHost) return; + // Create StyleSheet objects which will be adopted after. + data.styles?.forEach((style) => { + let newStyleSheet: CSSStyleSheet | null = null; + /** + * Constructed StyleSheet can't share across multiple documents. + * The replayer has to get the correct host window to recreate a StyleSheetObject. + */ + let hostWindow: IWindow | null = null; + if (hasShadowRoot(targetHost)) + hostWindow = targetHost.ownerDocument?.defaultView || null; + else if (targetHost.nodeName === '#document') + hostWindow = (targetHost as Document).defaultView; + + if (!hostWindow) return; + try { + newStyleSheet = new hostWindow.CSSStyleSheet(); + this.styleMirror.add(newStyleSheet, style.styleId); + // To reuse the code of applying stylesheet rules + this.applyStyleSheetRule( + { + source: IncrementalSource.StyleSheetRule, + adds: style.rules, + }, + newStyleSheet, + ); + } catch (e) { + // In case some browsers don't support constructing StyleSheet. + } + }); + + const MAX_RETRY_TIME = 10; + let count = 0; + const adoptStyleSheets = (targetHost: Node, styleIds: number[]) => { + const stylesToAdopt = styleIds + .map((styleId) => this.styleMirror.getStyle(styleId)) + .filter((style) => style !== null) as CSSStyleSheet[]; + if (hasShadowRoot(targetHost)) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (targetHost as HTMLElement).shadowRoot!.adoptedStyleSheets = stylesToAdopt; + else if (targetHost.nodeName === '#document') + (targetHost as Document).adoptedStyleSheets = stylesToAdopt; + + /** + * In the live mode where events are transferred over network without strict order guarantee, some newer events are applied before some old events and adopted stylesheets may haven't been created. + * This retry mechanism can help resolve this situation. + */ + if (stylesToAdopt.length !== styleIds.length && count < MAX_RETRY_TIME) { + setTimeout( + () => adoptStyleSheets(targetHost, styleIds), + 0 + 100 * count, + ); + count++; + } + }; + adoptStyleSheets(targetHost, data.styleIds); + } + private legacy_resolveMissingNode( map: missingNodeMap, parent: Node | RRNode, diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 05702dd5..23500507 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -93,6 +93,7 @@ export enum IncrementalSource { Drag, StyleDeclaration, Selection, + AdoptedStyleSheet, } export type mutationData = { @@ -148,6 +149,10 @@ export type selectionData = { source: IncrementalSource.Selection; } & selectionParam; +export type adoptedStyleSheetData = { + source: IncrementalSource.AdoptedStyleSheet; +} & adoptedStyleSheetParam; + export type incrementalData = | mutationData | mousemoveData @@ -160,7 +165,8 @@ export type incrementalData = | canvasMutationData | fontData | selectionData - | styleDeclarationData; + | styleDeclarationData + | adoptedStyleSheetData; export type event = | domContentLoadedEvent @@ -512,15 +518,33 @@ export type styleSheetDeleteRule = { }; export type styleSheetRuleParam = { - id: number; + id?: number; + styleId?: number; removes?: styleSheetDeleteRule[]; adds?: styleSheetAddRule[]; + replace?: string; + replaceSync?: string; }; export type styleSheetRuleCallback = (s: styleSheetRuleParam) => void; -export type styleDeclarationParam = { +export type adoptedStyleSheetParam = { + // id indicates the node id of document or shadow DOMs' host element. id: number; + // New CSSStyleSheets which have never appeared before. + styles?: { + styleId: number; + rules: styleSheetAddRule[]; + }[]; + // StyleSheet ids to be adopted. + styleIds: number[]; +}; + +export type adoptedStyleSheetCallback = (a: adoptedStyleSheetParam) => void; + +export type styleDeclarationParam = { + id?: number; + styleId?: number; index: number[]; set?: { property: string; diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 6130a002..b782bd52 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -454,3 +454,41 @@ export function uniqueTextMutations(mutations: textMutation[]): textMutation[] { return uniqueMutations; } + +export class StyleSheetMirror { + private id = 1; + private styleIDMap = new WeakMap(); + private idStyleMap = new Map(); + + getId(stylesheet: CSSStyleSheet): number { + return this.styleIDMap.get(stylesheet) ?? -1; + } + + has(stylesheet: CSSStyleSheet): boolean { + return this.styleIDMap.has(stylesheet); + } + + /** + * @returns If the stylesheet is in the mirror, returns the id of the stylesheet. If not, return the new assigned id. + */ + add(stylesheet: CSSStyleSheet, id?: number): number { + if (this.has(stylesheet)) return this.getId(stylesheet); + let newId: number; + if (id === undefined) { + newId = this.id++; + } else newId = id; + this.styleIDMap.set(stylesheet, newId); + this.idStyleMap.set(newId, stylesheet); + return newId; + } + + getStyle(id: number): CSSStyleSheet | null { + return this.idStyleMap.get(id) || null; + } + + reset(): void { + this.styleIDMap = new WeakMap(); + this.idStyleMap = new Map(); + this.id = 1; + } +} diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index a86e45dd..4081bbeb 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -197,6 +197,868 @@ exports[`record captures CORS stylesheets that are still loading 1`] = ` ]" `; +exports[`record captures adopted stylesheets in nested shadow doms and iframes 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"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\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"shadow-host-1\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"entry\\", + \\"id\\": 8 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"iframe-1\\" + }, + \\"childNodes\\": [], + \\"id\\": 9, + \\"isShadow\\": true + } + ], + \\"id\\": 7, + \\"isShadowHost\\": true + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 10 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"shadow-host-2\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"iframe-2\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 16, + \\"isShadow\\": true + } + ], + \\"rootId\\": 11, + \\"id\\": 15, + \\"isShadowHost\\": true + } + ], + \\"rootId\\": 11, + \\"id\\": 14 + } + ], + \\"rootId\\": 11, + \\"id\\": 12 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 11 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 16, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 17, + \\"id\\": 19 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"shadow-host-3\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"iframe-3\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 17, + \\"id\\": 22, + \\"isShadow\\": true + } + ], + \\"rootId\\": 17, + \\"id\\": 21, + \\"isShadowHost\\": true + } + ], + \\"rootId\\": 17, + \\"id\\": 20 + } + ], + \\"rootId\\": 17, + \\"id\\": 18 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 17 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 22, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 25 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"shadow-host-4\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"iframe-4\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 28, + \\"isShadow\\": true + } + ], + \\"rootId\\": 23, + \\"id\\": 27, + \\"isShadowHost\\": true + } + ], + \\"rootId\\": 23, + \\"id\\": 26 + } + ], + \\"rootId\\": 23, + \\"id\\": 24 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 23 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 28, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 29, + \\"id\\": 31 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"shadow-host-5\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 29, + \\"id\\": 33, + \\"isShadowHost\\": true + } + ], + \\"rootId\\": 29, + \\"id\\": 32 + } + ], + \\"rootId\\": 29, + \\"id\\": 30 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 29 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 29, + \\"styleIds\\": [ + 1 + ], + \\"styles\\": [ + { + \\"styleId\\": 1, + \\"rules\\": [ + { + \\"rule\\": \\"h1 { color: blue; }\\", + \\"index\\": 0 + } + ] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 33, + \\"styleIds\\": [ + 2 + ], + \\"styles\\": [ + { + \\"styleId\\": 2, + \\"rules\\": [ + { + \\"rule\\": \\"div { font-size: large; }\\", + \\"index\\": 0 + } + ] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"styleId\\": 1, + \\"adds\\": [ + { + \\"rule\\": \\"div { display: inline ; }\\", + \\"index\\": 1 + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"styleId\\": 2, + \\"replaceSync\\": \\"h1 { font-size: large; }\\" + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 29, + \\"styleIds\\": [ + 3, + 2 + ], + \\"styles\\": [ + { + \\"styleId\\": 3, + \\"rules\\": [ + { + \\"rule\\": \\"span { background-color: red; }\\", + \\"index\\": 0 + } + ] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 33, + \\"styleIds\\": [ + 1, + 3 + ] + } + } +]" +`; + +exports[`record captures adopted stylesheets in shadow doms and iframe 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"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\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"div in outermost document\\", + \\"id\\": 8 + } + ], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"shadow-host1\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"div in shadow dom 1\\", + \\"id\\": 12 + } + ], + \\"id\\": 11, + \\"isShadow\\": true + }, + { + \\"type\\": 2, + \\"tagName\\": \\"span\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"span in shadow dom 1\\", + \\"id\\": 14 + } + ], + \\"id\\": 13, + \\"isShadow\\": true + } + ], + \\"id\\": 10, + \\"isShadowHost\\": true + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"shadow-host2\\" + }, + \\"childNodes\\": [], + \\"id\\": 16 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 19 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 1, + \\"styleIds\\": [ + 1 + ], + \\"styles\\": [ + { + \\"styleId\\": 1, + \\"rules\\": [ + { + \\"rule\\": \\"div { color: yellow; }\\", + \\"index\\": 0 + }, + { + \\"rule\\": \\"h2 { color: orange; }\\", + \\"index\\": 1 + }, + { + \\"rule\\": \\"h3 { font-size: larger; }\\", + \\"index\\": 2 + } + ] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 10, + \\"styleIds\\": [ + 1, + 2 + ], + \\"styles\\": [ + { + \\"styleId\\": 2, + \\"rules\\": [ + { + \\"rule\\": \\"span { color: red; }\\", + \\"index\\": 0 + } + ] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 18, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 20, + \\"id\\": 22 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"h1\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"h1 in iframe\\", + \\"rootId\\": 20, + \\"id\\": 25 + } + ], + \\"rootId\\": 20, + \\"id\\": 24 + } + ], + \\"rootId\\": 20, + \\"id\\": 23 + } + ], + \\"rootId\\": 20, + \\"id\\": 21 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 20 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 20, + \\"styleIds\\": [ + 3 + ], + \\"styles\\": [ + { + \\"styleId\\": 3, + \\"rules\\": [ + { + \\"rule\\": \\"h1 { color: blue; }\\", + \\"index\\": 0 + } + ] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 1, + \\"styleIds\\": [ + 4, + 1, + 2 + ], + \\"styles\\": [ + { + \\"styleId\\": 4, + \\"rules\\": [ + { + \\"rule\\": \\"span { color: green; }\\", + \\"index\\": 0 + } + ] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 20, + \\"styleIds\\": [ + 5, + 3 + ], + \\"styles\\": [ + { + \\"styleId\\": 5, + \\"rules\\": [ + { + \\"rule\\": \\"h2 { color: purple; }\\", + \\"index\\": 0 + } + ] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 16, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"span\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 26, + \\"isShadow\\": true + } + }, + { + \\"parentId\\": 26, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"span in shadow dom 2\\", + \\"id\\": 27 + } + }, + { + \\"parentId\\": 16, + \\"nextId\\": 26, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 28, + \\"isShadow\\": true + } + }, + { + \\"parentId\\": 28, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"div in shadow dom 2\\", + \\"id\\": 29 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 16, + \\"styleIds\\": [ + 1, + 4 + ] + } + } +]" +`; + exports[`record captures inserted style text nodes correctly 1`] = ` "[ { @@ -327,6 +1189,306 @@ exports[`record captures inserted style text nodes correctly 1`] = ` ]" `; +exports[`record captures mutations on adopted stylesheets 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"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\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"div in outermost document\\", + \\"id\\": 8 + } + ], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 1, + \\"styleIds\\": [ + 1 + ], + \\"styles\\": [ + { + \\"styleId\\": 1, + \\"rules\\": [] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 10, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 12, + \\"id\\": 14 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"h1\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"h1 in iframe\\", + \\"rootId\\": 12, + \\"id\\": 17 + } + ], + \\"rootId\\": 12, + \\"id\\": 16 + } + ], + \\"rootId\\": 12, + \\"id\\": 15 + } + ], + \\"rootId\\": 12, + \\"id\\": 13 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 12 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 15, + \\"id\\": 12, + \\"styleIds\\": [ + 2 + ], + \\"styles\\": [ + { + \\"styleId\\": 2, + \\"rules\\": [] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"styleId\\": 1, + \\"replace\\": \\"div { color: yellow; }\\" + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"styleId\\": 2, + \\"replace\\": \\"h1 { color: blue; }\\" + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"styleId\\": 1, + \\"replaceSync\\": \\"div { display: inline ; }\\" + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"styleId\\": 2, + \\"replaceSync\\": \\"h1 { font-size: large; }\\" + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 13, + \\"styleId\\": 1, + \\"set\\": { + \\"property\\": \\"color\\", + \\"value\\": \\"green\\" + }, + \\"index\\": [ + 0 + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 13, + \\"styleId\\": 1, + \\"remove\\": { + \\"property\\": \\"display\\" + }, + \\"index\\": [ + 0 + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 13, + \\"styleId\\": 2, + \\"set\\": { + \\"property\\": \\"font-size\\", + \\"value\\": \\"medium\\", + \\"priority\\": \\"important\\" + }, + \\"index\\": [ + 0 + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"styleId\\": 2, + \\"adds\\": [ + { + \\"rule\\": \\"h2 { color: red; }\\" + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"styleId\\": 1, + \\"adds\\": [ + { + \\"rule\\": \\"body { border: 2px solid blue; }\\", + \\"index\\": 1 + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"styleId\\": 2, + \\"removes\\": [ + { + \\"index\\": 0 + } + ] + } + } +]" +`; + exports[`record captures nested stylesheet rules 1`] = ` "[ { diff --git a/packages/rrweb/test/events/adopted-style-sheet-modification.ts b/packages/rrweb/test/events/adopted-style-sheet-modification.ts new file mode 100644 index 00000000..13d09eef --- /dev/null +++ b/packages/rrweb/test/events/adopted-style-sheet-modification.ts @@ -0,0 +1,314 @@ +import { EventType, eventWithTime, IncrementalSource } from '../../src/types'; + +const now = Date.now(); +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: [], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: '\n ', + id: 6, + }, + { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'div in outermost document', + id: 8, + }, + ], + id: 7, + }, + { + type: 3, + textContent: ' \n ', + id: 9, + }, + { + type: 2, + tagName: 'iframe', + attributes: {}, + childNodes: [], + id: 10, + }, + { + type: 3, + textContent: '\n ', + id: 11, + }, + ], + id: 5, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { + left: 0, + top: 0, + }, + }, + timestamp: now + 100, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 1, + styleIds: [1], + styles: [ + { + styleId: 1, + rules: [], + }, + ], + }, + timestamp: now + 150, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: 0, + adds: [ + { + parentId: 10, + nextId: null, + node: { + type: 0, + childNodes: [ + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + rootId: 12, + id: 14, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'h1', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'h1 in iframe', + rootId: 12, + id: 17, + }, + ], + rootId: 12, + id: 16, + }, + ], + rootId: 12, + id: 15, + }, + ], + rootId: 12, + id: 13, + }, + ], + compatMode: 'BackCompat', + id: 12, + }, + }, + ], + removes: [], + texts: [], + attributes: [], + isAttachIframe: true, + }, + timestamp: now + 200, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 12, + styleIds: [2], + styles: [ + { + rules: [], + styleId: 2, + }, + ], + }, + timestamp: now + 250, + }, + // use CSSStyleSheet.replace api + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + styleId: 1, + replace: 'div { color: yellow; }', + }, + timestamp: now + 300, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + styleId: 2, + replace: 'h1 { color: blue; }', + }, + timestamp: now + 300, + }, + // use CSSStyleSheet.replaceSync api + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + styleId: 1, + replaceSync: 'div { display: inline ; }', + }, + timestamp: now + 400, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + styleId: 2, + replaceSync: 'h1 { font-size: large; }', + }, + timestamp: now + 400, + }, + // use StyleDeclaration.setProperty api + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleDeclaration, + styleId: 1, + set: { + property: 'color', + value: 'green', + priority: undefined, + }, + index: [0], + }, + timestamp: now + 500, + }, + // use StyleDeclaration.removeProperty api + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleDeclaration, + styleId: 1, + remove: { + property: 'display', + }, + index: [0], + }, + timestamp: now + 500, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleDeclaration, + styleId: 2, + set: { + property: 'font-size', + value: 'medium', + priority: 'important', + }, + index: [0], + }, + timestamp: now + 500, + }, + // use CSSStyleSheet.insertRule api + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + styleId: 2, + adds: [ + { + rule: 'h2 { color: red; }', + }, + ], + }, + timestamp: now + 500, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + styleId: 1, + adds: [ + { + rule: 'body { border: 2px solid blue; }', + index: 1, + }, + ], + }, + timestamp: now + 600, + }, + // use CSSStyleSheet.deleteRule api + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + styleId: 2, + removes: [ + { + index: 0, + }, + ], + }, + timestamp: now + 600, + }, +]; + +export default events; diff --git a/packages/rrweb/test/events/adopted-style-sheet.ts b/packages/rrweb/test/events/adopted-style-sheet.ts new file mode 100644 index 00000000..72e2a489 --- /dev/null +++ b/packages/rrweb/test/events/adopted-style-sheet.ts @@ -0,0 +1,354 @@ +import { EventType, eventWithTime, IncrementalSource } from '../../src/types'; + +const now = Date.now(); + +const events: eventWithTime[] = [ + { type: EventType.DomContentLoaded, data: {}, timestamp: now }, + { + type: EventType.Meta, + data: { + href: 'about:blank', + width: 1920, + height: 1080, + }, + timestamp: now + 100, + }, + { + 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: [], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: '\n ', + id: 6, + }, + { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'div in outermost document', + id: 8, + }, + ], + id: 7, + }, + { + type: 3, + textContent: '\n ', + id: 9, + }, + { + type: 2, + tagName: 'div', + attributes: { + id: 'shadow-host1', + }, + childNodes: [ + { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'div in shadow dom 1', + id: 12, + }, + ], + id: 11, + isShadow: true, + }, + { + type: 2, + tagName: 'span', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'span in shadow dom 1', + id: 14, + }, + ], + id: 13, + isShadow: true, + }, + ], + id: 10, + isShadowHost: true, + }, + { + type: 3, + textContent: '\n ', + id: 15, + }, + { + type: 2, + tagName: 'div', + attributes: { + id: 'shadow-host2', + }, + childNodes: [], + id: 16, + }, + { + type: 3, + textContent: '\n ', + id: 17, + }, + { + type: 2, + tagName: 'iframe', + attributes: {}, + childNodes: [], + id: 18, + }, + { + type: 3, + textContent: '\n ', + id: 19, + }, + ], + id: 5, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { + left: 0, + top: 0, + }, + }, + timestamp: now + 100, + }, + // Adopt the stylesheet #1 on document at 200ms + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 1, + styleIds: [1], + styles: [ + { + rules: [ + { + rule: 'div { color: yellow; }', + }, + ], + styleId: 1, + }, + ], + }, + timestamp: now + 200, + }, + // Add an IFrame element + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + adds: [ + { + parentId: 18, + nextId: null, + node: { + type: 0, + childNodes: [ + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + rootId: 20, + id: 22, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'h1', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'h1 in iframe', + rootId: 20, + id: 25, + }, + ], + rootId: 20, + id: 24, + }, + ], + rootId: 20, + id: 23, + }, + ], + rootId: 20, + id: 21, + }, + ], + compatMode: 'BackCompat', + id: 20, + }, + }, + ], + removes: [], + texts: [], + attributes: [], + isAttachIframe: true, + }, + timestamp: now + 250, + }, + // Adopt the stylesheet #2 on a shadow root at 300ms + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 10, + styleIds: [1, 2], + styles: [ + { + rules: [ + { + rule: 'span { color: red; }', + }, + ], + styleId: 2, + }, + ], + }, + timestamp: now + 300, + }, + // Adopt the stylesheet #3 on document of the IFrame element + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 20, + styleIds: [3], + styles: [ + { + rules: [ + { + rule: 'h1 { color: blue; }', + }, + ], + styleId: 3, + }, + ], + }, + timestamp: now + 300, + }, + // create a new shadow dom + { + type: EventType.IncrementalSnapshot, + data: { + source: 0, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 16, + nextId: null, + node: { + type: 2, + tagName: 'span', + attributes: {}, + childNodes: [], + id: 26, + isShadow: true, + }, + }, + { + parentId: 26, + nextId: null, + node: { + type: 3, + textContent: 'span in shadow dom 2', + id: 27, + }, + }, + { + parentId: 16, + nextId: 26, + node: { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [], + id: 28, + isShadow: true, + }, + }, + { + parentId: 28, + nextId: null, + node: { + type: 3, + textContent: 'div in shadow dom 2', + id: 29, + }, + }, + ], + }, + timestamp: now + 500, + }, + // Adopt the stylesheet #4 on the shadow dom + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 16, + styleIds: [4], + styles: [ + { + rules: [{ rule: 'span { color: green; }' }], + styleId: 4, + }, + ], + }, + timestamp: now + 550, + }, +]; + +export default events; diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 5ef6341a..db7e0be4 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type * as puppeteer from 'puppeteer'; +import 'construct-style-sheets-polyfill'; import { recordOptions, listenerHandler, @@ -462,6 +463,113 @@ describe('record', function (this: ISuite) { assertSnapshot(ctx.events); }); + it('captures mutations on adopted stylesheets', async () => { + await ctx.page.evaluate(() => { + document.body.innerHTML = ` +
div in outermost document
+ + `; + + const sheet = new CSSStyleSheet(); + // Add stylesheet to a document. + + document.adoptedStyleSheets = [sheet]; + + const iframe = document.querySelector('iframe'); + const sheet2 = new (iframe!.contentWindow! as Window & + typeof globalThis).CSSStyleSheet(); + + // Add stylesheet to an IFrame document. + iframe!.contentDocument!.adoptedStyleSheets = [sheet2]; + iframe!.contentDocument!.body.innerHTML = '

h1 in iframe

'; + + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + emit: ((window as unknown) as IWindow).emit, + }); + + setTimeout(() => { + sheet.replace!('div { color: yellow; }'); + sheet2.replace!('h1 { color: blue; }'); + }, 0); + + setTimeout(() => { + sheet.replaceSync!('div { display: inline ; }'); + sheet2.replaceSync!('h1 { font-size: large; }'); + }, 5); + + setTimeout(() => { + (sheet.cssRules[0] as CSSStyleRule).style.setProperty('color', 'green'); + (sheet.cssRules[0] as CSSStyleRule).style.removeProperty('display'); + (sheet2.cssRules[0] as CSSStyleRule).style.setProperty( + 'font-size', + 'medium', + 'important', + ); + sheet2.insertRule('h2 { color: red; }'); + }, 10); + + setTimeout(() => { + sheet.insertRule('body { border: 2px solid blue; }', 1); + sheet2.deleteRule(0); + }, 15); + }); + await waitForRAF(ctx.page); + assertSnapshot(ctx.events); + }); + + it('captures adopted stylesheets in nested shadow doms and iframes', async () => { + await ctx.page.evaluate(() => { + document.body.innerHTML = ` +
entry
+ `; + + let shadowHost = document.querySelector('div')!; + shadowHost!.attachShadow({ mode: 'open' }); + let iframeDocument: Document; + const NestedDepth = 4; + // construct nested shadow doms and iframe elements + for (let i = 1; i <= NestedDepth; i++) { + const shadowRoot = shadowHost.shadowRoot!; + const iframeElement = document.createElement('iframe'); + shadowRoot.appendChild(iframeElement); + iframeElement.id = `iframe-${i}`; + iframeDocument = iframeElement.contentDocument!; + shadowHost = iframeDocument.createElement('div'); + shadowHost.id = `shadow-host-${i + 1}`; + iframeDocument.body.append(shadowHost); + shadowHost!.attachShadow({ mode: 'open' }); + } + + const iframeWin = iframeDocument!.defaultView!; + const sheet1 = new iframeWin.CSSStyleSheet(); + sheet1.replaceSync!('h1 {color: blue;}'); + iframeDocument!.adoptedStyleSheets = [sheet1]; + const sheet2 = new iframeWin.CSSStyleSheet(); + sheet2.replaceSync!('div {font-size: large;}'); + shadowHost.shadowRoot!.adoptedStyleSheets = [sheet2]; + + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + emit: ((window as unknown) as IWindow).emit, + }); + + setTimeout(() => { + sheet1.insertRule!('div { display: inline ; }', 1); + sheet2.replaceSync!('h1 { font-size: large; }'); + }, 100); + + setTimeout(() => { + const sheet3 = new iframeWin.CSSStyleSheet(); + sheet3.replaceSync!('span {background-color: red;}'); + iframeDocument!.adoptedStyleSheets = [sheet3, sheet2]; + shadowHost.shadowRoot!.adoptedStyleSheets = [sheet1, sheet3]; + }, 150); + }); + await ctx.page.waitForTimeout(200); + assertSnapshot(ctx.events); + }); + it('captures stylesheets in iframes with `blob:` url', async () => { await ctx.page.evaluate(() => { const iframe = document.createElement('iframe'); @@ -579,6 +687,73 @@ describe('record', function (this: ISuite) { assertSnapshot(ctx.events); }); + + it('captures adopted stylesheets in shadow doms and iframe', async () => { + await ctx.page.evaluate(() => { + document.body.innerHTML = ` +
div in outermost document
+
+
+ + `; + + const sheet = new CSSStyleSheet(); + sheet.replaceSync!( + 'div { color: yellow; } h2 { color: orange; } h3 { font-size: larger;}', + ); + // Add stylesheet to a document. + + document.adoptedStyleSheets = [sheet]; + + // Add stylesheet to a shadow host. + const host = document.querySelector('#shadow-host1'); + const shadow = host!.attachShadow({ mode: 'open' }); + shadow.innerHTML = + '
div in shadow dom 1
span in shadow dom 1'; + const sheet2 = new CSSStyleSheet(); + + sheet2.replaceSync!('span { color: red; }'); + + shadow.adoptedStyleSheets = [sheet, sheet2]; + + // Add stylesheet to an IFrame document. + const iframe = document.querySelector('iframe'); + const sheet3 = new (iframe!.contentWindow! as IWindow & + typeof globalThis).CSSStyleSheet(); + sheet3.replaceSync!('h1 { color: blue; }'); + + iframe!.contentDocument!.adoptedStyleSheets = [sheet3]; + + const ele = iframe!.contentDocument!.createElement('h1'); + ele.innerText = 'h1 in iframe'; + iframe!.contentDocument!.body.appendChild(ele); + + ((window as unknown) as IWindow).rrweb.record({ + emit: ((window.top as unknown) as IWindow).emit, + }); + + // Make incremental changes to shadow dom. + setTimeout(() => { + const host = document.querySelector('#shadow-host2'); + const shadow = host!.attachShadow({ mode: 'open' }); + shadow.innerHTML = + '
div in shadow dom 2
span in shadow dom 2'; + const sheet4 = new CSSStyleSheet(); + sheet4.replaceSync!('span { color: green; }'); + shadow.adoptedStyleSheets = [sheet, sheet4]; + + document.adoptedStyleSheets = [sheet4, sheet, sheet2]; + + const sheet5 = new (iframe!.contentWindow! as IWindow & + typeof globalThis).CSSStyleSheet(); + sheet5.replaceSync!('h2 { color: purple; }'); + iframe!.contentDocument!.adoptedStyleSheets = [sheet5, sheet3]; + }, 10); + }); + await waitForRAF(ctx.page); // wait till events get sent + + assertSnapshot(ctx.events); + }); }); describe('record iframes', function (this: ISuite) { diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index d7a6944c..7bc09ae2 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type * as puppeteer from 'puppeteer'; +import 'construct-style-sheets-polyfill'; import { assertDomSnapshot, launchPuppeteer, @@ -17,6 +18,8 @@ import selectionEvents from './events/selection'; import shadowDomEvents from './events/shadow-dom'; 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'; interface ISuite { code: string; @@ -718,4 +721,232 @@ describe('replayer', function () { wrapper = await page.$(`.${replayerWrapperClassName}`); expect(wrapper).toBeNull(); }); + + it('can replay adopted stylesheet events', async () => { + await page.evaluate(` + events = ${JSON.stringify(adoptedStyleSheet)}; + const { Replayer } = rrweb; + var replayer = new Replayer(events,{showDebug:true}); + replayer.play(); + `); + await page.waitForTimeout(600); + const iframe = await page.$('iframe'); + const contentDocument = await iframe!.contentFrame()!; + const colorRGBMap = { + yellow: 'rgb(255, 255, 0)', + red: 'rgb(255, 0, 0)', + blue: 'rgb(0, 0, 255)', + green: 'rgb(0, 128, 0)', + }; + const checkCorrectness = async () => { + // check the adopted stylesheet is applied on the outermost document + expect( + await contentDocument!.$eval( + 'div', + (element) => window.getComputedStyle(element).color, + ), + ).toEqual(colorRGBMap.yellow); + + // check the adopted stylesheet is applied on the shadow dom #1's root + expect( + await contentDocument!.evaluate( + () => + window.getComputedStyle( + document + .querySelector('#shadow-host1')! + .shadowRoot!.querySelector('span')!, + ).color, + ), + ).toEqual(colorRGBMap.red); + + // check the adopted stylesheet is applied on document of the IFrame element + expect( + await contentDocument!.$eval( + 'iframe', + (element) => + window.getComputedStyle( + (element as HTMLIFrameElement).contentDocument!.querySelector( + 'h1', + )!, + ).color, + ), + ).toEqual(colorRGBMap.blue); + + // check the adopted stylesheet is applied on the shadow dom #2's root + expect( + await contentDocument!.evaluate( + () => + window.getComputedStyle( + document + .querySelector('#shadow-host2')! + .shadowRoot!.querySelector('span')!, + ).color, + ), + ).toEqual(colorRGBMap.green); + }; + await checkCorrectness(); + + // To test the correctness of replaying adopted stylesheet events in the fast-forward mode. + await page.evaluate('replayer.play(0);'); + await waitForRAF(page); + await page.evaluate('replayer.pause(600);'); + await checkCorrectness(); + }); + + it('can replay modification events for adoptedStyleSheet', async () => { + await page.evaluate(` + events = ${JSON.stringify(adoptedStyleSheetModification)}; + const { Replayer } = rrweb; + var replayer = new Replayer(events,{showDebug:true}); + replayer.play(); + `); + const iframe = await page.$('iframe'); + const contentDocument = await iframe!.contentFrame()!; + + // At 250ms, the adopted stylesheet is still empty. + const check250ms = async () => { + expect( + await contentDocument!.evaluate( + () => + document.adoptedStyleSheets.length === 1 && + document.adoptedStyleSheets[0].cssRules.length === 0, + ), + ).toBeTruthy(); + expect( + await contentDocument!.evaluate( + () => + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets.length === 1 && + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules.length === 0, + ), + ).toBeTruthy(); + }; + + // At 300ms, the adopted stylesheet is replaced with new content. + const check300ms = async () => { + expect( + await contentDocument!.evaluate( + () => + document.adoptedStyleSheets[0].cssRules.length === 1 && + document.adoptedStyleSheets[0].cssRules[0].cssText === + 'div { color: yellow; }', + ), + ).toBeTruthy(); + expect( + await contentDocument!.evaluate( + () => + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules.length === 1 && + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules[0].cssText === + 'h1 { color: blue; }', + ), + ).toBeTruthy(); + }; + + // At 400ms, check replaceSync API. + const check400ms = async () => { + expect( + await contentDocument!.evaluate( + () => + document.adoptedStyleSheets[0].cssRules.length === 1 && + document.adoptedStyleSheets[0].cssRules[0].cssText === + 'div { display: inline; }', + ), + ).toBeTruthy(); + expect( + await contentDocument!.evaluate( + () => + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules.length === 1 && + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules[0].cssText === + 'h1 { font-size: large; }', + ), + ).toBeTruthy(); + }; + + // At 500ms, check CSSStyleDeclaration API. + const check500ms = async () => { + expect( + await contentDocument!.evaluate( + () => + document.adoptedStyleSheets[0].cssRules.length === 1 && + document.adoptedStyleSheets[0].cssRules[0].cssText === + 'div { color: green; }', + ), + ).toBeTruthy(); + expect( + await contentDocument!.evaluate( + () => + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules.length === 2 && + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules[0].cssText === + 'h2 { color: red; }' && + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules[1].cssText === + 'h1 { font-size: medium !important; }', + ), + ).toBeTruthy(); + }; + + // At 600ms, check insertRule and deleteRule API. + const check600ms = async () => { + expect( + await contentDocument!.evaluate( + () => + document.adoptedStyleSheets[0].cssRules.length === 2 && + document.adoptedStyleSheets[0].cssRules[0].cssText === + 'div { color: green; }' && + document.adoptedStyleSheets[0].cssRules[1].cssText === + 'body { border: 2px solid blue; }', + ), + ).toBeTruthy(); + expect( + await contentDocument!.evaluate( + () => + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules.length === 1 && + document.querySelector('iframe')!.contentDocument! + .adoptedStyleSheets[0].cssRules[0].cssText === + 'h1 { font-size: medium !important; }', + ), + ).toBeTruthy(); + }; + + await page.waitForTimeout(235); + await check250ms(); + + await page.waitForTimeout(50); + await check300ms(); + + await page.waitForTimeout(100); + await check400ms(); + + await page.waitForTimeout(100); + await check500ms(); + + await page.waitForTimeout(100); + await check600ms(); + + // To test the correctness of replaying adopted stylesheet mutation events in the fast-forward mode. + await page.evaluate('replayer.play(0);'); + await waitForRAF(page); + await page.evaluate('replayer.pause(280);'); + await check250ms(); + + await page.evaluate('replayer.pause(330);'); + await check300ms(); + + await page.evaluate('replayer.pause(430);'); + await check400ms(); + + await page.evaluate('replayer.pause(530);'); + await check500ms(); + + await page.evaluate('replayer.pause(630);'); + await check600ms(); + }); }); diff --git a/packages/rrweb/test/util.test.ts b/packages/rrweb/test/util.test.ts new file mode 100644 index 00000000..964005c8 --- /dev/null +++ b/packages/rrweb/test/util.test.ts @@ -0,0 +1,78 @@ +/** + * @jest-environment jsdom + */ +import { StyleSheetMirror } from '../src/utils'; + +describe('Utilities for other modules', () => { + describe('StyleSheetMirror', () => { + it('should create a StyleSheetMirror', () => { + const mirror = new StyleSheetMirror(); + expect(mirror).toBeDefined(); + expect(mirror.add).toBeDefined(); + expect(mirror.has).toBeDefined(); + expect(mirror.reset).toBeDefined(); + expect(mirror.getId).toBeDefined(); + }); + + it('can add CSSStyleSheet into the mirror without ID parameter', () => { + const mirror = new StyleSheetMirror(); + const styleSheet = new CSSStyleSheet(); + expect(mirror.has(styleSheet)).toBeFalsy(); + expect(mirror.add(styleSheet)).toEqual(1); + expect(mirror.has(styleSheet)).toBeTruthy(); + // This stylesheet has been added before so just return its assigned id. + expect(mirror.add(styleSheet)).toEqual(1); + + for (let i = 0; i < 10; i++) { + const styleSheet = new CSSStyleSheet(); + expect(mirror.has(styleSheet)).toBeFalsy(); + expect(mirror.add(styleSheet)).toEqual(i + 2); + expect(mirror.has(styleSheet)).toBeTruthy(); + } + }); + + it('can add CSSStyleSheet into the mirror with ID parameter', () => { + const mirror = new StyleSheetMirror(); + for (let i = 0; i < 10; i++) { + const styleSheet = new CSSStyleSheet(); + expect(mirror.has(styleSheet)).toBeFalsy(); + expect(mirror.add(styleSheet, i)).toEqual(i); + expect(mirror.has(styleSheet)).toBeTruthy(); + } + }); + + it('can get the id from the mirror', () => { + const mirror = new StyleSheetMirror(); + for (let i = 0; i < 10; i++) { + const styleSheet = new CSSStyleSheet(); + mirror.add(styleSheet); + expect(mirror.getId(styleSheet)).toBe(i + 1); + } + expect(mirror.getId(new CSSStyleSheet())).toBe(-1); + }); + + it('can get CSSStyleSheet objects with id', () => { + const mirror = new StyleSheetMirror(); + for (let i = 0; i < 10; i++) { + const styleSheet = new CSSStyleSheet(); + mirror.add(styleSheet); + expect(mirror.getStyle(i + 1)).toBe(styleSheet); + } + }); + + it('can reset the mirror', () => { + const mirror = new StyleSheetMirror(); + const styleList: CSSStyleSheet[] = []; + for (let i = 0; i < 10; i++) { + const styleSheet = new CSSStyleSheet(); + mirror.add(styleSheet); + expect(mirror.getId(styleSheet)).toBe(i + 1); + styleList.push(styleSheet); + } + expect(mirror.reset()).toBeUndefined(); + for (let s of styleList) expect(mirror.has(s)).toBeFalsy(); + for (let i = 0; i < 10; i++) expect(mirror.getStyle(i + 1)).toBeNull(); + expect(mirror.add(new CSSStyleSheet())).toBe(1); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index a887541e..b2f2d2fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1618,6 +1618,16 @@ npmlog "^4.1.2" write-file-atomic "^3.0.3" +"@mdn/browser-compat-data@^3.3.14": + version "3.3.14" + resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-3.3.14.tgz#b72a37c654e598f9ae6f8335faaee182bebc6b28" + integrity sha512-n2RC9d6XatVbWFdHLimzzUJxJ1KY8LdjqrW6YvGPiRmsHkhOUx74/Ct10x5Yo7bC/Jvqx7cDEW8IMPv/+vwEzA== + +"@mdn/browser-compat-data@^4.1.5": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8" + integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA== + "@microsoft/tsdoc-config@0.16.1": version "0.16.1" resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.16.1.tgz#4de11976c1202854c4618f364bf499b4be33e657" @@ -2766,6 +2776,13 @@ assert-plus@1.0.0, assert-plus@^1.0.0: resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= +ast-metadata-inferer@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/ast-metadata-inferer/-/ast-metadata-inferer-0.7.0.tgz#c45d874cbdecabea26dc5de11fc6fa1919807c66" + integrity sha512-OkMLzd8xelb3gmnp6ToFvvsHLtS6CbagTkFQvQ+ZYFe3/AIl9iKikNR9G7pY3GfOR/2Xc222hwBjzI7HLkE76Q== + dependencies: + "@mdn/browser-compat-data" "^3.3.14" + async-limiter@~1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz" @@ -3052,6 +3069,16 @@ browserslist@^4.16.6: node-releases "^1.1.77" picocolors "^0.2.1" +browserslist@^4.16.8, browserslist@^4.21.4: + version "4.21.4" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.4.tgz#e7496bbc67b9e39dd0f98565feccdcb0d4ff6987" + integrity sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw== + dependencies: + caniuse-lite "^1.0.30001400" + electron-to-chromium "^1.4.251" + node-releases "^2.0.6" + update-browserslist-db "^1.0.9" + browserslist@^4.17.5: version "4.19.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz" @@ -3217,20 +3244,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219: - version "1.0.30001246" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001246.tgz" - integrity sha512-Tc+ff0Co/nFNbLOrziBXmMVtpt9S2c2Y+Z9Nk9Khj09J+0zR9ejvIW5qkZAErCbOrVODCx/MN+GpB5FNBs5GFA== - -caniuse-lite@^1.0.30001264: - version "1.0.30001265" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001265.tgz" - integrity sha512-YzBnspggWV5hep1m9Z6sZVLOt7vrju8xWooFAgN6BA5qvy98qPAPb7vNUzypFaoh2pb3vlfzbDO8tB57UPGbtw== - -caniuse-lite@^1.0.30001286: - version "1.0.30001297" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001297.tgz" - integrity sha512-6bbIbowYG8vFs/Lk4hU9jFt7NknGDleVAciK916tp6ft1j+D//ZwwL6LbF1wXMQ32DMSjeuUV8suhh6dlmFjcA== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001264, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001400: + version "1.0.30001402" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001402.tgz" + integrity sha512-Mx4MlhXO5NwuvXGgVb+hg65HZ+bhUYsz8QtDGDo2QmaJS2GBX47Xfi2koL86lc8K+l+htXeTEB/Aeqvezoo6Ew== caseless@~0.12.0: version "0.12.0" @@ -3607,6 +3624,11 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0: resolved "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= +construct-style-sheets-polyfill@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/construct-style-sheets-polyfill/-/construct-style-sheets-polyfill-3.1.0.tgz#c490abd79efdb359fafa62ec14ea55232be0eecf" + integrity sha512-HBLKP0chz8BAY6rBdzda11c3wAZeCZ+kIG4weVC2NM3AXzxx09nhe8t0SQNdloAvg5GLuHwq/0SPOOSPvtCcKw== + content-disposition@0.5.3: version "0.5.3" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz" @@ -3724,6 +3746,11 @@ core-js@^2.4.0: resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz" integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== +core-js@^3.16.2: + version "3.25.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.25.1.tgz#5818e09de0db8956e16bf10e2a7141e931b7c69c" + integrity sha512-sr0FY4lnO1hkQ4gLDr24K0DGnweGO1QwSj5BpfQjpSJPdqWalja4cTps29Y/PJVG/P7FYlPDkH3hO+Tr0CvDgQ== + core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" @@ -4291,6 +4318,11 @@ electron-to-chromium@^1.4.17: resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.37.tgz" integrity sha512-XIvFB1omSAxYgHYX48sC+HR8i/p7lx7R+0cX9faElg1g++h9IilCrJ12+bQuY+d96Wp7zkBiJwMOv+AhLtLrTg== +electron-to-chromium@^1.4.251: + version "1.4.254" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.254.tgz#c6203583890abf88dfc0be046cd72d3b48f8beb6" + integrity sha512-Sh/7YsHqQYkA6ZHuHMy24e6TE4eX6KZVsZb9E/DvU1nQRIrH4BflO/4k+83tfdYvDl+MObvlqHPRICzEdC9c6Q== + emittery@^0.8.1: version "0.8.1" resolved "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz" @@ -4614,6 +4646,20 @@ eslint-config-google@^0.14.0: resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.14.0.tgz#4f5f8759ba6e11b424294a219dbfa18c508bcc1a" integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw== +eslint-plugin-compat@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-compat/-/eslint-plugin-compat-4.0.2.tgz#b058627a7d25d352adf0ec16dca8fcf92d9c7af7" + integrity sha512-xqvoO54CLTVaEYGMzhu35Wzwk/As7rCvz/2dqwnFiWi0OJccEtGIn+5qq3zqIu9nboXlpdBN579fZcItC73Ycg== + dependencies: + "@mdn/browser-compat-data" "^4.1.5" + ast-metadata-inferer "^0.7.0" + browserslist "^4.16.8" + caniuse-lite "^1.0.30001304" + core-js "^3.16.2" + find-up "^5.0.0" + lodash.memoize "4.1.2" + semver "7.3.5" + eslint-plugin-jest@^26.5.3: version "26.5.3" resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-26.5.3.tgz#a3ceeaf4a757878342b8b00eca92379b246e5505" @@ -7677,7 +7723,7 @@ lodash.ismatch@^4.4.0: resolved "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz" integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc= -lodash.memoize@4.x, lodash.memoize@^4.1.2: +lodash.memoize@4.1.2, lodash.memoize@4.x, lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= @@ -8270,6 +8316,11 @@ node-releases@^2.0.1: resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz" integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA== +node-releases@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" + integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== + nopt@^4.0.1: version "4.0.3" resolved "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz" @@ -10117,7 +10168,7 @@ semver-match@0.1.1: resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -semver@7.x, semver@^7.1.1, semver@^7.1.3, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: +semver@7.3.5, semver@7.x, semver@^7.1.1, semver@^7.1.3, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: version "7.3.5" resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz" integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== @@ -11301,6 +11352,14 @@ upath@^2.0.1: resolved "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz" integrity sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w== +update-browserslist-db@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz#2924d3927367a38d5c555413a7ce138fc95fcb18" + integrity sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg== + dependencies: + escalade "^3.1.1" + picocolors "^1.0.0" + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"