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
5 changes: 5 additions & 0 deletions .changeset/rn-no-capture-deep-ancestor.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 29 additions & 11 deletions packages/react-native/src/autocapture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand All @@ -154,24 +165,31 @@ const captureFromEvent = (
const autocaptureProperties: Record<string, JsonType> = {}

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<string, JsonType> = {}

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<string, JsonType> = {}

if (props) {
// Capture data-ph-capture-attribute props as event properties.
// Element props are only captured from propsToCapture.
Expand Down
112 changes: 111 additions & 1 deletion packages/react-native/test/autocapture.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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

Expand Down