Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 from mouse and trackpad input,\nemitted with `$event_type: 'click'`, since browsers fire `touchend` only for touch input.",
"type": "boolean",
"name": "captureTouches"
},
Expand Down
32 changes: 30 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 @@ -173,11 +174,38 @@ 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:
// RNW's Pressable calls stopPropagation so a bubble-phase handler never sees presses on a
// button, and RNW's Modal portals its content to document.body, outside this provider's subtree.
// 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 handler = (e: any): void => {

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.

[question] Document scope means clicks outside the provider's subtree get captured too — a sibling button under a named app component comes back as elements: ["OutsideAppComponent"], $event_type: "click", where native captures nothing. Probably the right trade for Modal portals, but posthog.com currently says "touch events for children of PostHogProvider are tracked" — can we update that line in the same batch? (Portals work either way; React keeps the fiber .return chain intact through them.)

@ioannisj ioannisj Aug 25, 2026

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.

Scoped in 16b0fca. The listener stays on the document so Modal still works, but capture now walks the fiber .return chain and drops anything whose ancestry doesn't pass through a mounted provider's root node. SO OutsideAppComponent case captures nothing now, and a click in one provider's subtree no longer reaches a second provider's client. This was verified in the browser.

Tbh I did weigh leaving it to a follow-up, multiple roots with distinct clients is rare I would think, but once the wide behaviour ships people depend on it and narrowing later becomes the breaking change

autocaptureFromTouchEvent({ target: e.target, nativeEvent: e }, posthog, optionsRef.current, 'click')
}
Comment thread
ioannisj marked this conversation as resolved.
doc.addEventListener('click', handler, true)
Comment thread
veria-ai[bot] marked this conversation as resolved.
Outdated

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.

[question] Two sibling providers sharing one client each register their own listener, so one click in one subtree enqueues two $autocapture events (measured in jsdom: expected 1, got 2 — native gives 1, since onTouchEndCapture is subtree-scoped). The autocapture spec has a scenario for exactly this — "Repeated setup does not install duplicate autocapture observers", exactly one event should be enqueued for that interaction (openspec/specs/autocapture/spec.md). Worth refcounting the listener per client, or is one-provider-per-app the assumption we're happy with?

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.

Good catch on the spec, I had this filed as a preference rather than a conformance gap.

Fixed in 8797151. The document listener is now refcounted per client in a module-level WeakMap, so however many providers mount against one client exactly one listener is installed, and a single interaction enqueues one $autocapture. Two tests came with it: two sibling providers sharing a client produce one event, and the listener only detaches when the last provider unmounts. Both fail against the previous commit, so they're real regression guards rather than passing by accident.

Worth being explicit that this fixes the duplication and not the scope. Clicks outside the provider's subtree are still captured, and with two different clients each still gets its own document listener, so the cross-client misrouting greptile raised is untouched. Picking that up separately since it needs a fiber ancestry check rather than a counter.

return () => doc.removeEventListener('click', handler, true)
}, [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
80 changes: 69 additions & 11 deletions packages/react-native/src/autocapture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,58 @@ 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.
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 +121,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 +183,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 +222,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,
})
}
}
5 changes: 4 additions & 1 deletion packages/react-native/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ 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 from mouse and trackpad input,
* emitted with `$event_type: 'click'`, since browsers fire `touchend` only for touch input.

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.

[suggestion] click also fires for keyboard activation and programmatic .click(), not just pointers — worth saying so, since those produce autocapture events with no touch behind them.

Suggested change
* On React Native Web this also captures `click` events from mouse and trackpad input,
* emitted with `$event_type: 'click'`, since browsers fire `touchend` only for touch input.
* 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.

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.

Applied verbatim in d12d768. You're right that keyboard activation and programmatic .click() both land here with no pointer behind them, and the old wording denied it.

