Skip to content

Commit f094ebf

Browse files
antonisclaude
andauthored
feat(core): Respect Mask boundaries when reading sentry-label for touch breadcrumbs (#6142)
Skip sentry-label when maskAllText is enabled or the touched element is inside a RNSentryReplayMask ancestor, preventing masked text content from leaking into breadcrumbs. Fallback labels (accessibilityLabel, testID) still work. Also skips text extraction from children inside Mask ancestors. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 22998d0 commit f094ebf

3 files changed

Lines changed: 287 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- New `ready` prop. When a screen has multiple async data sources, mount one `<TimeToFullDisplay ready={...} />` per source — TTID/TTFD is recorded only when every instance reports `ready === true`.
1515
- The existing `record` prop is unchanged BUT it is now deprecated in favor of `ready`.
1616
- 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))
17+
- Respect Replay Mask boundaries when reading `sentry-label` for touch breadcrumbs ([#6142](https://github.com/getsentry/sentry-react-native/pull/6142))
1718

1819
### Fixes
1920

packages/core/src/js/touchevents.tsx

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,10 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
244244

245245
let currentInst: ElementInstance | undefined = e._targetInst;
246246
const touchPath: TouchedComponentInfo[] = [];
247-
const shouldExtractText = this._shouldExtractText();
247+
const maskAllText = this._isMaskAllTextEnabled();
248+
const isInsideMask = !maskAllText && hasAncestorMask(e._targetInst);
249+
const shouldReadSentryLabel = !maskAllText && !isInsideMask;
250+
const shouldExtractText = this._shouldExtractText() && !isInsideMask;
248251

249252
while (
250253
currentInst &&
@@ -259,7 +262,7 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
259262
break;
260263
}
261264

262-
const info = getTouchedComponentInfo(currentInst, this.props.labelName, shouldExtractText);
265+
const info = getTouchedComponentInfo(currentInst, this.props.labelName, shouldExtractText, shouldReadSentryLabel);
263266
this._pushIfNotIgnored(touchPath, info);
264267

265268
currentInst = currentInst.return;
@@ -302,25 +305,27 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
302305
}
303306
}
304307

