diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e67e7c700..8f0ead4365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ ### Fixes +- Restore touch breadcrumbs and rage tap detection on the New Architecture from React Native 0.88 ([#7013](https://github.com/getsentry/sentry-react-native/pull/7013)) - Mask React Native's prepared text layout views in Android Session Replay and error screenshots ([#7011](https://github.com/getsentry/sentry-react-native/pull/7011)) - Apply `Sentry.Mask` and `Sentry.Unmask` on iOS when you start a replay manually with both replay sample rates at `0` ([#7008](https://github.com/getsentry/sentry-react-native/pull/7008)) diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 97d488850d..d47ca7d466 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -124,6 +124,10 @@ interface PrivateGestureResponderEvent extends GestureResponderEvent { _targetInst?: ElementInstance; } +interface PrivateEventTarget { + __internalInstanceHandle?: ElementInstance; +} + /** * Boundary to log breadcrumbs for interaction events. */ @@ -238,14 +242,16 @@ class TouchEventBoundary extends React.Component { * @param e */ private _onTouchStart(e: PrivateGestureResponderEvent): void { - if (!e._targetInst) { + const targetInst = getTargetInstance(e); + if (!targetInst) { + debug.warn('[TouchEvents] Could not find the touched component on the event.'); return; } - let currentInst: ElementInstance | undefined = e._targetInst; + let currentInst: ElementInstance | undefined = targetInst; const touchPath: TouchedComponentInfo[] = []; const maskAllText = this._isMaskAllTextEnabled(); - const isInsideMask = !maskAllText && hasAncestorMask(e._targetInst); + const isInsideMask = !maskAllText && hasAncestorMask(targetInst); const shouldReadSentryLabel = !maskAllText && !isInsideMask; const shouldExtractText = this._shouldExtractText() && !isInsideMask; @@ -283,7 +289,7 @@ class TouchEventBoundary extends React.Component { // Apply custom attributes from sentry-span-attributes prop // Traverse the component tree to find custom attributes - let instForAttributes: ElementInstance | undefined = e._targetInst; + let instForAttributes: ElementInstance | undefined = targetInst; let customAttributes: Record | undefined; while (instForAttributes) { @@ -356,6 +362,14 @@ class TouchEventBoundary extends React.Component { } } +/** + * Returns the touched fiber. Events dispatched through EventTarget have no `_targetInst`, + * so fall back to the private fiber handle on the event target. + */ +function getTargetInstance(e: PrivateGestureResponderEvent): ElementInstance | undefined { + return e._targetInst ?? (e.target as unknown as PrivateEventTarget | null | undefined)?.__internalInstanceHandle; +} + function getTouchedComponentInfo( currentInst: ElementInstance, labelKey: string | undefined, diff --git a/packages/core/test/touchevents.renderer.test.tsx b/packages/core/test/touchevents.renderer.test.tsx new file mode 100644 index 0000000000..0d7e181000 --- /dev/null +++ b/packages/core/test/touchevents.renderer.test.tsx @@ -0,0 +1,125 @@ +// Runs against the real Fabric renderer and event dispatch, faking only the native side. +// It fails if a React Native upgrade stops exposing the touched fiber to the boundary. + +const mockParentHandles = new Map(); + +jest.mock('react-native/src/private/webapis/dom/nodes/specs/NativeDOM', () => ({ + __esModule: true, + default: new Proxy( + {}, + { + get: (_target, key) => + key === 'getParentNode' ? (node: FakeNode) => mockParentHandles.get(node.reactTag) ?? null : () => undefined, + }, + ), +})); + +interface FakeNode { + reactTag: number; + props: Record; + handle: unknown; +} + +const viewConfig = { + uiViewClassName: 'RCTView', + validAttributes: { testID: true, 'sentry-label': true }, + bubblingEventTypes: { + topTouchStart: { phasedRegistrationNames: { bubbled: 'onTouchStart', captured: 'onTouchStartCapture' } }, + }, + directEventTypes: {}, +}; + +function fakeFabricUIManager(nodes: FakeNode[], onEventHandler: (handler: unknown) => void): unknown { + const implemented: Record = { + createNode: ( + reactTag: number, + _viewName: string, + _rootTag: number, + props: Record, + handle: unknown, + ) => { + const node = { reactTag, props, handle }; + nodes.push(node); + return node; + }, + appendChild: (parent: FakeNode, child: FakeNode) => { + mockParentHandles.set(child.reactTag, parent.handle); + }, + createChildSet: () => [], + registerEventHandler: onEventHandler, + unstable_DefaultEventPriority: 32, + unstable_DiscreteEventPriority: 2, + unstable_getCurrentEventPriority: () => 32, + }; + return new Proxy(implemented, { + get: (target, key: string) => (key in target ? target[key] : () => undefined), + }); +} + +async function tapLeafView(enableEventTargetDispatching: boolean): Promise { + jest.resetModules(); + mockParentHandles.clear(); + + const nodes: FakeNode[] = []; + let emitEvent: (handle: unknown, type: string, payload: unknown) => void = () => undefined; + (global as any).nativeFabricUIManager = fakeFabricUIManager(nodes, handler => { + emitEvent = handler as typeof emitEvent; + }); + + require('react-native/src/private/featureflags/ReactNativeFeatureFlags').override({ + enableNativeEventTargetEventDispatching: () => enableEventTargetDispatching, + }); + const registry = require('react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry'); + registry.register('View', () => viewConfig); + + const React = require('react'); + const { View } = require('react-native'); + const ReactFabric = require('react-native/Libraries/Renderer/shims/ReactFabric').default; + const addBreadcrumb = jest.spyOn(require('@sentry/core'), 'addBreadcrumb'); + const { TouchEventBoundary } = require('../src/js/touchevents'); + + await React.act(async () => { + ReactFabric.render( + + + , + 11, + null, + true, + ); + }); + + const leaf = nodes.find(node => node.props.testID === 'leaf') as FakeNode; + const touch = { identifier: 1, target: leaf.reactTag, pageX: 1, pageY: 1, locationX: 1, locationY: 1, timestamp: 1 }; + await React.act(async () => { + emitEvent(leaf.handle, 'topTouchStart', { ...touch, touches: [touch], changedTouches: [touch] }); + }); + + return addBreadcrumb.mock.calls.map(call => call[0]); +} + +describe('TouchEventBoundary with the real Fabric renderer', () => { + beforeEach(() => { + (global as any).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + delete (global as any).IS_REACT_ACT_ENVIRONMENT; + delete (global as any).nativeFabricUIManager; + jest.restoreAllMocks(); + }); + + it.each([ + ['EventTarget dispatching', true], + ['legacy plugin dispatching', false], + ])('records a touch breadcrumb with %s', async (_name, enableEventTargetDispatching) => { + const breadcrumbs = await tapLeafView(enableEventTargetDispatching); + + expect(breadcrumbs).toEqual([ + expect.objectContaining({ + category: 'touch', + message: 'Touch event within element: pay-button', + }), + ]); + }); +}); diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index 917aab92b8..35982fdee5 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -1673,4 +1673,144 @@ describe('TouchEventBoundary._onTouchStart', () => { ); }); }); + + describe('when the event has no _targetInst', () => { + const eventForFiber = (fiber: unknown): unknown => ({ target: { __internalInstanceHandle: fiber } }); + + it('logs the component path and label from the event target fiber', () => { + const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps); + const event = eventForFiber({ + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'submit' }, + return: { elementType: { displayName: 'Screen' }, memoizedProps: {} }, + }); + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: submit', + data: { path: [{ name: 'Button', label: 'submit' }, { name: 'Screen' }] }, + }), + ); + }); + + it('prefers _targetInst when both are present', () => { + const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps); + const event = { + _targetInst: { elementType: { displayName: 'Legacy' }, memoizedProps: {} }, + target: { __internalInstanceHandle: { elementType: { displayName: 'Fallback' }, memoizedProps: {} } }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Touch event within element: Legacy' }), + ); + }); + + it.each([[undefined], [null], [123], [{}]])('does not log when the target is %p', target => { + const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps); + + // @ts-expect-error Calling private member + boundary._onTouchStart({ target }); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('warns when the touched component cannot be found', () => { + const warn = jest.spyOn(core.debug, 'warn'); + const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps); + + // @ts-expect-error Calling private member + boundary._onTouchStart({ target: {} }); + + expect(warn).toHaveBeenCalledWith('[TouchEvents] Could not find the touched component on the event.'); + }); + + it('detects rage taps', () => { + jest.spyOn(Date, 'now').mockReturnValue(1000); + const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps); + const event = eventForFiber({ + 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); + + expect(addBreadcrumb).toHaveBeenLastCalledWith( + expect.objectContaining({ + category: 'ui.multiClick', + data: expect.objectContaining({ clickCount: 3 }), + }), + ); + }); + + it('applies sentry-span-attributes to the user interaction span', () => { + const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps); + const mockSpan = { setAttribute: jest.fn(), setAttributes: jest.fn() }; + jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any); + const event = eventForFiber({ + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-span-attributes': { 'cart.items': '3' } }, + }); + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(mockSpan.setAttributes).toHaveBeenCalledWith({ 'cart.items': '3' }); + }); + + it('extracts text from child fibers', () => { + const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + const event = eventForFiber({ + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { elementType: { name: 'Text' }, memoizedProps: { children: 'Save workout' } }, + }); + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Save workout', + data: { path: [{ name: 'TouchableOpacity', label: 'Save workout' }] }, + }), + ); + }); + + it('skips sentry-label and text extraction inside a mask boundary', () => { + const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue({ + name: 'MobileReplay', + options: { maskAllText: false }, + } as any); + const event = eventForFiber({ + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'secret-label' }, + child: { elementType: { name: 'Text' }, memoizedProps: { children: 'secret text' } }, + return: { elementType: { name: 'RNSentryReplayMask' } }, + }); + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Button', + data: { path: [{ name: 'Button' }] }, + }), + ); + }); + }); });