diff --git a/CHANGELOG.md b/CHANGELOG.md index 918609f0cd..69d45c97c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 6d7439fc2f..1f9549f89f 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -244,7 +244,10 @@ class TouchEventBoundary extends React.Component { 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; while ( currentInst && @@ -259,7 +262,7 @@ class TouchEventBoundary extends React.Component { 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; @@ -302,25 +305,27 @@ class TouchEventBoundary extends React.Component { } } - 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(); } /** @@ -355,6 +360,7 @@ function getTouchedComponentInfo( currentInst: ElementInstance, labelKey: string | undefined, shouldExtractText: boolean, + shouldReadSentryLabel: boolean, ): TouchedComponentInfo | undefined { const displayName = currentInst.elementType?.displayName; @@ -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({ // provided by @sentry/babel-plugin-component-annotate @@ -410,8 +418,12 @@ function getFileName(props: Record): string | undefined { ); } -function getLabelValue(props: Record, labelKey: string | undefined): string | undefined { - if (typeof props[SENTRY_LABEL_PROP_KEY] === 'string' && props[SENTRY_LABEL_PROP_KEY].length > 0) { +function getLabelValue( + props: Record, + 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]; } @@ -454,6 +466,17 @@ function getSpanAttributes(currentInst: ElementInstance): Record { ); }); + 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); @@ -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' }] }, + }), + ); + }); + }); });