Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
22 changes: 18 additions & 4 deletions packages/core/src/js/touchevents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ interface PrivateGestureResponderEvent extends GestureResponderEvent {
_targetInst?: ElementInstance;
}

interface PrivateEventTarget {
__internalInstanceHandle?: ElementInstance;
}

/**
* Boundary to log breadcrumbs for interaction events.
*/
Expand Down Expand Up @@ -238,14 +242,16 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
* @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;

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

// 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<string, SpanAttributeValue> | undefined;

while (instForAttributes) {
Expand Down Expand Up @@ -356,6 +362,14 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
}
}

/**
* 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,
Expand Down
125 changes: 125 additions & 0 deletions packages/core/test/touchevents.renderer.test.tsx
Original file line number Diff line number Diff line change
@@ -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<number, unknown>();

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<string, unknown>;
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<string, unknown> = {
createNode: (
reactTag: number,
_viewName: string,
_rootTag: number,
props: Record<string, unknown>,
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<unknown[]> {
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(
<TouchEventBoundary {...TouchEventBoundary.defaultProps}>
<View testID="leaf" sentry-label="pay-button" />
</TouchEventBoundary>,
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',
}),
]);
});
});
140 changes: 140 additions & 0 deletions packages/core/test/touchevents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] },
}),
);
});
});
});
Loading