feat: Allow to pass errorHandler as record option (#1107)

* feat: Allow to pass `errorHandler` as record option

* add docs

* Apply formatting changes
This commit is contained in:
Francesco Novy
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 271501b7b9
commit 74411fc544
7 changed files with 918 additions and 310 deletions

View File

@@ -0,0 +1,5 @@
---
'rrweb': minor
---
feat: Allow to pass `errorHandler` as record option

View File

@@ -163,6 +163,7 @@ The parameter of `rrweb.record` accepts the following options.
| collectFonts | false | whether to collect fonts in the website | | collectFonts | false | whether to collect fonts in the website |
| userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) | | userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) |
| plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) | | plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) |
| errorHandler | - | A callback that is called if something inside of rrweb throws an error. The callback receives the error as argument. |
#### Privacy #### Privacy

View File

@@ -0,0 +1,36 @@
import type { ErrorHandler } from '../types';
type Callback = (...args: unknown[]) => unknown;
let errorHandler: ErrorHandler | undefined;
export function registerErrorHandler(handler: ErrorHandler | undefined) {
errorHandler = handler;
}
export function unregisterErrorHandler() {
errorHandler = undefined;
}
/**
* Wrap callbacks in a wrapper that allows to pass errors to a configured `errorHandler` method.
*/
export const callbackWrapper = <T extends Callback>(cb: T): T => {
if (!errorHandler) {
return cb;
}
const rrwebWrapped = ((...rest: unknown[]) => {
try {
return cb(...rest);
} catch (error) {
if (errorHandler && errorHandler(error) === true) {
return;
}
throw error;
}
}) as unknown as T;
return rrwebWrapped;
};

View File

