diff --git a/.changeset/sixty-impalas-laugh.md b/.changeset/sixty-impalas-laugh.md new file mode 100644 index 00000000..95d8d263 --- /dev/null +++ b/.changeset/sixty-impalas-laugh.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +Only apply touch-active styling on flush diff --git a/.changeset/violet-zebras-cry.md b/.changeset/violet-zebras-cry.md new file mode 100644 index 00000000..5f4c5244 --- /dev/null +++ b/.changeset/violet-zebras-cry.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +Trigger mouse movement and hover with mouse up and mouse down events when replayer.pause(...) is called. diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 91ee9d5f..1bc9c6a6 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -148,6 +148,7 @@ export class Replayer { private mousePos: mouseMovePos | null = null; private touchActive: boolean | null = null; + private lastMouseDownEvent: [Node, Event] | null = null; // Keep the rootNode of the last hovered element. So when hovering a new element, we can remove the last hovered element's :hover style. private lastHoveredRootNode: Document | ShadowRoot; @@ -299,6 +300,20 @@ export class Replayer { ); this.mousePos = null; } + + if (this.touchActive === true) { + this.mouse.classList.add('touch-active'); + } else if (this.touchActive === false) { + this.mouse.classList.remove('touch-active'); + } + this.touchActive = null; + + if (this.lastMouseDownEvent) { + const [target, event] = this.lastMouseDownEvent; + target.dispatchEvent(event); + } + this.lastMouseDownEvent = null; + if (this.lastSelectionData) { this.applySelection(this.lastSelectionData); this.lastSelectionData = null; @@ -614,12 +629,6 @@ export class Replayer { const castFn = this.getCastFn(event, true); castFn(); } - if (this.touchActive === true) { - this.mouse.classList.add('touch-active'); - } else if (this.touchActive === false) { - this.mouse.classList.remove('touch-active'); - } - this.touchActive = null; }; private getCastFn = (event: eventWithTime, isSync = false) => { @@ -1108,7 +1117,7 @@ export class Replayer { /** * Same as the situation of missing input target. */ - if (d.id === -1 || isSync) { + if (d.id === -1) { break; } const event = new Event(MouseInteractions[d.type].toLowerCase()); @@ -1137,12 +1146,19 @@ export class Replayer { 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, @@ -1172,6 +1188,9 @@ export class Replayer { 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); } } break; diff --git a/packages/rrweb/test/events/hover.ts b/packages/rrweb/test/events/hover.ts new file mode 100644 index 00000000..73b2a4ea --- /dev/null +++ b/packages/rrweb/test/events/hover.ts @@ -0,0 +1,113 @@ +import { IncrementalSource, MouseInteractions } from '@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.MouseDown, + id: 16, + x: 30, + y: 30, + }, + timestamp: 100, + }, + { + type: 3, + data: { + source: IncrementalSource.MouseInteraction, + type: MouseInteractions.MouseUp, + id: 16, + x: 30, + y: 30, + }, + timestamp: 150, + }, + { + type: 3, + data: { + source: IncrementalSource.MouseInteraction, + type: MouseInteractions.Click, + id: 16, + x: 30, + y: 30, + }, + timestamp: 155, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replay/__image_snapshots__/hover-test-ts-replayer-hover-should-trigger-hover-on-mouse-down-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/hover-test-ts-replayer-hover-should-trigger-hover-on-mouse-down-1-snap.png new file mode 100644 index 00000000..4142d17a Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/hover-test-ts-replayer-hover-should-trigger-hover-on-mouse-down-1-snap.png differ diff --git a/packages/rrweb/test/replay/hover.test.ts b/packages/rrweb/test/replay/hover.test.ts new file mode 100644 index 00000000..d4cdec89 --- /dev/null +++ b/packages/rrweb/test/replay/hover.test.ts @@ -0,0 +1,71 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { launchPuppeteer, waitForRAF } from '../utils'; +import { toMatchImageSnapshot } from 'jest-image-snapshot'; +import type * as puppeteer from 'puppeteer'; +import events from '../events/hover'; + +interface ISuite { + code: string; + styles: string; + browser: puppeteer.Browser; + page: puppeteer.Page; +} + +expect.extend({ toMatchImageSnapshot }); + +describe('replayer', function () { + jest.setTimeout(10_000); + + let code: ISuite['code']; + let styles: ISuite['styles']; + let browser: ISuite['browser']; + let page: ISuite['page']; + + beforeAll(async () => { + browser = await launchPuppeteer({ devtools: true }); + + const bundlePath = path.resolve(__dirname, '../../dist/rrweb.js'); + const stylePath = path.resolve( + __dirname, + '../../src/replay/styles/style.css', + ); + code = fs.readFileSync(bundlePath, 'utf8'); + styles = fs.readFileSync(stylePath, 'utf8'); + }); + + beforeEach(async () => { + page = await browser.newPage(); + await page.goto('about:blank'); + await page.addStyleTag({ + content: styles, + }); + await page.evaluate(code); + await page.evaluate(`let events = ${JSON.stringify(events)}`); + + page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); + }); + + afterEach(async () => { + await page.close(); + }); + + afterAll(async () => { + await browser.close(); + }); + + describe('hover', () => { + it('should trigger hover on mouseDown', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + replayer.pause(110); // mouseDown event is at 100 + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); + }); + }); +});