Skip to content

Commit 2924b32

Browse files
committed
feat(replay): Consider window.open for slow clicks
When a click triggers `window.open()`, do not consider it a slow click.
1 parent dba9a3d commit 2924b32

File tree

4 files changed

+116
-0
lines changed

4 files changed

+116
-0
lines changed

packages/browser-integration-tests/suites/replay/slowClick/template.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<button id="scrollLateButton">Trigger scroll late</button>
2020
<button id="mutationIgnoreButton" class="ignore-class">Trigger scroll late</button>
2121
<button id="mouseDownButton">Trigger mutation on mouse down</button>
22+
<button id="windowOpenButton">Window open</button>
2223

2324
<a href="#" id="link">Link</a>
2425
<a href="#" target="_blank" id="linkExternal">Link external</a>
@@ -73,6 +74,9 @@ <h1 id="h2">Bottom</h1>
7374
document.getElementById('mouseDownButton').addEventListener('mousedown', () => {
7475
document.getElementById('out').innerHTML += 'mutationButton clicked<br>';
7576
});
77+
document.getElementById('windowOpenButton').addEventListener('click', () => {
78+
window.open('https://example.com/', '_self');
79+
});
7680

7781
// Do nothing on these elements
7882
document
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest('window.open() is considered for slow click', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipReplayTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
const reqPromise0 = waitForReplayRequest(page, 0);
12+
13+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
14+
return route.fulfill({
15+
status: 200,
16+
contentType: 'application/json',
17+
body: JSON.stringify({ id: 'test-id' }),
18+
});
19+
});
20+
21+
const url = await getLocalTestUrl({ testDir: __dirname });
22+
23+
await page.goto(url);
24+
await reqPromise0;
25+
26+
const reqPromise1 = waitForReplayRequest(page, (event, res) => {
27+
const { breadcrumbs } = getCustomRecordingEvents(res);
28+
29+
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
30+
});
31+
32+
await page.click('#windowOpenButton');
33+
const navPromise = page.waitForURL('https://example.com/');
34+
35+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
36+
37+
expect(breadcrumbs).toEqual([
38+
{
39+
category: 'ui.click',
40+
data: {
41+
node: {
42+
attributes: {
43+
id: 'windowOpenButton',
44+
},
45+
id: expect.any(Number),
46+
tagName: 'button',
47+
textContent: '****** ****',
48+
},
49+
nodeId: expect.any(Number),
50+
},
51+
message: 'body > button#windowOpenButton',
52+
timestamp: expect.any(Number),
53+
type: 'default',
54+
},
55+
]);
56+
57+
await navPromise;
58+
59+
// Ensure window.open() still works as expected
60+
expect(await page.url()).toBe('https://example.com/');
61+
});

packages/replay/src/coreHandlers/handleClick.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { WINDOW } from '../constants';
44
import type { MultiClickFrame, ReplayClickDetector, ReplayContainer, SlowClickConfig, SlowClickFrame } from '../types';
55
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
66
import { getClickTargetNode } from './util/domUtils';
7+
import { onWindowOpen } from './util/onWindowOpen';
78

89
type ClickBreadcrumb = Breadcrumb & {
910
timestamp: number;
@@ -68,6 +69,11 @@ export class ClickDetector implements ReplayClickDetector {
6869
this._lastScroll = nowInSeconds();
6970
};
7071

72+
const cleanupWindowOpen = onWindowOpen(() => {
73+
// Treat window.open as mutation
74+
this._lastMutation = nowInSeconds();
75+
});
76+
7177
const clickHandler = (event: MouseEvent): void => {
7278
if (!event.target) {
7379
return;
@@ -94,6 +100,7 @@ export class ClickDetector implements ReplayClickDetector {
94100
this._teardown = () => {
95101
WINDOW.removeEventListener('scroll', scrollHandler);
96102
WINDOW.removeEventListener('click', clickHandler);
103+
cleanupWindowOpen();
97104

98105
obs.disconnect();
99106
this._clicks = [];
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { fill } from '@sentry/utils';
2+
3+
import { WINDOW } from '../../constants';
4+
5+
type WindowOpenHandler = () => void;
6+
7+
let handlers: undefined | WindowOpenHandler[];
8+
9+
/**
10+
* Register a handler to be called when `window.open()` is called.
11+
* Returns a cleanup function.
12+
*/
13+
export function onWindowOpen(cb: WindowOpenHandler): () => void {
14+
// Ensure to only register this once
15+
if (!handlers) {
16+
handlers = [];
17+
monkeyPatchWindowOpen();
18+
}
19+
20+
handlers.push(cb);
21+
22+
return () => {
23+
const pos = handlers ? handlers.indexOf(cb) : -1;
24+
if (pos > -1) {
25+
(handlers as WindowOpenHandler[]).splice(pos, 1);
26+
}
27+
};
28+
}
29+
30+
function monkeyPatchWindowOpen(): void {
31+
fill(WINDOW, 'open', function (originalWindowOpen: () => void): () => void {
32+
return function (...args: unknown[]): void {
33+
if (handlers) {
34+
try {
35+
handlers.forEach(handler => handler());
36+
} catch (e) {
37+
// ignore errors in here
38+
}
39+
}
40+
41+
return originalWindowOpen.apply(WINDOW, args);
42+
};
43+
});
44+
}

0 commit comments

Comments
 (0)