From 031a72721cc3e5ddd37fcafb6b2f48be98398b8c Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] Fix: Trigger mouse movement & hover with mouse up/down in sync mode (#1191) * Trigger mouse movement & hover with mouse up/down in sync mode * Trigger touchActive and mouseDown on flush --- .changeset/sixty-impalas-laugh.md | 5 + .changeset/violet-zebras-cry.md | 5 + packages/rrweb/src/replay/index.ts | 33 +++-- packages/rrweb/test/events/hover.ts | 113 ++++++++++++++++++ ...uld-trigger-hover-on-mouse-down-1-snap.png | Bin 0 -> 11774 bytes packages/rrweb/test/replay/hover.test.ts | 71 +++++++++++ 6 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 .changeset/sixty-impalas-laugh.md create mode 100644 .changeset/violet-zebras-cry.md create mode 100644 packages/rrweb/test/events/hover.ts create mode 100644 packages/rrweb/test/replay/__image_snapshots__/hover-test-ts-replayer-hover-should-trigger-hover-on-mouse-down-1-snap.png create mode 100644 packages/rrweb/test/replay/hover.test.ts 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 0000000000000000000000000000000000000000..4142d17a7fdf077bec94553adcd307f87ef00652 GIT binary patch literal 11774 zcmeHNdrVtZ7(azFIvp?PWXfa1IZ-#pNEKLIscuZfDHwGR9-}pL?X)nqU>K}V90U-j zPJ--?(z=iZr>>Mu#yTD?ZZdT(SF;VUqiY3MHXgmjj+R0n-u7NhOvo}ajmfqm0RTWi>dVP*0N^hIfSgEKfZn-zxoac(M=E$DWdo4) zgx>;SIY>=@@vS29m@e+4NMnX-;$lre{Btc0nfq3TDZYAaM3Ovyg1oC)8zDZ%5eJ6U zeEDhUg3twzTv_;bT>PRyPWi*emr9@Ba&BoO0a9*UUQ~#|ux*Y%J8m zHk}i(Wu@u)NQZ4q*nx1F9P~o1@Lc_tq?Qp9)&|=HNlUTj@(sXaE_zi2i}3Ep$xQDZ zAfn76mN@mLU#!ycN17TFmp_Cmu?!TzCl4$V9pCGN4cy<_+uO^^%gejE6Rg&(NC)}c zTgPi9zlUQZKW{SW+P;n?F;9r+tA7qND-K@L-c*(NUebQJQmfTwPEs%}s=$m)Ac20J zZrx?-ZW>k9ZlmF^D6h^Y1z*_{f^{vGSr3*J_+0NsnsqXO>1>dILd6F+Len;?!lmT&VpJ<@!eA;h6 z;&N6=O$W)5I*63(I=QSp136ru#rO-4<*iz%8Vzd{9-nF(x9X^;JNLa;`Bx_&?M&!X zo-L(GGQN8PA1&f}uZ^ASTRgx;6oLh^u*rA{4u$ z%4r=U6%|;_f0uEbbxrEoIWxbgJKPzQv#Pk~yXsx#<$*e>e_cwX!qk(_7EE=k>Fx2B zGCgEJSU^<@O`$d)T-naaex>vj{%WtyolK#Ekj5W@QM)aNtCh=!zh6}q>=?9MlHlyYv}L{vBpKQMeAGc1 z`o!n<>$wL^I(mFvT{)X&{Q1~S!EJMQcKcqofamO8Z>vc`qFuP&nX5nfn$L^}JJ#x_ zyVYfzQO``>;8rMc_z2FNN)XX2hoq3*aRPErt!$+;gk0lf0(*^sKWv;0?Q7O#)By2F z$FwcTZJNGD9fo@xH(EoZ%iN>N{e|&(G;~auY^Y)|#Aau4Y!0NDil(A&OCG!wTcN@g zy1wOH2ZC_V^M;lnS@Cs&#Wqs2>2|%{;MAkh%@OX}$A5O#I(egy`Nn4IHbw^7Fh125 z!D9$Fk#Q!qw$he}VCjx_+l&U`J?rXO{df}vPn|I~_Za}M{?0U^ckOoQta6HDx{lf> zkQasFKK&vW_A{iE4FshagszDpCr(o@bTw|JsjB4>C2)Pc!m8Vnjw~-9R3K7@NEsq!h?JRg$B`fh zf*k$_#qYLcHuS|E=k}+`%Tqy#Af{ z&OhNd!f$sz8zK;L{~_c;9?0R)n(-}o(Z)4*cXs-ptYppxsJKXUAvS%0y+8QP^K6sA z?>~uOp`i0gv|!*HPb~l*5@OErI^rIV-wBBRlMp3f2>>yrdr@$=^)EyM5(#)ODTCxk zYSg3WWQ=_LpxK}r_cN>8S;op3oQ}e`a}g(ke+2`v#h+Nlpq?QnO)os|1$*Aqg*}TN^d|$^9f5p9xf@vvqGrf>oG2 nSd7;r1LlU=GT&Dz%=ryQ@vWgIl|AK5^E{TiDJ@yHf%(Co`@Dcl literal 0 HcmV?d00001 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(); + }); + }); +});