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:
5
.changeset/pretty-plums-rescue.md
Normal file
5
.changeset/pretty-plums-rescue.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'rrweb': minor
|
||||
---
|
||||
|
||||
feat: Allow to pass `errorHandler` as record option
|
||||
1
guide.md
1
guide.md
@@ -163,6 +163,7 @@ The parameter of `rrweb.record` accepts the following options.
|
||||
| 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) |
|
||||
| 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
|
||||
|
||||
|
||||
36
packages/rrweb/src/record/error-handler.ts
Normal file
36
packages/rrweb/src/record/error-handler.ts
Normal 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;
|
||||
};
|
||||
@@ -36,6 +36,11 @@ import { IframeManager } from './iframe-manager';
|
||||
import { ShadowDomManager } from './shadow-dom-manager';
|
||||
import { CanvasManager } from './observers/canvas/canvas-manager';
|
||||
import { StylesheetManager } from './stylesheet-manager';
|
||||
import {
|
||||
callbackWrapper,
|
||||
registerErrorHandler,
|
||||
unregisterErrorHandler,
|
||||
} from './error-handler';
|
||||
|
||||
function wrapEvent(e: event): eventWithTime {
|
||||
return {
|
||||
@@ -85,8 +90,11 @@ function record<T = eventWithTime>(
|
||||
plugins,
|
||||
keepIframeSrcFn = () => false,
|
||||
ignoreCSSAttributes = new Set([]),
|
||||
errorHandler,
|
||||
} = options;
|
||||
|
||||
registerErrorHandler(errorHandler);
|
||||
|
||||
const inEmittingFrame = recordCrossOriginIframes
|
||||
? window.parent === window
|
||||
: true;
|
||||
@@ -416,7 +424,7 @@ function record<T = eventWithTime>(
|
||||
const handlers: listenerHandler[] = [];
|
||||
|
||||
const observe = (doc: Document) => {
|
||||
return initObservers(
|
||||
return callbackWrapper(initObservers)(
|
||||
{
|
||||
mutationCb: wrappedMutationEmit,
|
||||
mousemoveCb: (positions, source) =>
|
||||
@@ -609,6 +617,7 @@ function record<T = eventWithTime>(
|
||||
return () => {
|
||||
handlers.forEach((h) => h());
|
||||
recording = false;
|
||||
unregisterErrorHandler();
|
||||
};
|
||||
} catch (error) {
|
||||
// TODO: handle internal error
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '@rrweb/types';
|
||||
import MutationBuffer from './mutation';
|
||||
import ProcessedNodeManager from './processed-node-manager';
|
||||
import { callbackWrapper } from './error-handler';
|
||||
|
||||
type WindowWithStoredMutationObserver = IWindow & {
|
||||
__rrMutationObserver?: MutationObserver;
|
||||
@@ -110,7 +111,9 @@ export function initMutationObserver(
|
||||
}
|
||||
const observer = new (mutationObserverCtor as new (
|
||||
callback: MutationCallback,
|
||||
) => MutationObserver)(mutationBuffer.processMutations.bind(mutationBuffer));
|
||||
) => MutationObserver)(
|
||||
callbackWrapper(mutationBuffer.processMutations.bind(mutationBuffer)),
|
||||
);
|
||||
observer.observe(rootEl, {
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
@@ -144,6 +147,7 @@ function initMoveObserver({
|
||||
let positions: mousePosition[] = [];
|
||||
let timeBaseline: number | null;
|
||||
const wrappedCb = throttle(
|
||||
callbackWrapper(
|
||||
(
|
||||
source:
|
||||
| IncrementalSource.MouseMove
|
||||
@@ -161,10 +165,12 @@ function initMoveObserver({
|
||||
positions = [];
|
||||
timeBaseline = null;
|
||||
},
|
||||
),
|
||||
callbackThreshold,
|
||||
);
|
||||
const updatePosition = throttle<MouseEvent | TouchEvent | DragEvent>(
|
||||
(evt) => {
|
||||
const updatePosition = callbackWrapper(
|
||||
throttle<MouseEvent | TouchEvent | DragEvent>(
|
||||
callbackWrapper((evt) => {
|
||||
const target = getEventTarget(evt);
|
||||
const { clientX, clientY } = isTouchEvent(evt)
|
||||
? evt.changedTouches[0]
|
||||
@@ -187,20 +193,21 @@ function initMoveObserver({
|
||||
? IncrementalSource.MouseMove
|
||||
: IncrementalSource.TouchMove,
|
||||
);
|
||||
},
|
||||
}),
|
||||
threshold,
|
||||
{
|
||||
trailing: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
const handlers = [
|
||||
on('mousemove', updatePosition, doc),
|
||||
on('touchmove', updatePosition, doc),
|
||||
on('drag', updatePosition, doc),
|
||||
];
|
||||
return () => {
|
||||
return callbackWrapper(() => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function initMouseInteractionObserver({
|
||||
@@ -235,7 +242,7 @@ function initMouseInteractionObserver({
|
||||
}
|
||||
const id = mirror.getId(target);
|
||||
const { clientX, clientY } = e;
|
||||
mouseInteractionCb({
|
||||
callbackWrapper(mouseInteractionCb)({
|
||||
type: MouseInteractions[eventKey],
|
||||
id,
|
||||
x: clientX,
|
||||
@@ -255,9 +262,9 @@ function initMouseInteractionObserver({
|
||||
const handler = getHandler(eventKey);
|
||||
handlers.push(on(eventName, handler, doc));
|
||||
});
|
||||
return () => {
|
||||
return callbackWrapper(() => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function initScrollObserver({
|
||||
@@ -271,9 +278,14 @@ export function initScrollObserver({
|
||||
observerParam,
|
||||
'scrollCb' | 'doc' | 'mirror' | 'blockClass' | 'blockSelector' | 'sampling'
|
||||
>): listenerHandler {
|
||||
const updatePosition = throttle<UIEvent>((evt) => {
|
||||
const updatePosition = callbackWrapper(
|
||||
throttle<UIEvent>(
|
||||
callbackWrapper((evt) => {
|
||||
const target = getEventTarget(evt);
|
||||
if (!target || isBlocked(target as Node, blockClass, blockSelector, true)) {
|
||||
if (
|
||||
!target ||
|
||||
isBlocked(target as Node, blockClass, blockSelector, true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = mirror.getId(target as Node);
|
||||
@@ -291,7 +303,10 @@ export function initScrollObserver({
|
||||
y: (target as HTMLElement).scrollTop,
|
||||
});
|
||||
}
|
||||
}, sampling.scroll || 100);
|
||||
}),
|
||||
sampling.scroll || 100,
|
||||
),
|
||||
);
|
||||
return on('scroll', updatePosition, doc);
|
||||
}
|
||||
|
||||
@@ -300,7 +315,9 @@ function initViewportResizeObserver({
|
||||
}: observerParam): listenerHandler {
|
||||
let lastH = -1;
|
||||
let lastW = -1;
|
||||
const updateDimension = throttle(() => {
|
||||
const updateDimension = callbackWrapper(
|
||||
throttle(
|
||||
callbackWrapper(() => {
|
||||
const height = getWindowHeight();
|
||||
const width = getWindowWidth();
|
||||
if (lastH !== height || lastW !== width) {
|
||||
@@ -311,7 +328,10 @@ function initViewportResizeObserver({
|
||||
lastH = height;
|
||||
lastW = width;
|
||||
}
|
||||
}, 200);
|
||||
}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
return on('resize', updateDimension, window);
|
||||
}
|
||||
|
||||
@@ -382,7 +402,7 @@ function initInputObserver({
|
||||
}
|
||||
cbWithDedup(
|
||||
target,
|
||||
wrapEventWithUserTriggeredFlag(
|
||||
callbackWrapper(wrapEventWithUserTriggeredFlag)(
|
||||
{ text, isChecked, userTriggered },
|
||||
userTriggeredOnInput,
|
||||
),
|
||||
@@ -397,7 +417,7 @@ function initInputObserver({
|
||||
if (el !== target) {
|
||||
cbWithDedup(
|
||||
el,
|
||||
wrapEventWithUserTriggeredFlag(
|
||||
callbackWrapper(wrapEventWithUserTriggeredFlag)(
|
||||
{
|
||||
text: (el as HTMLInputElement).value,
|
||||
isChecked: !isChecked,
|
||||
@@ -419,7 +439,7 @@ function initInputObserver({
|
||||
) {
|
||||
lastInputValueMap.set(target, v);
|
||||
const id = mirror.getId(target as Node);
|
||||
inputCb({
|
||||
callbackWrapper(inputCb)({
|
||||
...v,
|
||||
id,
|
||||
});
|
||||
@@ -427,7 +447,7 @@ function initInputObserver({
|
||||
}
|
||||
const events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
|
||||
const handlers: Array<listenerHandler | hookResetter> = events.map(
|
||||
(eventName) => on(eventName, eventHandler, doc),
|
||||
(eventName) => on(eventName, callbackWrapper(eventHandler), doc),
|
||||
);
|
||||
const currentWindow = doc.defaultView;
|
||||
if (!currentWindow) {
|
||||
@@ -457,7 +477,7 @@ function initInputObserver({
|
||||
{
|
||||
set() {
|
||||
// mock to a normal event
|
||||
eventHandler({
|
||||
callbackWrapper(eventHandler)({
|
||||
target: this as EventTarget,
|
||||
isTrusted: false, // userTriggered to false as this could well be programmatic
|
||||
} as Event);
|
||||
@@ -469,9 +489,9 @@ function initInputObserver({
|
||||
),
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
return callbackWrapper(() => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type GroupingCSSRule =
|
||||
@@ -548,13 +568,17 @@ function initStyleSheetObserver(
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const insertRule = win.CSSStyleSheet.prototype.insertRule;
|
||||
win.CSSStyleSheet.prototype.insertRule = function (
|
||||
this: CSSStyleSheet,
|
||||
rule: string,
|
||||
index?: number,
|
||||
) {
|
||||
win.CSSStyleSheet.prototype.insertRule = new Proxy(insertRule, {
|
||||
apply: callbackWrapper(
|
||||
(
|
||||
target: typeof insertRule,
|
||||
thisArg: CSSStyleSheet,
|
||||
argumentsList: [string, number | undefined],
|
||||
) => {
|
||||
const [rule, index] = argumentsList;
|
||||
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this,
|
||||
thisArg,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
@@ -566,17 +590,24 @@ function initStyleSheetObserver(
|
||||
adds: [{ rule, index }],
|
||||
});
|
||||
}
|
||||
return insertRule.apply(this, [rule, index]);
|
||||
};
|
||||
return target.apply(thisArg, argumentsList);
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const deleteRule = win.CSSStyleSheet.prototype.deleteRule;
|
||||
win.CSSStyleSheet.prototype.deleteRule = function (
|
||||
this: CSSStyleSheet,
|
||||
index: number,
|
||||
) {
|
||||
win.CSSStyleSheet.prototype.deleteRule = new Proxy(deleteRule, {
|
||||
apply: callbackWrapper(
|
||||
(
|
||||
target: typeof deleteRule,
|
||||
thisArg: CSSStyleSheet,
|
||||
argumentsList: [number],
|
||||
) => {
|
||||
const [index] = argumentsList;
|
||||
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this,
|
||||
thisArg,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
@@ -588,19 +619,27 @@ function initStyleSheetObserver(
|
||||
removes: [{ index }],
|
||||
});
|
||||
}
|
||||
return deleteRule.apply(this, [index]);
|
||||
};
|
||||
return target.apply(thisArg, argumentsList);
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
let replace: (text: string) => Promise<CSSStyleSheet>;
|
||||
|
||||
if (win.CSSStyleSheet.prototype.replace) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
replace = win.CSSStyleSheet.prototype.replace;
|
||||
win.CSSStyleSheet.prototype.replace = function (
|
||||
this: CSSStyleSheet,
|
||||
text: string,
|
||||
) {
|
||||
win.CSSStyleSheet.prototype.replace = new Proxy(replace, {
|
||||
apply: callbackWrapper(
|
||||
(
|
||||
target: typeof replace,
|
||||
thisArg: CSSStyleSheet,
|
||||
argumentsList: [string],
|
||||
) => {
|
||||
const [text] = argumentsList;
|
||||
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this,
|
||||
thisArg,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
@@ -612,20 +651,27 @@ function initStyleSheetObserver(
|
||||
replace: text,
|
||||
});
|
||||
}
|
||||
return replace.apply(this, [text]);
|
||||
};
|
||||
return target.apply(thisArg, argumentsList);
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let replaceSync: (text: string) => void;
|
||||
if (win.CSSStyleSheet.prototype.replaceSync) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
replaceSync = win.CSSStyleSheet.prototype.replaceSync;
|
||||
win.CSSStyleSheet.prototype.replaceSync = function (
|
||||
this: CSSStyleSheet,
|
||||
text: string,
|
||||
) {
|
||||
win.CSSStyleSheet.prototype.replaceSync = new Proxy(replaceSync, {
|
||||
apply: callbackWrapper(
|
||||
(
|
||||
target: typeof replaceSync,
|
||||
thisArg: CSSStyleSheet,
|
||||
argumentsList: [string],
|
||||
) => {
|
||||
const [text] = argumentsList;
|
||||
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this,
|
||||
thisArg,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
@@ -637,8 +683,10 @@ function initStyleSheetObserver(
|
||||
replaceSync: text,
|
||||
});
|
||||
}
|
||||
return replaceSync.apply(this, [text]);
|
||||
};
|
||||
return target.apply(thisArg, argumentsList);
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const supportedNestedCSSRuleTypes: {
|
||||
@@ -677,13 +725,19 @@ function initStyleSheetObserver(
|
||||
deleteRule: type.prototype.deleteRule,
|
||||
};
|
||||
|
||||
type.prototype.insertRule = function (
|
||||
this: CSSGroupingRule,
|
||||
rule: string,
|
||||
index?: number,
|
||||
) {
|
||||
type.prototype.insertRule = new Proxy(
|
||||
unmodifiedFunctions[typeKey].insertRule,
|
||||
{
|
||||
apply: callbackWrapper(
|
||||
(
|
||||
target: typeof insertRule,
|
||||
thisArg: CSSRule,
|
||||
argumentsList: [string, number | undefined],
|
||||
) => {
|
||||
const [rule, index] = argumentsList;
|
||||
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this.parentStyleSheet,
|
||||
thisArg.parentStyleSheet,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
@@ -696,22 +750,32 @@ function initStyleSheetObserver(
|
||||
{
|
||||
rule,
|
||||
index: [
|
||||
...getNestedCSSRulePositions(this as CSSRule),
|
||||
...getNestedCSSRulePositions(thisArg),
|
||||
index || 0, // defaults to 0
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return unmodifiedFunctions[typeKey].insertRule.apply(this, [rule, index]);
|
||||
};
|
||||
return target.apply(thisArg, argumentsList);
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type.prototype.deleteRule = new Proxy(
|
||||
unmodifiedFunctions[typeKey].deleteRule,
|
||||
{
|
||||
apply: callbackWrapper(
|
||||
(
|
||||
target: typeof deleteRule,
|
||||
thisArg: CSSRule,
|
||||
argumentsList: [number],
|
||||
) => {
|
||||
const [index] = argumentsList;
|
||||
|
||||
type.prototype.deleteRule = function (
|
||||
this: CSSGroupingRule,
|
||||
index: number,
|
||||
) {
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this.parentStyleSheet,
|
||||
thisArg.parentStyleSheet,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
@@ -721,15 +785,18 @@ function initStyleSheetObserver(
|
||||
id,
|
||||
styleId,
|
||||
removes: [
|
||||
{ index: [...getNestedCSSRulePositions(this as CSSRule), index] },
|
||||
{ index: [...getNestedCSSRulePositions(thisArg), index] },
|
||||
],
|
||||
});
|
||||
}
|
||||
return unmodifiedFunctions[typeKey].deleteRule.apply(this, [index]);
|
||||
};
|
||||
return target.apply(thisArg, argumentsList);
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
return callbackWrapper(() => {
|
||||
win.CSSStyleSheet.prototype.insertRule = insertRule;
|
||||
win.CSSStyleSheet.prototype.deleteRule = deleteRule;
|
||||
replace && (win.CSSStyleSheet.prototype.replace = replace);
|
||||
@@ -738,7 +805,7 @@ function initStyleSheetObserver(
|
||||
type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule;
|
||||
type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule;
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function initAdoptedStyleSheetObserver(
|
||||
@@ -792,7 +859,7 @@ export function initAdoptedStyleSheetObserver(
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
return callbackWrapper(() => {
|
||||
Object.defineProperty(host, 'adoptedStyleSheets', {
|
||||
configurable: originalPropertyDescriptor.configurable,
|
||||
enumerable: originalPropertyDescriptor.enumerable,
|
||||
@@ -801,7 +868,7 @@ export function initAdoptedStyleSheetObserver(
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
set: originalPropertyDescriptor.set,
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function initStyleDeclarationObserver(
|
||||
@@ -815,18 +882,21 @@ function initStyleDeclarationObserver(
|
||||
): listenerHandler {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const setProperty = win.CSSStyleDeclaration.prototype.setProperty;
|
||||
win.CSSStyleDeclaration.prototype.setProperty = function (
|
||||
this: CSSStyleDeclaration,
|
||||
property: string,
|
||||
value: string,
|
||||
priority: string,
|
||||
) {
|
||||
win.CSSStyleDeclaration.prototype.setProperty = new Proxy(setProperty, {
|
||||
apply: callbackWrapper(
|
||||
(
|
||||
target: typeof setProperty,
|
||||
thisArg: CSSStyleDeclaration,
|
||||
argumentsList: [string, string, string],
|
||||
) => {
|
||||
const [property, value, priority] = argumentsList;
|
||||
|
||||
// ignore this mutation if we do not care about this css attribute
|
||||
if (ignoreCSSAttributes.has(property)) {
|
||||
return setProperty.apply(this, [property, value, priority]);
|
||||
return setProperty.apply(thisArg, [property, value, priority]);
|
||||
}
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this.parentRule?.parentStyleSheet,
|
||||
thisArg.parentRule?.parentStyleSheet,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
@@ -840,24 +910,31 @@ function initStyleDeclarationObserver(
|
||||
priority,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
index: getNestedCSSRulePositions(this.parentRule!),
|
||||
index: getNestedCSSRulePositions(thisArg.parentRule!),
|
||||
});
|
||||
}
|
||||
return setProperty.apply(this, [property, value, priority]);
|
||||
};
|
||||
return target.apply(thisArg, argumentsList);
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty;
|
||||
win.CSSStyleDeclaration.prototype.removeProperty = function (
|
||||
this: CSSStyleDeclaration,
|
||||
property: string,
|
||||
) {
|
||||
win.CSSStyleDeclaration.prototype.removeProperty = new Proxy(removeProperty, {
|
||||
apply: callbackWrapper(
|
||||
(
|
||||
target: typeof removeProperty,
|
||||
thisArg: CSSStyleDeclaration,
|
||||
argumentsList: [string],
|
||||
) => {
|
||||
const [property] = argumentsList;
|
||||
|
||||
// ignore this mutation if we do not care about this css attribute
|
||||
if (ignoreCSSAttributes.has(property)) {
|
||||
return removeProperty.apply(this, [property]);
|
||||
return removeProperty.apply(thisArg, [property]);
|
||||
}
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this.parentRule?.parentStyleSheet,
|
||||
thisArg.parentRule?.parentStyleSheet,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
@@ -869,16 +946,18 @@ function initStyleDeclarationObserver(
|
||||
property,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
index: getNestedCSSRulePositions(this.parentRule!),
|
||||
index: getNestedCSSRulePositions(thisArg.parentRule!),
|
||||
});
|
||||
}
|
||||
return removeProperty.apply(this, [property]);
|
||||
};
|
||||
return target.apply(thisArg, argumentsList);
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
return () => {
|
||||
return callbackWrapper(() => {
|
||||
win.CSSStyleDeclaration.prototype.setProperty = setProperty;
|
||||
win.CSSStyleDeclaration.prototype.removeProperty = removeProperty;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function initMediaInteractionObserver({
|
||||
@@ -888,8 +967,9 @@ function initMediaInteractionObserver({
|
||||
mirror,
|
||||
sampling,
|
||||
}: observerParam): listenerHandler {
|
||||
const handler = (type: MediaInteractions) =>
|
||||
throttle((event: Event) => {
|
||||
const handler = callbackWrapper((type: MediaInteractions) =>
|
||||
throttle(
|
||||
callbackWrapper((event: Event) => {
|
||||
const target = getEventTarget(event);
|
||||
if (
|
||||
!target ||
|
||||
@@ -907,7 +987,10 @@ function initMediaInteractionObserver({
|
||||
muted,
|
||||
playbackRate,
|
||||
});
|
||||
}, sampling.media || 500);
|
||||
}),
|
||||
sampling.media || 500,
|
||||
),
|
||||
);
|
||||
const handlers = [
|
||||
on('play', handler(MediaInteractions.Play)),
|
||||
on('pause', handler(MediaInteractions.Pause)),
|
||||
@@ -915,9 +998,9 @@ function initMediaInteractionObserver({
|
||||
on('volumechange', handler(MediaInteractions.VolumeChange)),
|
||||
on('ratechange', handler(MediaInteractions.RateChange)),
|
||||
];
|
||||
return () => {
|
||||
return callbackWrapper(() => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
|
||||
@@ -956,13 +1039,16 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
|
||||
'add',
|
||||
function (original: (font: FontFace) => void) {
|
||||
return function (this: FontFaceSet, fontFace: FontFace) {
|
||||
setTimeout(() => {
|
||||
setTimeout(
|
||||
callbackWrapper(() => {
|
||||
const p = fontMap.get(fontFace);
|
||||
if (p) {
|
||||
fontCb(p);
|
||||
fontMap.delete(fontFace);
|
||||
}
|
||||
}, 0);
|
||||
}),
|
||||
0,
|
||||
);
|
||||
return original.apply(this, [fontFace]);
|
||||
};
|
||||
},
|
||||
@@ -973,16 +1059,16 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
|
||||
});
|
||||
handlers.push(restoreHandler);
|
||||
|
||||
return () => {
|
||||
return callbackWrapper(() => {
|
||||
handlers.forEach((h) => h());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function initSelectionObserver(param: observerParam): listenerHandler {
|
||||
const { doc, mirror, blockClass, blockSelector, selectionCb } = param;
|
||||
let collapsed = true;
|
||||
|
||||
const updateSelection = () => {
|
||||
const updateSelection = callbackWrapper(() => {
|
||||
const selection = doc.getSelection();
|
||||
|
||||
if (!selection || (collapsed && selection?.isCollapsed)) return;
|
||||
@@ -1012,7 +1098,7 @@ function initSelectionObserver(param: observerParam): listenerHandler {
|
||||
}
|
||||
|
||||
selectionCb({ ranges });
|
||||
};
|
||||
});
|
||||
|
||||
updateSelection();
|
||||
|
||||
@@ -1148,7 +1234,7 @@ export function initObservers(
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
return callbackWrapper(() => {
|
||||
mutationBuffers.forEach((b) => b.reset());
|
||||
mutationObserver.disconnect();
|
||||
mousemoveHandler();
|
||||
@@ -1163,7 +1249,7 @@ export function initObservers(
|
||||
fontObserver();
|
||||
selectionObserver();
|
||||
pluginHandlers.forEach((h) => h());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type CSSGroupingProp =
|
||||
|
||||
@@ -69,6 +69,7 @@ export type recordOptions<T> = {
|
||||
// departed, please use sampling options
|
||||
mousemoveWait?: number;
|
||||
keepIframeSrcFn?: KeepIframeSrcFn;
|
||||
errorHandler?: ErrorHandler;
|
||||
};
|
||||
|
||||
export type observerParam = {
|
||||
@@ -211,3 +212,5 @@ export type CrossOriginIframeMessageEventContent<T = eventWithTime> = {
|
||||
};
|
||||
export type CrossOriginIframeMessageEvent =
|
||||
MessageEvent<CrossOriginIframeMessageEventContent>;
|
||||
|
||||
export type ErrorHandler = (error: unknown) => void | boolean;
|
||||
|
||||
468
packages/rrweb/test/record/error-handler.test.ts
Normal file
468
packages/rrweb/test/record/error-handler.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user