Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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/brave-moons-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-react-native': minor
---

Autocapture touches and clicks on React Native Web (including expo-router on web). Touch events there carry no `_targetInst` and every touch was silently dropped, so the element chain is now resolved by walking up from `e.target` to the nearest node carrying a React fiber. `captureTouches` also registers a capture-phase `click` listener on the document on web, emitted with `$event_type: 'click'`, since browsers fire `touchend` only for touch input (react-native-web's `Pressable` stops propagation, and `Modal` renders outside the provider's subtree). Autocapture no longer lets an exception escape into the host app's event dispatch.
Original file line number Diff line number Diff line change
Expand Up @@ -3046,7 +3046,7 @@
"name": "PostHogAutocaptureOptions",
"properties": [
{
"description": "Enable autocapture of touch events",
"description": "Enable autocapture of touch events.\n\nOn React Native Web this also captures `click` events — mouse, trackpad, keyboard\nactivation and programmatic clicks — emitted with `$event_type: 'click'`, since\nbrowsers fire `touchend` only for touch input.",
"type": "boolean",
"name": "captureTouches"
},
Expand Down
63 changes: 61 additions & 2 deletions packages/react-native/src/PostHogProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useMemo } from 'react'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { GestureResponderEvent, StyleProp, View, ViewStyle } from 'react-native'
import { PostHog, PostHogOptions } from './posthog-rn'
import { autocaptureFromTouchEvent } from './autocapture'
import { useNavigationTracker } from './hooks/useNavigationTracker'
import { PostHogContext } from './PostHogContext'
import { PostHogAutocaptureOptions } from './types'
import { isWeb } from './utils'
import { defaultPostHogLabelProp } from './autocapture'

