From 42009f33dfd8c384c26215bdd07c9ece54a8ddcc Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 31 May 2022 18:09:37 +0200 Subject: [PATCH 01/18] inline stylesheets when loaded --- packages/rrweb-snapshot/src/snapshot.ts | 115 ++++++++++- packages/rrweb-snapshot/src/types.ts | 5 + packages/rrweb-snapshot/typings/snapshot.d.ts | 10 +- packages/rrweb-snapshot/typings/types.d.ts | 1 + packages/rrweb/src/record/index.ts | 10 + packages/rrweb/src/record/mutation.ts | 5 + .../rrweb/src/record/stylesheet-manager.ts | 30 +++ packages/rrweb/src/types.ts | 3 + .../test/__snapshots__/record.test.ts.snap | 190 ++++++++++++++++++ packages/rrweb/test/record.test.ts | 55 +++++ packages/rrweb/typings/record/mutation.d.ts | 1 + .../typings/record/stylesheet-manager.d.ts | 9 + packages/rrweb/typings/types.d.ts | 4 +- 13 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 packages/rrweb/src/record/stylesheet-manager.ts create mode 100644 packages/rrweb/typings/record/stylesheet-manager.d.ts diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index d6247e44a1..ff737dc899 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, @@ -372,6 +373,44 @@ function onceIframeLoaded( iframeEl.addEventListener('load', listener); } +function isStylesheetLoaded(link: HTMLLinkElement) { + return link.sheet !== null; +} + +function onceStylesheetLoaded( + link: HTMLLinkElement, + listener: () => unknown, + iframeLoadTimeout: number, +) { + let fired = false; + let styleSheetLoaded: StyleSheet | null; + try { + styleSheetLoaded = link.sheet; + } catch (error) { + return; + } + + if (!styleSheetLoaded) { + const timer = setTimeout(() => { + if (!fired) { + listener(); + fired = true; + } + }, iframeLoadTimeout); + link.addEventListener('load', () => { + clearTimeout(timer); + fired = true; + listener(); + }); + return; + } + + // stylesheet was already loaded, make sure we wait to trigger the listener + // till _after_ the mutation that found this stylesheet has had time to process + setTimeout(listener, 0); + return; +} + function serializeNode( n: Node, options: { @@ -462,7 +501,7 @@ function serializeNode( }); let cssText: string | null = null; if (stylesheet) { - cssText = getCssRulesString(stylesheet ); + cssText = getCssRulesString(stylesheet); } if (cssText) { delete attributes.rel; @@ -816,9 +855,14 @@ export function serializeNodeWithId( onSerialize?: (n: Node) => unknown; onIframeLoad?: ( iframeNode: HTMLIFrameElement, - node: serializedNodeWithId, + node: serializedElementNodeWithId, ) => unknown; iframeLoadTimeout?: number; + onStylesheetLoad?: ( + linkNode: HTMLLinkElement, + node: serializedElementNodeWithId, + ) => unknown; + stylesheetLoadTimeout?: number; }, ): serializedNodeWithId | null { const { @@ -840,6 +884,8 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout = 5000, + onStylesheetLoad, + stylesheetLoadTimeout = 5000, keepIframeSrcFn = () => false, } = options; let { preserveWhiteSpace = true } = options; @@ -931,6 +977,8 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, }; for (const childN of Array.from(n.childNodes)) { @@ -988,7 +1036,10 @@ export function serializeNodeWithId( }); if (serializedIframeNode) { - onIframeLoad(n as HTMLIFrameElement, serializedIframeNode); + onIframeLoad( + n as HTMLIFrameElement, + serializedIframeNode as serializedElementNodeWithId, + ); } } }, @@ -996,6 +1047,53 @@ 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, + onStylesheetLoad, + iframeLoadTimeout, + 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; } @@ -1019,9 +1117,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 { @@ -1043,6 +1146,8 @@ function snapshot( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn = () => false, } = options || {}; const maskInputOptions: MaskInputOptions = @@ -1108,6 +1213,8 @@ function snapshot( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, }); } 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 0cdca439df..92642db03c 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; @@ -25,8 +25,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; + onStylesheetLoad?: (linkNode: HTMLLinkElement, node: serializedElementNodeWithId) => unknown; + stylesheetLoadTimeout?: number; }): serializedNodeWithId | null; declare function snapshot(n: Document, options?: { mirror?: Mirror; @@ -44,8 +46,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..7cc4ff22b0 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -27,6 +27,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 +216,10 @@ function record( mutationCb: wrappedMutationEmit, }); + const stylesheetManager = new StylesheetManager({ + mutationCb: wrappedMutationEmit, + }); + const canvasManager = new CanvasManager({ recordCanvas, mutationCb: wrappedCanvasMutationEmit, @@ -241,6 +246,7 @@ function record( sampling, slimDOMOptions, iframeManager, + stylesheetManager, canvasManager, }, mirror, @@ -284,6 +290,9 @@ function record( iframeManager.attachIframe(iframe, childSn, mirror); shadowDomManager.observeAttachShadow(iframe); }, + onStylesheetLoad: (linkEl, childSn) => { + this.stylesheetManager.attachStylesheet(linkEl, childSn, this.mirror); + }, keepIframeSrcFn, }); @@ -435,6 +444,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 91272adda2..bccbde021b 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -169,6 +169,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 +190,7 @@ export default class MutationBuffer { 'doc', 'mirror', 'iframeManager', + 'stylesheetManager', 'shadowDomManager', 'canvasManager', ] as const).forEach((key) => { @@ -308,6 +310,9 @@ export default class MutationBuffer { this.iframeManager.attachIframe(iframe, childSn, this.mirror); this.shadowDomManager.observeAttachShadow(iframe); }, + onStylesheetLoad: (link, childSn) => { + this.stylesheetManager.attachStylesheet(link, childSn, this.mirror); + }, }); if (sn) { adds.push({ diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts new file mode 100644 index 0000000000..c10db6c61e --- /dev/null +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -0,0 +1,30 @@ +import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; +import type { mutationCallBack } from '../types'; + +export class StylesheetManager { + // private stylesheets: WeakMap = new WeakMap(); + private mutationCb: mutationCallBack; + + constructor(options: { mutationCb: mutationCallBack }) { + this.mutationCb = options.mutationCb; + } + + public attachStylesheet( + linkEl: HTMLLinkElement, + childSn: serializedNodeWithId, + mirror: Mirror, + ) { + this.mutationCb({ + adds: [ + { + parentId: mirror.getId(linkEl), + nextId: null, + node: childSn, + }, + ], + removes: [], + texts: [], + attributes: [], + }); + } +} diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index a74a091c75..21a143cdd7 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/es/virtual-dom'; 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/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index 74c951afa8..8fb3793abf 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -640,6 +640,196 @@ exports[`record captures stylesheet rules 1`] = ` ]" `; +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 1d36fa5ac1..6717985933 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -364,6 +364,61 @@ 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 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); + }); }); describe('record iframes', function (this: ISuite) { 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..ecf873827c --- /dev/null +++ b/packages/rrweb/typings/record/stylesheet-manager.d.ts @@ -0,0 +1,9 @@ +import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; +import type { mutationCallBack } from '../types'; +export declare class StylesheetManager { + private mutationCb; + constructor(options: { + mutationCb: mutationCallBack; + }); + 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 61f3d8b84c..cb94de675c 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/es/virtual-dom'; 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; From b9ddfae3882c6c2dba466a82131c86f713fd4468 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 1 Jun 2022 16:18:04 +0200 Subject: [PATCH 02/18] set empty link elements to loaded by default --- packages/rrweb-snapshot/src/snapshot.ts | 1 + packages/rrweb/scripts/repl.js | 2 +- packages/rrweb/src/record/index.ts | 4 + packages/rrweb/src/record/mutation.ts | 21 ++- .../rrweb/src/record/stylesheet-manager.ts | 75 ++++++++++ packages/rrweb/src/utils.ts | 13 ++ packages/rrweb/test/record.test.ts | 140 +++++++++++++++++- .../typings/record/stylesheet-manager.d.ts | 2 + 8 files changed, 253 insertions(+), 5 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index ff737dc899..5be07d4ac4 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -374,6 +374,7 @@ function onceIframeLoaded( } function isStylesheetLoaded(link: HTMLLinkElement) { + if (!link.getAttribute('href')) return true; // nothing to load return link.sheet !== null; } diff --git a/packages/rrweb/scripts/repl.js b/packages/rrweb/scripts/repl.js index 9afdb70998..217b709a60 100644 --- a/packages/rrweb/scripts/repl.js +++ b/packages/rrweb/scripts/repl.js @@ -119,7 +119,7 @@ void (async () => { await page.evaluate(`;${code} window.__IS_RECORDING__ = true rrweb.record({ - emit: event => window._replLog(event), + emit: event => console.log(event), recordCanvas: true, collectFonts: true }); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 7cc4ff22b0..76ef856212 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, @@ -282,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); } diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index bccbde021b..55f6ee3e69 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 = { @@ -302,6 +303,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); } @@ -469,6 +473,21 @@ export default class MutationBuffer { if (isBlocked(m.target, this.blockClass) || value === m.oldValue) { return; } + + // external stylesheets might need to be loaded before their mutations can be applied + if ( + m.attributeName === 'href' && + m.target.nodeName === 'LINK' && + 'getAttribute' in m.target && + (m.target as HTMLLinkElement).getAttribute('rel') === 'stylesheet' + ) { + this.stylesheetManager.attributeMutation( + m.target as HTMLLinkElement, + m.oldValue, + value, + ); + } + let item: attributeCursor | undefined = this.attributes.find( (a) => a.node === m.target, ); @@ -602,7 +621,7 @@ export default class MutationBuffer { // if this node is blocked `serializeNode` will turn it into a placeholder element // but we have to remove it's children otherwise they will be added as placeholders too if (!isBlocked(n, this.blockClass)) - (n ).childNodes.forEach((childN) => this.genAdds(childN)); + n.childNodes.forEach((childN) => this.genAdds(childN)); }; } diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index c10db6c61e..c211d44003 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -3,12 +3,58 @@ import type { mutationCallBack } from '../types'; export class StylesheetManager { // private stylesheets: WeakMap = new WeakMap(); + private trackedStylesheets: WeakSet = new WeakSet(); private mutationCb: mutationCallBack; + private loadListener?: (linkEl: HTMLLinkElement) => unknown; constructor(options: { mutationCb: mutationCallBack }) { this.mutationCb = options.mutationCb; } + public addStylesheet(linkEl: HTMLLinkElement) { + console.log('add stylesheet!', this.trackedStylesheets.has(linkEl)); + if (this.trackedStylesheets.has(linkEl)) return; + + this.trackStylesheet(linkEl); + this.trackedStylesheets.add(linkEl); + } + + public addLoadListener(cb: (linkEl: HTMLLinkElement) => unknown) { + this.loadListener = cb; + } + + public attributeMutation( + linkEl: HTMLLinkElement, + oldValue: string | null, + newValue: string | null, + ) { + console.log( + 'attributeMutation', + JSON.stringify(oldValue), + JSON.stringify(newValue), + // linkEl.sheet, + // linkEl.sheet?.cssRules[0]?.cssText, + ); + + // this.mutationCb({ + // adds: [], + // removes: [], + // texts: [], + // attributes: [ + // { + // id: mirror.getId(linkEl), + // attributes, + // }, + // ], + // }); + } + + private trackStylesheet(linkEl: HTMLLinkElement) { + linkEl.addEventListener('load', () => { + console.log('trackStylesheet', linkEl.sheet); + }); + } + public attachStylesheet( linkEl: HTMLLinkElement, childSn: serializedNodeWithId, @@ -26,5 +72,34 @@ export class StylesheetManager { texts: [], attributes: [], }); + this.trackStylesheet(linkEl); + // this.loadListener?.(linkEl); + + // wrappedEmit( + // wrapEvent({ + // type: EventType.IncrementalSnapshot, + // data: { + // source: IncrementalSource.Mutation, + // adds: [ + // { + // parentId: mirror.getId(linkEl.parentElement), + // nextId: null, + // node: childSn, + // }, + // ], + // removes: [], + // texts: [], + // attributes: [ + // // { + // // id: childSn.id, + // // attributes: { + // // href: null, + // // _cssText: childSn.attributes._cssText, + // // }, + // // }, + // ], + // }, + // }), + // ); } } diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 0b546b5fe7..3a54233840 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -352,6 +352,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/record.test.ts b/packages/rrweb/test/record.test.ts index 6717985933..4d9d9ee6f1 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -34,9 +34,12 @@ 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'); + // const bundlePath = path.resolve(__dirname, '../dist/rrweb-all.js'); ctx.code = fs.readFileSync(bundlePath, 'utf8'); }); @@ -57,6 +60,7 @@ const setup = function (this: ISuite, content: string): ISuite { }); afterEach(async () => { + // await ctx.page.waitForTimeout(60000); await ctx.page.close(); }); @@ -68,7 +72,8 @@ const setup = function (this: ISuite, content: string): ISuite { }; describe('record', function (this: ISuite) { - jest.setTimeout(10_000); + jest.setTimeout(180_000); + // jest.setTimeout(10_000); const ctx: ISuite = setup.call( this, @@ -419,10 +424,139 @@ describe('record', function (this: ISuite) { assertSnapshot(ctx.events); }); + + // it.only('captures stylesheets that change href', async () => { + // await ctx.page.evaluate(() => { + // const link1 = document.createElement('link'); + // link1.setAttribute('rel', 'stylesheet'); + // const blobUrl = URL.createObjectURL( + // new Blob(['.old { color: pink; }'], { + // type: 'text/css', + // }), + // ); + // (window as any).blobUrl = blobUrl; + // link1.setAttribute('href', blobUrl); + // 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, + // }); + + // const link = document.querySelector('link')!; + // link.addEventListener('load', () => console.log('loaded stylesheet')); + // link.setAttribute( + // 'href', + // URL.createObjectURL( + // new Blob(['.new { color: orange; }'], { + // type: 'text/css', + // }), + // ), + // ); + // }); + // // await waitForRAF(ctx.page); + // await ctx.page.waitForTimeout(60000); + + // // await ctx.page.evaluate(() => { + // // const { record } = ((window as unknown) as IWindow).rrweb; + + // // record({ + // // inlineStylesheet: true, + // // emit: ((window as unknown) as IWindow).emit, + // // }); + + // // const link = document.querySelector('link')!; + // // link.setAttribute('href', (window as any).blobUrl); + // // }); + + // // `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 contents of blob: uris even when those change', 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); + // console.log( + // 'doc', + // JSON.stringify(document.location), + // document.location.href, + // link1.getAttribute('href'), + // ); + // console.log('link1', link1.sheet?.cssRules[0].cssText); + // setTimeout(() => { + // console.log('link1-settimeout', link1.sheet?.cssRules[0].cssText); + // }, 10); + + // const { record } = ((window as unknown) as IWindow).rrweb; + + // record({ + // inlineStylesheet: true, + // emit: ((window as unknown) as IWindow).emit, + // }); + + // const link = document.createElement('link'); + // link.setAttribute('rel', 'stylesheet'); + // link.setAttribute( + // 'href', + // URL.createObjectURL( + // new Blob(['body { color: red; }'], { + // type: 'text/css', + // }), + // ), + // ); + // document.head.appendChild(link); + + // setTimeout(() => { + // link.setAttribute( + // 'href', + // URL.createObjectURL( + // new Blob(['body { color: blue; }'], { + // type: 'text/css', + // }), + // ), + // ); + // }, 0); + // }); + // // await ctx.page.waitForTimeout(60 * 1000 * 2); + // await waitForRAF(ctx.page); + // await ctx.page.evaluate(() => { + // const link3 = document.createElement('link'); + // link3.setAttribute('rel', 'stylesheet'); + // link3.setAttribute( + // 'href', + // URL.createObjectURL( + // new Blob(['body { color: orange; }'], { + // type: 'text/css', + // }), + // ), + // ); + // document.head.appendChild(link3); + // }); + // await waitForRAF(ctx.page); + + // assertSnapshot(ctx.events); + // }); }); describe('record iframes', function (this: ISuite) { - jest.setTimeout(10_000); + jest.setTimeout(180_000); const ctx: ISuite = setup.call( this, diff --git a/packages/rrweb/typings/record/stylesheet-manager.d.ts b/packages/rrweb/typings/record/stylesheet-manager.d.ts index ecf873827c..837c4bee24 100644 --- a/packages/rrweb/typings/record/stylesheet-manager.d.ts +++ b/packages/rrweb/typings/record/stylesheet-manager.d.ts @@ -2,8 +2,10 @@ import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; import type { mutationCallBack } from '../types'; export declare class StylesheetManager { private mutationCb; + private loadListener?; constructor(options: { mutationCb: mutationCallBack; }); + addLoadListener(cb: (linkEl: HTMLLinkElement) => unknown): void; attachStylesheet(linkEl: HTMLLinkElement, childSn: serializedNodeWithId, mirror: Mirror): void; } From 4c1080485e714e39218f0ebd3885430caa1283c0 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 1 Jun 2022 17:24:38 +0200 Subject: [PATCH 03/18] Clean up stylesheet manager --- packages/rrweb-snapshot/src/snapshot.ts | 29 ++-- .../rrweb/src/record/stylesheet-manager.ts | 71 +------- .../test/__snapshots__/record.test.ts.snap | 103 ++++++++++++ packages/rrweb/test/record.test.ts | 152 +++--------------- .../typings/record/stylesheet-manager.d.ts | 5 +- packages/rrweb/typings/utils.d.ts | 1 + 6 files changed, 148 insertions(+), 213 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 5be07d4ac4..10ae4adc5f 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -391,25 +391,20 @@ function onceStylesheetLoaded( return; } - if (!styleSheetLoaded) { - const timer = setTimeout(() => { - if (!fired) { - listener(); - fired = true; - } - }, iframeLoadTimeout); - link.addEventListener('load', () => { - clearTimeout(timer); - fired = true; + if (styleSheetLoaded) return; + + const timer = setTimeout(() => { + if (!fired) { listener(); - }); - return; - } + fired = true; + } + }, iframeLoadTimeout); - // stylesheet was already loaded, make sure we wait to trigger the listener - // till _after_ the mutation that found this stylesheet has had time to process - setTimeout(listener, 0); - return; + link.addEventListener('load', () => { + clearTimeout(timer); + fired = true; + listener(); + }); } function serializeNode( diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index c211d44003..f3332b3d0c 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -2,57 +2,24 @@ import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; import type { mutationCallBack } from '../types'; export class StylesheetManager { - // private stylesheets: WeakMap = new WeakMap(); private trackedStylesheets: WeakSet = new WeakSet(); private mutationCb: mutationCallBack; - private loadListener?: (linkEl: HTMLLinkElement) => unknown; constructor(options: { mutationCb: mutationCallBack }) { this.mutationCb = options.mutationCb; } public addStylesheet(linkEl: HTMLLinkElement) { - console.log('add stylesheet!', this.trackedStylesheets.has(linkEl)); if (this.trackedStylesheets.has(linkEl)) return; - this.trackStylesheet(linkEl); this.trackedStylesheets.add(linkEl); - } - - public addLoadListener(cb: (linkEl: HTMLLinkElement) => unknown) { - this.loadListener = cb; - } - - public attributeMutation( - linkEl: HTMLLinkElement, - oldValue: string | null, - newValue: string | null, - ) { - console.log( - 'attributeMutation', - JSON.stringify(oldValue), - JSON.stringify(newValue), - // linkEl.sheet, - // linkEl.sheet?.cssRules[0]?.cssText, - ); - - // this.mutationCb({ - // adds: [], - // removes: [], - // texts: [], - // attributes: [ - // { - // id: mirror.getId(linkEl), - // attributes, - // }, - // ], - // }); + this.trackStylesheet(linkEl); } private trackStylesheet(linkEl: HTMLLinkElement) { - linkEl.addEventListener('load', () => { - console.log('trackStylesheet', linkEl.sheet); - }); + // linkEl.addEventListener('load', () => { + // // re-loaded, maybe take another snapshot? + // }); } public attachStylesheet( @@ -72,34 +39,6 @@ export class StylesheetManager { texts: [], attributes: [], }); - this.trackStylesheet(linkEl); - // this.loadListener?.(linkEl); - - // wrappedEmit( - // wrapEvent({ - // type: EventType.IncrementalSnapshot, - // data: { - // source: IncrementalSource.Mutation, - // adds: [ - // { - // parentId: mirror.getId(linkEl.parentElement), - // nextId: null, - // node: childSn, - // }, - // ], - // removes: [], - // texts: [], - // attributes: [ - // // { - // // id: childSn.id, - // // attributes: { - // // href: null, - // // _cssText: childSn.attributes._cssText, - // // }, - // // }, - // ], - // }, - // }), - // ); + this.addStylesheet(linkEl); } } diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index 8fb3793abf..a52ffdf795 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`] = ` "[ { diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 4d9d9ee6f1..612019fd44 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -425,134 +425,30 @@ describe('record', function (this: ISuite) { assertSnapshot(ctx.events); }); - // it.only('captures stylesheets that change href', async () => { - // await ctx.page.evaluate(() => { - // const link1 = document.createElement('link'); - // link1.setAttribute('rel', 'stylesheet'); - // const blobUrl = URL.createObjectURL( - // new Blob(['.old { color: pink; }'], { - // type: 'text/css', - // }), - // ); - // (window as any).blobUrl = blobUrl; - // link1.setAttribute('href', blobUrl); - // 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, - // }); - - // const link = document.querySelector('link')!; - // link.addEventListener('load', () => console.log('loaded stylesheet')); - // link.setAttribute( - // 'href', - // URL.createObjectURL( - // new Blob(['.new { color: orange; }'], { - // type: 'text/css', - // }), - // ), - // ); - // }); - // // await waitForRAF(ctx.page); - // await ctx.page.waitForTimeout(60000); - - // // await ctx.page.evaluate(() => { - // // const { record } = ((window as unknown) as IWindow).rrweb; - - // // record({ - // // inlineStylesheet: true, - // // emit: ((window as unknown) as IWindow).emit, - // // }); - - // // const link = document.querySelector('link')!; - // // link.setAttribute('href', (window as any).blobUrl); - // // }); - - // // `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 contents of blob: uris even when those change', 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); - // console.log( - // 'doc', - // JSON.stringify(document.location), - // document.location.href, - // link1.getAttribute('href'), - // ); - // console.log('link1', link1.sheet?.cssRules[0].cssText); - // setTimeout(() => { - // console.log('link1-settimeout', link1.sheet?.cssRules[0].cssText); - // }, 10); - - // const { record } = ((window as unknown) as IWindow).rrweb; - - // record({ - // inlineStylesheet: true, - // emit: ((window as unknown) as IWindow).emit, - // }); - - // const link = document.createElement('link'); - // link.setAttribute('rel', 'stylesheet'); - // link.setAttribute( - // 'href', - // URL.createObjectURL( - // new Blob(['body { color: red; }'], { - // type: 'text/css', - // }), - // ), - // ); - // document.head.appendChild(link); - - // setTimeout(() => { - // link.setAttribute( - // 'href', - // URL.createObjectURL( - // new Blob(['body { color: blue; }'], { - // type: 'text/css', - // }), - // ), - // ); - // }, 0); - // }); - // // await ctx.page.waitForTimeout(60 * 1000 * 2); - // await waitForRAF(ctx.page); - // await ctx.page.evaluate(() => { - // const link3 = document.createElement('link'); - // link3.setAttribute('rel', 'stylesheet'); - // link3.setAttribute( - // 'href', - // URL.createObjectURL( - // new Blob(['body { color: orange; }'], { - // type: 'text/css', - // }), - // ), - // ); - // document.head.appendChild(link3); - // }); - // 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) { diff --git a/packages/rrweb/typings/record/stylesheet-manager.d.ts b/packages/rrweb/typings/record/stylesheet-manager.d.ts index 837c4bee24..022fc54445 100644 --- a/packages/rrweb/typings/record/stylesheet-manager.d.ts +++ b/packages/rrweb/typings/record/stylesheet-manager.d.ts @@ -1,11 +1,12 @@ import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; import type { mutationCallBack } from '../types'; export declare class StylesheetManager { + private trackedStylesheets; private mutationCb; - private loadListener?; constructor(options: { mutationCb: mutationCallBack; }); - addLoadListener(cb: (linkEl: HTMLLinkElement) => unknown): void; + addStylesheet(linkEl: HTMLLinkElement): void; + private trackStylesheet; attachStylesheet(linkEl: HTMLLinkElement, childSn: serializedNodeWithId, mirror: Mirror): void; } diff --git a/packages/rrweb/typings/utils.d.ts b/packages/rrweb/typings/utils.d.ts index 0dacfb8245..f816f5a9c0 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; From e9f8e061cfbc2824875dc86b9850b1b24d4c7b5e Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Thu, 2 Jun 2022 11:20:12 +0200 Subject: [PATCH 04/18] Remove attribute mutation code --- packages/rrweb/src/record/mutation.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 55f6ee3e69..97a14a9449 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -474,20 +474,6 @@ export default class MutationBuffer { return; } - // external stylesheets might need to be loaded before their mutations can be applied - if ( - m.attributeName === 'href' && - m.target.nodeName === 'LINK' && - 'getAttribute' in m.target && - (m.target as HTMLLinkElement).getAttribute('rel') === 'stylesheet' - ) { - this.stylesheetManager.attributeMutation( - m.target as HTMLLinkElement, - m.oldValue, - value, - ); - } - let item: attributeCursor | undefined = this.attributes.find( (a) => a.node === m.target, ); From c11e05215d0d84bf94c6b1869627e48d20dcbbcf Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 11:21:32 +0200 Subject: [PATCH 05/18] Update packages/rrweb/test/record.test.ts --- packages/rrweb/test/record.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 612019fd44..63ec5effe6 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -39,7 +39,6 @@ const setup = function (this: ISuite, content: string): ISuite { }); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); - // const bundlePath = path.resolve(__dirname, '../dist/rrweb-all.js'); ctx.code = fs.readFileSync(bundlePath, 'utf8'); }); From 244a26af9ff35b9c64371ab9f197eb3a656be208 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 11:21:38 +0200 Subject: [PATCH 06/18] Update packages/rrweb/test/record.test.ts --- packages/rrweb/test/record.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 63ec5effe6..fad2c295a2 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -59,7 +59,6 @@ const setup = function (this: ISuite, content: string): ISuite { }); afterEach(async () => { - // await ctx.page.waitForTimeout(60000); await ctx.page.close(); }); From 1edb1ea5d0434c8bf0ba93f529ae90163bfa2906 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 11:21:42 +0200 Subject: [PATCH 07/18] Update packages/rrweb/test/record.test.ts --- packages/rrweb/test/record.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index fad2c295a2..0e5f54293c 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -70,8 +70,7 @@ const setup = function (this: ISuite, content: string): ISuite { }; describe('record', function (this: ISuite) { - jest.setTimeout(180_000); - // jest.setTimeout(10_000); + jest.setTimeout(10_000); const ctx: ISuite = setup.call( this, From 401c503912ac230ab403e3dbdb0a155050ec57e7 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 12:54:21 +0200 Subject: [PATCH 08/18] Update packages/rrweb/scripts/repl.js --- packages/rrweb/scripts/repl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rrweb/scripts/repl.js b/packages/rrweb/scripts/repl.js index 217b709a60..9afdb70998 100644 --- a/packages/rrweb/scripts/repl.js +++ b/packages/rrweb/scripts/repl.js @@ -119,7 +119,7 @@ void (async () => { await page.evaluate(`;${code} window.__IS_RECORDING__ = true rrweb.record({ - emit: event => console.log(event), + emit: event => window._replLog(event), recordCanvas: true, collectFonts: true }); From 2ec406b29737112dd4a126d96ba7ed2f8ab6b2f1 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 13:10:05 +0200 Subject: [PATCH 09/18] Update packages/rrweb/test/record.test.ts --- packages/rrweb/test/record.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 742e71a78b..3fcb43d3c7 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -466,7 +466,7 @@ describe('record', function (this: ISuite) { }); describe('record iframes', function (this: ISuite) { - jest.setTimeout(180_000); + jest.setTimeout(10_000); const ctx: ISuite = setup.call( this, From 2e8492e33058cd09a38711c72b1afde5c3f31f45 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 13:10:17 +0200 Subject: [PATCH 10/18] Update packages/rrweb/src/record/index.ts --- packages/rrweb/src/record/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 76ef856212..4135ff17c4 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -283,9 +283,6 @@ 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); } From 217bd7c5b15056f8fc8dd8dcb36a0f5e089e5196 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 15:56:44 +0200 Subject: [PATCH 11/18] Add todo --- packages/rrweb/src/record/stylesheet-manager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index f3332b3d0c..01a69744d7 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -16,6 +16,7 @@ export class StylesheetManager { 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? From c0371f021874bf3030b2b40dabd63f7e665e10f7 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 16:33:28 +0200 Subject: [PATCH 12/18] Move require out of time sensitive assert --- packages/rrdom/test/polyfill.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/rrdom/test/polyfill.test.ts b/packages/rrdom/test/polyfill.test.ts index a9d5f381f1..240976ad83 100644 --- a/packages/rrdom/test/polyfill.test.ts +++ b/packages/rrdom/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', () => { From dee14a6399bdfa5a84bdfbed36fe9207df47856c Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 16:45:37 +0200 Subject: [PATCH 13/18] Add waitForRAF, its more reliable than waitForTimeout --- packages/rrweb/test/record.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 3fcb43d3c7..8b82430edc 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -545,7 +545,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 && From 19651529fa0479d56f0f2e55d3d4b832fd2421ce Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 18:50:48 +0200 Subject: [PATCH 14/18] Remove flaky tests --- packages/rrweb/test/record.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 8b82430edc..4d8d4071bc 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -150,11 +150,18 @@ describe('record', function (this: ISuite) { 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, @@ -164,8 +171,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 () => { From 8ff7bc65631a46cf0ce5707842e54d30889d5e26 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 21:45:24 +0200 Subject: [PATCH 15/18] Add recording stylesheets in iframes --- packages/rrweb-snapshot/src/snapshot.ts | 5 +- packages/rrweb/src/record/index.ts | 5 +- .../test/__snapshots__/record.test.ts.snap | 302 ++++++++++++++++++ packages/rrweb/test/record.test.ts | 65 ++++ 4 files changed, 375 insertions(+), 2 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 46b6f8aec8..f6efa9fa17 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -1103,6 +1103,8 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, }); @@ -1147,8 +1149,9 @@ export function serializeNodeWithId( preserveWhiteSpace, onSerialize, onIframeLoad, - onStylesheetLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, }); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 4135ff17c4..fffb4ce8b8 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -283,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); } @@ -292,7 +295,7 @@ function record( shadowDomManager.observeAttachShadow(iframe); }, onStylesheetLoad: (linkEl, childSn) => { - this.stylesheetManager.attachStylesheet(linkEl, childSn, this.mirror); + stylesheetManager.attachStylesheet(linkEl, childSn, mirror); }, keepIframeSrcFn, }); diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index 96570b08c5..2c2b7c8d0d 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -743,6 +743,308 @@ 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`] = ` "[ { diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 4d8d4071bc..9261760883 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -416,6 +416,38 @@ describe('record', function (this: ISuite) { 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; @@ -444,6 +476,39 @@ describe('record', function (this: ISuite) { 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'; From d5a83be70e8b3781d4dcfe128c71d0d529fb3821 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 21:48:19 +0200 Subject: [PATCH 16/18] Remove variability from flaky test --- packages/rrweb/test/record.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 9261760883..b45a6ce46c 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -145,10 +145,7 @@ 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.filter((event: eventWithTime) => event.type === EventType.Meta) From 7e3a1a8634a7837e39e82b4753dbb29391b0bf20 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Jun 2022 21:57:44 +0200 Subject: [PATCH 17/18] Make test more robust --- packages/rrweb/test/integration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 797d537346..396743a4ae 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -576,7 +576,8 @@ describe('record integration tests', function (this: ISuite) { ); }); }); - await page.waitForTimeout(50); + await page.waitForTimeout(20); // 20ms of sleep time + await waitForRAF(page); // wait for events to get created const snapshots = await page.evaluate('window.snapshots'); assertSnapshot(snapshots); From 8c7a38f3797f206b632b3ede63829a62c4b6b749 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Mon, 27 Jun 2022 16:17:03 +0200 Subject: [PATCH 18/18] Fix naming --- packages/rrweb-snapshot/src/snapshot.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index f6efa9fa17..d2682dd820 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -386,7 +386,7 @@ function isStylesheetLoaded(link: HTMLLinkElement) { function onceStylesheetLoaded( link: HTMLLinkElement, listener: () => unknown, - iframeLoadTimeout: number, + styleSheetLoadTimeout: number, ) { let fired = false; let styleSheetLoaded: StyleSheet | null; @@ -403,7 +403,7 @@ function onceStylesheetLoaded( listener(); fired = true; } - }, iframeLoadTimeout); + }, styleSheetLoadTimeout); link.addEventListener('load', () => { clearTimeout(timer);