diff --git a/packages/rrdom-nodejs/test/polyfill.test.ts b/packages/rrdom-nodejs/test/polyfill.test.ts index a9d5f381f1..240976ad83 100644 --- a/packages/rrdom-nodejs/test/polyfill.test.ts +++ b/packages/rrdom-nodejs/test/polyfill.test.ts @@ -7,6 +7,7 @@ import { polyfillNode, polyfillDocument, } from '../src/polyfill'; +import { performance as nativePerformance } from 'perf_hooks'; describe('polyfill for nodejs', () => { it('should polyfill performance api', () => { @@ -16,10 +17,7 @@ describe('polyfill for nodejs', () => { expect(global.performance).toBeDefined(); expect(performance).toBeDefined(); expect(performance.now).toBeDefined(); - expect(performance.now()).toBeCloseTo( - require('perf_hooks').performance.now(), - 1e-10, - ); + expect(performance.now()).toBeCloseTo(nativePerformance.now(), 1e-10); }); it('should not polyfill performance if it already exists', () => { diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 1694d3f222..d2682dd820 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -10,6 +10,7 @@ import { MaskInputFn, KeepIframeSrcFn, ICanvas, + serializedElementNodeWithId, } from './types'; import { Mirror, @@ -377,6 +378,40 @@ function onceIframeLoaded( iframeEl.addEventListener('load', listener); } +function isStylesheetLoaded(link: HTMLLinkElement) { + if (!link.getAttribute('href')) return true; // nothing to load + return link.sheet !== null; +} + +function onceStylesheetLoaded( + link: HTMLLinkElement, + listener: () => unknown, + styleSheetLoadTimeout: number, +) { + let fired = false; + let styleSheetLoaded: StyleSheet | null; + try { + styleSheetLoaded = link.sheet; + } catch (error) { + return; + } + + if (styleSheetLoaded) return; + + const timer = setTimeout(() => { + if (!fired) { + listener(); + fired = true; + } + }, styleSheetLoadTimeout); + + link.addEventListener('load', () => { + clearTimeout(timer); + fired = true; + listener(); + }); +} + function serializeNode( n: Node, options: { @@ -876,6 +911,7 @@ export function serializeNodeWithId( maskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; + newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; @@ -888,10 +924,14 @@ export function serializeNodeWithId( onSerialize?: (n: Node) => unknown; onIframeLoad?: ( iframeNode: HTMLIFrameElement, - node: serializedNodeWithId, + node: serializedElementNodeWithId, ) => unknown; iframeLoadTimeout?: number; - newlyAddedElement?: boolean; + onStylesheetLoad?: ( + linkNode: HTMLLinkElement, + node: serializedElementNodeWithId, + ) => unknown; + stylesheetLoadTimeout?: number; }, ): serializedNodeWithId | null { const { @@ -913,6 +953,8 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout = 5000, + onStylesheetLoad, + stylesheetLoadTimeout = 5000, keepIframeSrcFn = () => false, newlyAddedElement = false, } = options; @@ -1006,6 +1048,8 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, }; for (const childN of Array.from(n.childNodes)) { @@ -1059,11 +1103,16 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, }); if (serializedIframeNode) { - onIframeLoad(n as HTMLIFrameElement, serializedIframeNode); + onIframeLoad( + n as HTMLIFrameElement, + serializedIframeNode as serializedElementNodeWithId, + ); } } }, @@ -1071,6 +1120,54 @@ export function serializeNodeWithId( ); } + // + if ( + serializedNode.type === NodeType.Element && + serializedNode.tagName === 'link' && + serializedNode.attributes.rel === 'stylesheet' + ) { + onceStylesheetLoaded( + n as HTMLLinkElement, + () => { + if (onStylesheetLoad) { + const serializedLinkNode = serializeNodeWithId(n, { + doc, + mirror, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + skipChild: false, + inlineStylesheet, + maskInputOptions, + maskTextFn, + maskInputFn, + slimDOMOptions, + dataURLOptions, + inlineImages, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, + keepIframeSrcFn, + }); + + if (serializedLinkNode) { + onStylesheetLoad( + n as HTMLLinkElement, + serializedLinkNode as serializedElementNodeWithId, + ); + } + } + }, + stylesheetLoadTimeout, + ); + if (isStylesheetLoaded(n as HTMLLinkElement) === false) return null; // add stylesheet in later mutation + } + return serializedNode; } @@ -1094,9 +1191,14 @@ function snapshot( onSerialize?: (n: Node) => unknown; onIframeLoad?: ( iframeNode: HTMLIFrameElement, - node: serializedNodeWithId, + node: serializedElementNodeWithId, ) => unknown; iframeLoadTimeout?: number; + onStylesheetLoad?: ( + linkNode: HTMLLinkElement, + node: serializedElementNodeWithId, + ) => unknown; + stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; }, ): serializedNodeWithId | null { @@ -1118,6 +1220,8 @@ function snapshot( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn = () => false, } = options || {}; const maskInputOptions: MaskInputOptions = @@ -1183,6 +1287,8 @@ function snapshot( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, newlyAddedElement: false, }); diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 6a09c633b4..488be6eaa1 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -63,6 +63,11 @@ export type serializedNode = ( export type serializedNodeWithId = serializedNode & { id: number }; +export type serializedElementNodeWithId = Extract< + serializedNodeWithId, + Record<'type', NodeType.Element> +>; + export type tagMap = { [key: string]: string; }; diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts index 72a7c4ca20..da722cd397 100644 --- a/packages/rrweb-snapshot/typings/snapshot.d.ts +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -1,4 +1,4 @@ -import { serializedNodeWithId, MaskInputOptions, SlimDOMOptions, DataURLOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn } from './types'; +import { serializedNodeWithId, MaskInputOptions, SlimDOMOptions, DataURLOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn, serializedElementNodeWithId } from './types'; import { Mirror } from './utils'; export declare const IGNORED_NODE = -2; export declare function absoluteToStylesheet(cssText: string | null, href: string): string; @@ -16,6 +16,7 @@ export declare function serializeNodeWithId(n: Node, options: { maskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; + newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; @@ -26,9 +27,10 @@ export declare function serializeNodeWithId(n: Node, options: { recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; - onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedNodeWithId) => unknown; + onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedElementNodeWithId) => unknown; iframeLoadTimeout?: number; - newlyAddedElement?: boolean; + onStylesheetLoad?: (linkNode: HTMLLinkElement, node: serializedElementNodeWithId) => unknown; + stylesheetLoadTimeout?: number; }): serializedNodeWithId | null; declare function snapshot(n: Document, options?: { mirror?: Mirror; @@ -46,8 +48,10 @@ declare function snapshot(n: Document, options?: { recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; - onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedNodeWithId) => unknown; + onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedElementNodeWithId) => unknown; iframeLoadTimeout?: number; + onStylesheetLoad?: (linkNode: HTMLLinkElement, node: serializedElementNodeWithId) => unknown; + stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; }): serializedNodeWithId | null; export declare function visitSnapshot(node: serializedNodeWithId, onVisit: (node: serializedNodeWithId) => unknown): void; diff --git a/packages/rrweb-snapshot/typings/types.d.ts b/packages/rrweb-snapshot/typings/types.d.ts index bac3fcfb1f..5282993e41 100644 --- a/packages/rrweb-snapshot/typings/types.d.ts +++ b/packages/rrweb-snapshot/typings/types.d.ts @@ -49,6 +49,7 @@ export declare type serializedNode = (documentNode | documentTypeNode | elementN export declare type serializedNodeWithId = serializedNode & { id: number; }; +export declare type serializedElementNodeWithId = Extract>; export declare type tagMap = { [key: string]: string; }; diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 3240e2f42a..fffb4ce8b8 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -12,6 +12,7 @@ import { polyfill, hasShadowRoot, isSerializedIframe, + isSerializedStylesheet, } from '../utils'; import { EventType, @@ -27,6 +28,7 @@ import { import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; import { CanvasManager } from './observers/canvas/canvas-manager'; +import { StylesheetManager } from './stylesheet-manager'; function wrapEvent(e: event): eventWithTime { return { @@ -215,6 +217,10 @@ function record( mutationCb: wrappedMutationEmit, }); + const stylesheetManager = new StylesheetManager({ + mutationCb: wrappedMutationEmit, + }); + const canvasManager = new CanvasManager({ recordCanvas, mutationCb: wrappedCanvasMutationEmit, @@ -241,6 +247,7 @@ function record( sampling, slimDOMOptions, iframeManager, + stylesheetManager, canvasManager, }, mirror, @@ -276,6 +283,9 @@ function record( if (isSerializedIframe(n, mirror)) { iframeManager.addIframe(n as HTMLIFrameElement); } + if (isSerializedStylesheet(n, mirror)) { + stylesheetManager.addStylesheet(n as HTMLLinkElement); + } if (hasShadowRoot(n)) { shadowDomManager.addShadowRoot(n.shadowRoot, document); } @@ -284,6 +294,9 @@ function record( iframeManager.attachIframe(iframe, childSn, mirror); shadowDomManager.observeAttachShadow(iframe); }, + onStylesheetLoad: (linkEl, childSn) => { + stylesheetManager.attachStylesheet(linkEl, childSn, mirror); + }, keepIframeSrcFn, }); @@ -435,6 +448,7 @@ function record( slimDOMOptions, mirror, iframeManager, + stylesheetManager, shadowDomManager, canvasManager, plugins: diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index aef3aa4cb4..a7b4dbe0fc 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -25,6 +25,7 @@ import { isSerialized, hasShadowRoot, isSerializedIframe, + isSerializedStylesheet, } from '../utils'; type DoubleLinkedListNode = { @@ -169,6 +170,7 @@ export default class MutationBuffer { private doc: observerParam['doc']; private mirror: observerParam['mirror']; private iframeManager: observerParam['iframeManager']; + private stylesheetManager: observerParam['stylesheetManager']; private shadowDomManager: observerParam['shadowDomManager']; private canvasManager: observerParam['canvasManager']; @@ -189,6 +191,7 @@ export default class MutationBuffer { 'doc', 'mirror', 'iframeManager', + 'stylesheetManager', 'shadowDomManager', 'canvasManager', ] as const).forEach((key) => { @@ -289,6 +292,7 @@ export default class MutationBuffer { maskTextClass: this.maskTextClass, maskTextSelector: this.maskTextSelector, skipChild: true, + newlyAddedElement: true, inlineStylesheet: this.inlineStylesheet, maskInputOptions: this.maskInputOptions, maskTextFn: this.maskTextFn, @@ -300,6 +304,9 @@ export default class MutationBuffer { if (isSerializedIframe(currentN, this.mirror)) { this.iframeManager.addIframe(currentN as HTMLIFrameElement); } + if (isSerializedStylesheet(currentN, this.mirror)) { + this.stylesheetManager.addStylesheet(currentN as HTMLLinkElement); + } if (hasShadowRoot(n)) { this.shadowDomManager.addShadowRoot(n.shadowRoot, document); } @@ -308,7 +315,9 @@ export default class MutationBuffer { this.iframeManager.attachIframe(iframe, childSn, this.mirror); this.shadowDomManager.observeAttachShadow(iframe); }, - newlyAddedElement: true, + onStylesheetLoad: (link, childSn) => { + this.stylesheetManager.attachStylesheet(link, childSn, this.mirror); + }, }); if (sn) { adds.push({ @@ -471,6 +480,7 @@ export default class MutationBuffer { ) { return; } + let item: attributeCursor | undefined = this.attributes.find( (a) => a.node === m.target, ); diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts new file mode 100644 index 0000000000..01a69744d7 --- /dev/null +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -0,0 +1,45 @@ +import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; +import type { mutationCallBack } from '../types'; + +export class StylesheetManager { + private trackedStylesheets: WeakSet = new WeakSet(); + private mutationCb: mutationCallBack; + + constructor(options: { mutationCb: mutationCallBack }) { + this.mutationCb = options.mutationCb; + } + + 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( + linkEl: HTMLLinkElement, + childSn: serializedNodeWithId, + mirror: Mirror, + ) { + this.mutationCb({ + adds: [ + { + parentId: mirror.getId(linkEl), + nextId: null, + node: childSn, + }, + ], + removes: [], + texts: [], + attributes: [], + }); + this.addStylesheet(linkEl); + } +} diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 63a10943dd..7a8e6a8d99 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -13,6 +13,7 @@ import type { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; import type { RRNode } from 'rrdom'; import type { CanvasManager } from './record/observers/canvas/canvas-manager'; +import type { StylesheetManager } from './record/stylesheet-manager'; export enum EventType { DomContentLoaded, @@ -280,6 +281,7 @@ export type observerParam = { doc: Document; mirror: Mirror; iframeManager: IframeManager; + stylesheetManager: StylesheetManager; shadowDomManager: ShadowDomManager; canvasManager: CanvasManager; plugins: Array<{ @@ -306,6 +308,7 @@ export type MutationBufferParam = Pick< | 'doc' | 'mirror' | 'iframeManager' + | 'stylesheetManager' | 'shadowDomManager' | 'canvasManager' >; diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 81108b58b5..d3c16a8919 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -354,6 +354,19 @@ export function isSerializedIframe( return Boolean(n.nodeName === 'IFRAME' && mirror.getMeta(n)); } +export function isSerializedStylesheet( + n: TNode, + mirror: IMirror, +): boolean { + return Boolean( + n.nodeName === 'LINK' && + n.nodeType === n.ELEMENT_NODE && + (n as HTMLElement).getAttribute && + (n as HTMLElement).getAttribute('rel') === 'stylesheet' && + mirror.getMeta(n), + ); +} + export function getBaseDimension( node: Node, rootIframe: Node, diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index e65b2372c8..2c2b7c8d0d 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -94,6 +94,109 @@ exports[`record can add custom event 1`] = ` ]" `; +exports[`record captures CORS stylesheets that are still loading 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\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"rel\\": \\"stylesheet\\", + \\"href\\": \\"https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css\\" + }, + \\"childNodes\\": [], + \\"id\\": 9 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + } +]" +`; + exports[`record captures inserted style text nodes correctly 1`] = ` "[ { @@ -640,6 +743,498 @@ exports[`record captures stylesheet rules 1`] = ` ]" `; +exports[`record captures stylesheets in iframes that are still loading 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\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 9 + } + ], + \\"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\\": 10, + \\"id\\": 12 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 14 + } + ], + \\"rootId\\": 10, + \\"id\\": 11 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 10 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 13, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"_cssText\\": \\"body { color: pink; }\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 13 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + } +]" +`; + +exports[`record captures stylesheets in iframes with \`blob:\` url 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\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 9 + } + ], + \\"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\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"_cssText\\": \\"body { color: pink; }\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 13 + } + ], + \\"rootId\\": 10, + \\"id\\": 12 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 14 + } + ], + \\"rootId\\": 10, + \\"id\\": 11 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 10 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + } +]" +`; + +exports[`record captures stylesheets that are still loading 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\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"_cssText\\": \\"body { color: pink; }\\" + }, + \\"childNodes\\": [], + \\"id\\": 9 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + } +]" +`; + +exports[`record captures stylesheets with \`blob:\` url 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\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"_cssText\\": \\"body { color: pink; }\\" + }, + \\"childNodes\\": [], + \\"id\\": 5 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 9 + } + ], + \\"id\\": 6 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + } +]" +`; + exports[`record iframes captures stylesheet mutations in iframes 1`] = ` "[ { diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 969f11ff5d..b45a6ce46c 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -34,7 +34,9 @@ const setup = function (this: ISuite, content: string): ISuite { const ctx = {} as ISuite; beforeAll(async () => { - ctx.browser = await launchPuppeteer(); + ctx.browser = await launchPuppeteer({ + devtools: true, + }); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); ctx.code = fs.readFileSync(bundlePath, 'utf8'); @@ -143,16 +145,20 @@ describe('record', function (this: ISuite) { checkoutEveryNms: 500, }); }); - let count = 30; - while (count--) { - await ctx.page.type('input', 'a'); - } + await ctx.page.type('input', 'a'); await ctx.page.waitForTimeout(300); - expect(ctx.events.length).toEqual(33); // before first automatic snapshot - await ctx.page.waitForTimeout(200); // could be 33 or 35 events by now depending on speed of test env + expect( + ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta) + .length, + ).toEqual(1); // before first automatic snapshot + expect( + ctx.events.filter( + (event: eventWithTime) => event.type === EventType.FullSnapshot, + ).length, + ).toEqual(1); // before first automatic snapshot + await ctx.page.waitForTimeout(200); await ctx.page.type('input', 'a'); await ctx.page.waitForTimeout(10); - expect(ctx.events.length).toEqual(36); // additionally includes the 2 checkout events expect( ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta) .length, @@ -162,8 +168,6 @@ describe('record', function (this: ISuite) { (event: eventWithTime) => event.type === EventType.FullSnapshot, ).length, ).toEqual(2); - expect(ctx.events[1].type).toEqual(EventType.FullSnapshot); - expect(ctx.events[35].type).toEqual(EventType.FullSnapshot); }); it('is safe to checkout during async callbacks', async () => { @@ -381,6 +385,151 @@ describe('record', function (this: ISuite) { await waitForRAF(ctx.page); assertSnapshot(ctx.events); }); + + it('captures stylesheets with `blob:` url', async () => { + await ctx.page.evaluate(() => { + const link1 = document.createElement('link'); + link1.setAttribute('rel', 'stylesheet'); + link1.setAttribute( + 'href', + URL.createObjectURL( + new Blob(['body { color: pink; }'], { + type: 'text/css', + }), + ), + ); + document.head.appendChild(link1); + }); + await waitForRAF(ctx.page); + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + }); + await waitForRAF(ctx.page); + assertSnapshot(ctx.events); + }); + + it('captures stylesheets in iframes with `blob:` url', async () => { + await ctx.page.evaluate(() => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('src', 'about:blank'); + document.body.appendChild(iframe); + + const linkEl = document.createElement('link'); + linkEl.setAttribute('rel', 'stylesheet'); + linkEl.setAttribute( + 'href', + URL.createObjectURL( + new Blob(['body { color: pink; }'], { + type: 'text/css', + }), + ), + ); + const iframeDoc = iframe.contentDocument!; + iframeDoc.head.appendChild(linkEl); + }); + await waitForRAF(ctx.page); + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + }); + await waitForRAF(ctx.page); + assertSnapshot(ctx.events); + }); + + it('captures stylesheets that are still loading', async () => { + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + + const link1 = document.createElement('link'); + link1.setAttribute('rel', 'stylesheet'); + link1.setAttribute( + 'href', + URL.createObjectURL( + new Blob(['body { color: pink; }'], { + type: 'text/css', + }), + ), + ); + document.head.appendChild(link1); + }); + + // `blob:` URLs are not available immediately, so we need to wait for the browser to load them + await waitForRAF(ctx.page); + + assertSnapshot(ctx.events); + }); + + it('captures stylesheets in iframes that are still loading', async () => { + await ctx.page.evaluate(() => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('src', 'about:blank'); + document.body.appendChild(iframe); + const iframeDoc = iframe.contentDocument!; + + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + + const linkEl = document.createElement('link'); + linkEl.setAttribute('rel', 'stylesheet'); + linkEl.setAttribute( + 'href', + URL.createObjectURL( + new Blob(['body { color: pink; }'], { + type: 'text/css', + }), + ), + ); + iframeDoc.head.appendChild(linkEl); + }); + + // `blob:` URLs are not available immediately, so we need to wait for the browser to load them + await waitForRAF(ctx.page); + + assertSnapshot(ctx.events); + }); + + it('captures CORS stylesheets that are still loading', async () => { + const corsStylesheetURL = + 'https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css'; + + // do not `await` the following function, otherwise `waitForResponse` _might_ not be called + void ctx.page.evaluate((corsStylesheetURL) => { + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + + const link1 = document.createElement('link'); + link1.setAttribute('rel', 'stylesheet'); + link1.setAttribute('href', corsStylesheetURL); + document.head.appendChild(link1); + }, corsStylesheetURL); + + await ctx.page.waitForResponse(corsStylesheetURL); // wait for stylesheet to be loaded + await waitForRAF(ctx.page); // wait for rrweb to emit events + + assertSnapshot(ctx.events); + }); }); describe('record iframes', function (this: ISuite) { @@ -463,7 +612,8 @@ describe('record iframes', function (this: ISuite) { }, 10); }, 10); }); - await ctx.page.waitForTimeout(50); + await ctx.page.waitForTimeout(50); // wait till setTimeout is called + await waitForRAF(ctx.page); // wait till events get sent const styleRelatedEvents = ctx.events.filter( (e) => e.type === EventType.IncrementalSnapshot && diff --git a/packages/rrweb/typings/record/mutation.d.ts b/packages/rrweb/typings/record/mutation.d.ts index 930d247848..e554eabe1e 100644 --- a/packages/rrweb/typings/record/mutation.d.ts +++ b/packages/rrweb/typings/record/mutation.d.ts @@ -25,6 +25,7 @@ export default class MutationBuffer { private doc; private mirror; private iframeManager; + private stylesheetManager; private shadowDomManager; private canvasManager; init(options: MutationBufferParam): void; diff --git a/packages/rrweb/typings/record/stylesheet-manager.d.ts b/packages/rrweb/typings/record/stylesheet-manager.d.ts new file mode 100644 index 0000000000..022fc54445 --- /dev/null +++ b/packages/rrweb/typings/record/stylesheet-manager.d.ts @@ -0,0 +1,12 @@ +import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; +import type { mutationCallBack } from '../types'; +export declare class StylesheetManager { + private trackedStylesheets; + private mutationCb; + constructor(options: { + mutationCb: mutationCallBack; + }); + addStylesheet(linkEl: HTMLLinkElement): void; + private trackStylesheet; + attachStylesheet(linkEl: HTMLLinkElement, childSn: serializedNodeWithId, mirror: Mirror): void; +} diff --git a/packages/rrweb/typings/types.d.ts b/packages/rrweb/typings/types.d.ts index 50a7109a75..c40a9f2a29 100644 --- a/packages/rrweb/typings/types.d.ts +++ b/packages/rrweb/typings/types.d.ts @@ -5,6 +5,7 @@ import type { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; import type { RRNode } from 'rrdom'; import type { CanvasManager } from './record/observers/canvas/canvas-manager'; +import type { StylesheetManager } from './record/stylesheet-manager'; export declare enum EventType { DomContentLoaded = 0, Load = 1, @@ -193,6 +194,7 @@ export declare type observerParam = { doc: Document; mirror: Mirror; iframeManager: IframeManager; + stylesheetManager: StylesheetManager; shadowDomManager: ShadowDomManager; canvasManager: CanvasManager; plugins: Array<{ @@ -201,7 +203,7 @@ export declare type observerParam = { options: unknown; }>; }; -export declare type MutationBufferParam = Pick; +export declare type MutationBufferParam = Pick; export declare type hooksParam = { mutation?: mutationCallBack; mousemove?: mousemoveCallBack; diff --git a/packages/rrweb/typings/utils.d.ts b/packages/rrweb/typings/utils.d.ts index 0fabf6f199..ed87d5fee1 100644 --- a/packages/rrweb/typings/utils.d.ts +++ b/packages/rrweb/typings/utils.d.ts @@ -28,6 +28,7 @@ export declare type AppendedIframe = { builtNode: HTMLIFrameElement | RRIFrameElement; }; export declare function isSerializedIframe(n: TNode, mirror: IMirror): boolean; +export declare function isSerializedStylesheet(n: TNode, mirror: IMirror): boolean; export declare function getBaseDimension(node: Node, rootIframe: Node): DocumentDimension; export declare function hasShadowRoot(n: T): n is T & { shadowRoot: ShadowRoot;