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;