/**
Expand Down Expand Up @@ -43,6 +44,11 @@ export interface PostHogProviderProps {
style?: StyleProp<ViewStyle>
}

// One document click listener per client, however many providers mount it. Two sibling
// providers must not enqueue two $autocapture events for a single interaction
// (sdk-specs autocapture: "exactly one event ... should be enqueued for that interaction").
const webClickListeners = new WeakMap<PostHog, { count: number; remove: () => void }>()

function PostHogNavigationHook({
options,
client,
Expand Down Expand Up @@ -173,11 +179,64 @@ export const PostHogProvider = ({
[captureTouches, posthog, autocaptureOptions]
)

// Browsers fire touchend only for touch input, so a mouse never reaches onTouchEndCapture.
// On web listen for click on the document in the CAPTURE phase, matching the browser SDK:
// react-native-web forwards `onClick` but not `onClickCapture`, and RNW's Pressable calls
// stopPropagation, so a bubble-phase React handler never sees presses on a button.
// The trade-off is that this is document-wide rather than scoped to the provider's subtree,
// so clicks outside it are captured too.
// Read through a ref so an inline `autocapture` object prop doesn't re-attach on every render.
const optionsRef = useRef(autocaptureOptions)
useEffect(() => {
optionsRef.current = autocaptureOptions
}, [autocaptureOptions])

useEffect(() => {
// The package targets ESNext without the DOM lib, so reach the document off the global.
const doc = (globalThis as any)?.document
if (!isWeb() || !captureTouches || !doc?.addEventListener) {
return
}

const existing = webClickListeners.get(posthog)
if (existing) {
existing.count += 1
} else {
// Options come from whichever provider installed the listener; sibling providers on one
// client are expected to be configured alike.
const optionsForClient = optionsRef
const handler = (e: any): void => {
autocaptureFromTouchEvent({ target: e.target, nativeEvent: e }, posthog, optionsForClient.current, 'click')
}
doc.addEventListener('click', handler, true)
webClickListeners.set(posthog, {
count: 1,
remove: () => doc.removeEventListener('click', handler, true),
})
}

return () => {
const entry = webClickListeners.get(posthog)
if (!entry) {
return
}
entry.count -= 1
if (entry.count === 0) {
entry.remove()
webClickListeners.delete(posthog)
}
}
Comment thread
ioannisj marked this conversation as resolved.
}, [captureTouches, posthog])

const captureProps = isWeb()
? {}
: { onTouchEndCapture: captureTouches ? (e: GestureResponderEvent) => onTouch('end', e) : undefined }

return (
<View
{...{ [phLabelProp]: 'PostHogProvider' }} // Dynamically setting customLabelProp (default: ph-label)
style={style || { flex: 1 }}
onTouchEndCapture={captureTouches ? (e) => onTouch('end', e) : undefined}
{...captureProps}
>
<PostHogContext.Provider value={{ client: posthog }}>
{captureScreens && <PostHogNavigationHook options={autocaptureOptions} client={posthog} />}
Expand Down
82 changes: 71 additions & 11 deletions packages/react-native/src/autocapture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,60 @@ export const defaultPostHogLabelProp = 'ph-label'

const captureAttributePrefix = 'data-ph-capture-attribute-'

export const autocaptureFromTouchEvent = (e: any, posthog: PostHog, options: PostHogAutocaptureOptions = {}): void => {
// react-native-web internals; skipped only where RNW puts them, so a same-named app component is kept.
// Verified against RNW 0.20.0 and 0.21.2: createElement wraps in LocaleProvider only when
// `domProps.dir` is set, so a nested element inside a text ancestor never gets one. Recheck on bump.
const frameworkInternalLabels = ['LocaleProvider']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Could we note the RNW version this was verified against? I checked 0.20.0 — createElement/index.js only wraps in LocaleProvider when domProps.dir is set, so a nested <Text> inside a text ancestor never gets one. That conditional is easy to lose track of on the next RNW bump.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted in d12d768. I checked 0.21.2 as well and the condition is unchanged, createElement only wraps in LocaleProvider when domProps.dir is set, so the comment now records both versions and says to recheck on a bump.


const reactFiberKeyPattern = /^__react(Fiber|InternalInstance)\$/

// Cycle guard, not a depth policy: real DOM chains null-terminate, a malformed parentNode may not.
const maxFallbackAncestors = 100

// Fires per interaction, so warn once: a persistent failure here silently disables autocapture.
let warnedCaptureFailure = false

// react-dom (RN Web) events have no _targetInst; the fiber sits on e.target under a randomised
// __reactFiber$ key. The clicked node may be a non-React node inside a React subtree, so walk up.
const getFallbackTargetInstance = (e: any): Element | undefined => {
let node = e.target

for (let depth = 0; node && typeof node === 'object' && depth < maxFallbackAncestors; depth++) {
const key = Object.getOwnPropertyNames(node).find((name) => reactFiberKeyPattern.test(name))
if (key) {
return node[key]
}
node = node.parentNode
}

return undefined
}

// 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).
export const autocaptureFromTouchEvent = (
e: any,
posthog: PostHog,
options: PostHogAutocaptureOptions = {},
eventType: 'touch' | 'click' = 'touch'
): void => {
try {
captureFromEvent(e, posthog, options, eventType)
} catch (error) {
if (!warnedCaptureFailure) {
warnedCaptureFailure = true
console.warn('PostHog autocapture: capturing the interaction threw:', error)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Not debug-gated, so this fires once in production for everyone. Fine given warn-once — just noting a systematic failure then goes fully silent, which is the case the comment above is worried about.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one as is. Bare console.warn is already the pattern across the package (storage.ts:92, 113, 128, 161 and PostHogProvider.tsx:137), so gating just this call site would make it the odd one out.

Routing it through posthog.logger isn't a free swap either. That ships a record to the logs product over the network on every failure, which is heavier than a console line and uncomfortably close to a loop when the thing failing is capture itself.

Agreed on the substance though, warn-once means a systematic failure surfaces once and then goes quiet for the life of the bundle. Worth revisiting if we ever grow an internal SDK-error channel that isn't the logs product.

}
}
}

const captureFromEvent = (
e: any,
posthog: PostHog,
options: PostHogAutocaptureOptions,
eventType: 'touch' | 'click'
): void => {
const {
noCaptureProp = 'ph-no-capture',
customLabelProp = defaultPostHogLabelProp,
Expand All @@ -70,13 +123,15 @@ export const autocaptureFromTouchEvent = (e: any, posthog: PostHog, options: Pos
propsToCapture = ['style', 'testID', 'accessibilityLabel', customLabelProp, 'children'],
} = options

if (!e._targetInst) {
const nativeInst = e._targetInst
const targetInst: Element | undefined = nativeInst || getFallbackTargetInstance(e)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: No-capture ancestor can be skipped

The new web fallback feeds clicks into a traversal that terminates when elements.length reaches maxElementsCaptured (20 by default), so it never checks any remaining outer fibers for noCaptureProp. An end user can click a control nested under 20 labeled components inside a ph-no-capture ancestor and have its text or captured props sent; scan the complete return chain for exclusions before truncating the emitted element chain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and it isn't web-only. The ph-no-capture check at autocapture.tsx:170 sits inside the walk that stops at elements.length < maxElementsCaptured (line 161), so the same truncation drops the opt-out on the native touch path too. This PR widens the exposure rather than introducing it.

Splitting it into its own PR since it's a native bug as much as a web one. The fix is the same shape as the ownership walk added here, scan the full .return chain for the opt-out before the truncating element walk, so the cap governs what gets emitted and never what gets decided.

if (!targetInst) {
return
}
const elements: PostHogAutocaptureElement[] = []
const autocaptureProperties: Record<string, JsonType> = {}

let currentInst: Element | undefined = e._targetInst
let currentInst: Element | undefined = targetInst

while (
currentInst &&
Expand Down Expand Up @@ -130,14 +185,19 @@ export const autocaptureFromTouchEvent = (e: any, posthog: PostHog, options: Pos
}

// Try and find a sensible label
const label =
typeof props?.[customLabelProp] !== 'undefined'
? `${props[customLabelProp]}`
: currentInst.elementType?.displayName || currentInst.elementType?.name
const hasCustomLabel = typeof props?.[customLabelProp] !== 'undefined'
const label = hasCustomLabel
? `${props?.[customLabelProp]}`
: currentInst.elementType?.displayName || currentInst.elementType?.name

Object.assign(autocaptureProperties, elAutocaptureProperties)

if (label && !ignoreLabels.includes(label)) {
// RNW wraps the touched host node directly, so its internals only ever head the chain; a match
// further up is the app's own component. A user-set label is never a framework internal.
const isFrameworkWrapper =
!nativeInst && !hasCustomLabel && elements.length === 0 && frameworkInternalLabels.includes(label as string)

if (label && !isFrameworkWrapper && !ignoreLabels.includes(label)) {
el.tag_name = sanitiseLabel(label)
elements.push(el)
}
Expand All @@ -164,10 +224,10 @@ export const autocaptureFromTouchEvent = (e: any, posthog: PostHog, options: Pos
element['tag_name'] = lastLabel
}
}
posthog.autocapture('touch', elements, {
posthog.autocapture(eventType, elements, {
...autocaptureProperties,
$touch_x: e.nativeEvent.pageX,
$touch_y: e.nativeEvent.pageY,
$touch_x: e.nativeEvent?.pageX,
$touch_y: e.nativeEvent?.pageY,
})
}
}
6 changes: 5 additions & 1 deletion packages/react-native/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ export type PostHogNavigationRef = {

export type PostHogAutocaptureOptions = {
/**
* Enable autocapture of touch events
* Enable autocapture of touch events.
*
* On React Native Web this also captures `click` events — mouse, trackpad, keyboard
* activation and programmatic clicks — emitted with `$event_type: 'click'`, since
* browsers fire `touchend` only for touch input.
*
* @default false
*/
Expand Down
Loading
Loading