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 @@ -11,6 +11,7 @@
### Features

- Extract text content from children of touched components as a label fallback for touch breadcrumbs ([#6106](https://github.com/getsentry/sentry-react-native/pull/6106))
- Respect Replay Mask boundaries when reading `sentry-label` for touch breadcrumbs ([#6142](https://github.com/getsentry/sentry-react-native/pull/6142))

### Dependencies

Expand Down
61 changes: 42 additions & 19 deletions packages/core/src/js/touchevents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,10 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {

let currentInst: ElementInstance | undefined = e._targetInst;
const touchPath: TouchedComponentInfo[] = [];
const shouldExtractText = this._shouldExtractText();
const maskAllText = this._isMaskAllTextEnabled();
const isInsideMask = !maskAllText && hasAncestorMask(e._targetInst);
const shouldReadSentryLabel = !maskAllText && !isInsideMask;
const shouldExtractText = this._shouldExtractText() && !isInsideMask;
Comment thread
antonis marked this conversation as resolved.

while (
currentInst &&
Expand All @@ -259,7 +262,7 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
break;
}

const info = getTouchedComponentInfo(currentInst, this.props.labelName, shouldExtractText);
const info = getTouchedComponentInfo(currentInst, this.props.labelName, shouldExtractText, shouldReadSentryLabel);
this._pushIfNotIgnored(touchPath, info);

currentInst = currentInst.return;
Expand Down Expand Up @@ -302,25 +305,27 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
}
}

private _shouldExtractText(): boolean {
if (!this.props.extractTextFromChildren) {
return false;
}
private _isMaskAllTextEnabled(): boolean {
const client = getClient();
if (!client) {
return true;
return false;
}
const replayIntegration = client.getIntegrationByName(MOBILE_REPLAY_INTEGRATION_NAME);
if (replayIntegration) {
if (!('options' in replayIntegration)) {
return false;
}
const options = replayIntegration.options as { maskAllText?: boolean };
if (options.maskAllText !== false) {
return false;
}
if (!replayIntegration) {
return false;
}
return true;
if (!('options' in replayIntegration)) {
return true;
}
const options = replayIntegration.options as { maskAllText?: boolean };
return options.maskAllText !== false;
}

private _shouldExtractText(): boolean {
if (!this.props.extractTextFromChildren) {
return false;
}
return !this._isMaskAllTextEnabled();
}

/**
Expand Down Expand Up @@ -355,6 +360,7 @@ function getTouchedComponentInfo(
currentInst: ElementInstance,
labelKey: string | undefined,
shouldExtractText: boolean,
shouldReadSentryLabel: boolean,
): TouchedComponentInfo | undefined {
const displayName = currentInst.elementType?.displayName;

Expand All @@ -368,7 +374,9 @@ function getTouchedComponentInfo(
return undefined;
}

const label = getLabelValue(props, labelKey) || (shouldExtractText ? extractTextFromFiber(currentInst) : undefined);
const label =
getLabelValue(props, labelKey, shouldReadSentryLabel) ||
(shouldExtractText ? extractTextFromFiber(currentInst) : undefined);

return dropUndefinedKeys<TouchedComponentInfo>({
// provided by @sentry/babel-plugin-component-annotate
Expand Down Expand Up @@ -410,8 +418,12 @@ function getFileName(props: Record<string, unknown>): string | undefined {
);
}

function getLabelValue(props: Record<string, unknown>, labelKey: string | undefined): string | undefined {
if (typeof props[SENTRY_LABEL_PROP_KEY] === 'string' && props[SENTRY_LABEL_PROP_KEY].length > 0) {
function getLabelValue(
props: Record<string, unknown>,
labelKey: string | undefined,
readSentryLabel: boolean = true,
): string | undefined {
if (readSentryLabel && typeof props[SENTRY_LABEL_PROP_KEY] === 'string' && props[SENTRY_LABEL_PROP_KEY].length > 0) {
return props[SENTRY_LABEL_PROP_KEY];
}

Expand Down Expand Up @@ -454,6 +466,17 @@ function getSpanAttributes(currentInst: ElementInstance): Record<string, SpanAtt
return undefined;
}

function hasAncestorMask(inst: ElementInstance): boolean {
let current = inst.return;
while (current) {
if (current.elementType?.name === MASK_COMPONENT_NAME || current.elementType?.displayName === MASK_COMPONENT_NAME) {
return true;
}
current = current.return;
}
return false;
}

function extractTextFromFiber(inst: ElementInstance): string | undefined {
const parts: string[] = [];
collectTextFromFiber(inst.child, parts, 0);
Expand Down
244 changes: 244 additions & 0 deletions packages/core/test/touchevents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,39 @@ describe('TouchEventBoundary._onTouchStart', () => {
);
});

it('does not extract text when element is inside a Mask ancestor', () => {
const { defaultProps } = TouchEventBoundary;
const boundary = new TouchEventBoundary(defaultProps);
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
name: 'MobileReplay',
options: { maskAllText: false },
} as any);

const event = {
_targetInst: {
elementType: { displayName: 'TouchableOpacity' },
memoizedProps: {},
child: {
elementType: { name: 'Text' },
memoizedProps: { children: 'Masked content' },
},
return: {
elementType: { name: 'RNSentryReplayMask' },
},
},
};

// @ts-expect-error Calling private member
boundary._onTouchStart(event);

expect(addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Touch event within element: TouchableOpacity',
data: { path: [{ name: 'TouchableOpacity' }] },
}),
);
});

it('handles string memoizedProps (raw text fiber nodes)', () => {
const { defaultProps } = TouchEventBoundary;
const boundary = new TouchEventBoundary(defaultProps);
Expand Down Expand Up @@ -1429,4 +1462,215 @@ describe('TouchEventBoundary._onTouchStart', () => {
);
});
});

describe('sentry-label masking', () => {
it('skips sentry-label when maskAllText is enabled', () => {
const { defaultProps } = TouchEventBoundary;
const boundary = new TouchEventBoundary(defaultProps);
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
name: 'MobileReplay',
options: { maskAllText: true },
} as any);

const event = {
_targetInst: {
elementType: { displayName: 'Button' },
memoizedProps: {
'sentry-label': 'secret-label',
accessibilityLabel: '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: 'Button', label: 'Save workout' }] },
}),
);
});

it('skips sentry-label when maskAllText defaults to true (not set)', () => {
const { defaultProps } = TouchEventBoundary;
const boundary = new TouchEventBoundary(defaultProps);
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
name: 'MobileReplay',
options: {},
} as any);

const event = {
_targetInst: {
elementType: { displayName: 'Button' },
memoizedProps: {
'sentry-label': 'secret-label',
testID: 'btn-test-id',
},
},
};

// @ts-expect-error Calling private member
boundary._onTouchStart(event);

expect(addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Touch event within element: btn-test-id',
data: { path: [{ name: 'Button', label: 'btn-test-id' }] },
}),
);
});

it('reads sentry-label when maskAllText is explicitly false', () => {
const { defaultProps } = TouchEventBoundary;
const boundary = new TouchEventBoundary(defaultProps);
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
name: 'MobileReplay',
options: { maskAllText: false },
} as any);

const event = {
_targetInst: {
elementType: { displayName: 'Button' },
memoizedProps: {
'sentry-label': 'explicit-label',
accessibilityLabel: 'Save workout',
},
},
};

// @ts-expect-error Calling private member
boundary._onTouchStart(event);

expect(addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Touch event within element: explicit-label',
data: { path: [{ name: 'Button', label: 'explicit-label' }] },
}),
);
});

it('skips sentry-label when element is inside a Mask ancestor', () => {
const { defaultProps } = TouchEventBoundary;
const boundary = new TouchEventBoundary(defaultProps);
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
name: 'MobileReplay',
options: { maskAllText: false },
} as any);

const event = {
_targetInst: {
elementType: { displayName: 'Button' },
memoizedProps: {
'sentry-label': 'masked-label',
accessibilityLabel: 'Fallback label',
},
return: {
elementType: { name: 'RNSentryReplayMask' },
},
},
};

// @ts-expect-error Calling private member
boundary._onTouchStart(event);

expect(addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Touch event within element: Fallback label',
data: { path: [{ name: 'Button', label: 'Fallback label' }] },
}),
);
});

it('skips sentry-label when Mask ancestor uses displayName', () => {
const { defaultProps } = TouchEventBoundary;
const boundary = new TouchEventBoundary(defaultProps);
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
name: 'MobileReplay',
options: { maskAllText: false },
} as any);

const event = {
_targetInst: {
elementType: { displayName: 'Button' },
memoizedProps: {
'sentry-label': 'masked-label',
testID: 'btn-id',
},
return: {
elementType: { displayName: 'RNSentryReplayMask' },
},
},
};

// @ts-expect-error Calling private member
boundary._onTouchStart(event);

expect(addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Touch event within element: btn-id',
data: { path: [{ name: 'Button', label: 'btn-id' }, { name: 'RNSentryReplayMask' }] },
}),
);
});

it('reads sentry-label when no replay integration is present', () => {
const { defaultProps } = TouchEventBoundary;
const boundary = new TouchEventBoundary(defaultProps);
jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined);

const event = {
_targetInst: {
elementType: { displayName: 'Button' },
memoizedProps: {
'sentry-label': 'my-label',
accessibilityLabel: 'Save workout',
},
},
};

// @ts-expect-error Calling private member
boundary._onTouchStart(event);

expect(addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Touch event within element: my-label',
data: { path: [{ name: 'Button', label: 'my-label' }] },
}),
);
});

it('does not check Mask ancestors when maskAllText is already enabled', () => {
const { defaultProps } = TouchEventBoundary;
const boundary = new TouchEventBoundary(defaultProps);
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
name: 'MobileReplay',
options: { maskAllText: true },
} as any);

const event = {
_targetInst: {
elementType: { displayName: 'Button' },
memoizedProps: {
'sentry-label': 'should-be-skipped',
accessibilityLabel: 'Accessible',
},
return: {
elementType: { name: 'RNSentryReplayMask' },
},
},
};

// @ts-expect-error Calling private member
boundary._onTouchStart(event);

expect(addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Touch event within element: Accessible',
data: { path: [{ name: 'Button', label: 'Accessible' }] },
}),
);
});
});
});
Loading