From 42debba89fcb0f50d00f49f0f5bb5dd59343d225 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 4 Mar 2026 22:30:06 +0000 Subject: [PATCH 1/3] Implement tap tracking for content entries in React Native SDK --- .../react-native-sdk/e2e/tap-tracking.test.js | 43 +++++ .../sections/ContentEntry.tsx | 4 +- .../src/components/Analytics.tsx | 71 ++++++-- .../src/components/OptimizationRoot.tsx | 50 +++++- .../src/components/Personalization.tsx | 111 +++++++----- .../context/InteractionTrackingContext.tsx | 107 ++++++++++++ .../src/context/LiveUpdatesContext.tsx | 4 +- .../src/hooks/useTapTracking.ts | 158 ++++++++++++++++++ .../src/hooks/useViewportTracking.ts | 22 ++- packages/react-native-sdk/src/index.ts | 9 + 10 files changed, 507 insertions(+), 72 deletions(-) create mode 100644 implementations/react-native-sdk/e2e/tap-tracking.test.js create mode 100644 packages/react-native-sdk/src/context/InteractionTrackingContext.tsx create mode 100644 packages/react-native-sdk/src/hooks/useTapTracking.ts diff --git a/implementations/react-native-sdk/e2e/tap-tracking.test.js b/implementations/react-native-sdk/e2e/tap-tracking.test.js new file mode 100644 index 00000000..bd0d835b --- /dev/null +++ b/implementations/react-native-sdk/e2e/tap-tracking.test.js @@ -0,0 +1,43 @@ +const { + clearProfileState, + ELEMENT_VISIBILITY_TIMEOUT, + waitForEventsCountAtLeast, +} = require('./helpers') + +describe('Tap Tracking', () => { + beforeAll(async () => { + await device.launchApp() + }) + + beforeEach(async () => { + await clearProfileState() + }) + + it('should emit component_click when tapping a content entry', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + await element(by.id('content-entry-1MwiFl4z7gkwqGYdvCmr8c')).tap() + + await waitForEventsCountAtLeast(1) + + await waitFor(element(by.id('event-component_click-1MwiFl4z7gkwqGYdvCmr8c'))) + .toBeVisible() + .whileElement(by.id('main-scroll-view')) + .scroll(500, 'down') + }) + + it('should emit component_click for a different entry', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + await element(by.id('content-entry-2Z2WLOx07InSewC3LUB3eX')).tap() + + await waitForEventsCountAtLeast(1) + + await waitFor(element(by.id('event-component_click-2Z2WLOx07InSewC3LUB3eX'))) + .toBeVisible() + .whileElement(by.id('main-scroll-view')) + .scroll(500, 'down') + }) +}) diff --git a/implementations/react-native-sdk/sections/ContentEntry.tsx b/implementations/react-native-sdk/sections/ContentEntry.tsx index 8a2c3992..773d7200 100644 --- a/implementations/react-native-sdk/sections/ContentEntry.tsx +++ b/implementations/react-native-sdk/sections/ContentEntry.tsx @@ -66,7 +66,7 @@ export function ContentEntry({ entry }: ContentEntryProps): React.JSX.Element { return ( {isPersonalizedEntry(entry) ? ( - + {(resolvedEntry) => ( {renderContent(resolvedEntry, entry.sys.id)} @@ -74,7 +74,7 @@ export function ContentEntry({ entry }: ContentEntryProps): React.JSX.Element { )} ) : ( - + {renderContent(entry, entry.sys.id)} )} diff --git a/packages/react-native-sdk/src/components/Analytics.tsx b/packages/react-native-sdk/src/components/Analytics.tsx index 19ea275f..28556933 100644 --- a/packages/react-native-sdk/src/components/Analytics.tsx +++ b/packages/react-native-sdk/src/components/Analytics.tsx @@ -1,6 +1,8 @@ import type { Entry } from 'contentful' import React, { type ReactNode } from 'react' import { View, type StyleProp, type ViewStyle } from 'react-native' +import { useInteractionTracking } from '../context/InteractionTrackingContext' +import { useTapTracking } from '../hooks/useTapTracking' import { useViewportTracking } from '../hooks/useViewportTracking' /** @@ -29,7 +31,7 @@ export interface AnalyticsProps { * Minimum time (in milliseconds) the component must be visible * before tracking fires. * - * @defaultValue 2000 + * @defaultValue `2000` */ viewTimeMs?: number @@ -37,7 +39,7 @@ export interface AnalyticsProps { * Minimum visibility ratio (0.0 - 1.0) required to consider * the component "visible". * - * @defaultValue 0.8 + * @defaultValue `0.8` */ threshold?: number @@ -50,16 +52,38 @@ export interface AnalyticsProps { * Optional testID for testing purposes. */ testID?: string + + /** + * Per-component override for view tracking. + * - `undefined`: inherits from `trackEntryInteraction.views` on {@link OptimizationRoot} + * - `true`: enable view tracking for this entry + * - `false`: disable view tracking for this entry + * + * @defaultValue `undefined` + */ + trackViews?: boolean + + /** + * Per-component override for tap tracking. + * - `undefined`: inherits from `trackEntryInteraction.taps` on {@link OptimizationRoot} + * - `true`: track taps using the existing entry metadata + * - `false`: disable tap tracking (overrides the global setting) + * - `(entry: Entry) => void`: track taps and invoke the callback + * with the entry after the tracking event is emitted + * + * @defaultValue `undefined` + */ + onTap?: boolean | ((entry: Entry) => void) } /** - * Tracks views of non-personalized Contentful entry components (content entries). + * Tracks views and taps of non-personalized Contentful entry components (content entries). * * Use this component for standard Contentful entries you want analytics on * (products, articles, etc.) that are not personalized. * - * @param props - Component props - * @returns A wrapper View with viewport tracking attached + * @param props - {@link AnalyticsProps} + * @returns A wrapper View with interaction tracking attached * * @remarks * Must be used within an {@link OptimizationProvider}. Works with or without a @@ -78,15 +102,12 @@ export interface AnalyticsProps { * * * ``` - * - * @example Custom Thresholds + * @example With Tap Tracking * ```tsx - * - * + * + * navigate(productEntry)}> + * + * * * ``` * @@ -101,16 +122,36 @@ export function Analytics({ threshold, style, testID, + trackViews, + onTap, }: AnalyticsProps): React.JSX.Element { - // Set up viewport tracking - the hook extracts tracking metadata from the entry + const interactionTracking = useInteractionTracking() + + const viewsEnabled = trackViews ?? interactionTracking.views + const tapsEnabled = + onTap === false ? false : onTap !== undefined ? true : interactionTracking.taps + const { onLayout } = useViewportTracking({ entry, threshold, viewTimeMs, + enabled: viewsEnabled, + }) + + const { onTouchStart, onTouchEnd } = useTapTracking({ + entry, + enabled: tapsEnabled, + onTap, }) return ( - + {children} ) diff --git a/packages/react-native-sdk/src/components/OptimizationRoot.tsx b/packages/react-native-sdk/src/components/OptimizationRoot.tsx index a4e95638..b7c6976f 100644 --- a/packages/react-native-sdk/src/components/OptimizationRoot.tsx +++ b/packages/react-native-sdk/src/components/OptimizationRoot.tsx @@ -1,5 +1,6 @@ import type { CoreStatefulConfig } from '@contentful/optimization-core' import React, { type ReactNode } from 'react' +import { InteractionTrackingProvider, TrackEntryInteractionOptions } from '../context/InteractionTrackingContext' import { LiveUpdatesProvider } from '../context/LiveUpdatesContext' import type { PreviewPanelConfig } from '../preview' import { PreviewPanelOverlay } from '../preview/components/PreviewPanelOverlay' @@ -22,14 +23,38 @@ export interface OptimizationRootProps extends CoreStatefulConfig { /** * Whether {@link Personalization} components should react to state changes in real-time. * + * @defaultValue `false` + * * @remarks * Live updates are always enabled when the preview panel is open, * regardless of this setting. - * - * @defaultValue false */ liveUpdates?: boolean + /** + * Controls which entry interactions are tracked automatically for all + * {@link Personalization} and {@link Analytics} components. Individual + * components can override each interaction type with their `trackViews` + * and `onTap` props. + * + * @defaultValue `{ views: true, taps: false }` + * + * @remarks + * Mirrors the web SDK's `autoTrackEntryInteraction` pattern. Uses `taps` + * instead of `clicks` to match React Native terminology. + * + * @example + * ```tsx + * + * + * + * ``` + */ + trackEntryInteraction?: TrackEntryInteractionOptions + /** * Children components that will have access to the Optimization instance. */ @@ -38,7 +63,7 @@ export interface OptimizationRootProps extends CoreStatefulConfig { /** * Recommended top-level wrapper that combines {@link OptimizationProvider} with optional - * preview panel and live updates support. + * preview panel, live updates, and interaction tracking support. * * Handles SDK initialization internally — pass config properties directly as props. * @@ -51,7 +76,15 @@ export interface OptimizationRootProps extends CoreStatefulConfig { * * * ``` - * + * @example With interaction tracking + * ```tsx + * + * + * + * ``` * @example With preview panel * ```tsx * - {content} + + + {content} + + ) } diff --git a/packages/react-native-sdk/src/components/Personalization.tsx b/packages/react-native-sdk/src/components/Personalization.tsx index 71402d5a..c39f658e 100644 --- a/packages/react-native-sdk/src/components/Personalization.tsx +++ b/packages/react-native-sdk/src/components/Personalization.tsx @@ -3,8 +3,10 @@ import type { SelectedPersonalizationArray } from '@contentful/optimization-core import type { Entry, EntrySkeletonType } from 'contentful' import React, { useEffect, useMemo, useState, type ReactNode } from 'react' import { View, type StyleProp, type ViewStyle } from 'react-native' +import { useInteractionTracking } from '../context/InteractionTrackingContext' import { useLiveUpdates } from '../context/LiveUpdatesContext' import { useOptimization } from '../context/OptimizationContext' +import { useTapTracking } from '../hooks/useTapTracking' import { useViewportTracking } from '../hooks/useViewportTracking' /** @@ -32,6 +34,7 @@ export interface PersonalizationProps { * or with the selected variant entry if personalization applies. * * @param resolvedEntry - The entry to display (baseline or variant) + * * @returns ReactNode to render * * @example @@ -52,7 +55,7 @@ export interface PersonalizationProps { * Minimum time (in milliseconds) the component must be visible * before tracking fires. * - * @defaultValue 2000 + * @defaultValue `2000` */ viewTimeMs?: number @@ -60,7 +63,7 @@ export interface PersonalizationProps { * Minimum visibility ratio (0.0 - 1.0) required to consider * the component "visible". * - * @defaultValue 0.8 + * @defaultValue `0.8` */ threshold?: number @@ -81,21 +84,43 @@ export interface PersonalizationProps { * it receives, preventing UI flashing when user actions change their qualification. * When `true`, the component updates immediately when personalizations change. * + * @defaultValue `undefined` + * * @remarks * Live updates are always enabled when the preview panel is open, * regardless of this setting. - * - * @defaultValue undefined */ liveUpdates?: boolean + + /** + * Per-component override for view tracking. + * - `undefined`: inherits from `trackEntryInteraction.views` on {@link OptimizationRoot} + * - `true`: enable view tracking for this entry + * - `false`: disable view tracking for this entry + * + * @defaultValue `undefined` + */ + trackViews?: boolean + + /** + * Per-component override for tap tracking. + * - `undefined`: inherits from `trackEntryInteraction.taps` on {@link OptimizationRoot} + * - `true`: track taps using the existing entry metadata + * - `false`: disable tap tracking (overrides the global setting) + * - `(resolvedEntry: Entry) => void`: track taps and invoke the callback + * with the resolved entry after the tracking event is emitted + * + * @defaultValue `undefined` + */ + onTap?: boolean | ((resolvedEntry: Entry) => void) } /** - * Tracks views of personalized Contentful entry components and resolves variants + * Tracks views and taps of personalized Contentful entry components and resolves variants * based on the user's profile and active personalizations. * - * @param props - Component props - * @returns A wrapper View with viewport tracking attached + * @param props - {@link PersonalizationProps} + * @returns A wrapper View with interaction tracking attached * * @remarks * "Component tracking" refers to tracking Contentful entry components (content entries), @@ -120,27 +145,19 @@ export interface PersonalizationProps { * * * ``` - * - * @example Custom Thresholds - * ```tsx - * - * {(resolvedEntry) => } - * - * ``` - * - * @example Live Updates + * @example With Tap Tracking * ```tsx - * - * {(resolvedEntry) => } + * + * {(resolvedEntry) => ( + * navigate(resolvedEntry)}> + * + * + * )} * * ``` * * @see {@link Analytics} for tracking non-personalized entries - * @see {@link OptimizationRoot} for configuring global live updates + * @see {@link OptimizationRoot} for configuring global interaction tracking * * @public */ @@ -152,40 +169,28 @@ export function Personalization({ style, testID, liveUpdates, + trackViews, + onTap, }: PersonalizationProps): React.JSX.Element { const optimization = useOptimization() const liveUpdatesContext = useLiveUpdates() + const interactionTracking = useInteractionTracking() - // Determine if live updates should be enabled for this component: - // 1. Preview panel visible always enables live updates (highest priority) - // 2. Per-component liveUpdates prop overrides global setting - // 3. Global liveUpdates from OptimizationRoot - // 4. Default: lock on first non-undefined value const shouldLiveUpdate = liveUpdatesContext?.previewPanelVisible === true || (liveUpdates ?? liveUpdatesContext?.globalLiveUpdates ?? false) - // Track personalization state with lock-on-first-value behavior - // When shouldLiveUpdate is false, we only accept the first non-undefined value const [lockedPersonalizations, setLockedPersonalizations] = useState< SelectedPersonalizationArray | undefined >(undefined) useEffect(() => { const subscription = optimization.states.personalizations.subscribe((p) => { - setLockedPersonalizations((previous) => { - if (shouldLiveUpdate) { - // Live updates enabled - always mirror state updates. - return p - } - - // First non-undefined value - lock it, then ignore subsequent updates. - if (previous === undefined && p !== undefined) { - return p - } - - return previous - }) + if (shouldLiveUpdate) { + setLockedPersonalizations(p) + } else if (lockedPersonalizations === undefined && p !== undefined) { + setLockedPersonalizations(p) + } }) return () => { @@ -198,15 +203,33 @@ export function Personalization({ [baselineEntry, optimization, lockedPersonalizations], ) + const viewsEnabled = trackViews ?? interactionTracking.views + const tapsEnabled = + onTap === false ? false : onTap !== undefined ? true : interactionTracking.taps + const { onLayout } = useViewportTracking({ entry: resolvedData.entry, personalization: resolvedData.personalization, threshold, viewTimeMs, + enabled: viewsEnabled, + }) + + const { onTouchStart, onTouchEnd } = useTapTracking({ + entry: resolvedData.entry, + personalization: resolvedData.personalization, + enabled: tapsEnabled, + onTap, }) return ( - + {children(resolvedData.entry)} ) diff --git a/packages/react-native-sdk/src/context/InteractionTrackingContext.tsx b/packages/react-native-sdk/src/context/InteractionTrackingContext.tsx new file mode 100644 index 00000000..4e3c4922 --- /dev/null +++ b/packages/react-native-sdk/src/context/InteractionTrackingContext.tsx @@ -0,0 +1,107 @@ +import React, { createContext, useContext, useMemo, type ReactNode } from 'react' + +/** + * Supported entry interaction types for React Native. + * + * @remarks + * Mirrors the web SDK's `EntryInteraction` but uses `taps` instead of `clicks` + * (RN terminology) and omits `hovers` (no mouse in RN). + * + * @public + */ +export type EntryInteraction = 'views' | 'taps' + +/** + * Auto-tracking configuration for entry interactions, mirroring the web SDK's + * `autoTrackEntryInteraction` pattern. + * + * @remarks + * Omitted keys fall back to their defaults: `views` defaults to `true`, + * `taps` defaults to `false`. + * + * @public + */ +export type TrackEntryInteractionOptions = Partial> + +/** + * Resolved interaction tracking state provided to descendant components. + * + * @internal + */ +interface InteractionTrackingContextValue { + /** Whether view tracking is enabled globally. */ + views: boolean + /** Whether tap tracking is enabled globally. */ + taps: boolean +} + +const DEFAULT_VIEWS = true +const DEFAULT_TAPS = false + +const InteractionTrackingContext = createContext({ + views: DEFAULT_VIEWS, + taps: DEFAULT_TAPS, +}) + +/** + * Returns the resolved interaction tracking configuration from the nearest + * {@link InteractionTrackingProvider}. + * + * @returns The resolved interaction tracking state with `views` and `taps` booleans. + * + * @example + * ```tsx + * function MyComponent() { + * const { views, taps } = useInteractionTracking() + * return Views: {String(views)}, Taps: {String(taps)} + * } + * ``` + * + * @public + */ +export function useInteractionTracking(): InteractionTrackingContextValue { + return useContext(InteractionTrackingContext) +} + +/** + * @internal + */ +interface InteractionTrackingProviderProps { + trackEntryInteraction?: TrackEntryInteractionOptions + children: ReactNode +} + +/** + * Resolves entry interaction tracking configuration and provides it to + * {@link Personalization} and {@link Analytics} components. + * + * @param props - Provider props. + * @returns A context provider wrapping the children. + * + * @remarks + * Typically wrapped by {@link OptimizationRoot} -- not used directly. + * Resolves partial `trackEntryInteraction` options against defaults + * (`views: true`, `taps: false`). + * + * @internal + */ +export function InteractionTrackingProvider({ + trackEntryInteraction, + children, +}: InteractionTrackingProviderProps): React.JSX.Element { + const value = useMemo( + () => ({ + views: trackEntryInteraction?.views ?? DEFAULT_VIEWS, + taps: trackEntryInteraction?.taps ?? DEFAULT_TAPS, + }), + [trackEntryInteraction?.views, trackEntryInteraction?.taps], + ) + + return ( + + {children} + + ) +} + +export default InteractionTrackingContext diff --git a/packages/react-native-sdk/src/context/LiveUpdatesContext.tsx b/packages/react-native-sdk/src/context/LiveUpdatesContext.tsx index 6b3ca3a5..0a483bc4 100644 --- a/packages/react-native-sdk/src/context/LiveUpdatesContext.tsx +++ b/packages/react-native-sdk/src/context/LiveUpdatesContext.tsx @@ -42,8 +42,8 @@ interface LiveUpdatesProviderProps { /** * Manages live updates configuration for {@link Personalization} components. * - * @param props - Provider props - * @returns A context provider wrapping the children + * @param props - Provider props. + * @returns A context provider wrapping the children. * * @remarks * Typically wrapped by {@link OptimizationRoot} — not used directly. Tracks the global diff --git a/packages/react-native-sdk/src/hooks/useTapTracking.ts b/packages/react-native-sdk/src/hooks/useTapTracking.ts new file mode 100644 index 00000000..fc2d8536 --- /dev/null +++ b/packages/react-native-sdk/src/hooks/useTapTracking.ts @@ -0,0 +1,158 @@ +import type { SelectedPersonalization } from '@contentful/optimization-core/api-schemas' +import { createScopedLogger } from '@contentful/optimization-core/logger' +import type { Entry } from 'contentful' +import { useCallback, useRef } from 'react' +import type { GestureResponderEvent } from 'react-native' +import { useOptimization } from '../context/OptimizationContext' +import { extractTrackingMetadata } from './useViewportTracking' + +const logger = createScopedLogger('RN:TapTracking') + +/** + * Maximum distance (in points) a touch can move between start and end + * and still be classified as a tap rather than a scroll or drag gesture. + * + * @internal + */ +const TAP_DISTANCE_THRESHOLD = 10 + +/** + * Options for the {@link useTapTracking} hook. + * + * @public + */ +export interface UseTapTrackingOptions { + /** + * The resolved Contentful entry to track (baseline or variant). + */ + entry: Entry + + /** + * Personalization data for variant tracking. Omit for baseline/non-personalized entries. + */ + personalization?: SelectedPersonalization + + /** + * Whether tap tracking is enabled for this component. + */ + enabled: boolean + + /** + * Optional callback invoked after the tap event is tracked. + * When `true`, taps are tracked without a callback. + * When a function, it is called with the entry after the tracking event is emitted. + * + * @defaultValue `undefined` + */ + onTap?: boolean | ((entry: Entry) => void) +} + +/** + * Return value of the {@link useTapTracking} hook. + * + * @public + */ +export interface UseTapTrackingReturn { + /** Touch start handler to attach to a View. `undefined` when tracking is disabled. */ + onTouchStart: ((e: GestureResponderEvent) => void) | undefined + + /** Touch end handler to attach to a View. `undefined` when tracking is disabled. */ + onTouchEnd: ((e: GestureResponderEvent) => void) | undefined +} + +/** + * Detects taps on a View via raw touch events and emits `component_click` + * analytics events through the existing Insights pipeline. + * + * @param options - Tracking options including the entry, personalization data, and enabled state. + * @returns {@link UseTapTrackingReturn} with touch handlers to spread onto a View, + * or `undefined` handlers when tracking is disabled. + * + * @throws If called outside of an {@link OptimizationProvider}. + * + * @remarks + * Uses `onTouchStart`/`onTouchEnd` rather than wrapping children in a + * `Pressable`, so taps are captured even when a child `Pressable` handles + * the gesture. A touch is classified as a tap only when the finger moves + * less than {@link TAP_DISTANCE_THRESHOLD} points between start and end. + * + * @example + * ```tsx + * function TrackedEntry({ entry }: { entry: Entry }) { + * const { onTouchStart, onTouchEnd } = useTapTracking({ + * entry, + * enabled: true, + * }) + * + * return ( + * + * {entry.fields.title} + * + * ) + * } + * ``` + * + * @public + */ +export function useTapTracking({ + entry, + personalization, + enabled, + onTap, +}: UseTapTrackingOptions): UseTapTrackingReturn { + const optimization = useOptimization() + const optimizationRef = useRef(optimization) + optimizationRef.current = optimization + + const touchStartRef = useRef<{ pageX: number; pageY: number } | null>(null) + + const { componentId, experienceId, variantIndex } = extractTrackingMetadata( + entry, + personalization, + ) + + const handleTouchStart = useCallback((e: GestureResponderEvent) => { + const { + nativeEvent: { pageX, pageY }, + } = e + touchStartRef.current = { pageX, pageY } + }, []) + + const handleTouchEnd = useCallback( + (e: GestureResponderEvent) => { + const { current: start } = touchStartRef + if (!start) return + + const { + nativeEvent: { pageX, pageY }, + } = e + const distance = Math.sqrt((pageX - start.pageX) ** 2 + (pageY - start.pageY) ** 2) + + touchStartRef.current = null + + if (distance >= TAP_DISTANCE_THRESHOLD) return + + logger.info(`Tap detected on ${componentId}, emitting component_click`) + + void optimizationRef.current.trackComponentClick({ + componentId, + experienceId, + variantIndex, + }) + + if (typeof onTap === 'function') { + onTap(entry) + } + }, + [componentId, experienceId, variantIndex, entry, onTap], + ) + + if (!enabled) { + return { onTouchStart: undefined, onTouchEnd: undefined } + } + + return { + onTouchStart: handleTouchStart, + onTouchEnd: handleTouchEnd, + } +} diff --git a/packages/react-native-sdk/src/hooks/useViewportTracking.ts b/packages/react-native-sdk/src/hooks/useViewportTracking.ts index b6dbcc35..21220c4d 100644 --- a/packages/react-native-sdk/src/hooks/useViewportTracking.ts +++ b/packages/react-native-sdk/src/hooks/useViewportTracking.ts @@ -37,6 +37,15 @@ export interface UseViewportTrackingOptions { * @defaultValue 2000 */ viewTimeMs?: number + + /** + * Whether view tracking is enabled for this component. + * When `false`, the hook returns a no-op `onLayout` and `isVisible: false` + * without setting up timers or scroll listeners. + * + * @defaultValue `true` + */ + enabled?: boolean } /** @@ -67,9 +76,13 @@ const createComponentViewId = (): string => { /** * Extracts tracking metadata from a resolved entry and optional personalization data. * + * @param resolvedEntry - The resolved Contentful entry (baseline or variant). + * @param personalization - Optional personalization selection for variant tracking. + * @returns An object containing `componentId`, optional `experienceId`, and `variantIndex`. + * * @internal */ -function extractTrackingMetadata( +export function extractTrackingMetadata( resolvedEntry: Entry, personalization?: SelectedPersonalization, ): { @@ -104,7 +117,7 @@ function extractTrackingMetadata( * Tracks whether a component is visible in the viewport and fires a component view * event when visibility and time thresholds are met. * - * @param options - Tracking options including the entry, thresholds, and personalization data + * @param options - {@link UseViewportTrackingOptions} including the entry, thresholds, and personalization data. * @returns An object with `isVisible` state and an `onLayout` callback for the tracked View * * @throws Error if called outside of an {@link OptimizationProvider} @@ -137,6 +150,7 @@ export function useViewportTracking({ personalization, threshold = DEFAULT_THRESHOLD, viewTimeMs = DEFAULT_VIEW_TIME_MS, + enabled = true, }: UseViewportTrackingOptions): UseViewportTrackingReturn { const optimization = useOptimization() // We invoke useScrollContext here to check if the OptimizationScrollProvider is mounted and the scroll context is available. @@ -189,6 +203,8 @@ export function useViewportTracking({ const startTrackingTimer = useCallback( (visibilityPercent: number) => { + if (!enabled) return + logger.info( `Component ${componentId} became visible (${visibilityPercent.toFixed(1)}%), starting ${viewTimeMs}ms timer`, ) @@ -227,7 +243,7 @@ export function useViewportTracking({ } }, viewTimeMs) }, - [componentId, experienceId, variantIndex, viewTimeMs], + [enabled, componentId, experienceId, variantIndex, viewTimeMs], ) const canCheckVisibility = useCallback((): boolean => { diff --git a/packages/react-native-sdk/src/index.ts b/packages/react-native-sdk/src/index.ts index 6442c946..3c4b1e5c 100644 --- a/packages/react-native-sdk/src/index.ts +++ b/packages/react-native-sdk/src/index.ts @@ -40,12 +40,21 @@ export type { OptimizationScrollProviderProps } from './context/OptimizationScro export { LiveUpdatesProvider, useLiveUpdates } from './context/LiveUpdatesContext' export { useOptimization } from './context/OptimizationContext' +export { useInteractionTracking } from './context/InteractionTrackingContext' +export type { + EntryInteraction, + TrackEntryInteractionOptions, +} from './context/InteractionTrackingContext' + export { useViewportTracking } from './hooks/useViewportTracking' export type { UseViewportTrackingOptions, UseViewportTrackingReturn, } from './hooks/useViewportTracking' +export { useTapTracking } from './hooks/useTapTracking' +export type { UseTapTrackingOptions, UseTapTrackingReturn } from './hooks/useTapTracking' + // Export screen tracking hooks export { useScreenTracking, useScreenTrackingCallback } from './hooks/useScreenTracking' export type { UseScreenTrackingOptions, UseScreenTrackingReturn } from './hooks/useScreenTracking' From bff6776f1a69ff02991f400ff36acec90c237802 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Thu, 5 Mar 2026 12:18:19 +0000 Subject: [PATCH 2/3] Rename onTap -> trackTaps --- .../sections/ContentEntry.tsx | 4 ++-- .../src/components/Analytics.tsx | 19 +++++++++++++------ .../src/components/OptimizationRoot.tsx | 2 +- .../src/components/Personalization.tsx | 19 +++++++++++++------ .../src/hooks/useTapTracking.ts | 6 ++---- 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/implementations/react-native-sdk/sections/ContentEntry.tsx b/implementations/react-native-sdk/sections/ContentEntry.tsx index 773d7200..5a400731 100644 --- a/implementations/react-native-sdk/sections/ContentEntry.tsx +++ b/implementations/react-native-sdk/sections/ContentEntry.tsx @@ -66,7 +66,7 @@ export function ContentEntry({ entry }: ContentEntryProps): React.JSX.Element { return ( {isPersonalizedEntry(entry) ? ( - + {(resolvedEntry) => ( {renderContent(resolvedEntry, entry.sys.id)} @@ -74,7 +74,7 @@ export function ContentEntry({ entry }: ContentEntryProps): React.JSX.Element { )} ) : ( - + {renderContent(entry, entry.sys.id)} )} diff --git a/packages/react-native-sdk/src/components/Analytics.tsx b/packages/react-native-sdk/src/components/Analytics.tsx index 28556933..eb687183 100644 --- a/packages/react-native-sdk/src/components/Analytics.tsx +++ b/packages/react-native-sdk/src/components/Analytics.tsx @@ -66,14 +66,20 @@ export interface AnalyticsProps { /** * Per-component override for tap tracking. * - `undefined`: inherits from `trackEntryInteraction.taps` on {@link OptimizationRoot} - * - `true`: track taps using the existing entry metadata + * - `true`: enable tap tracking for this entry * - `false`: disable tap tracking (overrides the global setting) - * - `(entry: Entry) => void`: track taps and invoke the callback - * with the entry after the tracking event is emitted * * @defaultValue `undefined` */ - onTap?: boolean | ((entry: Entry) => void) + trackTaps?: boolean + + /** + * Optional callback invoked with the entry after a tap tracking event is emitted. + * When provided, implicitly enables tap tracking unless `trackTaps` is explicitly `false`. + * + * @defaultValue `undefined` + */ + onTap?: (entry: Entry) => void } /** @@ -104,7 +110,7 @@ export interface AnalyticsProps { * ``` * @example With Tap Tracking * ```tsx - * + * * navigate(productEntry)}> * * @@ -123,13 +129,14 @@ export function Analytics({ style, testID, trackViews, + trackTaps, onTap, }: AnalyticsProps): React.JSX.Element { const interactionTracking = useInteractionTracking() const viewsEnabled = trackViews ?? interactionTracking.views const tapsEnabled = - onTap === false ? false : onTap !== undefined ? true : interactionTracking.taps + trackTaps === false ? false : (trackTaps ?? onTap) ? true : interactionTracking.taps const { onLayout } = useViewportTracking({ entry, diff --git a/packages/react-native-sdk/src/components/OptimizationRoot.tsx b/packages/react-native-sdk/src/components/OptimizationRoot.tsx index b7c6976f..5191ad26 100644 --- a/packages/react-native-sdk/src/components/OptimizationRoot.tsx +++ b/packages/react-native-sdk/src/components/OptimizationRoot.tsx @@ -35,7 +35,7 @@ export interface OptimizationRootProps extends CoreStatefulConfig { * Controls which entry interactions are tracked automatically for all * {@link Personalization} and {@link Analytics} components. Individual * components can override each interaction type with their `trackViews` - * and `onTap` props. + * and `trackTaps` props. * * @defaultValue `{ views: true, taps: false }` * diff --git a/packages/react-native-sdk/src/components/Personalization.tsx b/packages/react-native-sdk/src/components/Personalization.tsx index c39f658e..a9e4e04c 100644 --- a/packages/react-native-sdk/src/components/Personalization.tsx +++ b/packages/react-native-sdk/src/components/Personalization.tsx @@ -105,14 +105,20 @@ export interface PersonalizationProps { /** * Per-component override for tap tracking. * - `undefined`: inherits from `trackEntryInteraction.taps` on {@link OptimizationRoot} - * - `true`: track taps using the existing entry metadata + * - `true`: enable tap tracking for this entry * - `false`: disable tap tracking (overrides the global setting) - * - `(resolvedEntry: Entry) => void`: track taps and invoke the callback - * with the resolved entry after the tracking event is emitted * * @defaultValue `undefined` */ - onTap?: boolean | ((resolvedEntry: Entry) => void) + trackTaps?: boolean + + /** + * Optional callback invoked with the resolved entry after a tap tracking event is emitted. + * When provided, implicitly enables tap tracking unless `trackTaps` is explicitly `false`. + * + * @defaultValue `undefined` + */ + onTap?: (resolvedEntry: Entry) => void } /** @@ -147,7 +153,7 @@ export interface PersonalizationProps { * ``` * @example With Tap Tracking * ```tsx - * + * * {(resolvedEntry) => ( * navigate(resolvedEntry)}> * @@ -170,6 +176,7 @@ export function Personalization({ testID, liveUpdates, trackViews, + trackTaps, onTap, }: PersonalizationProps): React.JSX.Element { const optimization = useOptimization() @@ -205,7 +212,7 @@ export function Personalization({ const viewsEnabled = trackViews ?? interactionTracking.views const tapsEnabled = - onTap === false ? false : onTap !== undefined ? true : interactionTracking.taps + trackTaps === false ? false : (trackTaps ?? onTap) ? true : interactionTracking.taps const { onLayout } = useViewportTracking({ entry: resolvedData.entry, diff --git a/packages/react-native-sdk/src/hooks/useTapTracking.ts b/packages/react-native-sdk/src/hooks/useTapTracking.ts index fc2d8536..9816a228 100644 --- a/packages/react-native-sdk/src/hooks/useTapTracking.ts +++ b/packages/react-native-sdk/src/hooks/useTapTracking.ts @@ -38,13 +38,11 @@ export interface UseTapTrackingOptions { enabled: boolean /** - * Optional callback invoked after the tap event is tracked. - * When `true`, taps are tracked without a callback. - * When a function, it is called with the entry after the tracking event is emitted. + * Optional callback invoked with the entry after the tap tracking event is emitted. * * @defaultValue `undefined` */ - onTap?: boolean | ((entry: Entry) => void) + onTap?: (entry: Entry) => void } /** From a6633c6e6c47f388a5dbf903c3d8a6e9845a8ac4 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Fri, 6 Mar 2026 14:54:09 +0000 Subject: [PATCH 3/3] Fix lint and live update hook --- .../src/components/OptimizationRoot.tsx | 5 ++++- .../src/components/Personalization.tsx | 13 +++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/react-native-sdk/src/components/OptimizationRoot.tsx b/packages/react-native-sdk/src/components/OptimizationRoot.tsx index 5191ad26..98243450 100644 --- a/packages/react-native-sdk/src/components/OptimizationRoot.tsx +++ b/packages/react-native-sdk/src/components/OptimizationRoot.tsx @@ -1,6 +1,9 @@ import type { CoreStatefulConfig } from '@contentful/optimization-core' import React, { type ReactNode } from 'react' -import { InteractionTrackingProvider, TrackEntryInteractionOptions } from '../context/InteractionTrackingContext' +import { + InteractionTrackingProvider, + type TrackEntryInteractionOptions, +} from '../context/InteractionTrackingContext' import { LiveUpdatesProvider } from '../context/LiveUpdatesContext' import type { PreviewPanelConfig } from '../preview' import { PreviewPanelOverlay } from '../preview/components/PreviewPanelOverlay' diff --git a/packages/react-native-sdk/src/components/Personalization.tsx b/packages/react-native-sdk/src/components/Personalization.tsx index a9e4e04c..f7fcb480 100644 --- a/packages/react-native-sdk/src/components/Personalization.tsx +++ b/packages/react-native-sdk/src/components/Personalization.tsx @@ -1,7 +1,7 @@ import type { ResolvedData } from '@contentful/optimization-core' import type { SelectedPersonalizationArray } from '@contentful/optimization-core/api-schemas' import type { Entry, EntrySkeletonType } from 'contentful' -import React, { useEffect, useMemo, useState, type ReactNode } from 'react' +import React, { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { View, type StyleProp, type ViewStyle } from 'react-native' import { useInteractionTracking } from '../context/InteractionTrackingContext' import { useLiveUpdates } from '../context/LiveUpdatesContext' @@ -191,11 +191,20 @@ export function Personalization({ SelectedPersonalizationArray | undefined >(undefined) + const isLockedRef = useRef(false) + + useEffect(() => { + if (shouldLiveUpdate) { + isLockedRef.current = false + } + }, [shouldLiveUpdate]) + useEffect(() => { const subscription = optimization.states.personalizations.subscribe((p) => { if (shouldLiveUpdate) { setLockedPersonalizations(p) - } else if (lockedPersonalizations === undefined && p !== undefined) { + } else if (!isLockedRef.current && p !== undefined) { + isLockedRef.current = true setLockedPersonalizations(p) } })