diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 1febcc401b..849a15ed44 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -190,6 +190,10 @@ export class Replayer { mouseTail: defaultMouseTailConfig, useVirtualDom: true, // Virtual-dom optimization is enabled by default. logger: console, + onError: (e: Error) => { + // maintain the original behaviour of throwing any otherwise unhandled errors + throw e; + }, }; this.config = Object.assign({}, defaultConfig, config); @@ -1070,299 +1074,308 @@ export class Replayer { isSync: boolean, ) { const { data: d } = e; - switch (d.source) { - case IncrementalSource.Mutation: { - try { - this.applyMutation(d, isSync); - } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions - this.warn(`Exception in mutation ${error.message || error}`, d); + try { + switch (d.source) { + case IncrementalSource.Mutation: { + try { + this.applyMutation(d, isSync); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions + this.warn(`Exception in mutation ${error.message || error}`, d); + } + break; } - break; - } - case IncrementalSource.Drag: - case IncrementalSource.TouchMove: - case IncrementalSource.MouseMove: - if (isSync) { - const lastPosition = d.positions[d.positions.length - 1]; - this.mousePos = { - x: lastPosition.x, - y: lastPosition.y, - id: lastPosition.id, - debugData: d, - }; - } else { - d.positions.forEach((p) => { - const action = { - doAction: () => { - this.moveAndHover(p.x, p.y, p.id, isSync, d); - }, - delay: - p.timeOffset + - e.timestamp - - this.service.state.context.baselineTime, + case IncrementalSource.Drag: + case IncrementalSource.TouchMove: + case IncrementalSource.MouseMove: + if (isSync) { + const lastPosition = d.positions[d.positions.length - 1]; + this.mousePos = { + x: lastPosition.x, + y: lastPosition.y, + id: lastPosition.id, + debugData: d, }; - this.timer.addAction(action); - }); - // add a dummy action to keep timer alive - this.timer.addAction({ - doAction() { - // - }, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - delay: e.delay! - d.positions[0]?.timeOffset, - }); - } - break; - case IncrementalSource.MouseInteraction: { - /** - * Same as the situation of missing input target. - */ - if (d.id === -1) { + } else { + d.positions.forEach((p) => { + const action = { + doAction: () => { + this.moveAndHover(p.x, p.y, p.id, isSync, d); + }, + delay: + p.timeOffset + + e.timestamp - + this.service.state.context.baselineTime, + }; + this.timer.addAction(action); + }); + // add a dummy action to keep timer alive + this.timer.addAction({ + doAction() { + // + }, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + delay: e.delay! - d.positions[0]?.timeOffset, + }); + } break; - } - const event = new Event(toLowerCase(MouseInteractions[d.type])); - const target = this.mirror.getNode(d.id); - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - this.emitter.emit(ReplayerEvents.MouseInteraction, { - type: d.type, - target, - }); - const { triggerFocus } = this.config; - switch (d.type) { - case MouseInteractions.Blur: - if ('blur' in (target as HTMLElement)) { - (target as HTMLElement).blur(); - } - break; - case MouseInteractions.Focus: - if (triggerFocus && (target as HTMLElement).focus) { - (target as HTMLElement).focus({ - preventScroll: true, - }); - } + case IncrementalSource.MouseInteraction: { + /** + * Same as the situation of missing input target. + */ + if (d.id === -1) { break; - case MouseInteractions.Click: - case MouseInteractions.TouchStart: - case MouseInteractions.TouchEnd: - case MouseInteractions.MouseDown: - case MouseInteractions.MouseUp: - if (isSync) { - if (d.type === MouseInteractions.TouchStart) { - this.touchActive = true; - } else if (d.type === MouseInteractions.TouchEnd) { - this.touchActive = false; + } + const event = new Event(toLowerCase(MouseInteractions[d.type])); + const target = this.mirror.getNode(d.id); + if (!target) { + return this.debugNodeNotFound(d, d.id); + } + this.emitter.emit(ReplayerEvents.MouseInteraction, { + type: d.type, + target, + }); + const { triggerFocus } = this.config; + switch (d.type) { + case MouseInteractions.Blur: + if ('blur' in (target as HTMLElement)) { + (target as HTMLElement).blur(); } - if (d.type === MouseInteractions.MouseDown) { - this.lastMouseDownEvent = [target, event]; - } else if (d.type === MouseInteractions.MouseUp) { - this.lastMouseDownEvent = null; + break; + case MouseInteractions.Focus: + if (triggerFocus && (target as HTMLElement).focus) { + (target as HTMLElement).focus({ + preventScroll: true, + }); } - this.mousePos = { - x: d.x, - y: d.y, - id: d.id, - debugData: d, - }; - } else { - if (d.type === MouseInteractions.TouchStart) { - // don't draw a trail as user has lifted finger and is placing at a new point - this.tailPositions.length = 0; + break; + case MouseInteractions.Click: + case MouseInteractions.TouchStart: + case MouseInteractions.TouchEnd: + case MouseInteractions.MouseDown: + case MouseInteractions.MouseUp: + if (isSync) { + if (d.type === MouseInteractions.TouchStart) { + this.touchActive = true; + } else if (d.type === MouseInteractions.TouchEnd) { + this.touchActive = false; + } + if (d.type === MouseInteractions.MouseDown) { + this.lastMouseDownEvent = [target, event]; + } else if (d.type === MouseInteractions.MouseUp) { + this.lastMouseDownEvent = null; + } + this.mousePos = { + x: d.x, + y: d.y, + id: d.id, + debugData: d, + }; + } else { + if (d.type === MouseInteractions.TouchStart) { + // don't draw a trail as user has lifted finger and is placing at a new point + this.tailPositions.length = 0; + } + this.moveAndHover(d.x, d.y, d.id, isSync, d); + if (d.type === MouseInteractions.Click) { + /* + * don't want target.click() here as could trigger an iframe navigation + * instead any effects of the click should already be covered by mutations + */ + /* + * removal and addition of .active class (along with void line to trigger repaint) + * triggers the 'click' css animation in styles/style.css + */ + this.mouse.classList.remove('active'); + void this.mouse.offsetWidth; + this.mouse.classList.add('active'); + } else if (d.type === MouseInteractions.TouchStart) { + void this.mouse.offsetWidth; // needed for the position update of moveAndHover to apply without the .touch-active transition + this.mouse.classList.add('touch-active'); + } else if (d.type === MouseInteractions.TouchEnd) { + this.mouse.classList.remove('touch-active'); + } else { + // for MouseDown & MouseUp also invoke default behavior + target.dispatchEvent(event); + } } - this.moveAndHover(d.x, d.y, d.id, isSync, d); - if (d.type === MouseInteractions.Click) { - /* - * don't want target.click() here as could trigger an iframe navigation - * instead any effects of the click should already be covered by mutations - */ - /* - * removal and addition of .active class (along with void line to trigger repaint) - * triggers the 'click' css animation in styles/style.css - */ - this.mouse.classList.remove('active'); - void this.mouse.offsetWidth; - this.mouse.classList.add('active'); - } else if (d.type === MouseInteractions.TouchStart) { - void this.mouse.offsetWidth; // needed for the position update of moveAndHover to apply without the .touch-active transition - this.mouse.classList.add('touch-active'); - } else if (d.type === MouseInteractions.TouchEnd) { - this.mouse.classList.remove('touch-active'); + break; + case MouseInteractions.TouchCancel: + if (isSync) { + this.touchActive = false; } else { - // for MouseDown & MouseUp also invoke default behavior - target.dispatchEvent(event); + this.mouse.classList.remove('touch-active'); } - } + break; + default: + target.dispatchEvent(event); + } + break; + } + case IncrementalSource.Scroll: { + /** + * Same as the situation of missing input target. + */ + if (d.id === -1) { break; - case MouseInteractions.TouchCancel: - if (isSync) { - this.touchActive = false; - } else { - this.mouse.classList.remove('touch-active'); + } + if (this.usingVirtualDom) { + const target = this.virtualDom.mirror.getNode(d.id) as RRElement; + if (!target) { + return this.debugNodeNotFound(d, d.id); } + target.scrollData = d; break; - default: - target.dispatchEvent(event); - } - break; - } - case IncrementalSource.Scroll: { - /** - * Same as the situation of missing input target. - */ - if (d.id === -1) { - break; - } - if (this.usingVirtualDom) { - const target = this.virtualDom.mirror.getNode(d.id) as RRElement; - if (!target) { - return this.debugNodeNotFound(d, d.id); } - target.scrollData = d; + // Use isSync rather than this.usingVirtualDom because not every fast-forward process uses virtual dom optimization. + this.applyScroll(d, isSync); break; } - // Use isSync rather than this.usingVirtualDom because not every fast-forward process uses virtual dom optimization. - this.applyScroll(d, isSync); - break; - } - case IncrementalSource.ViewportResize: - this.emitter.emit(ReplayerEvents.Resize, { - width: d.width, - height: d.height, - }); - break; - case IncrementalSource.Input: { - /** - * Input event on an unserialized node usually means the event - * was synchrony triggered programmatically after the node was - * created. This means there was not an user observable interaction - * and we do not need to replay it. - */ - if (d.id === -1) { + case IncrementalSource.ViewportResize: + this.emitter.emit(ReplayerEvents.Resize, { + width: d.width, + height: d.height, + }); + break; + case IncrementalSource.Input: { + /** + * Input event on an unserialized node usually means the event + * was synchrony triggered programmatically after the node was + * created. This means there was not an user observable interaction + * and we do not need to replay it. + */ + if (d.id === -1) { + break; + } + if (this.usingVirtualDom) { + const target = this.virtualDom.mirror.getNode(d.id) as RRElement; + if (!target) { + return this.debugNodeNotFound(d, d.id); + } + target.inputData = d; + break; + } + this.applyInput(d); break; } - if (this.usingVirtualDom) { - const target = this.virtualDom.mirror.getNode(d.id) as RRElement; + case IncrementalSource.MediaInteraction: { + const target = this.usingVirtualDom + ? this.virtualDom.mirror.getNode(d.id) + : this.mirror.getNode(d.id); if (!target) { return this.debugNodeNotFound(d, d.id); } - target.inputData = d; + const mediaEl = target as HTMLMediaElement | RRMediaElement; + try { + if (d.currentTime !== undefined) { + mediaEl.currentTime = d.currentTime; + } + if (d.volume !== undefined) { + mediaEl.volume = d.volume; + } + if (d.muted !== undefined) { + mediaEl.muted = d.muted; + } + if (d.type === MediaInteractions.Pause) { + mediaEl.pause(); + } + if (d.type === MediaInteractions.Play) { + // remove listener for 'canplay' event because play() is async and returns a promise + // i.e. media will evntualy start to play when data is loaded + // 'canplay' event fires even when currentTime attribute changes which may lead to + // unexpeted behavior + void mediaEl.play(); + } + if (d.type === MediaInteractions.RateChange) { + mediaEl.playbackRate = d.playbackRate; + } + } catch (error) { + this.warn( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions + `Failed to replay media interactions: ${error.message || error}`, + ); + } break; } - this.applyInput(d); - break; - } - case IncrementalSource.MediaInteraction: { - const target = this.usingVirtualDom - ? this.virtualDom.mirror.getNode(d.id) - : this.mirror.getNode(d.id); - if (!target) { - return this.debugNodeNotFound(d, d.id); + case IncrementalSource.StyleSheetRule: + case IncrementalSource.StyleDeclaration: { + if (this.usingVirtualDom) { + if (d.styleId) this.constructedStyleMutations.push(d); + else if (d.id) + ( + this.virtualDom.mirror.getNode(d.id) as RRStyleElement | null + )?.rules.push(d); + } else this.applyStyleSheetMutation(d); + break; } - const mediaEl = target as HTMLMediaElement | RRMediaElement; - try { - if (d.currentTime !== undefined) { - mediaEl.currentTime = d.currentTime; - } - if (d.volume !== undefined) { - mediaEl.volume = d.volume; - } - if (d.muted !== undefined) { - mediaEl.muted = d.muted; - } - if (d.type === MediaInteractions.Pause) { - mediaEl.pause(); - } - if (d.type === MediaInteractions.Play) { - // remove listener for 'canplay' event because play() is async and returns a promise - // i.e. media will evntualy start to play when data is loaded - // 'canplay' event fires even when currentTime attribute changes which may lead to - // unexpeted behavior - void mediaEl.play(); + case IncrementalSource.CanvasMutation: { + if (!this.config.UNSAFE_replayCanvas) { + return; } - if (d.type === MediaInteractions.RateChange) { - mediaEl.playbackRate = d.playbackRate; + if (this.usingVirtualDom) { + const target = this.virtualDom.mirror.getNode( + d.id, + ) as RRCanvasElement; + if (!target) { + return this.debugNodeNotFound(d, d.id); + } + target.canvasMutations.push({ + event: e as canvasEventWithTime, + mutation: d, + }); + } else { + const target = this.mirror.getNode(d.id); + if (!target) { + return this.debugNodeNotFound(d, d.id); + } + void canvasMutation({ + event: e, + mutation: d, + target: target as HTMLCanvasElement, + imageMap: this.imageMap, + canvasEventMap: this.canvasEventMap, + errorHandler: this.warnCanvasMutationFailed.bind(this), + }); } - } catch (error) { - this.warn( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions - `Failed to replay media interactions: ${error.message || error}`, - ); - } - break; - } - case IncrementalSource.StyleSheetRule: - case IncrementalSource.StyleDeclaration: { - if (this.usingVirtualDom) { - if (d.styleId) this.constructedStyleMutations.push(d); - else if (d.id) - ( - this.virtualDom.mirror.getNode(d.id) as RRStyleElement | null - )?.rules.push(d); - } else this.applyStyleSheetMutation(d); - break; - } - case IncrementalSource.CanvasMutation: { - if (!this.config.UNSAFE_replayCanvas) { - return; + break; } - if (this.usingVirtualDom) { - const target = this.virtualDom.mirror.getNode( - d.id, - ) as RRCanvasElement; - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - target.canvasMutations.push({ - event: e as canvasEventWithTime, - mutation: d, - }); - } else { - const target = this.mirror.getNode(d.id); - if (!target) { - return this.debugNodeNotFound(d, d.id); + case IncrementalSource.Font: { + try { + const fontFace = new FontFace( + d.family, + d.buffer + ? new Uint8Array(JSON.parse(d.fontSource) as Iterable) + : d.fontSource, + d.descriptors, + ); + this.iframe.contentDocument?.fonts.add(fontFace); + } catch (error) { + this.warn(error); } - void canvasMutation({ - event: e, - mutation: d, - target: target as HTMLCanvasElement, - imageMap: this.imageMap, - canvasEventMap: this.canvasEventMap, - errorHandler: this.warnCanvasMutationFailed.bind(this), - }); + break; } - break; - } - case IncrementalSource.Font: { - try { - const fontFace = new FontFace( - d.family, - d.buffer - ? new Uint8Array(JSON.parse(d.fontSource) as Iterable) - : d.fontSource, - d.descriptors, - ); - this.iframe.contentDocument?.fonts.add(fontFace); - } catch (error) { - this.warn(error); + case IncrementalSource.Selection: { + if (isSync) { + this.lastSelectionData = d; + break; + } + this.applySelection(d); + break; } - break; - } - case IncrementalSource.Selection: { - if (isSync) { - this.lastSelectionData = d; + case IncrementalSource.AdoptedStyleSheet: { + if (this.usingVirtualDom) this.adoptedStyleSheets.push(d); + else this.applyAdoptedStyleSheet(d); break; } - this.applySelection(d); - break; + default: } - case IncrementalSource.AdoptedStyleSheet: { - if (this.usingVirtualDom) this.adoptedStyleSheets.push(d); - else this.applyAdoptedStyleSheet(d); - break; + } catch (e) { + const onError = this.config.onError; + if (onError) { + onError(e as Error); + } else { + throw e; } - default: } } diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index e815ad753b..5414598ecf 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -193,6 +193,7 @@ export type playerConfig = { warn: (...args: Parameters) => void; }; plugins?: ReplayPlugin[]; + onError?: (e: Error) => void; }; export type missingNode = { diff --git a/packages/rrweb/test/replay/error-handler.test.ts b/packages/rrweb/test/replay/error-handler.test.ts new file mode 100644 index 0000000000..33a3c566aa --- /dev/null +++ b/packages/rrweb/test/replay/error-handler.test.ts @@ -0,0 +1,62 @@ +/** + * @jest-environment jsdom + */ +import { polyfillWebGLGlobals } from '../utils'; +polyfillWebGLGlobals(); + +import { EventType, eventWithTime } from '@rrweb/types'; +import { Replayer } from '../../src/replay'; +import type { playerConfig } from '../../typings/types'; + +const event = (): eventWithTime => { + return { + timestamp: 1, + type: EventType.DomContentLoaded, + data: {}, + }; +}; + +describe('replayer error handler', () => { + function makeThrowingReplayer(config: Partial) { + const errorThrowingStyleMirrorMock = { + getStyle: () => { + throw new Error('mock error'); + }, + }; + + const replayer = new Replayer( + // Get around the error "Replayer need at least 2 events." + [event(), event()], + ); + (replayer as any).styleMirror = errorThrowingStyleMirrorMock; + + return replayer; + } + + it('the default is to throw an error', () => { + const replayer = makeThrowingReplayer({}); + expect(() => + replayer['applyIncremental']( + { + data: { source: 8, styleId: 'id-presence-causes-mock-to-be-called' }, + } as unknown as any, + false, + ), + ).toThrow('mock error'); + }); + it('can receive an error handler', () => { + const errorHandler = jest.fn(); + const replayer = makeThrowingReplayer({ + onError: errorHandler, + }); + expect(() => + replayer['applyIncremental']( + { + data: { source: 8, styleId: 'id-presence-causes-mock-to-be-called' }, + } as unknown as any, + false, + ), + ).not.toThrow('mock error'); + expect(errorHandler).toHaveBeenCalled(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 14c3e9d86b..40f8fd25f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3543,16 +3543,16 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== -"@types/semver@^7.5.0": - version "7.5.6" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339" - integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A== - "@types/semver@^7.3.12": version "7.5.5" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.5.tgz#deed5ab7019756c9c90ea86139106b0346223f35" integrity sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg== +"@types/semver@^7.5.0": + version "7.5.6" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339" + integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A== + "@types/stack-utils@^2.0.0": version "2.0.1" resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz" @@ -5564,15 +5564,10 @@ csso@^4.0.2: dependencies: css-tree "^1.1.2" -cssom@^0.4.4, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": +cssom@^0.4.4, cssom@^0.5.0, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": version "0.6.0" resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" -cssom@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" - integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== - cssom@~0.3.6: version "0.3.8" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a"