From 17535f2c45c20aeb027a07f5180509f378e9f312 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 10 Nov 2026 13:31:42 +0100 Subject: [PATCH 1/3] fix(core): Restore touch breadcrumbs on New Architecture from RN 0.88 With EventTarget-based dispatching, touch events have no `_targetInst`, so TouchEventBoundary silently recorded nothing. Fall back to the fiber on the event target. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + packages/core/src/js/touchevents.tsx | 21 +++- packages/core/test/touchevents.test.tsx | 130 ++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e08831bc6..5c42d10e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Fixes + +- Restore touch breadcrumbs and rage tap detection on the New Architecture from React Native 0.88 ([#7009](https://github.com/getsentry/sentry-react-native/issues/7009)) + ### Dependencies - Bump Android SDK from v8.62.0 to v8.63.0 ([#6990](https://github.com/getsentry/sentry-react-native/pull/6990)) diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 97d488850d..b8b392b27c 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,15 @@ class TouchEventBoundary extends React.Component { * @param e */ private _onTouchStart(e: PrivateGestureResponderEvent): void { - if (!e._targetInst) { + const targetInst = getTargetInstance(e); + if (!targetInst) { 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 +288,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 +361,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.test.tsx b/packages/core/test/touchevents.test.tsx index 917aab92b8..298b72f193 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -1673,4 +1673,134 @@ 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('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' }] }, + }), + ); + }); + }); }); From 2f7fe4ebadf2728e2914f29e805a5a43f956477c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 10 Nov 2026 13:31:56 +0100 Subject: [PATCH 2/3] docs: Link the PR from the changelog entry Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c42d10e16..61c76c6313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Fixes -- Restore touch breadcrumbs and rage tap detection on the New Architecture from React Native 0.88 ([#7009](https://github.com/getsentry/sentry-react-native/issues/7009)) +- 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)) ### Dependencies From 97801aa1236cb0faf94c5219f833cee020d73fc1 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 10 Nov 2026 13:47:23 +0100 Subject: [PATCH 3/3] test(core): Guard touch breadcrumbs against the real Fabric renderer The existing tests hand-build events, so they cannot detect React Native changing how the touched fiber is exposed. Run the boundary through the real renderer and event dispatch, and warn in debug when no fiber is found. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/js/touchevents.tsx | 1 + .../core/test/touchevents.renderer.test.tsx | 125 ++++++++++++++++++ packages/core/test/touchevents.test.tsx | 10 ++ 3 files changed, 136 insertions(+) create mode 100644 packages/core/test/touchevents.renderer.test.tsx diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index b8b392b27c..d47ca7d466 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -244,6 +244,7 @@ class TouchEventBoundary extends React.Component { private _onTouchStart(e: PrivateGestureResponderEvent): void { const targetInst = getTargetInstance(e); if (!targetInst) { + debug.warn('[TouchEvents] Could not find the touched component on the event.'); return; } 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 298b72f193..35982fdee5 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -1721,6 +1721,16 @@ describe('TouchEventBoundary._onTouchStart', () => { 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);