Inline stylesheets on load (#909)

* inline stylesheets when loaded

* set empty link elements to loaded by default

* Clean up stylesheet manager

* Remove attribute mutation code

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/scripts/repl.js

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/src/record/index.ts

* Add todo

* Move require out of time sensitive assert

* Add waitForRAF, its more reliable than waitForTimeout

* Remove flaky tests

* Add recording stylesheets in iframes

* Remove variability from flaky test

* Make test more robust

* Fix naming
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent c52368fd91
commit 77e86d1f20
16 changed files with 985 additions and 25 deletions

View File

@@ -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', () => {

View File

@@ -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(
);
}
// <link rel=stylesheet href=...>
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,
});

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -49,6 +49,7 @@ export declare type serializedNode = (documentNode | documentTypeNode | elementN
export declare type serializedNodeWithId = serializedNode & {
id: number;
};
export declare type serializedElementNodeWithId = Extract<serializedNodeWithId, Record<'type', NodeType.Element>>;
export declare type tagMap = {
[key: string]: string;
};

View File

@@ -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<T = eventWithTime>(
mutationCb: wrappedMutationEmit,
});
const stylesheetManager = new StylesheetManager({
mutationCb: wrappedMutationEmit,
});
const canvasManager = new CanvasManager({
recordCanvas,
mutationCb: wrappedCanvasMutationEmit,
@@ -241,6 +247,7 @@ function record<T = eventWithTime>(
sampling,
slimDOMOptions,
iframeManager,
stylesheetManager,
canvasManager,
},
mirror,
@@ -276,6 +283,9 @@ function record<T = eventWithTime>(
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<T = eventWithTime>(
iframeManager.attachIframe(iframe, childSn, mirror);
shadowDomManager.observeAttachShadow(iframe);
},
onStylesheetLoad: (linkEl, childSn) => {
stylesheetManager.attachStylesheet(linkEl, childSn, mirror);
},
keepIframeSrcFn,
});
@@ -435,6 +448,7 @@ function record<T = eventWithTime>(
slimDOMOptions,
mirror,
iframeManager,
stylesheetManager,
shadowDomManager,
canvasManager,
plugins:

View File

@@ -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,
);

View File

@@ -0,0 +1,45 @@
import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
import type { mutationCallBack } from '../types';
export class StylesheetManager {
private trackedStylesheets: WeakSet<HTMLLinkElement> = 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);
}
}

View File

@@ -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'
>;

View File

@@ -354,6 +354,19 @@ export function isSerializedIframe<TNode extends Node | RRNode>(
return Boolean(n.nodeName === 'IFRAME' && mirror.getMeta(n));
}
export function isSerializedStylesheet<TNode extends Node | RRNode>(
n: TNode,
mirror: IMirror<TNode>,
): 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,

View File

@@ -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`] = `
"[
{

View File

@@ -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 &&

View File

@@ -25,6 +25,7 @@ export default class MutationBuffer {
private doc;
private mirror;
private iframeManager;
private stylesheetManager;
private shadowDomManager;
private canvasManager;
init(options: MutationBufferParam): void;

View File

@@ -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;
}

View File

@@ -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<observerParam, 'mutationCb' | 'blockClass' | 'blockSelector' | 'maskTextClass' | 'maskTextSelector' | 'inlineStylesheet' | 'maskInputOptions' | 'maskTextFn' | 'maskInputFn' | 'recordCanvas' | 'inlineImages' | 'slimDOMOptions' | 'doc' | 'mirror' | 'iframeManager' | 'shadowDomManager' | 'canvasManager'>;
export declare type MutationBufferParam = Pick<observerParam, 'mutationCb' | 'blockClass' | 'blockSelector' | 'maskTextClass' | 'maskTextSelector' | 'inlineStylesheet' | 'maskInputOptions' | 'maskTextFn' | 'maskInputFn' | 'recordCanvas' | 'inlineImages' | 'slimDOMOptions' | 'doc' | 'mirror' | 'iframeManager' | 'stylesheetManager' | 'shadowDomManager' | 'canvasManager'>;
export declare type hooksParam = {
mutation?: mutationCallBack;
mousemove?: mousemoveCallBack;

View File

@@ -28,6 +28,7 @@ export declare type AppendedIframe = {
builtNode: HTMLIFrameElement | RRIFrameElement;
};
export declare function isSerializedIframe<TNode extends Node | RRNode>(n: TNode, mirror: IMirror<TNode>): boolean;
export declare function isSerializedStylesheet<TNode extends Node | RRNode>(n: TNode, mirror: IMirror<TNode>): boolean;
export declare function getBaseDimension(node: Node, rootIframe: Node): DocumentDimension;
export declare function hasShadowRoot<T extends Node | RRNode>(n: T): n is T & {
shadowRoot: ShadowRoot;