305-
private _shouldExtractText(): boolean {
306-
if (!this.props.extractTextFromChildren) {
307-
return false;
308-
}
308+
private _isMaskAllTextEnabled(): boolean {
309309
const client = getClient();
310310
if (!client) {
311-
return true;
311+
return false;
312312
}
313313
const replayIntegration = client.getIntegrationByName(MOBILE_REPLAY_INTEGRATION_NAME);
314-
if (replayIntegration) {
315-
if (!('options' in replayIntegration)) {
316-
return false;
317-
}
318-
const options = replayIntegration.options as { maskAllText?: boolean };
319-
if (options.maskAllText !== false) {
320-
return false;
321-
}
314+
if (!replayIntegration) {
315+
return false;
322316
}
323-
return true;
317+
if (!('options' in replayIntegration)) {
318+
return true;
319+
}
320+
const options = replayIntegration.options as { maskAllText?: boolean };
321+
return options.maskAllText !== false;
322+
}
323+
324+
private _shouldExtractText(): boolean {
325+
if (!this.props.extractTextFromChildren) {
326+
return false;
327+
}
328+
return !this._isMaskAllTextEnabled();
324329
}
325330

326331
/**
@@ -355,6 +360,7 @@ function getTouchedComponentInfo(
355360
currentInst: ElementInstance,
356361
labelKey: string | undefined,
357362
shouldExtractText: boolean,
363+
shouldReadSentryLabel: boolean,
358364
): TouchedComponentInfo | undefined {
359365
const displayName = currentInst.elementType?.displayName;
360366

@@ -368,7 +374,9 @@ function getTouchedComponentInfo(
368374
return undefined;
369375
}
370376

371-
const label = getLabelValue(props, labelKey) || (shouldExtractText ? extractTextFromFiber(currentInst) : undefined);
377+
const label =
378+
getLabelValue(props, labelKey, shouldReadSentryLabel) ||
379+
(shouldExtractText ? extractTextFromFiber(currentInst) : undefined);
372380

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

413-
function getLabelValue(props: Record<string, unknown>, labelKey: string | undefined): string | undefined {
414-
if (typeof props[SENTRY_LABEL_PROP_KEY] === 'string' && props[SENTRY_LABEL_PROP_KEY].length > 0) {
421+
function getLabelValue(
422+
props: Record<string, unknown>,
423+
labelKey: string | undefined,
424+
readSentryLabel: boolean = true,
425+
): string | undefined {
426+
if (readSentryLabel && typeof props[SENTRY_LABEL_PROP_KEY] === 'string' && props[SENTRY_LABEL_PROP_KEY].length > 0) {
415427
return props[SENTRY_LABEL_PROP_KEY];
416428
}
417429

@@ -454,6 +466,17 @@ function getSpanAttributes(currentInst: ElementInstance): Record<string, SpanAtt
454466
return undefined;
455467
}
456468

469+
function hasAncestorMask(inst: ElementInstance): boolean {
470+
let current = inst.return;
471+
while (current) {
472+
if (current.elementType?.name === MASK_COMPONENT_NAME || current.elementType?.displayName === MASK_COMPONENT_NAME) {
473+
return true;
474+
}
475+
current = current.return;
476+
}
477+
return false;
478+
}
479+
457480
function extractTextFromFiber(inst: ElementInstance): string | undefined {
458481
const parts: string[] = [];
459482
collectTextFromFiber(inst.child, parts, 0);

packages/core/test/touchevents.test.tsx

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,6 +1400,39 @@ describe('TouchEventBoundary._onTouchStart', () => {
14001400
);
14011401
});
14021402

1403+
it('does not extract text when element is inside a Mask ancestor', () => {
1404+
const { defaultProps } = TouchEventBoundary;
1405+
const boundary = new TouchEventBoundary(defaultProps);
1406+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
1407+
name: 'MobileReplay',
1408+
options: { maskAllText: false },
1409+
} as any);
1410+
1411+
const event = {
1412+
_targetInst: {
1413+
elementType: { displayName: 'TouchableOpacity' },
1414+
memoizedProps: {},
1415+
child: {
1416+
elementType: { name: 'Text' },
1417+
memoizedProps: { children: 'Masked content' },
1418+
},
1419+
return: {
1420+
elementType: { name: 'RNSentryReplayMask' },
1421+
},
1422+
},
1423+
};
1424+
1425+
// @ts-expect-error Calling private member
1426+
boundary._onTouchStart(event);
1427+
1428+
expect(addBreadcrumb).toHaveBeenCalledWith(
1429+
expect.objectContaining({
1430+
message: 'Touch event within element: TouchableOpacity',
1431+
data: { path: [{ name: 'TouchableOpacity' }] },
1432+
}),
1433+
);
1434+
});
1435+
14031436
it('handles string memoizedProps (raw text fiber nodes)', () => {
14041437
const { defaultProps } = TouchEventBoundary;
14051438
const boundary = new TouchEventBoundary(defaultProps);
@@ -1429,4 +1462,215 @@ describe('TouchEventBoundary._onTouchStart', () => {
14291462
);
14301463
});
14311464
});
1465+
1466+
describe('sentry-label masking', () => {
1467+
it('skips sentry-label when maskAllText is enabled', () => {
1468+
const { defaultProps } = TouchEventBoundary;
1469+
const boundary = new TouchEventBoundary(defaultProps);
1470+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
1471+
name: 'MobileReplay',
1472+
options: { maskAllText: true },
1473+
} as any);
1474+
1475+
const event = {
1476+
_targetInst: {
1477+
elementType: { displayName: 'Button' },
1478+
memoizedProps: {
1479+
'sentry-label': 'secret-label',
1480+
accessibilityLabel: 'Save workout',
1481+
},
1482+
},
1483+
};
1484+
1485+
// @ts-expect-error Calling private member
1486+
boundary._onTouchStart(event);
1487+
1488+
expect(addBreadcrumb).toHaveBeenCalledWith(
1489+
expect.objectContaining({
1490+
message: 'Touch event within element: Save workout',
1491+
data: { path: [{ name: 'Button', label: 'Save workout' }] },
1492+
}),
1493+
);
1494+
});
1495+
1496+
it('skips sentry-label when maskAllText defaults to true (not set)', () => {
1497+
const { defaultProps } = TouchEventBoundary;
1498+
const boundary = new TouchEventBoundary(defaultProps);
1499+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
1500+
name: 'MobileReplay',
1501+
options: {},
1502+
} as any);
1503+
1504+
const event = {
1505+
_targetInst: {
1506+
elementType: { displayName: 'Button' },
1507+
memoizedProps: {
1508+
'sentry-label': 'secret-label',
1509+
testID: 'btn-test-id',
1510+
},
1511+
},
1512+
};
1513+
1514+
// @ts-expect-error Calling private member
1515+
boundary._onTouchStart(event);
1516+
1517+
expect(addBreadcrumb).toHaveBeenCalledWith(
1518+
expect.objectContaining({
1519+
message: 'Touch event within element: btn-test-id',
1520+
data: { path: [{ name: 'Button', label: 'btn-test-id' }] },
1521+
}),
1522+
);
1523+
});
1524+
1525+
it('reads sentry-label when maskAllText is explicitly false', () => {
1526+
const { defaultProps } = TouchEventBoundary;
1527+
const boundary = new TouchEventBoundary(defaultProps);
1528+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
1529+
name: 'MobileReplay',
1530+
options: { maskAllText: false },
1531+
} as any);
1532+
1533+
const event = {
1534+
_targetInst: {
1535+
elementType: { displayName: 'Button' },
1536+
memoizedProps: {
1537+
'sentry-label': 'explicit-label',
1538+
accessibilityLabel: 'Save workout',
1539+
},
1540+
},
1541+
};
1542+
1543+
// @ts-expect-error Calling private member
1544+
boundary._onTouchStart(event);
1545+
1546+
expect(addBreadcrumb).toHaveBeenCalledWith(
1547+
expect.objectContaining({
1548+
message: 'Touch event within element: explicit-label',
1549+
data: { path: [{ name: 'Button', label: 'explicit-label' }] },
1550+
}),
1551+
);
1552+
});
1553+
1554+
it('skips sentry-label when element is inside a Mask ancestor', () => {
1555+
const { defaultProps } = TouchEventBoundary;
1556+
const boundary = new TouchEventBoundary(defaultProps);
1557+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
1558+
name: 'MobileReplay',
1559+
options: { maskAllText: false },
1560+
} as any);
1561+
1562+
const event = {
1563+
_targetInst: {
1564+
elementType: { displayName: 'Button' },
1565+
memoizedProps: {
1566+
'sentry-label': 'masked-label',
1567+
accessibilityLabel: 'Fallback label',
1568+
},
1569+
return: {
1570+
elementType: { name: 'RNSentryReplayMask' },
1571+
},
1572+
},
1573+
};
1574+
1575+
// @ts-expect-error Calling private member
1576+
boundary._onTouchStart(event);
1577+
1578+
expect(addBreadcrumb).toHaveBeenCalledWith(
1579+
expect.objectContaining({
1580+
message: 'Touch event within element: Fallback label',
1581+
data: { path: [{ name: 'Button', label: 'Fallback label' }] },
1582+
}),
1583+
);
1584+
});
1585+
1586+
it('skips sentry-label when Mask ancestor uses displayName', () => {
1587+
const { defaultProps } = TouchEventBoundary;
1588+
const boundary = new TouchEventBoundary(defaultProps);
1589+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
1590+
name: 'MobileReplay',
1591+
options: { maskAllText: false },
1592+
} as any);
1593+
1594+
const event = {
1595+
_targetInst: {
1596+
elementType: { displayName: 'Button' },
1597+
memoizedProps: {
1598+
'sentry-label': 'masked-label',
1599+
testID: 'btn-id',
1600+
},
1601+
return: {
1602+
elementType: { displayName: 'RNSentryReplayMask' },
1603+
},
1604+
},
1605+
};
1606+
1607+
// @ts-expect-error Calling private member
1608+
boundary._onTouchStart(event);
1609+
1610+
expect(addBreadcrumb).toHaveBeenCalledWith(
1611+
expect.objectContaining({
1612+
message: 'Touch event within element: btn-id',
1613+
data: { path: [{ name: 'Button', label: 'btn-id' }, { name: 'RNSentryReplayMask' }] },
1614+
}),
1615+
);
1616+
});
1617+
1618+
it('reads sentry-label when no replay integration is present', () => {
1619+
const { defaultProps } = TouchEventBoundary;
1620+
const boundary = new TouchEventBoundary(defaultProps);
1621+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined);
1622+
1623+
const event = {
1624+
_targetInst: {
1625+
elementType: { displayName: 'Button' },
1626+
memoizedProps: {
1627+
'sentry-label': 'my-label',
1628+
accessibilityLabel: 'Save workout',
1629+
},
1630+
},
1631+
};
1632+
1633+
// @ts-expect-error Calling private member
1634+
boundary._onTouchStart(event);
1635+
1636+
expect(addBreadcrumb).toHaveBeenCalledWith(
1637+
expect.objectContaining({
1638+
message: 'Touch event within element: my-label',
1639+
data: { path: [{ name: 'Button', label: 'my-label' }] },
1640+
}),
1641+
);
1642+
});
1643+
1644+
it('does not check Mask ancestors when maskAllText is already enabled', () => {
1645+
const { defaultProps } = TouchEventBoundary;
1646+
const boundary = new TouchEventBoundary(defaultProps);
1647+
jest.spyOn(client, 'getIntegrationByName').mockReturnValue({
1648+
name: 'MobileReplay',
1649+
options: { maskAllText: true },
1650+
} as any);
1651+
1652+
const event = {
1653+
_targetInst: {
1654+
elementType: { displayName: 'Button' },
1655+
memoizedProps: {
1656+
'sentry-label': 'should-be-skipped',
1657+
accessibilityLabel: 'Accessible',
1658+
},
1659+
return: {
1660+
elementType: { name: 'RNSentryReplayMask' },
1661+
},
1662+
},
1663+
};
1664+
1665+
// @ts-expect-error Calling private member
1666+
boundary._onTouchStart(event);
1667+
1668+
expect(addBreadcrumb).toHaveBeenCalledWith(
1669+
expect.objectContaining({
1670+
message: 'Touch event within element: Accessible',
1671+
data: { path: [{ name: 'Button', label: 'Accessible' }] },
1672+
}),
1673+
);
1674+
});
1675+
});
14321676
});

0 commit comments

Comments
 (0)