*
* @default false
*/
Expand Down
129 changes: 128 additions & 1 deletion packages/react-native/test/PostHogProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** @jest-environment jsdom */
import React, { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { render, cleanup } from '@testing-library/react'
import { AppState, Linking } from 'react-native'
import { AppState, Linking, Platform } from 'react-native'

import { PostHogProvider } from '../src/PostHogProvider'
import { usePostHog } from '../src/hooks/usePostHog'
Expand All @@ -20,6 +21,132 @@ const CaptureClient = ({ onClient }: { onClient: (client: PostHog) => void }) =>
return null
}

const createClient = (): any => ({ debug: jest.fn(), autocapture: jest.fn() })

const renderOnWeb = (
client: any,
children: React.ReactNode = React.createElement('button', { type: 'button' }, 'press me')
): ReturnType<typeof render> => {
Platform.OS = 'web'
return render(
React.createElement(
PostHogProvider,
{ client, autocapture: { captureTouches: true, captureScreens: false } },
children
)
)
}

describe('PostHogProvider web click capture', () => {
const nativePlatform = Platform.OS

afterEach(() => {
Platform.OS = nativePlatform
cleanup()
})

it('should capture a click as $event_type click', () => {
const client = createClient()
const { getByText } = renderOnWeb(client)

getByText('press me').click()

expect(client.autocapture).toHaveBeenCalledTimes(1)
expect(client.autocapture.mock.calls[0][0]).toEqual('click')
})

it('should listen in the capture phase, since RNW Pressable stops propagation', () => {
const addEventListener = jest.spyOn(document, 'addEventListener')

renderOnWeb(createClient())

const click = addEventListener.mock.calls.find(([type]) => type === 'click')
expect(click).toBeDefined()
expect(click?.[2]).toEqual(true)
addEventListener.mockRestore()
})

it('should capture a click inside portalled content, as RNW Modal renders outside the provider', () => {
const client = createClient()
const { getByText } = renderOnWeb(
client,
createPortal(React.createElement('button', { type: 'button' }, 'in a modal'), document.body)
)

getByText('in a modal').click()

expect(client.autocapture).toHaveBeenCalledTimes(1)
expect(client.autocapture.mock.calls[0][0]).toEqual('click')
})

it('should keep the same listener across re-renders when autocapture options are inline', () => {
const client = createClient()
const addEventListener = jest.spyOn(document, 'addEventListener')
const element = React.createElement(
PostHogProvider,
{ client, autocapture: { captureTouches: true, captureScreens: false } },
React.createElement('button', { type: 'button' }, 'press me')
)

Platform.OS = 'web'
const { rerender } = render(element)
rerender(element)
rerender(element)

expect(addEventListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1)
addEventListener.mockRestore()
})

it('should remove the click listener on unmount', () => {
const removeEventListener = jest.spyOn(document, 'removeEventListener')
const client = createClient()
const { unmount, container } = renderOnWeb(client)
const button = container.querySelector('button') as HTMLButtonElement

unmount()
button.click()

expect(removeEventListener.mock.calls.some(([type, , capture]) => type === 'click' && capture === true)).toEqual(
true
)
expect(client.autocapture).not.toHaveBeenCalled()
removeEventListener.mockRestore()
})

it('should not listen for clicks on native, where onTouchEndCapture already fires', () => {
const addEventListener = jest.spyOn(document, 'addEventListener')
const client = createClient()

render(
React.createElement(
PostHogProvider,
{ client, autocapture: { captureTouches: true, captureScreens: false } },
React.createElement('button', { type: 'button' }, 'press me')
)
)

expect(addEventListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(0)
expect(client.autocapture).not.toHaveBeenCalled()
addEventListener.mockRestore()
})

it('should not listen for clicks when captureTouches is off', () => {
const addEventListener = jest.spyOn(document, 'addEventListener')

Platform.OS = 'web'
render(
React.createElement(
PostHogProvider,
{ client: createClient(), autocapture: { captureScreens: false } },
React.createElement('button', { type: 'button' }, 'press me')
)
)

expect(addEventListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(0)
addEventListener.mockRestore()
})
})

describe('PostHogProvider', () => {
beforeEach(() => {
;(globalThis as any).window.fetch = jest.fn(async () => ({
Expand Down
Loading
Loading