diff --git a/.changeset/brave-moons-shake.md b/.changeset/brave-moons-shake.md new file mode 100644 index 0000000000..6cf7170357 --- /dev/null +++ b/.changeset/brave-moons-shake.md @@ -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. diff --git a/packages/react-native/references/posthog-react-native-references-latest.json b/packages/react-native/references/posthog-react-native-references-latest.json index 87b22f8fd8..a1bbae980a 100644 --- a/packages/react-native/references/posthog-react-native-references-latest.json +++ b/packages/react-native/references/posthog-react-native-references-latest.json @@ -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" }, diff --git a/packages/react-native/src/PostHogProvider.tsx b/packages/react-native/src/PostHogProvider.tsx index dcef9f866d..30ebbed1cf 100644 --- a/packages/react-native/src/PostHogProvider.tsx +++ b/packages/react-native/src/PostHogProvider.tsx @@ -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 { autocaptureFromTouchEvent, findOwningNode } from './autocapture' import { useNavigationTracker } from './hooks/useNavigationTracker' import { PostHogContext } from './PostHogContext' import { PostHogAutocaptureOptions } from './types' +import { isWeb } from './utils' import { defaultPostHogLabelProp } from './autocapture' /** @@ -43,6 +44,13 @@ export interface PostHogProviderProps { style?: StyleProp } +// One document click listener per client, shared across sibling providers, so one interaction +// enqueues exactly one $autocapture event (sdk-specs autocapture). `owners` maps each mounted +// provider's host node to its own options, so a click is scoped to the owning subtree AND +// captured with that provider's config; its size doubles as the refcount. +type WebClickOwners = Map> +const webClickListeners = new WeakMap void }>() + function PostHogNavigationHook({ options, client, @@ -173,11 +181,77 @@ export const PostHogProvider = ({ [captureTouches, posthog, autocaptureOptions] ) + // Browsers fire touchend only for touch input, so a mouse never reaches onTouchEndCapture. + // Listen on the document in the CAPTURE phase: RNW forwards `onClick` but not `onClickCapture`, + // and its Pressable stops propagation before any bubble-phase handler runs. Document-wide so it + // still sees Modal; subtree scoping happens in autocapture.tsx. Read options through a ref so an + // inline `autocapture` prop doesn't re-attach every render. + const hostRef = useRef(null) + 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 ownerNode = hostRef.current + if (!ownerNode) { + // Without a host node nothing can be scoped to this provider, and a null key would collide + // with any sibling in the same state. + return + } + + const existing = webClickListeners.get(posthog) + if (existing) { + existing.owners.set(ownerNode, optionsRef) + } else { + const owners: WebClickOwners = new Map([[ownerNode, optionsRef]]) + const handler = (e: any): void => { + const owner = findOwningNode(e, owners) + if (!owner) { + return + } + const options = owners.get(owner)?.current + if (!options) { + return + } + autocaptureFromTouchEvent({ target: e.target, nativeEvent: e }, posthog, options, 'click') + } + doc.addEventListener('click', handler, true) + webClickListeners.set(posthog, { + owners, + remove: () => doc.removeEventListener('click', handler, true), + }) + } + + return () => { + const entry = webClickListeners.get(posthog) + if (!entry) { + return + } + entry.owners.delete(ownerNode) + if (entry.owners.size === 0) { + entry.remove() + webClickListeners.delete(posthog) + } + } + }, [captureTouches, posthog]) + + const captureProps = isWeb() + ? {} + : { onTouchEndCapture: captureTouches ? (e: GestureResponderEvent) => onTouch('end', e) : undefined } + return ( onTouch('end', e) : undefined} + {...captureProps} > {captureScreens && } diff --git a/packages/react-native/src/autocapture.tsx b/packages/react-native/src/autocapture.tsx index 6fdeb49285..16330fdc0a 100644 --- a/packages/react-native/src/autocapture.tsx +++ b/packages/react-native/src/autocapture.tsx @@ -9,6 +9,8 @@ interface Element { } memoizedProps?: Record return?: Element + // Host fibers carry their DOM node here; used to scope web capture to a provider's subtree. + stateNode?: unknown } const isAnimatedValue = (value: any): boolean => { @@ -61,7 +63,80 @@ 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'] + +const reactFiberKeyPattern = /^__react(Fiber|InternalInstance)\$/ + +// Cycle guard, not a depth policy: real DOM chains null-terminate, a malformed parentNode may not. +const maxFallbackAncestors = 100 + +// Separate bound for the fiber walk: unrelated to the DOM guard above, so tuning one never +// silently retunes the other. +const maxOwnerAncestors = 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 +} + +// Returns the owner node this event happened under, or undefined if none owns it. Walks the fiber +// tree, not the DOM: RNW's Modal portals to document.body, so the DOM parent chain leaves the +// subtree but fiber `.return` does not. Kept separate from the element walk in captureFromEvent, +// which stops at maxElementsCaptured and would falsely reject a deep target. +export const findOwningNode = (e: any, owners: { has(node: unknown): boolean }): unknown => { + let current: Element | undefined = e._targetInst || getFallbackTargetInstance(e) + for (let depth = 0; current && depth < maxOwnerAncestors; depth++) { + if (current.stateNode && owners.has(current.stateNode)) { + return current.stateNode + } + current = current.return + } + + 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) + } + } +} + +const captureFromEvent = ( + e: any, + posthog: PostHog, + options: PostHogAutocaptureOptions, + eventType: 'touch' | 'click' +): void => { const { noCaptureProp = 'ph-no-capture', customLabelProp = defaultPostHogLabelProp, @@ -70,13 +145,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) + if (!targetInst) { return } const elements: PostHogAutocaptureElement[] = [] const autocaptureProperties: Record = {} - let currentInst: Element | undefined = e._targetInst + let currentInst: Element | undefined = targetInst while ( currentInst && @@ -130,14 +207,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) } @@ -164,10 +246,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, }) } } diff --git a/packages/react-native/src/types.ts b/packages/react-native/src/types.ts index 1700b1966c..f5e60f1ae0 100644 --- a/packages/react-native/src/types.ts +++ b/packages/react-native/src/types.ts @@ -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 */ diff --git a/packages/react-native/test/PostHogProvider.spec.ts b/packages/react-native/test/PostHogProvider.spec.ts index d9bc3ca248..22414ee1cf 100644 --- a/packages/react-native/test/PostHogProvider.spec.ts +++ b/packages/react-native/test/PostHogProvider.spec.ts @@ -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' @@ -20,6 +21,258 @@ const CaptureClient = ({ onClient }: { onClient: (client: PostHog) => void }) => return null } +const createClient = (): any => ({ debug: jest.fn(), autocapture: jest.fn() }) + +// Named so the element walk finds a label for it; a bare