diff --git a/.changeset/rn-no-capture-deep-ancestor.md b/.changeset/rn-no-capture-deep-ancestor.md new file mode 100644 index 0000000000..9c6ca43b18 --- /dev/null +++ b/.changeset/rn-no-capture-deep-ancestor.md @@ -0,0 +1,5 @@ +--- +'posthog-react-native': patch +--- + +Respect `ph-no-capture` on any ancestor of a touched or clicked element. Previously an interaction deep inside an opted-out subtree could still send an `$autocapture` event carrying that subtree's element text and props, so apps relying on a high-level `ph-no-capture` may see fewer `$autocapture` events after upgrading. Interactions more than 1000 elements deep in the view hierarchy now produce no `$autocapture` event rather than a truncated one. A non-numeric `maxElementsCaptured` now falls back to the default of 20 instead of being treated as no cap at all. diff --git a/packages/react-native/src/autocapture.tsx b/packages/react-native/src/autocapture.tsx index 16330fdc0a..d9333316ed 100644 --- a/packages/react-native/src/autocapture.tsx +++ b/packages/react-native/src/autocapture.tsx @@ -112,6 +112,11 @@ export const findOwningNode = (e: any, owners: { has(node: unknown): boolean }): return undefined } +// Fail-closed bound on the walk; unrelated to maxElementsCaptured, which caps the emitted payload. +export const maxAncestorsTraversed = 1000 + +const defaultMaxElementsCaptured = 20 + // Autocapture must never break the host app: a throw would escape into RN's touch dispatch on // native, or the DOM click handler on web. Matches the browser SDK, which guards its equivalent // document-level handler (packages/browser/src/autocapture.ts). @@ -140,11 +145,17 @@ const captureFromEvent = ( const { noCaptureProp = 'ph-no-capture', customLabelProp = defaultPostHogLabelProp, - maxElementsCaptured = 20, + maxElementsCaptured: maxElementsCapturedOption = defaultMaxElementsCaptured, ignoreLabels = [], propsToCapture = ['style', 'testID', 'accessibilityLabel', customLabelProp, 'children'], } = options + // The destructure default only covers `undefined`; a NaN would make every comparison against it + // false, silently uncapping the payload instead of capping it. + const maxElementsCaptured = Number.isFinite(maxElementsCapturedOption) + ? maxElementsCapturedOption + : defaultMaxElementsCaptured + const nativeInst = e._targetInst const targetInst: Element | undefined = nativeInst || getFallbackTargetInstance(e) if (!targetInst) { @@ -154,24 +165,31 @@ const captureFromEvent = ( const autocaptureProperties: Record = {} let currentInst: Element | undefined = targetInst + let ancestorsTraversed = 0 - while ( - currentInst && - // maxComponentTreeSize will always be defined as we have a defaultProps. But ts needs a check so this is here. - elements.length < maxElementsCaptured - ) { - const el: PostHogAutocaptureElement = { - tag_name: '', - } - const elAutocaptureProperties: Record = {} - + while (currentInst) { const props = currentInst.memoizedProps + if (ancestorsTraversed++ >= maxAncestorsTraversed) { + return + } + if (props?.[noCaptureProp]) { // Immediately ignore events if a no capture is in the chain return } + if (elements.length >= maxElementsCaptured) { + // keep walking so a no capture ancestor above the cap is still seen + currentInst = currentInst.return + continue + } + + const el: PostHogAutocaptureElement = { + tag_name: '', + } + const elAutocaptureProperties: Record = {} + if (props) { // Capture data-ph-capture-attribute props as event properties. // Element props are only captured from propsToCapture. diff --git a/packages/react-native/test/autocapture.spec.ts b/packages/react-native/test/autocapture.spec.ts index e5b3865f82..050ce28ac9 100644 --- a/packages/react-native/test/autocapture.spec.ts +++ b/packages/react-native/test/autocapture.spec.ts @@ -1,4 +1,4 @@ -import { autocaptureFromTouchEvent } from '../src/autocapture' +import { autocaptureFromTouchEvent, maxAncestorsTraversed } from '../src/autocapture' import goodEvent from './data/autocapture-event.json' import ignoreEvent from './data/autocapture-event-no-capture.json' @@ -174,6 +174,116 @@ describe('PostHog React Native', () => { expect(mockPostHog.autocapture).toHaveBeenCalledTimes(0) }) + it('should ignore a no-capture ancestor beyond maxElementsCaptured', () => { + const mockPostHog = { autocapture: jest.fn() } as any + let targetInst: any = { + memoizedProps: { 'ph-no-capture': true }, + return: null, + } + + for (let i = 0; i <= 20; i++) { + targetInst = { + elementType: { name: `View${i}` }, + memoizedProps: {}, + return: targetInst, + } + } + + autocaptureFromTouchEvent({ _targetInst: targetInst, nativeEvent }, mockPostHog) + + expect(mockPostHog.autocapture).not.toHaveBeenCalled() + }) + + it('should still cap the emitted elements at maxElementsCaptured', () => { + const mockPostHog = { autocapture: jest.fn() } as any + let targetInst: any = null + + for (let i = 0; i < 25; i++) { + targetInst = { + elementType: { name: `View${i}` }, + memoizedProps: {}, + return: targetInst, + } + } + + autocaptureFromTouchEvent({ _targetInst: targetInst, nativeEvent }, mockPostHog) + + expect(mockPostHog.autocapture).toHaveBeenCalledTimes(1) + expect(mockPostHog.autocapture.mock.calls[0][1]).toHaveLength(20) + }) + + it('should fall back to the default cap when maxElementsCaptured is not a number', () => { + const mockPostHog = { autocapture: jest.fn() } as any + let targetInst: any = null + + for (let i = 0; i < 25; i++) { + targetInst = { + elementType: { name: `View${i}` }, + memoizedProps: {}, + return: targetInst, + } + } + + autocaptureFromTouchEvent({ _targetInst: targetInst, nativeEvent }, mockPostHog, { + maxElementsCaptured: NaN, + }) + + expect(mockPostHog.autocapture).toHaveBeenCalledTimes(1) + expect(mockPostHog.autocapture.mock.calls[0][1]).toHaveLength(20) + }) + + it('should still capture on an ordinarily deep component tree', () => { + const mockPostHog = { autocapture: jest.fn() } as any + let targetInst: any = null + + // autocapture-event.json is already 129 fibers deep for one trivial screen + for (let i = 0; i < 400; i++) { + targetInst = { + elementType: { name: `View${i}` }, + memoizedProps: {}, + return: targetInst, + } + } + + autocaptureFromTouchEvent({ _targetInst: targetInst, nativeEvent }, mockPostHog) + + expect(mockPostHog.autocapture).toHaveBeenCalledTimes(1) + }) + + it('should still capture at exactly the traversal bound', () => { + const mockPostHog = { autocapture: jest.fn() } as any + let targetInst: any = null + + for (let i = 0; i < maxAncestorsTraversed; i++) { + targetInst = { + elementType: { name: `View${i}` }, + memoizedProps: {}, + return: targetInst, + } + } + + autocaptureFromTouchEvent({ _targetInst: targetInst, nativeEvent }, mockPostHog) + + expect(mockPostHog.autocapture).toHaveBeenCalledTimes(1) + }) + + it('should fail closed when the ancestor chain exceeds the traversal bound', () => { + const mockPostHog = { autocapture: jest.fn() } as any + let targetInst: any = null + + for (let i = 0; i <= maxAncestorsTraversed; i++) { + targetInst = { + elementType: i % 10 === 0 ? { name: `View${i}` } : {}, + memoizedProps: {}, + return: targetInst, + } + } + + autocaptureFromTouchEvent({ _targetInst: targetInst, nativeEvent }, mockPostHog) + + expect(mockPostHog.autocapture).not.toHaveBeenCalled() + }) + it('should handle animated styles without errors', () => { const mockPostHog = { autocapture: jest.fn() } as any