@@ -36,6 +36,11 @@ import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager'; import { ShadowDomManager } from './shadow-dom-manager';
import { CanvasManager } from './observers/canvas/canvas-manager'; import { CanvasManager } from './observers/canvas/canvas-manager';
import { StylesheetManager } from './stylesheet-manager'; import { StylesheetManager } from './stylesheet-manager';
import {
callbackWrapper,
registerErrorHandler,
unregisterErrorHandler,
} from './error-handler';
function wrapEvent(e: event): eventWithTime { function wrapEvent(e: event): eventWithTime {
return { return {
@@ -85,8 +90,11 @@ function record<T = eventWithTime>(
plugins, plugins,
keepIframeSrcFn = () => false, keepIframeSrcFn = () => false,
ignoreCSSAttributes = new Set([]), ignoreCSSAttributes = new Set([]),
errorHandler,
} = options; } = options;
registerErrorHandler(errorHandler);
const inEmittingFrame = recordCrossOriginIframes const inEmittingFrame = recordCrossOriginIframes
? window.parent === window ? window.parent === window
: true; : true;
@@ -416,7 +424,7 @@ function record<T = eventWithTime>(
const handlers: listenerHandler[] = []; const handlers: listenerHandler[] = [];
const observe = (doc: Document) => { const observe = (doc: Document) => {
return initObservers( return callbackWrapper(initObservers)(
{ {
mutationCb: wrappedMutationEmit, mutationCb: wrappedMutationEmit,
mousemoveCb: (positions, source) => mousemoveCb: (positions, source) =>
@@ -609,6 +617,7 @@ function record<T = eventWithTime>(
return () => { return () => {
handlers.forEach((h) => h()); handlers.forEach((h) => h());
recording = false; recording = false;
unregisterErrorHandler();
}; };
} catch (error) { } catch (error) {
// TODO: handle internal error // TODO: handle internal error

View File

@@ -42,6 +42,7 @@ import {
} from '@rrweb/types'; } from '@rrweb/types';
import MutationBuffer from './mutation'; import MutationBuffer from './mutation';
import ProcessedNodeManager from './processed-node-manager'; import ProcessedNodeManager from './processed-node-manager';
import { callbackWrapper } from './error-handler';
type WindowWithStoredMutationObserver = IWindow & { type WindowWithStoredMutationObserver = IWindow & {
__rrMutationObserver?: MutationObserver; __rrMutationObserver?: MutationObserver;
@@ -110,7 +111,9 @@ export function initMutationObserver(
} }
const observer = new (mutationObserverCtor as new ( const observer = new (mutationObserverCtor as new (
callback: MutationCallback, callback: MutationCallback,
) => MutationObserver)(mutationBuffer.processMutations.bind(mutationBuffer)); ) => MutationObserver)(
callbackWrapper(mutationBuffer.processMutations.bind(mutationBuffer)),
);
observer.observe(rootEl, { observer.observe(rootEl, {
attributes: true, attributes: true,
attributeOldValue: true, attributeOldValue: true,
@@ -144,63 +147,67 @@ function initMoveObserver({
let positions: mousePosition[] = []; let positions: mousePosition[] = [];
let timeBaseline: number | null; let timeBaseline: number | null;
const wrappedCb = throttle( const wrappedCb = throttle(
( callbackWrapper(
source: (
| IncrementalSource.MouseMove source:
| IncrementalSource.TouchMove | IncrementalSource.MouseMove
| IncrementalSource.Drag, | IncrementalSource.TouchMove
) => { | IncrementalSource.Drag,
const totalOffset = Date.now() - timeBaseline!; ) => {
mousemoveCb( const totalOffset = Date.now() - timeBaseline!;
positions.map((p) => { mousemoveCb(
p.timeOffset -= totalOffset; positions.map((p) => {
return p; p.timeOffset -= totalOffset;
}), return p;
source, }),
); source,
positions = []; );
timeBaseline = null; positions = [];
}, timeBaseline = null;
},
),
callbackThreshold, callbackThreshold,
); );
const updatePosition = throttle<MouseEvent | TouchEvent | DragEvent>( const updatePosition = callbackWrapper(
(evt) => { throttle<MouseEvent | TouchEvent | DragEvent>(
const target = getEventTarget(evt); callbackWrapper((evt) => {
const { clientX, clientY } = isTouchEvent(evt) const target = getEventTarget(evt);
? evt.changedTouches[0] const { clientX, clientY } = isTouchEvent(evt)
: evt; ? evt.changedTouches[0]
if (!timeBaseline) { : evt;
timeBaseline = Date.now(); if (!timeBaseline) {
} timeBaseline = Date.now();
positions.push({ }
x: clientX, positions.push({
y: clientY, x: clientX,
id: mirror.getId(target as Node), y: clientY,
timeOffset: Date.now() - timeBaseline, id: mirror.getId(target as Node),
}); timeOffset: Date.now() - timeBaseline,
// it is possible DragEvent is undefined even on devices });
// that support event 'drag' // it is possible DragEvent is undefined even on devices
wrappedCb( // that support event 'drag'
typeof DragEvent !== 'undefined' && evt instanceof DragEvent wrappedCb(
? IncrementalSource.Drag typeof DragEvent !== 'undefined' && evt instanceof DragEvent
: evt instanceof MouseEvent ? IncrementalSource.Drag
? IncrementalSource.MouseMove : evt instanceof MouseEvent
: IncrementalSource.TouchMove, ? IncrementalSource.MouseMove
); : IncrementalSource.TouchMove,
}, );
threshold, }),
{ threshold,
trailing: false, {
}, trailing: false,
},
),
); );
const handlers = [ const handlers = [
on('mousemove', updatePosition, doc), on('mousemove', updatePosition, doc),
on('touchmove', updatePosition, doc), on('touchmove', updatePosition, doc),
on('drag', updatePosition, doc), on('drag', updatePosition, doc),
]; ];
return () => { return callbackWrapper(() => {
handlers.forEach((h) => h()); handlers.forEach((h) => h());
}; });
} }
function initMouseInteractionObserver({ function initMouseInteractionObserver({
@@ -235,7 +242,7 @@ function initMouseInteractionObserver({
} }
const id = mirror.getId(target); const id = mirror.getId(target);
const { clientX, clientY } = e; const { clientX, clientY } = e;
mouseInteractionCb({ callbackWrapper(mouseInteractionCb)({
type: MouseInteractions[eventKey], type: MouseInteractions[eventKey],
id, id,
x: clientX, x: clientX,
@@ -255,9 +262,9 @@ function initMouseInteractionObserver({
const handler = getHandler(eventKey); const handler = getHandler(eventKey);
handlers.push(on(eventName, handler, doc)); handlers.push(on(eventName, handler, doc));
}); });
return () => { return callbackWrapper(() => {
handlers.forEach((h) => h()); handlers.forEach((h) => h());
}; });
} }
export function initScrollObserver({ export function initScrollObserver({
@@ -271,27 +278,35 @@ export function initScrollObserver({
observerParam, observerParam,
'scrollCb' | 'doc' | 'mirror' | 'blockClass' | 'blockSelector' | 'sampling' 'scrollCb' | 'doc' | 'mirror' | 'blockClass' | 'blockSelector' | 'sampling'
>): listenerHandler { >): listenerHandler {
const updatePosition = throttle<UIEvent>((evt) => { const updatePosition = callbackWrapper(
const target = getEventTarget(evt); throttle<UIEvent>(
if (!target || isBlocked(target as Node, blockClass, blockSelector, true)) { callbackWrapper((evt) => {
return; const target = getEventTarget(evt);
} if (
const id = mirror.getId(target as Node); !target ||
if (target === doc && doc.defaultView) { isBlocked(target as Node, blockClass, blockSelector, true)
const scrollLeftTop = getWindowScroll(doc.defaultView); ) {
scrollCb({ return;
id, }
x: scrollLeftTop.left, const id = mirror.getId(target as Node);
y: scrollLeftTop.top, if (target === doc && doc.defaultView) {
}); const scrollLeftTop = getWindowScroll(doc.defaultView);
} else { scrollCb({
scrollCb({ id,
id, x: scrollLeftTop.left,
x: (target as HTMLElement).scrollLeft, y: scrollLeftTop.top,
y: (target as HTMLElement).scrollTop, });
}); } else {
} scrollCb({
}, sampling.scroll || 100); id,
x: (target as HTMLElement).scrollLeft,
y: (target as HTMLElement).scrollTop,
});
}
}),
sampling.scroll || 100,
),
);
return on('scroll', updatePosition, doc); return on('scroll', updatePosition, doc);
} }
@@ -300,18 +315,23 @@ function initViewportResizeObserver({
}: observerParam): listenerHandler { }: observerParam): listenerHandler {
let lastH = -1; let lastH = -1;
let lastW = -1; let lastW = -1;
const updateDimension = throttle(() => { const updateDimension = callbackWrapper(
const height = getWindowHeight(); throttle(
const width = getWindowWidth(); callbackWrapper(() => {
if (lastH !== height || lastW !== width) { const height = getWindowHeight();
viewportResizeCb({ const width = getWindowWidth();
width: Number(width), if (lastH !== height || lastW !== width) {
height: Number(height), viewportResizeCb({
}); width: Number(width),
lastH = height; height: Number(height),
lastW = width; });
} lastH = height;
}, 200); lastW = width;
}
}),
200,
),
);
return on('resize', updateDimension, window); return on('resize', updateDimension, window);
} }
@@ -382,7 +402,7 @@ function initInputObserver({
} }
cbWithDedup( cbWithDedup(
target, target,
wrapEventWithUserTriggeredFlag( callbackWrapper(wrapEventWithUserTriggeredFlag)(
{ text, isChecked, userTriggered }, { text, isChecked, userTriggered },
userTriggeredOnInput, userTriggeredOnInput,
), ),
@@ -397,7 +417,7 @@ function initInputObserver({
if (el !== target) { if (el !== target) {
cbWithDedup( cbWithDedup(
el, el,
wrapEventWithUserTriggeredFlag( callbackWrapper(wrapEventWithUserTriggeredFlag)(
{ {
text: (el as HTMLInputElement).value, text: (el as HTMLInputElement).value,
isChecked: !isChecked, isChecked: !isChecked,
@@ -419,7 +439,7 @@ function initInputObserver({
) { ) {
lastInputValueMap.set(target, v); lastInputValueMap.set(target, v);
const id = mirror.getId(target as Node); const id = mirror.getId(target as Node);
inputCb({ callbackWrapper(inputCb)({
...v, ...v,
id, id,
}); });
@@ -427,7 +447,7 @@ function initInputObserver({
} }
const events = sampling.input === 'last' ? ['change'] : ['input', 'change']; const events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
const handlers: Array<listenerHandler | hookResetter> = events.map( const handlers: Array<listenerHandler | hookResetter> = events.map(
(eventName) => on(eventName, eventHandler, doc), (eventName) => on(eventName, callbackWrapper(eventHandler), doc),
); );
const currentWindow = doc.defaultView; const currentWindow = doc.defaultView;
if (!currentWindow) { if (!currentWindow) {
@@ -457,7 +477,7 @@ function initInputObserver({
{ {
set() { set() {
// mock to a normal event // mock to a normal event
eventHandler({ callbackWrapper(eventHandler)({
target: this as EventTarget, target: this as EventTarget,
isTrusted: false, // userTriggered to false as this could well be programmatic isTrusted: false, // userTriggered to false as this could well be programmatic
} as Event); } as Event);
@@ -469,9 +489,9 @@ function initInputObserver({
), ),
); );
} }
return () => { return callbackWrapper(() => {
handlers.forEach((h) => h()); handlers.forEach((h) => h());
}; });
} }
type GroupingCSSRule = type GroupingCSSRule =
@@ -548,97 +568,125 @@ function initStyleSheetObserver(
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
const insertRule = win.CSSStyleSheet.prototype.insertRule; const insertRule = win.CSSStyleSheet.prototype.insertRule;
win.CSSStyleSheet.prototype.insertRule = function ( win.CSSStyleSheet.prototype.insertRule = new Proxy(insertRule, {
this: CSSStyleSheet, apply: callbackWrapper(
rule: string, (
index?: number, target: typeof insertRule,
) { thisArg: CSSStyleSheet,
const { id, styleId } = getIdAndStyleId( argumentsList: [string, number | undefined],
this, ) => {
mirror, const [rule, index] = argumentsList;
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) { const { id, styleId } = getIdAndStyleId(
styleSheetRuleCb({ thisArg,
id, mirror,
styleId, stylesheetManager.styleMirror,
adds: [{ rule, index }], );
});
} if ((id && id !== -1) || (styleId && styleId !== -1)) {
return insertRule.apply(this, [rule, index]); styleSheetRuleCb({
}; id,
styleId,
adds: [{ rule, index }],
});
}
return target.apply(thisArg, argumentsList);
},
),
});
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
const deleteRule = win.CSSStyleSheet.prototype.deleteRule; const deleteRule = win.CSSStyleSheet.prototype.deleteRule;
win.CSSStyleSheet.prototype.deleteRule = function ( win.CSSStyleSheet.prototype.deleteRule = new Proxy(deleteRule, {
this: CSSStyleSheet, apply: callbackWrapper(
index: number, (
) { target: typeof deleteRule,
const { id, styleId } = getIdAndStyleId( thisArg: CSSStyleSheet,
this, argumentsList: [number],
mirror, ) => {
stylesheetManager.styleMirror, const [index] = argumentsList;
);
if ((id && id !== -1) || (styleId && styleId !== -1)) { const { id, styleId } = getIdAndStyleId(
styleSheetRuleCb({ thisArg,
id, mirror,
styleId, stylesheetManager.styleMirror,
removes: [{ index }], );
});
} if ((id && id !== -1) || (styleId && styleId !== -1)) {
return deleteRule.apply(this, [index]); styleSheetRuleCb({
}; id,
styleId,
removes: [{ index }],
});
}
return target.apply(thisArg, argumentsList);
},
),
});
let replace: (text: string) => Promise<CSSStyleSheet>; let replace: (text: string) => Promise<CSSStyleSheet>;
if (win.CSSStyleSheet.prototype.replace) { if (win.CSSStyleSheet.prototype.replace) {
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
replace = win.CSSStyleSheet.prototype.replace; replace = win.CSSStyleSheet.prototype.replace;
win.CSSStyleSheet.prototype.replace = function ( win.CSSStyleSheet.prototype.replace = new Proxy(replace, {
this: CSSStyleSheet, apply: callbackWrapper(
text: string, (
) { target: typeof replace,
const { id, styleId } = getIdAndStyleId( thisArg: CSSStyleSheet,
this, argumentsList: [string],
mirror, ) => {
stylesheetManager.styleMirror, const [text] = argumentsList;
);
if ((id && id !== -1) || (styleId && styleId !== -1)) { const { id, styleId } = getIdAndStyleId(
styleSheetRuleCb({ thisArg,
id, mirror,
styleId, stylesheetManager.styleMirror,
replace: text, );
});
} if ((id && id !== -1) || (styleId && styleId !== -1)) {
return replace.apply(this, [text]); styleSheetRuleCb({
}; id,
styleId,
replace: text,
});
}
return target.apply(thisArg, argumentsList);
},
),
});
} }
let replaceSync: (text: string) => void; let replaceSync: (text: string) => void;
if (win.CSSStyleSheet.prototype.replaceSync) { if (win.CSSStyleSheet.prototype.replaceSync) {
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
replaceSync = win.CSSStyleSheet.prototype.replaceSync; replaceSync = win.CSSStyleSheet.prototype.replaceSync;
win.CSSStyleSheet.prototype.replaceSync = function ( win.CSSStyleSheet.prototype.replaceSync = new Proxy(replaceSync, {
this: CSSStyleSheet, apply: callbackWrapper(
text: string, (
) { target: typeof replaceSync,
const { id, styleId } = getIdAndStyleId( thisArg: CSSStyleSheet,
this, argumentsList: [string],
mirror, ) => {
stylesheetManager.styleMirror, const [text] = argumentsList;
);
if ((id && id !== -1) || (styleId && styleId !== -1)) { const { id, styleId } = getIdAndStyleId(
styleSheetRuleCb({ thisArg,
id, mirror,
styleId, stylesheetManager.styleMirror,
replaceSync: text, );
});
} if ((id && id !== -1) || (styleId && styleId !== -1)) {
return replaceSync.apply(this, [text]); styleSheetRuleCb({
}; id,
styleId,
replaceSync: text,
});
}
return target.apply(thisArg, argumentsList);
},
),
});
} }
const supportedNestedCSSRuleTypes: { const supportedNestedCSSRuleTypes: {
@@ -677,59 +725,78 @@ function initStyleSheetObserver(
deleteRule: type.prototype.deleteRule, deleteRule: type.prototype.deleteRule,
}; };
type.prototype.insertRule = function ( type.prototype.insertRule = new Proxy(
this: CSSGroupingRule, unmodifiedFunctions[typeKey].insertRule,
rule: string, {
index?: number, apply: callbackWrapper(
) { (
const { id, styleId } = getIdAndStyleId( target: typeof insertRule,
this.parentStyleSheet, thisArg: CSSRule,
mirror, argumentsList: [string, number | undefined],
stylesheetManager.styleMirror, ) => {
); const [rule, index] = argumentsList;
if ((id && id !== -1) || (styleId && styleId !== -1)) { const { id, styleId } = getIdAndStyleId(
styleSheetRuleCb({ thisArg.parentStyleSheet,
id, mirror,
styleId, stylesheetManager.styleMirror,
adds: [ );
{
rule,
index: [
...getNestedCSSRulePositions(this as CSSRule),
index || 0, // defaults to 0
],
},
],
});
}
return unmodifiedFunctions[typeKey].insertRule.apply(this, [rule, index]);
};
type.prototype.deleteRule = function ( if ((id && id !== -1) || (styleId && styleId !== -1)) {
this: CSSGroupingRule, styleSheetRuleCb({
index: number, id,
) { styleId,
const { id, styleId } = getIdAndStyleId( adds: [
this.parentStyleSheet, {
mirror, rule,
stylesheetManager.styleMirror, index: [
); ...getNestedCSSRulePositions(thisArg),
index || 0, // defaults to 0
],
},
],
});
}
return target.apply(thisArg, argumentsList);
},
),
},
);
if ((id && id !== -1) || (styleId && styleId !== -1)) { type.prototype.deleteRule = new Proxy(
styleSheetRuleCb({ unmodifiedFunctions[typeKey].deleteRule,
id, {
styleId, apply: callbackWrapper(
removes: [ (
{ index: [...getNestedCSSRulePositions(this as CSSRule), index] }, target: typeof deleteRule,
], thisArg: CSSRule,
}); argumentsList: [number],
} ) => {
return unmodifiedFunctions[typeKey].deleteRule.apply(this, [index]); const [index] = argumentsList;
};
const { id, styleId } = getIdAndStyleId(
thisArg.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleSheetRuleCb({
id,
styleId,
removes: [
{ index: [...getNestedCSSRulePositions(thisArg), index] },
],
});
}
return target.apply(thisArg, argumentsList);
},
),
},
);
}); });
return () => { return callbackWrapper(() => {
win.CSSStyleSheet.prototype.insertRule = insertRule; win.CSSStyleSheet.prototype.insertRule = insertRule;
win.CSSStyleSheet.prototype.deleteRule = deleteRule; win.CSSStyleSheet.prototype.deleteRule = deleteRule;
replace && (win.CSSStyleSheet.prototype.replace = replace); replace && (win.CSSStyleSheet.prototype.replace = replace);
@@ -738,7 +805,7 @@ function initStyleSheetObserver(
type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule; type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule;
type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule; type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule;
}); });
}; });
} }
export function initAdoptedStyleSheetObserver( export function initAdoptedStyleSheetObserver(
@@ -792,7 +859,7 @@ export function initAdoptedStyleSheetObserver(
}, },
}); });
return () => { return callbackWrapper(() => {
Object.defineProperty(host, 'adoptedStyleSheets', { Object.defineProperty(host, 'adoptedStyleSheets', {
configurable: originalPropertyDescriptor.configurable, configurable: originalPropertyDescriptor.configurable,
enumerable: originalPropertyDescriptor.enumerable, enumerable: originalPropertyDescriptor.enumerable,
@@ -801,7 +868,7 @@ export function initAdoptedStyleSheetObserver(
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
set: originalPropertyDescriptor.set, set: originalPropertyDescriptor.set,
}); });
}; });
} }
function initStyleDeclarationObserver( function initStyleDeclarationObserver(
@@ -815,70 +882,82 @@ function initStyleDeclarationObserver(
): listenerHandler { ): listenerHandler {
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
const setProperty = win.CSSStyleDeclaration.prototype.setProperty; const setProperty = win.CSSStyleDeclaration.prototype.setProperty;
win.CSSStyleDeclaration.prototype.setProperty = function ( win.CSSStyleDeclaration.prototype.setProperty = new Proxy(setProperty, {
this: CSSStyleDeclaration, apply: callbackWrapper(
property: string, (
value: string, target: typeof setProperty,
priority: string, thisArg: CSSStyleDeclaration,
) { argumentsList: [string, string, string],
// ignore this mutation if we do not care about this css attribute ) => {
if (ignoreCSSAttributes.has(property)) { const [property, value, priority] = argumentsList;
return setProperty.apply(this, [property, value, priority]);
} // ignore this mutation if we do not care about this css attribute
const { id, styleId } = getIdAndStyleId( if (ignoreCSSAttributes.has(property)) {
this.parentRule?.parentStyleSheet, return setProperty.apply(thisArg, [property, value, priority]);
mirror, }
stylesheetManager.styleMirror, const { id, styleId } = getIdAndStyleId(
); thisArg.parentRule?.parentStyleSheet,
if ((id && id !== -1) || (styleId && styleId !== -1)) { mirror,
styleDeclarationCb({ stylesheetManager.styleMirror,
id, );
styleId, if ((id && id !== -1) || (styleId && styleId !== -1)) {
set: { styleDeclarationCb({
property, id,
value, styleId,
priority, set: {
}, property,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion value,
index: getNestedCSSRulePositions(this.parentRule!), priority,
}); },
} // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return setProperty.apply(this, [property, value, priority]); index: getNestedCSSRulePositions(thisArg.parentRule!),
}; });
}
return target.apply(thisArg, argumentsList);
},
),
});
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty; const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty;
win.CSSStyleDeclaration.prototype.removeProperty = function ( win.CSSStyleDeclaration.prototype.removeProperty = new Proxy(removeProperty, {
this: CSSStyleDeclaration, apply: callbackWrapper(
property: string, (
) { target: typeof removeProperty,
// ignore this mutation if we do not care about this css attribute thisArg: CSSStyleDeclaration,
if (ignoreCSSAttributes.has(property)) { argumentsList: [string],
return removeProperty.apply(this, [property]); ) => {
} const [property] = argumentsList;
const { id, styleId } = getIdAndStyleId(
this.parentRule?.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleDeclarationCb({
id,
styleId,
remove: {
property,
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
index: getNestedCSSRulePositions(this.parentRule!),
});
}
return removeProperty.apply(this, [property]);
};
return () => { // ignore this mutation if we do not care about this css attribute
if (ignoreCSSAttributes.has(property)) {
return removeProperty.apply(thisArg, [property]);
}
const { id, styleId } = getIdAndStyleId(
thisArg.parentRule?.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
if ((id && id !== -1) || (styleId && styleId !== -1)) {
styleDeclarationCb({
id,
styleId,
remove: {
property,
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
index: getNestedCSSRulePositions(thisArg.parentRule!),
});
}
return target.apply(thisArg, argumentsList);
},
),
});
return callbackWrapper(() => {
win.CSSStyleDeclaration.prototype.setProperty = setProperty; win.CSSStyleDeclaration.prototype.setProperty = setProperty;
win.CSSStyleDeclaration.prototype.removeProperty = removeProperty; win.CSSStyleDeclaration.prototype.removeProperty = removeProperty;
}; });
} }
function initMediaInteractionObserver({ function initMediaInteractionObserver({
@@ -888,26 +967,30 @@ function initMediaInteractionObserver({
mirror, mirror,
sampling, sampling,
}: observerParam): listenerHandler { }: observerParam): listenerHandler {
const handler = (type: MediaInteractions) => const handler = callbackWrapper((type: MediaInteractions) =>
throttle((event: Event) => { throttle(
const target = getEventTarget(event); callbackWrapper((event: Event) => {
if ( const target = getEventTarget(event);
!target || if (
isBlocked(target as Node, blockClass, blockSelector, true) !target ||
) { isBlocked(target as Node, blockClass, blockSelector, true)
return; ) {
} return;
const { currentTime, volume, muted, playbackRate } = }
target as HTMLMediaElement; const { currentTime, volume, muted, playbackRate } =
mediaInteractionCb({ target as HTMLMediaElement;
type, mediaInteractionCb({
id: mirror.getId(target as Node), type,
currentTime, id: mirror.getId(target as Node),
volume, currentTime,
muted, volume,
playbackRate, muted,
}); playbackRate,
}, sampling.media || 500); });
}),
sampling.media || 500,
),
);
const handlers = [ const handlers = [
on('play', handler(MediaInteractions.Play)), on('play', handler(MediaInteractions.Play)),
on('pause', handler(MediaInteractions.Pause)), on('pause', handler(MediaInteractions.Pause)),
@@ -915,9 +998,9 @@ function initMediaInteractionObserver({
on('volumechange', handler(MediaInteractions.VolumeChange)), on('volumechange', handler(MediaInteractions.VolumeChange)),
on('ratechange', handler(MediaInteractions.RateChange)), on('ratechange', handler(MediaInteractions.RateChange)),
]; ];
return () => { return callbackWrapper(() => {
handlers.forEach((h) => h()); handlers.forEach((h) => h());
}; });
} }
function initFontObserver({ fontCb, doc }: observerParam): listenerHandler { function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
@@ -956,13 +1039,16 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
'add', 'add',
function (original: (font: FontFace) => void) { function (original: (font: FontFace) => void) {
return function (this: FontFaceSet, fontFace: FontFace) { return function (this: FontFaceSet, fontFace: FontFace) {
setTimeout(() => { setTimeout(
const p = fontMap.get(fontFace); callbackWrapper(() => {
if (p) { const p = fontMap.get(fontFace);
fontCb(p); if (p) {
fontMap.delete(fontFace); fontCb(p);
} fontMap.delete(fontFace);
}, 0); }
}),
0,
);
return original.apply(this, [fontFace]); return original.apply(this, [fontFace]);
}; };
}, },
@@ -973,16 +1059,16 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
}); });
handlers.push(restoreHandler); handlers.push(restoreHandler);
return () => { return callbackWrapper(() => {
handlers.forEach((h) => h()); handlers.forEach((h) => h());
}; });
} }
function initSelectionObserver(param: observerParam): listenerHandler { function initSelectionObserver(param: observerParam): listenerHandler {
const { doc, mirror, blockClass, blockSelector, selectionCb } = param; const { doc, mirror, blockClass, blockSelector, selectionCb } = param;
let collapsed = true; let collapsed = true;
const updateSelection = () => { const updateSelection = callbackWrapper(() => {
const selection = doc.getSelection(); const selection = doc.getSelection();
if (!selection || (collapsed && selection?.isCollapsed)) return; if (!selection || (collapsed && selection?.isCollapsed)) return;
@@ -1012,7 +1098,7 @@ function initSelectionObserver(param: observerParam): listenerHandler {
} }
selectionCb({ ranges }); selectionCb({ ranges });
}; });
updateSelection(); updateSelection();
@@ -1148,7 +1234,7 @@ export function initObservers(
); );
} }
return () => { return callbackWrapper(() => {
mutationBuffers.forEach((b) => b.reset()); mutationBuffers.forEach((b) => b.reset());
mutationObserver.disconnect(); mutationObserver.disconnect();
mousemoveHandler(); mousemoveHandler();
@@ -1163,7 +1249,7 @@ export function initObservers(
fontObserver(); fontObserver();
selectionObserver(); selectionObserver();
pluginHandlers.forEach((h) => h()); pluginHandlers.forEach((h) => h());
}; });
} }
type CSSGroupingProp = type CSSGroupingProp =

View File

@@ -69,6 +69,7 @@ export type recordOptions<T> = {
// departed, please use sampling options // departed, please use sampling options
mousemoveWait?: number; mousemoveWait?: number;
keepIframeSrcFn?: KeepIframeSrcFn; keepIframeSrcFn?: KeepIframeSrcFn;
errorHandler?: ErrorHandler;
}; };
export type observerParam = { export type observerParam = {
@@ -211,3 +212,5 @@ export type CrossOriginIframeMessageEventContent<T = eventWithTime> = {
}; };
export type CrossOriginIframeMessageEvent = export type CrossOriginIframeMessageEvent =
MessageEvent<CrossOriginIframeMessageEventContent>; MessageEvent<CrossOriginIframeMessageEventContent>;
export type ErrorHandler = (error: unknown) => void | boolean;

View File

@@ -0,0 +1,468 @@
import * as fs from 'fs';
import * as path from 'path';
import type * as puppeteer from 'puppeteer';
import type { recordOptions } from '../../src/types';
import { listenerHandler, eventWithTime, EventType } from '@rrweb/types';
import { launchPuppeteer } from '../utils';
import {
callbackWrapper,
registerErrorHandler,
unregisterErrorHandler,
} from '../../src/record/error-handler';
interface ISuite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
events: eventWithTime[];
}
interface IWindow extends Window {
rrweb: {
record: (
options: recordOptions<eventWithTime>,
) => listenerHandler | undefined;
addCustomEvent<T>(tag: string, payload: T): void;
};
emit: (e: eventWithTime) => undefined;
}
const setup = function (
this: ISuite,
content: string,
canvasSample: 'all' | number = 'all',
): ISuite {
const ctx = {} as ISuite;
beforeAll(async () => {
ctx.browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.js');
ctx.code = fs.readFileSync(bundlePath, 'utf8');
});
beforeEach(async () => {
ctx.page = await ctx.browser.newPage();
await ctx.page.goto('about:blank');
await ctx.page.setContent(content);
await ctx.page.evaluate(ctx.code);
ctx.events = [];
await ctx.page.exposeFunction('emit', (e: eventWithTime) => {
if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) {
return;
}
ctx.events.push(e);
});
ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
});
afterEach(async () => {
await ctx.page.close();
});
afterAll(async () => {
await ctx.browser.close();
});
return ctx;
};
describe('error-handler', function (this: ISuite) {
jest.setTimeout(100_000);
const ctx: ISuite = setup.call(
this,
`
<!DOCTYPE html>
<html>
<head>
<style>
body { background: red; }
</style>
</head>
<body>
<div id='in'></div>
<div id='out'></div>
</body>
</html>
`,
);
describe('CSSStyleSheet.prototype', () => {
it('triggers for errors from insertRule', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleSheet.prototype.insertRule = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
// @ts-ignore
document.styleSheets[0].insertRule('body { background: blue; }');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from deleteRule', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleSheet.prototype.deleteRule = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet delete
setTimeout(() => {
document.styleSheets[0].deleteRule(0);
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from replace', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleSheet.prototype.replace = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
// @ts-ignore
document.styleSheets[0].replace('body { background: blue; }');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from replaceSync', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleSheet.prototype.replaceSync = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
// @ts-ignore
document.styleSheets[0].replaceSync('body { background: blue; }');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from CSSGroupingRule.insertRule', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSGroupingRule.prototype.insertRule = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
document.styleSheets[0].insertRule('@media {}');
const atMediaRule = document.styleSheets[0]
.cssRules[0] as CSSMediaRule;
const ruleIdx0 = atMediaRule.insertRule(
'body { background: #000; }',
0,
);
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from CSSGroupingRule.deleteRule', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSGroupingRule.prototype.deleteRule = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet delete
setTimeout(() => {
document.styleSheets[0].insertRule('@media {}');
const atMediaRule = document.styleSheets[0]
.cssRules[0] as CSSMediaRule;
const ruleIdx0 = atMediaRule.deleteRule(0);
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from CSSStyleDeclaration.setProperty', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleDeclaration.prototype.setProperty = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
(
document.styleSheets[0].cssRules[0] as unknown as {
style: CSSStyleDeclaration;
}
).style.setProperty('background', 'blue');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from CSSStyleDeclaration.removeProperty', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleDeclaration.prototype.removeProperty = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
(
document.styleSheets[0].cssRules[0] as unknown as {
style: CSSStyleDeclaration;
}
).style.removeProperty('background');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
});
it('triggers for errors from mutation observer', async () => {
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy mutation observer
setTimeout(() => {
const el = document.getElementById('in')!;
// @ts-ignore we want to trigger an error in the mutation observer, which uses this
el.getAttribute = undefined;
el.setAttribute('data-attr', 'new');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual('TypeError: m.target.getAttribute is not a function');
});
});
describe('errorHandler unit', function () {
afterEach(function () {
unregisterErrorHandler();
});
it('does not swallow if no errorHandler set', () => {
unregisterErrorHandler();
const wrapped = callbackWrapper(() => {
throw new Error('test');
});
expect(() => wrapped()).toThrowError('test');
});
it('does not swallow if errorHandler returns void', () => {
registerErrorHandler(() => {
// do nothing
});
const wrapped = callbackWrapper(() => {
throw new Error('test');
});
expect(() => wrapped()).toThrowError('test');
});
it('does not swallow if errorHandler returns false', () => {
registerErrorHandler(() => {
return false;
});
const wrapped = callbackWrapper(() => {
throw new Error('test');
});
expect(() => wrapped()).toThrowError('test');
});
it('swallows if errorHandler returns true', () => {
registerErrorHandler(() => {
return true;
});
const wrapped = callbackWrapper(() => {
throw new Error('test');
});
expect(() => wrapped()).not.toThrowError('test');
});
});