From dbc62a2ff354bacd8de7007182fef324ded9056f Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Mon, 24 Jun 2024 11:22:35 -0700 Subject: [PATCH] fix(rrweb): clean up pointer tap circles when seeking by breadcrumb (#209) - fixes https://github.com/getsentry/sentry/issues/72959 - cleans up the tap circles when the user seeks by breadcrumb (aka when `isSync` is true) so that there aren't a bunch left on the screen at once - verified that with this change, multitouch still works correctly before: https://github.com/getsentry/rrweb/assets/56095982/6ab6b903-1bb3-494d-8f6e-9e27cf97f199 after (video also shows playback working normally): https://github.com/getsentry/rrweb/assets/56095982/610fe5e5-1615-4eb5-8837-4ac5e4df1a05 --- packages/rrweb/src/replay/index.ts | 14 ++ packages/rrweb/test/events/is-sync-tap.ts | 181 ++++++++++++++++++++++ packages/rrweb/test/replayer.test.ts | 39 ++++- 3 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 packages/rrweb/test/events/is-sync-tap.ts diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index af8d639317..9269860a0a 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1219,6 +1219,20 @@ export class Replayer { if (isSync) { if (d.type === MouseInteractions.TouchStart) { pointer.touchActive = true; + + // prevents multiple touch circles from staying on the screen + // when the user seeks by breadcrumbs + Object.values(this.pointers).forEach((p) => { + // don't set p.touchActive to false if p.touchActive is already true + // so that multitouch still works. + // p.touchActive can be null (in which case + // we still want to set it as false) - it's set as null + // in the ReplayerEvents.Flush handler after + // the 'touch-active' class is added or removed. + if (p !== pointer && !p.touchActive) { + p.touchActive = false; + } + }); } else if (d.type === MouseInteractions.TouchEnd) { pointer.touchActive = false; pointer.pointerEl.remove(); diff --git a/packages/rrweb/test/events/is-sync-tap.ts b/packages/rrweb/test/events/is-sync-tap.ts new file mode 100644 index 0000000000..ab7cedafa0 --- /dev/null +++ b/packages/rrweb/test/events/is-sync-tap.ts @@ -0,0 +1,181 @@ +import { + EventType, + IncrementalSource, + MouseInteractions, +} from '@sentry-internal/rrweb-types'; +import type { eventWithTime } from '../../../types/src'; + +const events: eventWithTime[] = [ + { + type: 4, + data: { + href: '', + width: 1600, + height: 900, + }, + timestamp: 0, + }, + { + type: 2, + data: { + node: { + type: 0, + childNodes: [ + { type: 1, name: 'html', publicId: '', systemId: '', id: 2 }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { + id: 101, + type: 2, + tagName: 'style', + attributes: {}, + childNodes: [ + { + id: 102, + type: 3, + isStyle: true, + textContent: 'div:hover { background-color: gold; }', + }, + ], + }, + ], + id: 4, + }, + { type: 3, textContent: '\n ', id: 13 }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { type: 3, textContent: '\n ', id: 15 }, + { + type: 2, + tagName: 'div', + attributes: { + style: + 'border: 1px solid #000000; width: 100px; height: 100px;', + }, + childNodes: [{ type: 3, textContent: '\n ', id: 17 }], + id: 16, + }, + ], + id: 14, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { left: 0, top: 0 }, + }, + timestamp: 10, + }, + { + type: 3, + data: { + source: IncrementalSource.MouseInteraction, + type: MouseInteractions.TouchStart, + id: 16, + x: 30, + y: 30, + pointerId: 0, + }, + timestamp: 100, + }, + { + type: 3, + data: { + source: IncrementalSource.TouchMove, + positions: [ + { + id: 0, + x: 149.436, + y: 433.929, + timeOffset: 30, + }, + { + id: 1, + x: 243.436, + y: 155.929, + timeOffset: 0, + }, + ], + pointerId: 0, + }, + timestamp: 150, + }, + { + type: 3, + data: { + source: IncrementalSource.MouseInteraction, + type: MouseInteractions.TouchEnd, + id: 16, + x: 30, + y: 30, + pointerId: 0, + }, + timestamp: 155, + }, + { + type: 3, + data: { + source: IncrementalSource.MouseInteraction, + type: MouseInteractions.TouchStart, + id: 16, + x: 30, + y: 30, + pointerId: 1, + }, + timestamp: 160, + }, + { + type: 3, + data: { + source: IncrementalSource.TouchMove, + positions: [ + { + id: 0, + x: 149.436, + y: 433.929, + timeOffset: 30, + }, + { + id: 1, + x: 243.436, + y: 155.929, + timeOffset: 0, + }, + ], + pointerId: 1, + }, + timestamp: 170, + }, + { + type: 3, + data: { + source: IncrementalSource.MouseInteraction, + type: MouseInteractions.TouchEnd, + id: 16, + x: 30, + y: 30, + pointerId: 1, + }, + timestamp: 180, + }, + { + type: EventType.IncrementalSnapshot, + data: { source: IncrementalSource.Scroll, id: 1, x: 0, y: 250 }, + timestamp: 220, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index bd8d0d9d09..59c5a9a90c 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -15,6 +15,7 @@ import touchAllPointerEvents from './events/touch-all-pointer-id'; import touchSomePointerEvents from './events/touch-some-pointer-id'; import mouseEvents from './events/mouse'; import scrollEvents from './events/scroll'; +import isSyncTapEvents from './events/is-sync-tap'; import scrollWithParentStylesEvents from './events/scroll-with-parent-styles'; import inputEvents from './events/input'; import iframeEvents from './events/iframe'; @@ -881,7 +882,7 @@ describe('replayer', function () { `); // No pointer should exist yet - await expect( + expect( await page.evaluate( () => document.querySelectorAll('.replayer-mouse')!.length, ), @@ -892,7 +893,7 @@ describe('replayer', function () { `); // One mouse pointer should exist - await expect( + expect( await page.evaluate( () => document.querySelectorAll('.replayer-mouse')!.length, ), @@ -903,13 +904,45 @@ describe('replayer', function () { `); // Pointer should still exist after all events execute - await expect( + expect( await page.evaluate( () => document.querySelectorAll('.replayer-mouse')!.length, ), ).toEqual(1); }); + it('removes tap circles from the screen when isSync = true', async () => { + await page.evaluate(`events = ${JSON.stringify(isSyncTapEvents)}`); + await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + `); + + // No pointer should exist yet + expect( + await page.evaluate( + () => document.querySelectorAll('.replayer-mouse')!.length, + ), + ).toEqual(0); + + // Seek to second tap + await page.evaluate(` + replayer.pause(161); + `); + + // Seek to first tap + await page.evaluate(` + replayer.pause(101); + `); + + // Only one tap should exist, not both + expect( + await page.evaluate( + () => document.querySelectorAll('.touch-active')!.length, + ), + ).toEqual(1); + }); + it('should destroy the replayer after calling destroy()', async () => { await page.evaluate(`events = ${JSON.stringify(events)}`); await page.evaluate(`