From 557a02dc8053a5216a84e883feb0ef575e9f14e1 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 10:57:46 +0200 Subject: [PATCH 1/9] feat(core): Extract text from children of touched components for breadcrumb labels Walks the React fiber tree downward (child/sibling) to extract text content from touched components as a lowest-priority label fallback. Gated on Session Replay's maskAllText setting and respects per-view Sentry.Mask boundaries. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 + packages/core/src/js/touchevents.tsx | 98 ++++++- packages/core/test/touchevents.test.tsx | 352 ++++++++++++++++++++++++ 3 files changed, 447 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b37b59967..68db1fefcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Extract text content from children of touched components as a label fallback for touch breadcrumbs ([#6097](https://github.com/getsentry/sentry-react-native/issues/6097)) + ### Fixes - Fix the issue with uploading iOS Debug Symbols in EAS Build when using pnpm ([#6076](https://github.com/getsentry/sentry-react-native/issues/6076)) diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 7cf64956b8..bd1807fc21 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable eslint(max-lines) */ import type { SeverityLevel, SpanAttributeValue } from '@sentry/core'; import type { GestureResponderEvent } from 'react-native'; @@ -9,6 +10,7 @@ import type { TouchedComponentInfo } from './ragetap'; import { createIntegration } from './integrations/factory'; import { DEFAULT_RAGE_TAP_THRESHOLD, DEFAULT_RAGE_TAP_TIME_WINDOW, RageTapDetector } from './ragetap'; +import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay'; import { startUserInteractionSpan } from './tracing/integrations/userInteraction'; import { UI_ACTION_TOUCH } from './tracing/ops'; import { SPAN_ORIGIN_AUTO_INTERACTION } from './tracing/origin'; @@ -70,6 +72,17 @@ export type TouchEventBoundaryProps = { * @default 1000 */ rageTapTimeWindow?: number; + /** + * Extract text content from children of touched components as a label fallback. + * Automatically disabled when Session Replay's `maskAllText` is enabled (the default) + * to avoid leaking masked content via breadcrumbs. Set `maskAllText: false` in your + * `mobileReplayIntegration` config to enable text extraction. + * Per-view `Sentry.Mask` boundaries are also respected. + * Set to `false` to opt out of text extraction entirely. + * + * @default true + */ + extractTextFromChildren?: boolean; }; const touchEventStyles = StyleSheet.create({ @@ -88,13 +101,21 @@ const SENTRY_COMPONENT_PROP_KEY = 'data-sentry-component'; const SENTRY_ELEMENT_PROP_KEY = 'data-sentry-element'; const SENTRY_FILE_PROP_KEY = 'data-sentry-source-file'; +const MASK_COMPONENT_NAME = 'RNSentryReplayMask'; +const MAX_TEXT_LENGTH = 64; +const MAX_TEXT_EXTRACTION_DEPTH = 3; +const MAX_SIBLINGS_TO_VISIT = 5; + interface ElementInstance { elementType?: { displayName?: string; name?: string; }; - memoizedProps?: Record; + // Raw text fiber nodes store a string instead of an object + memoizedProps?: Record | string; return?: ElementInstance; + child?: ElementInstance; + sibling?: ElementInstance; } interface PrivateGestureResponderEvent extends GestureResponderEvent { @@ -114,6 +135,7 @@ class TouchEventBoundary extends React.Component { enableRageTapDetection: true, rageTapThreshold: DEFAULT_RAGE_TAP_THRESHOLD, rageTapTimeWindow: DEFAULT_RAGE_TAP_TIME_WINDOW, + extractTextFromChildren: true, }; public readonly name: string = 'TouchEventBoundary'; @@ -220,6 +242,7 @@ class TouchEventBoundary extends React.Component { let currentInst: ElementInstance | undefined = e._targetInst; const touchPath: TouchedComponentInfo[] = []; + const shouldExtractText = this._shouldExtractText(); while ( currentInst && @@ -234,7 +257,7 @@ class TouchEventBoundary extends React.Component { break; } - const info = getTouchedComponentInfo(currentInst, this.props.labelName); + const info = getTouchedComponentInfo(currentInst, this.props.labelName, shouldExtractText); this._pushIfNotIgnored(touchPath, info); currentInst = currentInst.return; @@ -277,6 +300,24 @@ class TouchEventBoundary extends React.Component { } } + private _shouldExtractText(): boolean { + if (!this.props.extractTextFromChildren) { + return false; + } + const client = getClient(); + if (!client) { + return true; + } + const replayIntegration = client.getIntegrationByName(MOBILE_REPLAY_INTEGRATION_NAME); + if (replayIntegration && 'options' in replayIntegration) { + const options = replayIntegration.options as { maskAllText?: boolean }; + if (options.maskAllText !== false) { + return false; + } + } + return true; + } + /** * Pushes the name to the componentTreeNames array if it is not ignored. */ @@ -308,12 +349,12 @@ class TouchEventBoundary extends React.Component { function getTouchedComponentInfo( currentInst: ElementInstance, labelKey: string | undefined, + shouldExtractText: boolean, ): TouchedComponentInfo | undefined { const displayName = currentInst.elementType?.displayName; const props = currentInst.memoizedProps; - if (!props) { - // Early return if no props are available, as we can't extract any useful information + if (!props || typeof props === 'string') { if (displayName) { return { name: displayName, @@ -322,14 +363,16 @@ function getTouchedComponentInfo( return undefined; } + const label = getLabelValue(props, labelKey) + || (shouldExtractText ? extractTextFromFiber(currentInst) : undefined); + return dropUndefinedKeys({ // provided by @sentry/babel-plugin-component-annotate name: getComponentName(props) || displayName, element: getElementName(props), file: getFileName(props), - // `sentry-label` or user defined label key - label: getLabelValue(props, labelKey), + label, }); } @@ -376,7 +419,7 @@ function getLabelValue(props: Record, labelKey: string | undefi } function getSpanAttributes(currentInst: ElementInstance): Record | undefined { - if (!currentInst.memoizedProps) { + if (!currentInst.memoizedProps || typeof currentInst.memoizedProps === 'string') { return undefined; } @@ -391,6 +434,47 @@ function getSpanAttributes(currentInst: ElementInstance): Record MAX_TEXT_LENGTH) { + return `${text.slice(0, MAX_TEXT_LENGTH)}...`; + } + return text; +} + +function collectTextFromFiber( + inst: ElementInstance | undefined, + parts: string[], + depth: number, + siblingIndex: number = 0, +): void { + if (!inst || depth > MAX_TEXT_EXTRACTION_DEPTH || siblingIndex >= MAX_SIBLINGS_TO_VISIT) { + return; + } + + if (inst.elementType?.name === MASK_COMPONENT_NAME || inst.elementType?.displayName === MASK_COMPONENT_NAME) { + return; + } + + const props = inst.memoizedProps; + if (typeof props === 'string') { + parts.push(props); + } else if (typeof props?.children === 'string') { + parts.push(props.children); + } + + collectTextFromFiber(inst.child, parts, depth + 1, 0); + collectTextFromFiber(inst.sibling, parts, depth, siblingIndex + 1); +} + /** * Convenience Higher-Order-Component for TouchEventBoundary * @param WrappedComponent any React Component diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index ba54115330..c3680c465d 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -690,4 +690,356 @@ describe('TouchEventBoundary._onTouchStart', () => { expect(() => boundary._onTouchStart(event)).not.toThrow(); }); }); + + describe('text extraction from children', () => { + it('extracts text from child fiber nodes when no label is set', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + 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('extracts text from nested fiber children', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'Pressable' }, + memoizedProps: {}, + child: { + elementType: { name: 'View' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Continue' }, + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Continue', + }), + ); + }); + + it('collects text from sibling fiber nodes', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Add' }, + sibling: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'to cart' }, + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Add to cart', + }), + ); + }); + + it('truncates long text at 64 characters', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const longText = 'A'.repeat(100); + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: longText }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: `Touch event within element: ${'A'.repeat(64)}...`, + }), + ); + }); + + it('does not extract text 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: '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: TouchableOpacity', + data: { path: [{ name: 'TouchableOpacity' }] }, + }), + ); + }); + + it('extracts text 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: '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', + }), + ); + }); + + it('does not extract text when MobileReplay integration has no options property', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue({ + name: 'MobileReplay', + setupOnce: jest.fn(), + } as any); + + const event = { + _targetInst: { + 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', + }), + ); + }); + + it('does not extract text when maskAllText is not set (defaults to masked)', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue({ + name: 'MobileReplay', + options: {}, + } as any); + + const event = { + _targetInst: { + 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: TouchableOpacity', + data: { path: [{ name: 'TouchableOpacity' }] }, + }), + ); + }); + + it('stops at Sentry.Mask boundary', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'RNSentryReplayMask' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Secret text' }, + }, + }, + }, + }; + + // @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('sentry-label takes priority over extracted text', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: { 'sentry-label': 'my-button' }, + 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: my-button', + }), + ); + }); + + it('does not extract text when extractTextFromChildren prop is false', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary({ + ...defaultProps, + extractTextFromChildren: false, + }); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + 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: TouchableOpacity', + data: { path: [{ name: 'TouchableOpacity' }] }, + }), + ); + }); + + it('handles string memoizedProps (raw text fiber nodes)', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: {}, + child: { + memoizedProps: 'Hello world', + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Hello world', + }), + ); + }); + }); }); From 20bbc1aba2ae1fab6f67d79bcabc5dcb2d439bd8 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 10:58:39 +0200 Subject: [PATCH 2/9] chore: Update CHANGELOG entry with PR number Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68db1fefcd..c0e8870b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- Extract text content from children of touched components as a label fallback for touch breadcrumbs ([#6097](https://github.com/getsentry/sentry-react-native/issues/6097)) +- 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)) ### Fixes From f93f81895bdb83e8d5171f3bf30642caf4189757 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 11:20:29 +0200 Subject: [PATCH 3/9] style(core): Fix oxfmt formatting in touchevents Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/touchevents.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index bd1807fc21..438377e8a9 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -363,8 +363,7 @@ function getTouchedComponentInfo( return undefined; } - const label = getLabelValue(props, labelKey) - || (shouldExtractText ? extractTextFromFiber(currentInst) : undefined); + const label = getLabelValue(props, labelKey) || (shouldExtractText ? extractTextFromFiber(currentInst) : undefined); return dropUndefinedKeys({ // provided by @sentry/babel-plugin-component-annotate From 142085ae1b9fc937d3eceb46fe420b5b8d0137fd Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 11:43:06 +0200 Subject: [PATCH 4/9] fix(core): Continue sibling traversal after Mask boundary in text extraction Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/touchevents.tsx | 2 ++ packages/core/test/touchevents.test.tsx | 35 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 438377e8a9..5877c91986 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -460,6 +460,8 @@ function collectTextFromFiber( } if (inst.elementType?.name === MASK_COMPONENT_NAME || inst.elementType?.displayName === MASK_COMPONENT_NAME) { + // Skip masked node's children but still visit its siblings + collectTextFromFiber(inst.sibling, parts, depth, siblingIndex + 1); return; } diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index c3680c465d..89c3c26452 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -957,6 +957,41 @@ describe('TouchEventBoundary._onTouchStart', () => { ); }); + it('collects sibling text after Sentry.Mask boundary', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'RNSentryReplayMask' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Secret text' }, + }, + sibling: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Visible text' }, + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Visible text', + data: { path: [{ name: 'TouchableOpacity', label: 'Visible text' }] }, + }), + ); + }); + it('sentry-label takes priority over extracted text', () => { const { defaultProps } = TouchEventBoundary; const boundary = new TouchEventBoundary(defaultProps); From f66b8351010f21d67b052c4d647d4eb00b576239 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 12:01:36 +0200 Subject: [PATCH 5/9] docs(core): Remove misleading @default true from extractTextFromChildren The prop defaults to true but effective behavior depends on maskAllText, which defaults to true in mobileReplayIntegration, disabling extraction. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/touchevents.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 5877c91986..8982644665 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -79,8 +79,6 @@ export type TouchEventBoundaryProps = { * `mobileReplayIntegration` config to enable text extraction. * Per-view `Sentry.Mask` boundaries are also respected. * Set to `false` to opt out of text extraction entirely. - * - * @default true */ extractTextFromChildren?: boolean; }; From 92517ae3a5d38f98bdcc2bda7ba26776029fa2b8 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 8 May 2026 12:37:30 +0200 Subject: [PATCH 6/9] Update CHANGELOG --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17190d12d0..5373d3922a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### 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)) + ### Dependencies - Bump JavaScript SDK from v10.51.0 to v10.52.0 ([#6108](https://github.com/getsentry/sentry-react-native/pull/6108)) @@ -19,7 +23,6 @@ ### Features - Use `accessibilityLabel`, `aria-label`, and `testID` as fallback labels for touch breadcrumbs when `sentry-label` is not set ([#6103](https://github.com/getsentry/sentry-react-native/pull/6103)) -- 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)) ### Fixes From f14d1f9341b87c7610cd7ce627b87886c4ced066 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 8 May 2026 12:52:17 +0200 Subject: [PATCH 7/9] fix(core): Prevent text duplication from props.children and HostText child fibers In real React Native fiber trees, Hello has both memoizedProps.children = 'Hello' on the Text fiber and a child HostText fiber with memoizedProps = 'Hello'. Skip child recursion when props.children is a string to avoid collecting the same text twice. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/touchevents.tsx | 6 ++++- packages/core/test/touchevents.test.tsx | 36 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 20feff7805..2a474fcd72 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -484,12 +484,16 @@ function collectTextFromFiber( const props = inst.memoizedProps; if (typeof props === 'string') { + // Raw text fiber (HostText) — no children to recurse into parts.push(props); } else if (typeof props?.children === 'string') { + // Component with string children — skip child recursion to avoid + // duplicating text from the HostText child fiber parts.push(props.children); + } else { + collectTextFromFiber(inst.child, parts, depth + 1, 0); } - collectTextFromFiber(inst.child, parts, depth + 1, 0); collectTextFromFiber(inst.sibling, parts, depth, siblingIndex + 1); } diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index 17ee48776f..03af780bff 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -934,6 +934,42 @@ describe('TouchEventBoundary._onTouchStart', () => { ); }); + it('does not duplicate text when props.children and HostText child both exist', () => { + // In real React Native fiber trees, Hello has both: + // - Text fiber: memoizedProps = { children: 'Hello' } + // - HostText child fiber: memoizedProps = 'Hello' (raw string) + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Save workout' }, + child: { + // HostText fiber — raw string props + memoizedProps: '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('extracts text from nested fiber children', () => { const { defaultProps } = TouchEventBoundary; const boundary = new TouchEventBoundary(defaultProps); From 2c4692048b74874deb4536e695b37b4a9bb56754 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 8 May 2026 16:18:38 +0200 Subject: [PATCH 8/9] fix(core): Block text extraction when MobileReplay has no options property Address review feedback: default to not extracting text when the MobileReplay integration exists but has no options, consistent with the safe-by-default masking behavior. Fix misleading test name/assertion and add @default JSDoc tag. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/touchevents.tsx | 6 +++++- packages/core/test/touchevents.test.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 2a474fcd72..6d7439fc2f 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -79,6 +79,7 @@ export type TouchEventBoundaryProps = { * `mobileReplayIntegration` config to enable text extraction. * Per-view `Sentry.Mask` boundaries are also respected. * Set to `false` to opt out of text extraction entirely. + * @default true */ extractTextFromChildren?: boolean; }; @@ -310,7 +311,10 @@ class TouchEventBoundary extends React.Component { return true; } const replayIntegration = client.getIntegrationByName(MOBILE_REPLAY_INTEGRATION_NAME); - if (replayIntegration && 'options' in replayIntegration) { + if (replayIntegration) { + if (!('options' in replayIntegration)) { + return false; + } const options = replayIntegration.options as { maskAllText?: boolean }; if (options.maskAllText !== false) { return false; diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index 03af780bff..3e9b12104e 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -1140,7 +1140,7 @@ describe('TouchEventBoundary._onTouchStart', () => { expect(addBreadcrumb).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Touch event within element: Save workout', + message: 'Touch event within element: TouchableOpacity', }), ); }); From 6fdacc4b9f2bc8462ab09a21d08426f81eeab0cd Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 8 May 2026 16:23:22 +0200 Subject: [PATCH 9/9] test(core): Add depth limit and sibling limit tests for text extraction Co-Authored-By: Claude Opus 4.6 --- packages/core/test/touchevents.test.tsx | 103 ++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index 3e9b12104e..a947ccc0d3 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -1297,6 +1297,109 @@ describe('TouchEventBoundary._onTouchStart', () => { ); }); + it('stops collecting text beyond depth limit', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + // Build a fiber tree beyond the depth limit (> 3), depths 0-3 are allowed + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + // depth 0 + elementType: { name: 'View' }, + memoizedProps: {}, + child: { + // depth 1 + elementType: { name: 'View' }, + memoizedProps: {}, + child: { + // depth 2 + elementType: { name: 'View' }, + memoizedProps: {}, + child: { + // depth 3 + elementType: { name: 'View' }, + memoizedProps: {}, + child: { + // depth 4 — beyond limit, should be ignored + elementType: { name: 'Text' }, + memoizedProps: { children: 'Too deep' }, + }, + }, + }, + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: TouchableOpacity', + }), + ); + }); + + it('stops collecting text beyond sibling limit', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + // Build 6 sibling text nodes, limit is 5 + const sibling6 = { + elementType: { name: 'Text' }, + memoizedProps: { children: 'six' }, + sibling: undefined, + }; + const sibling5 = { + elementType: { name: 'Text' }, + memoizedProps: { children: 'five' }, + sibling: sibling6, + }; + const sibling4 = { + elementType: { name: 'Text' }, + memoizedProps: { children: 'four' }, + sibling: sibling5, + }; + const sibling3 = { + elementType: { name: 'Text' }, + memoizedProps: { children: 'three' }, + sibling: sibling4, + }; + const sibling2 = { + elementType: { name: 'Text' }, + memoizedProps: { children: 'two' }, + sibling: sibling3, + }; + const sibling1 = { + elementType: { name: 'Text' }, + memoizedProps: { children: 'one' }, + sibling: sibling2, + }; + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: sibling1, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: one two three four five', + }), + ); + }); + it('handles string memoizedProps (raw text fiber nodes)', () => { const { defaultProps } = TouchEventBoundary; const boundary = new TouchEventBoundary(defaultProps);