Skip to content

Commit cc13e90

Browse files
antonisclaude
andauthored
fix(core): Restore touch breadcrumbs on New Architecture from RN 0.88 (#7013)
* 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 <noreply@anthropic.com> * docs: Link the PR from the changelog entry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 322ad1b commit cc13e90

4 files changed

Lines changed: 284 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
### Fixes
2727

28+
- 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))
2829
- 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))
2930
- 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))
3031

‎packages/core/src/js/touchevents.tsx‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ interface PrivateGestureResponderEvent extends GestureResponderEvent {
124124
_targetInst?: ElementInstance;
125125
}
126126

127+
interface PrivateEventTarget {
128+
__internalInstanceHandle?: ElementInstance;
129+
}
130+
127131
/**
128132
* Boundary to log breadcrumbs for interaction events.
129133
*/
@@ -238,14 +242,16 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
238242
* @param e
239243
*/
240244
private _onTouchStart(e: PrivateGestureResponderEvent): void {
241-
if (!e._targetInst) {
245+
const targetInst = getTargetInstance(e);
246+
if (!targetInst) {
247+
debug.warn('[TouchEvents] Could not find the touched component on the event.');
242248
return;
243249
}
244250

245-
let currentInst: ElementInstance | undefined = e._targetInst;
251+
let currentInst: ElementInstance | undefined = targetInst;
246252
const touchPath: TouchedComponentInfo[] = [];
247253
const maskAllText = this._isMaskAllTextEnabled();
248-
const isInsideMask = !maskAllText && hasAncestorMask(e._targetInst);
254+
const isInsideMask = !maskAllText && hasAncestorMask(targetInst);
249255
const shouldReadSentryLabel = !maskAllText && !isInsideMask;
250256
const shouldExtractText = this._shouldExtractText() && !isInsideMask;
251257

@@ -283,7 +289,7 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
283289

284290
// Apply custom attributes from sentry-span-attributes prop
285291
// Traverse the component tree to find custom attributes
286-
let instForAttributes: ElementInstance | undefined = e._targetInst;
292+
let instForAttributes: ElementInstance | undefined = targetInst;
287293
let customAttributes: Record<string, SpanAttributeValue> | undefined;
288294

289295
while (instForAttributes) {
@@ -356,6 +362,14 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
356362
}
357363
}
358364

365+
/**
366+
* Returns the touched fiber. Events dispatched through EventTarget have no `_targetInst`,
367+
* so fall back to the private fiber handle on the event target.
368+
*/
369+
function getTargetInstance(e: PrivateGestureResponderEvent): ElementInstance | undefined {
370+
return e._targetInst ?? (e.target as unknown as PrivateEventTarget | null | undefined)?.__internalInstanceHandle;
371+
}
372+
359373
function getTouchedComponentInfo(
360374
currentInst: ElementInstance,
361375
labelKey: string | undefined,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Runs against the real Fabric renderer and event dispatch, faking only the native side.
2+
// It fails if a React Native upgrade stops exposing the touched fiber to the boundary.
3+
4+
const mockParentHandles = new Map<number, unknown>();
5+
6+
jest.mock('react-native/src/private/webapis/dom/nodes/specs/NativeDOM', () => ({
7+
__esModule: true,
8+
default: new Proxy(
9+
{},
10+
{
11+
get: (_target, key) =>
12+
key === 'getParentNode' ? (node: FakeNode) => mockParentHandles.get(node.reactTag) ?? null : () => undefined,
13+
},
14+
),
15+
}));
16+
17+
interface FakeNode {
18+
reactTag: number;
19+
props: Record<string, unknown>;
20+
handle: unknown;
21+
}
22+
23+
const viewConfig = {
24+
uiViewClassName: 'RCTView',
25+
validAttributes: { testID: true, 'sentry-label': true },
26+
bubblingEventTypes: {
27+
topTouchStart: { phasedRegistrationNames: { bubbled: 'onTouchStart', captured: 'onTouchStartCapture' } },
28+
},
29+
directEventTypes: {},
30+
};
31+
32+
function fakeFabricUIManager(nodes: FakeNode[], onEventHandler: (handler: unknown) => void): unknown {
33+
const implemented: Record<string, unknown> = {
34+
createNode: (
35+
reactTag: number,
36+
_viewName: string,
37+
_rootTag: number,
38+
props: Record<string, unknown>,
39+
handle: unknown,
40+
) => {
41+
const node = { reactTag, props, handle };
42+
nodes.push(node);
43+
return node;
44+
},
45+
appendChild: (parent: FakeNode, child: FakeNode) => {
46+
mockParentHandles.set(child.reactTag, parent.handle);
47+
},
48+
createChildSet: () => [],
49+
registerEventHandler: onEventHandler,
50+
unstable_DefaultEventPriority: 32,
51+
unstable_DiscreteEventPriority: 2,
52+
unstable_getCurrentEventPriority: () => 32,
53+
};
54+
return new Proxy(implemented, {
55+
get: (target, key: string) => (key in target ? target[key] : () => undefined),
56+
});
57+
}
58+
59+
async function tapLeafView(enableEventTargetDispatching: boolean): Promise<unknown[]> {
60+
jest.resetModules();
61+
mockParentHandles.clear();
62+
63+
const nodes: FakeNode[] = [];
64+
let emitEvent: (handle: unknown, type: string, payload: unknown) => void = () => undefined;
65+
(global as any).nativeFabricUIManager = fakeFabricUIManager(nodes, handler => {
66+
emitEvent = handler as typeof emitEvent;
67+
});
68+
69+
require('react-native/src/private/featureflags/ReactNativeFeatureFlags').override({
70+
enableNativeEventTargetEventDispatching: () => enableEventTargetDispatching,
71+
});
72+
const registry = require('react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry');
73+
registry.register('View', () => viewConfig);
74+
75+
const React = require('react');
76+
const { View } = require('react-native');
77+
const ReactFabric = require('react-native/Libraries/Renderer/shims/ReactFabric').default;
78+
const addBreadcrumb = jest.spyOn(require('@sentry/core'), 'addBreadcrumb');
79+
const { TouchEventBoundary } = require('../src/js/touchevents');
80+
81+
await React.act(async () => {
82+
ReactFabric.render(
83+
<TouchEventBoundary {...TouchEventBoundary.defaultProps}>
84+
<View testID="leaf" sentry-label="pay-button" />
85+
</TouchEventBoundary>,
86+
11,
87+
null,
88+
true,
89+
);
90+
});
91+
92+
const leaf = nodes.find(node => node.props.testID === 'leaf') as FakeNode;
93+
const touch = { identifier: 1, target: leaf.reactTag, pageX: 1, pageY: 1, locationX: 1, locationY: 1, timestamp: 1 };
94+
await React.act(async () => {
95+
emitEvent(leaf.handle, 'topTouchStart', { ...touch, touches: [touch], changedTouches: [touch] });
96+
});
97+
98+
return addBreadcrumb.mock.calls.map(call => call[0]);
99+
}
100+
101+
describe('TouchEventBoundary with the real Fabric renderer', () => {
102+
beforeEach(() => {
103+
(global as any).IS_REACT_ACT_ENVIRONMENT = true;
104+
});
105+
106+
afterEach(() => {
107+
delete (global as any).IS_REACT_ACT_ENVIRONMENT;
108+
delete (global as any).nativeFabricUIManager;
109+
jest.restoreAllMocks();
110+
});
111+
112+
it.each([
113+
['EventTarget dispatching', true],
114+
['legacy plugin dispatching', false],
115+
])('records a touch breadcrumb with %s', async (_name, enableEventTargetDispatching) => {
116+
const breadcrumbs = await tapLeafView(enableEventTargetDispatching);
117+
118+
expect(breadcrumbs).toEqual([
119+
expect.objectContaining({
120+
category: 'touch',
121+
message: 'Touch event within element: pay-button',
122+
}),
123+
]);
124+
});
125+
});

‎packages/core/test/touchevents.test.tsx‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,4 +1673,144 @@ describe('TouchEventBoundary._onTouchStart', () => {
16731673
);
16741674
});
16751675
});
1676+
1677+
describe('when the event has no _targetInst', () => {
1678+
const eventForFiber = (fiber: unknown): unknown => ({ target: { __internalInstanceHandle: fiber } });
1679+
1680+
it('logs the component path and label from the event target fiber', () => {
1681+
const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps);
1682+
const event = eventForFiber({
1683+
elementType: { displayName: 'Button' },
1684+
memoizedProps: { 'sentry-label': 'submit' },
1685+
return: { elementType: { displayName: 'Screen' }, memoizedProps: {} },
1686+
});
1687+
1688+
// @ts-expect-error Calling private member
1689+
boundary._onTouchStart(event);
1690+
1691+
expect(addBreadcrumb).toHaveBeenCalledWith(
1692+
expect.objectContaining({
1693+
message: 'Touch event within element: submit',
1694+
data: { path: [{ name: 'Button', label: 'submit' }, { name: 'Screen' }] },
1695+
}),
1696+
);
1697+
});
1698+
1699+
it('prefers _targetInst when both are present', () => {
1700+
const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps);
1701+
const event = {
1702+
_targetInst: { elementType: { displayName: 'Legacy' }, memoizedProps: {} },
1703+
target: { __internalInstanceHandle: { elementType: { displayName: 'Fallback' }, memoizedProps: {} } },
1704+
};
1705+
1706+
// @ts-expect-error Calling private member
1707+
boundary._onTouchStart(event);
1708+
1709+
expect(addBreadcrumb).toHaveBeenCalledTimes(1);
1710+
expect(addBreadcrumb).toHaveBeenCalledWith(
1711+
expect.objectContaining({ message: 'Touch event within element: Legacy' }),
1712+
);
1713+
});
1714+
1715+
it.each([[undefined], [null], [123], [{}]])('does not log when the target is %p', target => {
1716+
const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps);
1717+
1718+
// @ts-expect-error Calling private member
1719+
boundary._onTouchStart({ target });
1720+
1721+
expect(addBreadcrumb).not.toHaveBeenCalled();
1722+
});
1723+
1724+
it('warns when the touched component cannot be found', () => {
1725+
const warn = jest.spyOn(core.debug, 'warn');
1726+
const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps);
1727+
1728+
// @ts-expect-error Calling private member
1729+
boundary._onTouchStart({ target: {} });
1730+
1731+
expect(warn).toHaveBeenCalledWith('[TouchEvents] Could not find the touched component on the event.');
1732+
});
1733+
1734+
it('detects rage taps', () => {
1735+
jest.spyOn(Date, 'now').mockReturnValue(1000);
1736+
const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps);
1737+
const event = eventForFiber({
1738+
elementType: { displayName: 'Button' },
1739+
memoizedProps: { 'sentry-label': 'submit' },
1740+
});
1741+
1742+
// @ts-expect-error Calling private member
1743+
boundary._onTouchStart(event);
1744+
// @ts-expect-error Calling private member
1745+
boundary._onTouchStart(event);
1746+
// @ts-expect-error Calling private member
1747+
boundary._onTouchStart(event);
1748+
1749+
expect(addBreadcrumb).toHaveBeenLastCalledWith(
1750+
expect.objectContaining({
1751+
category: 'ui.multiClick',
1752+
data: expect.objectContaining({ clickCount: 3 }),
1753+
}),
1754+
);
1755+
});
1756+
1757+
it('applies sentry-span-attributes to the user interaction span', () => {
1758+
const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps);
1759+
const mockSpan = { setAttribute: jest.fn(), setAttributes: jest.fn() };
1760+
jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
1761+
const event = eventForFiber({
1762+
elementType: { displayName: 'Button' },
1763+
memoizedProps: { 'sentry-span-attributes': { 'cart.items': '3' } },
1764+
});
1765+
1766+
// @ts-expect-error Calling private member
1767+
boundary._onTouchStart(event);
1768+
1769+
expect(mockSpan.setAttributes).toHaveBeenCalledWith({ 'cart.items': '3' });
1770+
});
1771+
1772+
it('extracts text from child fibers', () => {
1773+
const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps);
1774+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined);
1775+
const event = eventForFiber({
1776+
elementType: { displayName: 'TouchableOpacity' },
1777+
memoizedProps: {},
1778+
child: { elementType: { name: 'Text' }, memoizedProps: { children: 'Save workout' } },
1779+
});
1780+
1781+
// @ts-expect-error Calling private member
1782+
boundary._onTouchStart(event);
1783+
1784+
expect(addBreadcrumb).toHaveBeenCalledWith(
1785+
expect.objectContaining({
1786+
message: 'Touch event within element: Save workout',
1787+
data: { path: [{ name: 'TouchableOpacity', label: 'Save workout' }] },
1788+
}),
1789+
);
1790+
});
1791+
1792+
it('skips sentry-label and text extraction inside a mask boundary', () => {
1793+
const boundary = new TouchEventBoundary(TouchEventBoundary.defaultProps);
1794+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
1795+
name: 'MobileReplay',
1796+
options: { maskAllText: false },
1797+
} as any);
1798+
const event = eventForFiber({
1799+
elementType: { displayName: 'Button' },
1800+
memoizedProps: { 'sentry-label': 'secret-label' },
1801+
child: { elementType: { name: 'Text' }, memoizedProps: { children: 'secret text' } },
1802+
return: { elementType: { name: 'RNSentryReplayMask' } },
1803+
});
1804+
1805+
// @ts-expect-error Calling private member
1806+
boundary._onTouchStart(event);
1807+
1808+
expect(addBreadcrumb).toHaveBeenCalledWith(
1809+
expect.objectContaining({
1810+
message: 'Touch event within element: Button',
1811+
data: { path: [{ name: 'Button' }] },
1812+
}),
1813+
);
1814+
});
1815+
});
16761816
});

0 commit comments

Comments
 (0)