Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
4a01050
chore: start migration from Yarn -> PNPM (#1443)
daniel-graham-amplitude Dec 29, 2025
5ed0db2
chore: use PNPM workspaces (#1446)
daniel-graham-amplitude Dec 30, 2025
244d9db
chore: migrate publish workflows to PNPM (#1448)
daniel-graham-amplitude Dec 30, 2025
253e268
docs: update documentation to use pnpm over yarn (#1449)
daniel-graham-amplitude Dec 30, 2025
bfe323e
merge from main
daniel-graham-amplitude Jan 1, 2026
7b1baaa
fix: action use pnpm over yarn
daniel-graham-amplitude Jan 1, 2026
87c90f1
fix: apply rage clicks to window over viewport
daniel-graham-amplitude Jan 1, 2026
afc8b78
fix test
daniel-graham-amplitude Jan 2, 2026
7ff875d
chore: update publish tag
daniel-graham-amplitude Jan 2, 2026
7c1a535
chore: fix -- problem with pnpm (#1462)
daniel-graham-amplitude Jan 2, 2026
b6afc2a
chore(release): publish
amplitude-sdk-dev Jan 2, 2026
359ec7c
chore: remove unknown args pnpm publish
daniel-graham-amplitude Jan 2, 2026
2f2e86b
chore: rebase pnpm-migration
daniel-graham-amplitude Jan 2, 2026
c2808e9
Revert "chore(release): publish"
daniel-graham-amplitude Jan 2, 2026
d36dc92
chore: clean up PR
daniel-graham-amplitude Jan 2, 2026
003570e
chore: clean up PR
daniel-graham-amplitude Jan 2, 2026
771aada
dummy commit
daniel-graham-amplitude Jan 2, 2026
6c9c7d6
chore: fix git-checks version to publish
daniel-graham-amplitude Jan 2, 2026
db34376
empty
daniel-graham-amplitude Jan 2, 2026
b8a027e
Revert "empty"
daniel-graham-amplitude Jan 2, 2026
935fc68
Merge branch 'main' of github.com:amplitude/Amplitude-TypeScript into…
daniel-graham-amplitude Jan 2, 2026
0a24f2d
chore: fix git-checks version to publish
daniel-graham-amplitude Jan 2, 2026
96defd0
again
daniel-graham-amplitude Jan 2, 2026
3feb114
chore: fix git-checks version to publish
daniel-graham-amplitude Jan 2, 2026
cdbcdb2
Merge branch 'main' of github.com:amplitude/Amplitude-TypeScript into…
daniel-graham-amplitude Jan 2, 2026
07b1239
Merge branch 'pnpm-migration' of github.com:amplitude/Amplitude-TypeS…
daniel-graham-amplitude Jan 2, 2026
6749750
fix: implement highlight text fix
daniel-graham-amplitude Jan 2, 2026
eba9524
chore(release): publish
amplitude-sdk-dev Jan 2, 2026
ce48dcd
fix: add selection observer to cancel out rage clicks
daniel-graham-amplitude Jan 3, 2026
e974e48
chore: add selection observable test
daniel-graham-amplitude Jan 3, 2026
e8bab23
again
daniel-graham-amplitude Jan 2, 2026
438d897
chore: add "auto" yes to version
daniel-graham-amplitude Jan 2, 2026
66ffd36
chore: clean up PR
daniel-graham-amplitude Jan 3, 2026
d8c094d
Merge branch 'main' of github.com:amplitude/Amplitude-TypeScript into…
daniel-graham-amplitude Jan 3, 2026
bd7598b
fix: test coverage
daniel-graham-amplitude Jan 3, 2026
d0cc2e7
fix: make selection unsubscribable too
daniel-graham-amplitude Jan 3, 2026
a6793e0
chore: make unit tests leaner
daniel-graham-amplitude Jan 3, 2026
eb2a3ba
again
daniel-graham-amplitude Jan 3, 2026
2e728e5
refactor tests
daniel-graham-amplitude Jan 3, 2026
73f6737
chore: fix deploy script
daniel-graham-amplitude Jan 5, 2026
c3e9774
refactor tests
daniel-graham-amplitude Jan 5, 2026
454f675
add back targeting manager
daniel-graham-amplitude Jan 5, 2026
9ef44ba
fix: apply rage clicks to window over viewport
daniel-graham-amplitude Jan 1, 2026
a2076e4
fix test
daniel-graham-amplitude Jan 2, 2026
2f9f9a5
chore: cleanup rage click tests
daniel-graham-amplitude Jan 5, 2026
fe3dc36
Merge branch 'AMP-146045-rage-click-fix-mobile-scroll-false-positive'…
daniel-graham-amplitude Jan 5, 2026
8a4fbdd
Merge branch 'main' of github.com:amplitude/Amplitude-TypeScript into…
daniel-graham-amplitude Jan 5, 2026
961ca02
Merge branch 'AMP-146045-rage-click-fix-mobile-scroll-false-positive'…
daniel-graham-amplitude Jan 5, 2026
eaeb3d1
Merge branch 'main' of github.com:amplitude/Amplitude-TypeScript into…
daniel-graham-amplitude Jan 5, 2026
9eb5742
fix: handle selectionStart exceptions
daniel-graham-amplitude Jan 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/plugin-autocapture-browser/src/autocapture-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export enum ObservablesEnum {
// ErrorObservable = 'errorObservable',
NavigateObservable = 'navigateObservable',
MutationObservable = 'mutationObservable',
SelectionObservable = 'selectionObservable',
}

export interface AllWindowObservables {
Expand All @@ -65,6 +66,7 @@ export interface AllWindowObservables {
[ObservablesEnum.ClickObservable]: Observable<ElementBasedTimestampedEvent<MouseEvent>>;
[ObservablesEnum.MutationObservable]: Observable<TimestampedEvent<MutationRecord[]>>;
[ObservablesEnum.NavigateObservable]?: Observable<TimestampedEvent<NavigateEvent>>;
[ObservablesEnum.SelectionObservable]?: Observable<void>;
}

export const autocapturePlugin = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export function trackRageClicks({
allObservables: AllWindowObservables;
shouldTrackRageClick: shouldTrackEvent;
}) {
const { clickObservable }: AllWindowObservables = allObservables;
const { clickObservable, selectionObservable }: AllWindowObservables = allObservables;

// Keep track of all clicks within the sliding window
let clickWindow: ClickEvent[] = [];
Expand Down Expand Up @@ -152,7 +152,6 @@ export function trackRageClicks({
if (pendingRageClick) {
resolutionValue = getRageClickAnalyticsEvent(clickWindow);
}

resetClickWindow(click);
} else {
clickWindow.push(click);
Expand Down Expand Up @@ -184,11 +183,25 @@ export function trackRageClicks({
},
);

return rageClickObservable.subscribe((data: RageClickEvent | null) => {
// reset the click window when a selection change occurs
/* istanbul ignore next */
const selectionSubscription = selectionObservable?.subscribe(() => {
resetClickWindow();
});

const rageClickSubscription = rageClickObservable.subscribe((data: RageClickEvent | null) => {
/* istanbul ignore if */
if (data === null) {
return;
}
amplitude.track(AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT, data.rageClickEvent, { time: data.time });
});

return {
unsubscribe: () => {
rageClickSubscription.unsubscribe();
/* istanbul ignore next */
selectionSubscription?.unsubscribe();
},
Comment on lines +201 to +205
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pending rage‑click timeout isn’t cleared, so it can fire after teardown or a window reset and emit unexpectedly. Suggest always cancel and null any pending rageClick timer during teardown and whenever the click window is reset.

Suggested change
unsubscribe: () => {
rageClickSubscription.unsubscribe();
/* istanbul ignore next */
selectionSubscription?.unsubscribe();
},
unsubscribe: () => {
if (pendingRageClick) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
clearTimeout(pendingRageClick.timerId);
pendingRageClick = null;
}
rageClickSubscription.unsubscribe();
/* istanbul ignore next */
selectionSubscription?.unsubscribe();
},

🚀 Want me to fix this? Reply ex: "fix it for me".

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine, this was here before. It just means that if it's unsubscribed and a rage click happens within the short window after unsubscribing, it will fire off one last event.

};
}
35 changes: 35 additions & 0 deletions packages/plugin-autocapture-browser/src/frustration-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface AllWindowObservables {
[ObservablesEnum.ClickObservable]: Observable<ElementBasedTimestampedEvent<MouseEvent>>;
[ObservablesEnum.MutationObservable]: Observable<TimestampedEvent<MutationRecord[]>>;
[ObservablesEnum.NavigateObservable]?: Observable<TimestampedEvent<NavigateEvent>>;
[ObservablesEnum.SelectionObservable]?: Observable<void>;
}

type BrowserEnrichmentPlugin = EnrichmentPlugin<BrowserClient, BrowserConfig>;
Expand Down Expand Up @@ -91,10 +92,44 @@ export const frustrationPlugin = (options: FrustrationInteractionsOptions = {}):
);
}

const selectionObservable = multicast(
Copy link
Collaborator

@Dogfalo Dogfalo Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use more comments here as code to handle selections is not as common. Try to explain high level approach

new Observable<void>((observer) => {
const handler = () => {
// handle input and textarea
const el: HTMLElement | null = document.activeElement as HTMLElement;

if (el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) {
let start: number | null | undefined;
let end: number | null | undefined;
try {
start = (el as HTMLInputElement | HTMLTextAreaElement).selectionStart;
end = (el as HTMLInputElement | HTMLTextAreaElement).selectionEnd;
if (start === end) return; // collapsed
} catch (error) {
// input that doesn't support selectionStart/selectionEnd (like checkbox)
// do nothing here
return;
}
return observer.next();
}

// handle non-input elements
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
return observer.next();
};
window.document.addEventListener('selectionchange', handler);
return () => {
window.document.removeEventListener('selectionchange', handler);
};
}),
);

return {
[ObservablesEnum.ClickObservable]: clickObservable as Observable<ElementBasedTimestampedEvent<MouseEvent>>,
[ObservablesEnum.MutationObservable]: enrichedMutationObservable,
[ObservablesEnum.NavigateObservable]: enrichedNavigateObservable,
[ObservablesEnum.SelectionObservable]: selectionObservable,
};
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { frustrationPlugin } from '../../src/frustration-plugin';
import { BrowserConfig, EnrichmentPlugin, ILogger } from '@amplitude/analytics-core';
import { AllWindowObservables, frustrationPlugin } from '../../src/frustration-plugin';
import { BrowserConfig, EnrichmentPlugin, ILogger, Unsubscribable } from '@amplitude/analytics-core';
import { createMockBrowserClient } from '../mock-browser-client';
import { trackDeadClick } from '../../src/autocapture/track-dead-click';
import { trackRageClicks } from '../../src/autocapture/track-rage-click';
Expand Down Expand Up @@ -329,5 +329,103 @@ describe('frustrationPlugin', () => {
// expect no event listeners left on window.navigation
expect((window.navigation as any)._handlers.length).toBe(0);
});

describe('selection observable', () => {
let plugin: EnrichmentPlugin | undefined;
let rageClickCall: any;
let observables: AllWindowObservables;
let subscription: Unsubscribable | undefined;
let selectionSpy: jest.Mock;

beforeEach(async () => {
plugin = frustrationPlugin({});
await plugin?.setup?.(config as BrowserConfig, instance);
rageClickCall = (trackRageClicks as jest.Mock).mock.calls[0][0];
observables = rageClickCall.allObservables;
selectionSpy = jest.fn();
subscription = observables.selectionObservable?.subscribe(selectionSpy);
jest.clearAllMocks();
});

afterEach(() => {
subscription?.unsubscribe();
});

it('should trigger on selection highlighted', async () => {
const div = document.createElement('div');
div.focus();

expect(observables).toHaveProperty('selectionObservable');

jest.spyOn(window, 'getSelection').mockReturnValue({
isCollapsed: false,
} as any);
const mockSelectionEvent: any = new Event('selectionchange');
(window.document as any).dispatchEvent(mockSelectionEvent);

expect(selectionSpy).toHaveBeenCalled();
});

it('should not trigger on non-input element selection change if selection is collapsed', async () => {
// Trigger a mock selection event
const div = document.createElement('div');
div.focus();
(window.document as any).dispatchEvent(new Event('selectionchange'));
expect(selectionSpy).not.toHaveBeenCalled();
});

it('should trigger on input element selection change', async () => {
// Trigger a mock selection event
['textarea', 'input'].forEach((tag) => {
const input = document.createElement(tag) as HTMLTextAreaElement | HTMLInputElement;
input.value = 'some text here'; // Add text so there's something to select
input.selectionStart = 0;
input.selectionEnd = 10;
document.body.appendChild(input);
input.focus(); // This sets document.activeElement to the input
(window.document as any).dispatchEvent(new Event('selectionchange'));
document.body.removeChild(input);
});

expect(selectionSpy).toHaveBeenCalledTimes(2);
});

it('should not trigger on input element selection change if selection is collapsed', async () => {
// Trigger a mock selection event on input and textarea elements
['textarea', 'input'].forEach((tag) => {
const input = document.createElement(tag) as HTMLTextAreaElement | HTMLInputElement;
input.value = 'some text here'; // Add text so there's something to select
input.selectionStart = 0;
input.selectionEnd = 0;
document.body.appendChild(input);
input.focus(); // This sets document.activeElement to the input
(window.document as any).dispatchEvent(new Event('selectionchange'));
document.body.removeChild(input);
});

expect(selectionSpy).not.toHaveBeenCalled();
});

it('should not trigger on input element that does not support selectionStart/selectionEnd (like checkbox)', async () => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
// make .selectionStart and .selectionEnd throw an error
// simulating "Chrome" behavior
Object.defineProperty(checkbox, 'selectionStart', {
get: () => {
throw new Error('Not supported');
},
});
Object.defineProperty(checkbox, 'selectionEnd', {
get: () => {
throw new Error('Not supported');
},
});
document.body.appendChild(checkbox);
checkbox.focus(); // This sets document.activeElement to the checkbox
(window.document as any).dispatchEvent(new Event('selectionchange'));
document.body.removeChild(checkbox);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DEFAULT_RAGE_CLICK_THRESHOLD,
DEFAULT_RAGE_CLICK_WINDOW_MS,
Observable,
Unsubscribable,
} from '@amplitude/analytics-core';
import { trackRageClicks } from '../../src/autocapture/track-rage-click';
import { AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT } from '../../src/constants';
Expand All @@ -15,9 +16,11 @@ import { AllWindowObservables } from '../../src/frustration-plugin';
describe('trackRageClicks', () => {
let mockAmplitude: jest.Mocked<BrowserClient>;
let clickObservable: Observable<any>;
let selectionObservable: Observable<any>;
let allObservables: AllWindowObservables;
let shouldTrackRageClick: jest.Mock;
let clickObserver: any;
let selectionObserver: any;
let subscription: ReturnType<typeof trackRageClicks>;

beforeEach(() => {
Expand All @@ -28,11 +31,15 @@ describe('trackRageClicks', () => {
clickObservable = new Observable<any>((observer) => {
clickObserver = observer;
});
selectionObservable = new Observable<any>((observer) => {
selectionObserver = observer;
});

allObservables = {
[ObservablesEnum.ClickObservable]: clickObservable,
[ObservablesEnum.NavigateObservable]: new Observable<any>(() => {}),
[ObservablesEnum.MutationObservable]: new Observable<any>(() => {}),
[ObservablesEnum.SelectionObservable]: selectionObservable,
};
shouldTrackRageClick = jest.fn().mockReturnValue(true);

Expand All @@ -49,6 +56,74 @@ describe('trackRageClicks', () => {
jest.useRealTimers();
});

describe('selection change', () => {
let subscription: Unsubscribable | undefined;
let mockElement: HTMLElement;
let ancestorElement: HTMLElement;
let startTime: number;

beforeEach(async () => {
subscription = trackRageClicks({
amplitude: mockAmplitude,
allObservables,
shouldTrackRageClick,
});
mockElement = document.createElement('div');
ancestorElement = document.createElement('div');
startTime = Date.now();
});

afterEach(() => {
subscription?.unsubscribe();
});

it('should not track rage clicks when the text selection has changed', async () => {
for (let i = 0; i < DEFAULT_RAGE_CLICK_THRESHOLD; i++) {
clickObserver.next({
event: {
target: mockElement,
pageX: 100,
pageY: 100,
},
timestamp: startTime + i * 20,
closestTrackedAncestor: ancestorElement,
targetElementProperties: { id: 'test-element' },
});
if (i === DEFAULT_RAGE_CLICK_THRESHOLD - 2) {
jest.advanceTimersByTime(10);
selectionObserver.next();
}
}
jest.advanceTimersByTime(DEFAULT_RAGE_CLICK_WINDOW_MS + 100);
await jest.runAllTimersAsync();
expect(mockAmplitude.track).not.toHaveBeenCalled();
});

it('should track rage click if selection changes but 4 clicks happen after selection change', async () => {
for (let i = 0; i < DEFAULT_RAGE_CLICK_THRESHOLD + 3; i++) {
clickObserver.next({
event: {
target: mockElement,
pageX: 100,
pageY: 100,
},
timestamp: startTime + i * 20,
closestTrackedAncestor: ancestorElement,
targetElementProperties: { id: 'test-element' },
});

// make the 3rd click trigger a selection change
if (i === 2) {
jest.advanceTimersByTime(10);
selectionObserver.next();
}
}
jest.advanceTimersByTime(DEFAULT_RAGE_CLICK_WINDOW_MS + 100);
await jest.runAllTimersAsync();
expect(mockAmplitude.track).toHaveBeenCalled();
});
});

it('should track rage clicks when threshold is met', async () => {
// Create a mock element
const mockElement = document.createElement('div');
Expand Down
Loading