diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4f8fd300..99f458c5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Expose screenshot masking options (`screenshot.maskAllText`, `screenshot.maskAllImages`, `screenshot.maskedViewClasses`, `screenshot.unmaskedViewClasses`) for error screenshots ([#6007](https://github.com/getsentry/sentry-react-native/pull/6007)) - Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984)) - Add `Sentry.GlobalErrorBoundary` component (and `withGlobalErrorBoundary` HOC) that renders a fallback UI for fatal non-rendering JS errors routed through `ErrorUtils` in addition to the render-phase errors caught by `Sentry.ErrorBoundary`. Opt-in flags `includeNonFatalGlobalErrors` and `includeUnhandledRejections` extend the fallback to non-fatal errors and unhandled promise rejections respectively. ([#6023](https://github.com/getsentry/sentry-react-native/pull/6023)) +- Add rage tap detection — rapid consecutive taps on the same element emit `ui.multiClick` breadcrumbs and appear on the replay timeline with the rage click icon ([#5992](https://github.com/getsentry/sentry-react-native/pull/5992)) ### Fixes diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt index d05d50655b..f49085af0b 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt @@ -90,6 +90,34 @@ class RNSentryReplayBreadcrumbConverterTest { assertEquals(null, actual) } + @Test + fun convertMultiClickBreadcrumb() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.level = SentryLevel.WARNING + testBreadcrumb.type = "default" + testBreadcrumb.category = "ui.multiClick" + testBreadcrumb.message = "Submit" + testBreadcrumb.setData( + "path", + arrayListOf( + mapOf( + "name" to "SubmitButton", + "label" to "Submit", + "file" to "form.tsx", + ), + ), + ) + testBreadcrumb.setData("clickCount", 3.0) + testBreadcrumb.setData("metric", true) + val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent + + assertRRWebBreadcrumbDefaults(actual) + assertEquals(SentryLevel.WARNING, actual.level) + assertEquals("ui.multiClick", actual.category) + assertEquals("Submit(form.tsx)", actual.message) + } + @Test fun convertTouchBreadcrumb() { val converter = RNSentryReplayBreadcrumbConverter() diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift index 00e543aad5..6416b580d6 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift @@ -100,6 +100,33 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase { XCTAssertNil(actual) } + func testConvertMultiClickBreadcrumb() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.timestamp = Date() + testBreadcrumb.level = .warning + testBreadcrumb.type = "default" + testBreadcrumb.category = "ui.multiClick" + testBreadcrumb.message = "Submit" + testBreadcrumb.data = [ + "path": [ + ["name": "SubmitButton", "label": "Submit", "file": "form.tsx"] + ], + "clickCount": 3, + "metric": true + ] + let actual = converter.convert(from: testBreadcrumb) + + XCTAssertNotNil(actual) + let event = actual!.serialize() + let data = event["data"] as! [String: Any?] + let payload = data["payload"] as! [String: Any?] + assertRRWebBreadcrumbDefaults(actual: event) + XCTAssertEqual("warning", payload["level"] as! String) + XCTAssertEqual("ui.multiClick", payload["category"] as! String) + XCTAssertEqual("Submit(form.tsx)", payload["message"] as! String) + } + func testConvertTouchBreadcrumb() { let converter = RNSentryReplayBreadcrumbConverter() let testBreadcrumb = Breadcrumb() diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java index 6a3d2872a6..00146efb8f 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -32,6 +32,9 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc if ("touch".equals(breadcrumb.getCategory())) { return convertTouchBreadcrumb(breadcrumb); } + if ("ui.multiClick".equals(breadcrumb.getCategory())) { + return convertMultiClickBreadcrumb(breadcrumb); + } if ("navigation".equals(breadcrumb.getCategory())) { return convertNavigationBreadcrumb(breadcrumb); } @@ -72,6 +75,22 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc return rrWebBreadcrumb; } + @TestOnly + public @Nullable RRWebEvent convertMultiClickBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + if (breadcrumb.getData("path") == null) { + return null; + } + + final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent(); + + rrWebBreadcrumb.setCategory("ui.multiClick"); + + rrWebBreadcrumb.setMessage(getTouchPathMessage(breadcrumb.getData("path"))); + + setRRWebEventDefaultsFrom(rrWebBreadcrumb, breadcrumb); + return rrWebBreadcrumb; + } + @TestOnly public static @Nullable String getTouchPathMessage(final @Nullable Object maybePath) { if (!(maybePath instanceof List)) { diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m index 59bf29e0e6..6bc5366481 100644 --- a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m @@ -35,6 +35,10 @@ - (instancetype _Nonnull)init return [self convertTouch:breadcrumb]; } + if ([breadcrumb.category isEqualToString:@"ui.multiClick"]) { + return [self convertMultiClick:breadcrumb]; + } + if ([breadcrumb.category isEqualToString:@"navigation"]) { return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp category:breadcrumb.category @@ -75,6 +79,26 @@ - (instancetype _Nonnull)init data:breadcrumb.data]; } +- (id _Nullable)convertMultiClick:(SentryBreadcrumb *_Nonnull)breadcrumb +{ + if (breadcrumb.data == nil) { + return nil; + } + + id maybePath = [breadcrumb.data valueForKey:@"path"]; + if (![maybePath isKindOfClass:[NSArray class]]) { + return nil; + } + + NSString *message = [RNSentryReplayBreadcrumbConverter getTouchPathMessageFrom:maybePath]; + + return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp + category:@"ui.multiClick" + message:message + level:breadcrumb.level + data:breadcrumb.data]; +} + + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path { if (path == nil) { diff --git a/packages/core/src/js/ragetap.ts b/packages/core/src/js/ragetap.ts new file mode 100644 index 0000000000..af230ea8d6 --- /dev/null +++ b/packages/core/src/js/ragetap.ts @@ -0,0 +1,190 @@ +import type { SeverityLevel } from '@sentry/core'; +import { addBreadcrumb, debug } from '@sentry/core'; + +import { getCurrentReactNativeTracingIntegration } from './tracing/reactnativetracing'; + +const DEFAULT_RAGE_TAP_THRESHOLD = 3; +const DEFAULT_RAGE_TAP_TIME_WINDOW = 1000; + +export interface TouchedComponentInfo { + name?: string; + label?: string; + element?: string; + file?: string; +} + +export interface RageTapDetectorOptions { + enabled: boolean; + threshold: number; + timeWindow: number; +} + +interface RecentTap { + identity: string; + timestamp: number; +} + +/** + * Detects rage taps (repeated rapid taps on the same target) and emits + * `ui.multiClick` breadcrumbs when the threshold is hit. + * + * Uses the same breadcrumb category and data shape as the web JS SDK's + * rage click detection so the Sentry replay timeline renders the fire + * icon and "Rage Click" label automatically. + */ +export class RageTapDetector { + private _recentTaps: RecentTap[] = []; + private _enabled: boolean; + private _threshold: number; + private _timeWindow: number; + + public constructor(options?: Partial) { + this._enabled = options?.enabled ?? true; + this._threshold = options?.threshold ?? DEFAULT_RAGE_TAP_THRESHOLD; + this._timeWindow = options?.timeWindow ?? DEFAULT_RAGE_TAP_TIME_WINDOW; + } + + /** + * Update options at runtime (e.g. when React props change). + */ + public updateOptions(options: Partial): void { + if (options.enabled !== undefined) { + this._enabled = options.enabled; + if (!this._enabled) { + this._recentTaps = []; + } + } + if (options.threshold !== undefined) { + this._threshold = options.threshold; + } + if (options.timeWindow !== undefined) { + this._timeWindow = options.timeWindow; + } + } + + /** + * Call after each touch event. If a rage tap is detected, a `ui.multiClick` + * breadcrumb is emitted automatically. + */ + public check(touchPath: TouchedComponentInfo[], label?: string): void { + if (!this._enabled) { + return; + } + + const root = touchPath[0]; + if (!root) { + return; + } + + const identity = getTapIdentity(root, label); + const now = Date.now(); + const tapCount = this._detect(identity, now); + + if (tapCount > 0) { + const message = buildTouchMessage(root, label); + const node = buildNodeFromTouchPath(root, label); + + addBreadcrumb({ + category: 'ui.multiClick', + type: 'default', + level: 'warning' as SeverityLevel, + message, + data: { + clickCount: tapCount, + metric: true, + route: getCurrentRoute(), + node, + path: touchPath, + }, + }); + + debug.log(`[TouchEvents] Rage tap detected: ${tapCount} taps on ${message}`); + } + } + + /** + * Returns the tap count if rage tap is detected, 0 otherwise. + */ + private _detect(identity: string, now: number): number { + // If the target changed, reset the buffer — only truly consecutive + // taps on the same target count. This prevents false positives where + // time-window pruning removes interleaved taps on other targets. + const lastTap = this._recentTaps[this._recentTaps.length - 1]; + if (lastTap && lastTap.identity !== identity) { + this._recentTaps = []; + } + + this._recentTaps.push({ identity, timestamp: now }); + + // Prune taps outside the time window + const cutoff = now - this._timeWindow; + this._recentTaps = this._recentTaps.filter(tap => tap.timestamp >= cutoff); + + if (this._recentTaps.length >= this._threshold) { + const count = this._recentTaps.length; + this._recentTaps = []; + return count; + } + + return 0; + } +} + +function getTapIdentity(root: TouchedComponentInfo, label?: string): string { + const base = `name:${root.name ?? ''}|file:${root.file ?? ''}`; + if (label) { + return `label:${label}|${base}`; + } + return base; +} + +/** + * Build a human-readable message matching the touch breadcrumb format. + */ +function buildTouchMessage(root: TouchedComponentInfo, label?: string): string { + if (label) { + return label; + } + return `${root.name}${root.file ? ` (${root.file})` : ''}`; +} + +/** + * Build a node object compatible with the web SDK's `ReplayBaseDomFrameData` + * so that `stringifyNodeAttributes` in the Sentry frontend can render it. + * + * Maps the React Native component info to the DOM-like shape: + * - `tagName` → element type (e.g. "RCTView") or component name + * - `attributes['data-sentry-component']` → component name from babel plugin + * - `attributes['data-sentry-source-file']` → source file + */ +function buildNodeFromTouchPath( + root: TouchedComponentInfo, + label?: string, +): { id: number; tagName: string; textContent: string; attributes: Record } { + const attributes: Record = {}; + + if (root.name) { + attributes['data-sentry-component'] = root.name; + } + if (root.file) { + attributes['data-sentry-source-file'] = root.file; + } + if (label) { + attributes['sentry-label'] = label; + } + + return { + id: 0, + tagName: root.element ?? root.name ?? 'unknown', + textContent: '', + attributes, + }; +} + +function getCurrentRoute(): string | undefined { + try { + return getCurrentReactNativeTracingIntegration()?.state.currentRoute; + } catch { + return undefined; + } +} diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 27c31d0c33..d47e794603 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -6,6 +6,8 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import { createIntegration } from './integrations/factory'; +import type { TouchedComponentInfo } from './ragetap'; +import { RageTapDetector } from './ragetap'; import { startUserInteractionSpan } from './tracing/integrations/userInteraction'; import { UI_ACTION_TOUCH } from './tracing/ops'; import { SPAN_ORIGIN_AUTO_INTERACTION } from './tracing/origin'; @@ -48,6 +50,25 @@ export type TouchEventBoundaryProps = { * @experimental This API is experimental and may change in future releases. */ spanAttributes?: Record; + /** + * Enable rage tap detection. When enabled, rapid consecutive taps on the + * same element are detected and emitted as `ui.multiClick` breadcrumbs. + * + * @default true + */ + enableRageTapDetection?: boolean; + /** + * Number of taps within the time window to trigger a rage tap. + * + * @default 3 + */ + rageTapThreshold?: number; + /** + * Time window in milliseconds for rage tap detection. + * + * @default 1000 + */ + rageTapTimeWindow?: number; }; const touchEventStyles = StyleSheet.create({ @@ -75,13 +96,6 @@ interface ElementInstance { return?: ElementInstance; } -interface TouchedComponentInfo { - name?: string; - label?: string; - element?: string; - file?: string; -} - interface PrivateGestureResponderEvent extends GestureResponderEvent { _targetInst?: ElementInstance; } @@ -96,10 +110,15 @@ class TouchEventBoundary extends React.Component { breadcrumbType: DEFAULT_BREADCRUMB_TYPE, ignoreNames: [], maxComponentTreeSize: DEFAULT_MAX_COMPONENT_TREE_SIZE, + enableRageTapDetection: true, + rageTapThreshold: 3, + rageTapTimeWindow: 1000, }; public readonly name: string = 'TouchEventBoundary'; + private _rageTapDetector: RageTapDetector = new RageTapDetector(); + /** * Registers the TouchEventBoundary as a Sentry Integration. */ @@ -203,6 +222,12 @@ class TouchEventBoundary extends React.Component { const label = touchPath.find(info => info.label)?.label; if (touchPath.length > 0) { this._logTouchEvent(touchPath, label); + this._rageTapDetector.updateOptions({ + enabled: this.props.enableRageTapDetection, + threshold: this.props.rageTapThreshold, + timeWindow: this.props.rageTapTimeWindow, + }); + this._rageTapDetector.check(touchPath, label); } const span = startUserInteractionSpan({ diff --git a/packages/core/test/ragetap.test.ts b/packages/core/test/ragetap.test.ts new file mode 100644 index 0000000000..0991096dd5 --- /dev/null +++ b/packages/core/test/ragetap.test.ts @@ -0,0 +1,201 @@ +import * as core from '@sentry/core'; + +import { RageTapDetector } from '../src/js/ragetap'; + +describe('RageTapDetector', () => { + let addBreadcrumb: jest.SpyInstance; + + beforeEach(() => { + jest.resetAllMocks(); + addBreadcrumb = jest.spyOn(core, 'addBreadcrumb'); + jest.spyOn(Date, 'now').mockReturnValue(1000); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('emits ui.multiClick breadcrumb after 3 taps on same label', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'Button', label: 'submit' }]; + + detector.check(path, 'submit'); + detector.check(path, 'submit'); + detector.check(path, 'submit'); + + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'ui.multiClick', + level: 'warning', + type: 'default', + message: 'submit', + data: expect.objectContaining({ + clickCount: 3, + metric: true, + path, + node: expect.objectContaining({ + tagName: 'Button', + attributes: expect.objectContaining({ + 'data-sentry-component': 'Button', + 'sentry-label': 'submit', + }), + }), + }), + }), + ); + }); + + it('does not emit for taps on different targets', () => { + const detector = new RageTapDetector(); + + detector.check([{ name: 'A', label: 'a' }], 'a'); + detector.check([{ name: 'B', label: 'b' }], 'b'); + detector.check([{ name: 'A', label: 'a' }], 'a'); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does not emit when taps are outside the time window', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'Button', label: 'ok' }]; + const nowMock = jest.spyOn(Date, 'now'); + + nowMock.mockReturnValue(1000); + detector.check(path, 'ok'); + + nowMock.mockReturnValue(1500); + detector.check(path, 'ok'); + + // Third tap is beyond 1000ms from the first + nowMock.mockReturnValue(2500); + detector.check(path, 'ok'); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('resets buffer after rage tap is detected', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'Button', label: 'ok' }]; + + // Trigger rage tap + detector.check(path, 'ok'); + detector.check(path, 'ok'); + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + + // Two more taps should NOT re-trigger (buffer was reset) + detector.check(path, 'ok'); + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it('does nothing when disabled', () => { + const detector = new RageTapDetector({ enabled: false }); + const path = [{ name: 'Button', label: 'ok' }]; + + detector.check(path, 'ok'); + detector.check(path, 'ok'); + detector.check(path, 'ok'); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('respects custom threshold', () => { + const detector = new RageTapDetector({ threshold: 5 }); + const path = [{ name: 'Button', label: 'ok' }]; + + for (let i = 0; i < 4; i++) { + detector.check(path, 'ok'); + } + expect(addBreadcrumb).not.toHaveBeenCalled(); + + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ clickCount: 5 }), + }), + ); + }); + + it('respects custom time window', () => { + const detector = new RageTapDetector({ timeWindow: 500 }); + const path = [{ name: 'Button', label: 'ok' }]; + const nowMock = jest.spyOn(Date, 'now'); + + nowMock.mockReturnValue(1000); + detector.check(path, 'ok'); + + nowMock.mockReturnValue(1200); + detector.check(path, 'ok'); + + // 600ms after first tap — outside 500ms window + nowMock.mockReturnValue(1600); + detector.check(path, 'ok'); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('uses component name+file as identity when no label', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'SubmitButton', file: 'form.tsx' }]; + + detector.check(path); + detector.check(path); + detector.check(path); + + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'SubmitButton (form.tsx)', + data: expect.objectContaining({ + node: expect.objectContaining({ + tagName: 'SubmitButton', + attributes: expect.objectContaining({ + 'data-sentry-component': 'SubmitButton', + 'data-sentry-source-file': 'form.tsx', + }), + }), + }), + }), + ); + }); + + it('treats same name but different files as different targets', () => { + const detector = new RageTapDetector(); + + detector.check([{ name: 'Button', file: 'a.tsx' }]); + detector.check([{ name: 'Button', file: 'b.tsx' }]); + detector.check([{ name: 'Button', file: 'a.tsx' }]); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does nothing for empty touch path', () => { + const detector = new RageTapDetector(); + + detector.check([]); + detector.check([]); + detector.check([]); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('can trigger again after buffer reset and enough new taps', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'Button', label: 'ok' }]; + + // First rage tap + detector.check(path, 'ok'); + detector.check(path, 'ok'); + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + + // Three more taps → second rage tap + detector.check(path, 'ok'); + detector.check(path, 'ok'); + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index d446457272..6840bed56f 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -314,6 +314,145 @@ describe('TouchEventBoundary._onTouchStart', () => { }); }); + describe('rage tap detection', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(1000); + }); + + it('emits ui.multiClick breadcrumb after 3 taps on same target', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'submit' }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + // 3 touch breadcrumbs + 1 multiClick breadcrumb + expect(addBreadcrumb).toHaveBeenCalledTimes(4); + expect(addBreadcrumb).toHaveBeenLastCalledWith( + expect.objectContaining({ + category: 'ui.multiClick', + level: 'warning', + type: 'default', + data: expect.objectContaining({ + clickCount: 3, + metric: true, + }), + }), + ); + }); + + it('does not emit frustration breadcrumb when disabled via prop', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary({ + ...defaultProps, + enableRageTapDetection: false, + }); + + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'submit' }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + // Only touch breadcrumbs + expect(addBreadcrumb).toHaveBeenCalledTimes(3); + for (const call of addBreadcrumb.mock.calls) { + expect(call[0].category).toBe('touch'); + } + }); + + it('respects custom threshold and time window props', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary({ + ...defaultProps, + rageTapThreshold: 5, + rageTapTimeWindow: 2000, + }); + + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'submit' }, + }, + }; + + // 3 taps should not trigger with threshold=5 + for (let i = 0; i < 3; i++) { + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + } + expect(addBreadcrumb).toHaveBeenCalledTimes(3); + for (const call of addBreadcrumb.mock.calls) { + expect(call[0].category).toBe('touch'); + } + + // 2 more taps (total 5) should trigger + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledTimes(6); // 5 touch + 1 multiClick + expect(addBreadcrumb).toHaveBeenLastCalledWith( + expect.objectContaining({ + category: 'ui.multiClick', + data: expect.objectContaining({ clickCount: 5 }), + }), + ); + }); + + it('does not trigger when taps are outside the time window', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + const nowMock = jest.spyOn(Date, 'now'); + + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'submit' }, + }, + }; + + nowMock.mockReturnValue(1000); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + nowMock.mockReturnValue(1500); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + // Third tap beyond 1000ms default window + nowMock.mockReturnValue(2500); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + // Only touch breadcrumbs, no multiClick + expect(addBreadcrumb).toHaveBeenCalledTimes(3); + for (const call of addBreadcrumb.mock.calls) { + expect(call[0].category).toBe('touch'); + } + }); + }); + describe('sentry-span-attributes', () => { it('sets custom attributes from prop on user interaction span', () => { const { defaultProps } = TouchEventBoundary;