diff --git a/implementations/react-native-sdk/components/AnalyticsEventDisplay.tsx b/implementations/react-native-sdk/components/AnalyticsEventDisplay.tsx index 400ab19e..33d618ae 100644 --- a/implementations/react-native-sdk/components/AnalyticsEventDisplay.tsx +++ b/implementations/react-native-sdk/components/AnalyticsEventDisplay.tsx @@ -6,43 +6,108 @@ import { useOptimization } from '@contentful/optimization-react-native' interface AnalyticsEvent { type: string componentId?: string + viewDurationMs?: number + componentViewId?: string timestamp: number } -function isValidEvent(event: unknown): event is { type: string; componentId?: unknown } { +function isValidEvent(event: unknown): event is { + type: string + componentId?: unknown + viewDurationMs?: unknown + componentViewId?: unknown +} { return ( event !== null && typeof event === 'object' && 'type' in event && typeof event.type === 'string' ) } +interface ComponentStats { + count: number + latestViewDurationMs: number | undefined + latestComponentViewId: string | undefined +} + +// Module-level stores that persist across unmount/remount cycles within the same +// app session. Cleared naturally when the app process restarts (relaunchCleanApp). +let persistedEvents: AnalyticsEvent[] = [] +let persistedComponentStats: Record = {} + +// Callback to trigger a re-render when mounted; null when unmounted. +let rerender: (() => void) | null = null + +// Active subscription kept alive across unmounts to capture cleanup events. +let activeSubscription: { unsubscribe: () => void } | null = null + +function buildEvent(event: { + type: string + componentId?: unknown + viewDurationMs?: unknown + componentViewId?: unknown +}): AnalyticsEvent { + const { type, componentId, viewDurationMs, componentViewId } = event + const newEvent: AnalyticsEvent = { type, timestamp: Date.now() } + + if (componentId && typeof componentId === 'string') { + newEvent.componentId = componentId + } + if (typeof viewDurationMs === 'number') { + newEvent.viewDurationMs = viewDurationMs + } + if (typeof componentViewId === 'string') { + newEvent.componentViewId = componentViewId + } + + return newEvent +} + +function updateComponentStats(newEvent: AnalyticsEvent): void { + if (!newEvent.componentId || newEvent.type !== 'component') return + + const { componentId: cid } = newEvent + const { [cid]: existing } = persistedComponentStats + persistedComponentStats = { + ...persistedComponentStats, + [cid]: { + count: (existing?.count ?? 0) + 1, + latestViewDurationMs: newEvent.viewDurationMs ?? existing?.latestViewDurationMs, + latestComponentViewId: newEvent.componentViewId ?? existing?.latestComponentViewId, + }, + } +} + +function processEvent(event: unknown): void { + if (!isValidEvent(event)) return + + const newEvent = buildEvent(event) + persistedEvents = [newEvent, ...persistedEvents] + updateComponentStats(newEvent) + rerender?.() +} + export function AnalyticsEventDisplay(): React.JSX.Element { const sdk = useOptimization() - const [events, setEvents] = useState([]) + const [, setTick] = useState(0) useEffect(() => { - const handleEvent = (event: unknown): void => { - if (isValidEvent(event)) { - const { type, componentId } = event - const newEvent: AnalyticsEvent = { - type, - timestamp: Date.now(), - } - - if (componentId && typeof componentId === 'string') { - newEvent.componentId = componentId - } - - setEvents((prev) => [newEvent, ...prev]) - } + rerender = () => { + setTick((n) => n + 1) } - const subscription = sdk.states.eventStream.subscribe(handleEvent) + // (Re)subscribe when SDK instance changes (e.g. after reset). + activeSubscription?.unsubscribe() + activeSubscription = sdk.states.eventStream.subscribe(processEvent) return () => { - subscription.unsubscribe() + rerender = null + // Intentionally keep subscription alive to capture events emitted + // during sibling component cleanup (e.g. final view tracking events). } }, [sdk]) + const events = persistedEvents + const componentStats = persistedComponentStats + if (events.length === 0) { return ( @@ -57,26 +122,42 @@ export function AnalyticsEventDisplay(): React.JSX.Element { Analytics Events Events: {events.length} - {events.map((event, index) => { - const accessibilityLabel = `${event.type} - Component: ${event.componentId ?? 'none'}` - const testID = event.componentId - ? `event-${event.type}-${event.componentId}` - : `event-${event.type}-${index}` - return ( - - - {event.type} - {event.componentId ? ` - Component: ${event.componentId}` : ''} - - - ) - })} + + {events + .filter((event) => event.type !== 'component') + .map((event, index) => { + const accessibilityLabel = `${event.type} - Component: ${event.componentId ?? 'none'} - Duration: ${event.viewDurationMs ?? 'none'}` + const testID = event.componentId + ? `event-${event.type}-${event.componentId}` + : `event-${event.type}-${index}` + return ( + + + {event.type} + {event.componentId ? ` - Component: ${event.componentId}` : ''} + {event.viewDurationMs !== undefined ? ` - ${event.viewDurationMs}ms` : ''} + + + ) + })} + + {Object.entries(componentStats).map(([cid, stats]) => ( + + Count: {stats.count} + + Duration: {stats.latestViewDurationMs ?? 'N/A'} + + + ViewId: {stats.latestComponentViewId ?? 'N/A'} + + + ))} ) } diff --git a/implementations/react-native-sdk/e2e/analytics.test.js b/implementations/react-native-sdk/e2e/analytics.test.js index 452f0082..144ab978 100644 --- a/implementations/react-native-sdk/e2e/analytics.test.js +++ b/implementations/react-native-sdk/e2e/analytics.test.js @@ -14,17 +14,14 @@ describe('Analytics Events', () => { }) it('should track component impression events for visible entries', async () => { - // Wait for the app to load const analyticsTitle = element(by.text('Analytics Events')) await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - // Wait until the analytics stream has emitted events. await waitForEventsCountAtLeast(1) - // Look for component events with entry IDs - // The merge tag entry should trigger a component event - // Use waitFor().whileElement().scroll() pattern to scroll until element is visible - await waitFor(element(by.id('event-component-1MwiFl4z7gkwqGYdvCmr8c'))) + // With periodic tracking, per-component stats are rendered with unique testIDs. + // Use the component-stats summary element to verify the merge tag entry was tracked. + await waitFor(element(by.id('component-stats-1MwiFl4z7gkwqGYdvCmr8c'))) .toBeVisible() .whileElement(by.id('main-scroll-view')) .scroll(500, 'down') diff --git a/implementations/react-native-sdk/e2e/extended-view-tracking.test.js b/implementations/react-native-sdk/e2e/extended-view-tracking.test.js new file mode 100644 index 00000000..8cb81b31 --- /dev/null +++ b/implementations/react-native-sdk/e2e/extended-view-tracking.test.js @@ -0,0 +1,287 @@ +const { expect: jestExpect } = require('expect') +const { + clearProfileState, + ELEMENT_VISIBILITY_TIMEOUT, + getComponentViewDuration, + getComponentViewId, + getElementTextById, + isVisibleById, + sleep, + waitForComponentEventCount, + waitForEventsCountAtLeast, +} = require('./helpers') + +// The merge tag entry is always first in the list and visible immediately on launch. +const VISIBLE_ENTRY_ID = '1MwiFl4z7gkwqGYdvCmr8c' + +// Second entry visible on launch (immediately after the merge tag entry). +const SECOND_ENTRY_ID = '4ib0hsHWoSOnCVdDkizE8d' + +// An entry that starts below the fold (not visible on launch). +const BELOW_FOLD_ENTRY_ID = '7pa5bOx8Z9NmNcr7mISvD' + +// Extended timeouts for periodic event tests - need to wait for dwell (2s) + update intervals (5s each) +const EXTENDED_TIMEOUT = 30000 + +describe('Extended View Tracking', () => { + beforeAll(async () => { + await device.launchApp() + }) + + beforeEach(async () => { + await clearProfileState({ requireFreshAppInstance: true }) + }) + + it('should emit periodic events for a continuously visible entry', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Wait for the initial event (after dwell threshold ~2s) + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 1, EXTENDED_TIMEOUT) + + // Wait for at least one periodic update (dwell 2s + update interval 5s = ~7s total) + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 2, EXTENDED_TIMEOUT) + }) + + it('should report increasing viewDurationMs across periodic events', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Wait for at least 2 events so we can check duration is increasing + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 2, EXTENDED_TIMEOUT) + + const duration = await getComponentViewDuration(VISIBLE_ENTRY_ID) + + // Duration should exceed the dwell threshold (2000ms) since we've had at least 2 events + jestExpect(duration).toBeGreaterThan(2000) + }) + + it('should maintain a stable componentViewId within a visibility cycle', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Wait for at least 2 events + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 2, EXTENDED_TIMEOUT) + + const viewId = await getComponentViewId(VISIBLE_ENTRY_ID) + + // The viewId should be a non-null string (UUID or fallback format) + jestExpect(viewId).not.toBeNull() + jestExpect(typeof viewId).toBe('string') + jestExpect(viewId.length).toBeGreaterThan(0) + }) + + it('should emit a final event when scrolling a tracked entry out of view', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Wait for at least 1 event from the visible entry + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 1, EXTENDED_TIMEOUT) + + const preScrollViewId = await getComponentViewId(VISIBLE_ENTRY_ID) + + // Scroll the entry out of the viewport (scroll down far enough) + await element(by.id('main-scroll-view')).scroll(1500, 'down') + + // Give the final event time to fire + await sleep(1000) + + // Scroll back to the top so the stats elements become visible again + await element(by.id('main-scroll-view')).scrollTo('top') + + // Scroll to the analytics display to read updated stats + await waitFor(element(by.id(`event-count-${VISIBLE_ENTRY_ID}`))) + .toBeVisible() + .whileElement(by.id('main-scroll-view')) + .scroll(300, 'down') + + // The event count should have incremented by the final event + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 2, ELEMENT_VISIBILITY_TIMEOUT) + + // The viewId should still match the original cycle + const postScrollViewId = await getComponentViewId(VISIBLE_ENTRY_ID) + jestExpect(postScrollViewId).toBe(preScrollViewId) + }) + + it('should generate a new componentViewId after scrolling away and back', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Wait for at least 1 event in the first visibility cycle + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 1, EXTENDED_TIMEOUT) + + const firstCycleViewId = await getComponentViewId(VISIBLE_ENTRY_ID) + + // Scroll the entry out of the viewport + await element(by.id('main-scroll-view')).scroll(1500, 'down') + await sleep(1000) + + // Scroll back to the top to make the entry visible again + await element(by.id('main-scroll-view')).scrollTo('top') + await sleep(500) + + // Wait for new events from the second visibility cycle (at least 1 more beyond what we had) + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 3, EXTENDED_TIMEOUT) + + const secondCycleViewId = await getComponentViewId(VISIBLE_ENTRY_ID) + + // The second cycle should have a different componentViewId + jestExpect(secondCycleViewId).not.toBeNull() + jestExpect(secondCycleViewId).not.toBe(firstCycleViewId) + }) + + it('should emit zero events when entry scrolls out before dwell threshold', async () => { + const analyticsTitle = element(by.text('Analytics Events')) + await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Scroll down to bring the below-fold entry into view briefly + await waitFor(element(by.id(`content-entry-${BELOW_FOLD_ENTRY_ID}`))) + .toBeVisible() + .whileElement(by.id('main-scroll-view')) + .scroll(300, 'down') + + // Immediately scroll back to top — the entry was visible for well under 2s + await element(by.id('main-scroll-view')).scrollTo('top') + + // Wait long enough that an event WOULD have fired if tracking hadn't been cancelled + await sleep(3000) + + // The component-stats element only renders when a 'component' event has fired. + // It should not exist for the below-fold entry since it wasn't visible long enough. + const statsVisible = await isVisibleById(`component-stats-${BELOW_FOLD_ENTRY_ID}`, 2000) + jestExpect(statsVisible).toBe(false) + }) + + it('should track multiple visible entries simultaneously with independent viewIds', async () => { + await waitFor(element(by.text('Analytics Events'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Wait for at least 1 event from each visible entry + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 1, EXTENDED_TIMEOUT) + await waitForComponentEventCount(SECOND_ENTRY_ID, 1, EXTENDED_TIMEOUT) + + // Get viewIds for both entries + const viewId1 = await getComponentViewId(VISIBLE_ENTRY_ID) + const viewId2 = await getComponentViewId(SECOND_ENTRY_ID) + + // Both should have non-null, distinct viewIds + jestExpect(viewId1).not.toBeNull() + jestExpect(viewId2).not.toBeNull() + jestExpect(viewId1).not.toBe(viewId2) + }) + + it('should emit a final event when navigating away (unmount) during active tracking', async () => { + await waitFor(element(by.text('Analytics Events'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Wait for at least 1 tracking event (active cycle with emitted event) + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 1, EXTENDED_TIMEOUT) + + // Record the current event count + const preNavText = await getElementTextById(`event-count-${VISIBLE_ENTRY_ID}`) + const preNavMatch = /Count:\s*(\d+)/.exec(preNavText) + const preNavCount = preNavMatch && preNavMatch[1] ? Number(preNavMatch[1]) : 0 + + // Scroll back to top so the Navigation Test button is accessible + try { + await element(by.id('main-scroll-view')).scrollTo('top') + } catch { + // May not be scrollable + } + + // Navigate away — this unmounts all Analytics components, triggering cleanup + await element(by.id('navigation-test-button')).tap() + await waitFor(element(by.id('close-navigation-test-button'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Give the final event time to fire + await sleep(500) + + // Navigate back to main screen + await element(by.id('close-navigation-test-button')).tap() + + // Wait for the analytics display to reappear (component remounts with persisted state) + await waitFor(element(by.text('Analytics Events'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + await waitFor(element(by.id(`event-count-${VISIBLE_ENTRY_ID}`))) + .toBeVisible() + .whileElement(by.id('main-scroll-view')) + .scroll(300, 'down') + + // The event count should have increased (final event emitted during unmount) + const postNavText = await getElementTextById(`event-count-${VISIBLE_ENTRY_ID}`) + const postNavMatch = /Count:\s*(\d+)/.exec(postNavText) + const postNavCount = postNavMatch && postNavMatch[1] ? Number(postNavMatch[1]) : 0 + + jestExpect(postNavCount).toBeGreaterThan(preNavCount) + }) + + it('should pause tracking on app background and resume on foreground', async () => { + await waitFor(element(by.text('Analytics Events'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Wait for at least 1 tracking event + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 1, EXTENDED_TIMEOUT) + + const preBackgroundViewId = await getComponentViewId(VISIBLE_ENTRY_ID) + + // Send app to background + await device.sendToHome() + await sleep(1000) + + // Bring app back to foreground + await device.launchApp({ newInstance: false }) + + // Wait for the stats display to be visible again + await waitFor(element(by.id(`event-count-${VISIBLE_ENTRY_ID}`))) + .toBeVisible() + .whileElement(by.id('main-scroll-view')) + .scroll(300, 'down') + + // Backgrounding ends the cycle (final event) and foregrounding starts a new one. + // Wait for events from the new cycle. + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 3, EXTENDED_TIMEOUT) + + // The viewId should differ — new cycle after background/foreground + const postForegroundViewId = await getComponentViewId(VISIBLE_ENTRY_ID) + jestExpect(postForegroundViewId).not.toBe(preBackgroundViewId) + }) + + it('should reset accumulated duration for a new visibility cycle', async () => { + await waitFor(element(by.text('Analytics Events'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Wait for at least 2 events so duration accumulates beyond the dwell threshold + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 2, EXTENDED_TIMEOUT) + const firstCycleDuration = await getComponentViewDuration(VISIBLE_ENTRY_ID) + + // Duration after 2 events should be well above the dwell threshold + jestExpect(firstCycleDuration).toBeGreaterThan(4000) + + // Scroll entry out of view (end cycle, triggers final event) + await element(by.id('main-scroll-view')).scroll(1500, 'down') + await sleep(1000) + + // Scroll back to top (entry visible again, new cycle starts) + await element(by.id('main-scroll-view')).scrollTo('top') + await sleep(500) + + // Wait for the new cycle's initial event + // Cycle 1: initial(1) + periodic(2) + final(3) = 3 events + // New cycle initial = event 4 + await waitForComponentEventCount(VISIBLE_ENTRY_ID, 4, EXTENDED_TIMEOUT) + + // The new cycle's duration should be around the dwell threshold (~2000ms), + // not carrying over the 4000+ms from cycle 1 + const secondCycleDuration = await getComponentViewDuration(VISIBLE_ENTRY_ID) + jestExpect(secondCycleDuration).toBeGreaterThanOrEqual(2000) + jestExpect(secondCycleDuration).toBeLessThan(4000) + }) +}) diff --git a/implementations/react-native-sdk/e2e/helpers.js b/implementations/react-native-sdk/e2e/helpers.js index 95605ffe..acb6b30d 100644 --- a/implementations/react-native-sdk/e2e/helpers.js +++ b/implementations/react-native-sdk/e2e/helpers.js @@ -117,11 +117,66 @@ async function clearProfileState(options = {}) { await relaunchCleanApp() } +async function waitForComponentEventCount( + componentId, + minCount, + timeout = ELEMENT_VISIBILITY_TIMEOUT, +) { + const testId = `event-count-${componentId}` + + // Ensure the stats section is scrolled into view + try { + await element(by.id('main-scroll-view')).scrollTo('top') + } catch { + // Scroll may not be possible if view is not scrollable + } + + try { + await waitFor(element(by.id(testId))) + .toBeVisible() + .whileElement(by.id('main-scroll-view')) + .scroll(300, 'down') + } catch { + // May already be visible + } + + await waitForElementTextById( + testId, + (text) => { + const match = /Count:\s*(\d+)/.exec(text) + if (!match || !match[1]) { + return false + } + return Number(match[1]) >= minCount + }, + timeout, + ) +} + +async function getComponentViewDuration(componentId) { + const testId = `event-duration-${componentId}` + const text = await getElementTextById(testId) + const match = /Duration:\s*(\d+)/.exec(text) + return match && match[1] ? Number(match[1]) : null +} + +async function getComponentViewId(componentId) { + const testId = `event-view-id-${componentId}` + const text = await getElementTextById(testId) + const match = /ViewId:\s*(.+)/.exec(text) + return match && match[1] && match[1] !== 'N/A' ? match[1].trim() : null +} + module.exports = { clearProfileState, ELEMENT_VISIBILITY_TIMEOUT, + getComponentViewDuration, + getComponentViewId, getElementTextById, + isVisibleById, + sleep, tapIfVisibleById, + waitForComponentEventCount, waitForElementTextById, waitForEventsCountAtLeast, waitForTextChangeById, diff --git a/packages/react-native-sdk/src/components/Analytics.tsx b/packages/react-native-sdk/src/components/Analytics.tsx index eb687183..1b61b62f 100644 --- a/packages/react-native-sdk/src/components/Analytics.tsx +++ b/packages/react-native-sdk/src/components/Analytics.tsx @@ -43,6 +43,14 @@ export interface AnalyticsProps { */ threshold?: number + /** + * Interval (in milliseconds) between periodic view duration update events + * after the initial event has fired. + * + * @defaultValue 5000 + */ + viewDurationUpdateIntervalMs?: number + /** * Optional style prop for the wrapper View. */ @@ -126,6 +134,7 @@ export function Analytics({ children, viewTimeMs, threshold, + viewDurationUpdateIntervalMs, style, testID, trackViews, @@ -142,6 +151,7 @@ export function Analytics({ entry, threshold, viewTimeMs, + viewDurationUpdateIntervalMs, enabled: viewsEnabled, }) diff --git a/packages/react-native-sdk/src/components/Personalization.tsx b/packages/react-native-sdk/src/components/Personalization.tsx index f7fcb480..9fdfa553 100644 --- a/packages/react-native-sdk/src/components/Personalization.tsx +++ b/packages/react-native-sdk/src/components/Personalization.tsx @@ -67,6 +67,14 @@ export interface PersonalizationProps { */ threshold?: number + /** + * Interval (in milliseconds) between periodic view duration update events + * after the initial event has fired. + * + * @defaultValue 5000 + */ + viewDurationUpdateIntervalMs?: number + /** * Optional style prop for the wrapper View. */ @@ -172,6 +180,7 @@ export function Personalization({ children, viewTimeMs, threshold, + viewDurationUpdateIntervalMs, style, testID, liveUpdates, @@ -228,6 +237,7 @@ export function Personalization({ personalization: resolvedData.personalization, threshold, viewTimeMs, + viewDurationUpdateIntervalMs, enabled: viewsEnabled, }) diff --git a/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts b/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts new file mode 100644 index 00000000..ffbe7871 --- /dev/null +++ b/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts @@ -0,0 +1,351 @@ +import type { SelectedPersonalization } from '@contentful/optimization-core/api-schemas' +import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core' +import type { Entry } from 'contentful' +import type { LayoutChangeEvent } from 'react-native' + +rs.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Dimensions: { + get: rs.fn(() => ({ width: 375, height: 667 })), + addEventListener: rs.fn(() => ({ remove: rs.fn() })), + }, + AppState: { + addEventListener: rs.fn(() => ({ remove: rs.fn() })), + }, + NativeModules: {}, +})) + +rs.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: rs.fn(), + setItem: rs.fn(), + removeItem: rs.fn(), + }, +})) + +rs.mock('@contentful/optimization-core/logger', () => ({ + logger: { + info: rs.fn(), + debug: rs.fn(), + error: rs.fn(), + warn: rs.fn(), + }, + createScopedLogger: () => ({ + debug: rs.fn(), + info: rs.fn(), + log: rs.fn(), + warn: rs.fn(), + error: rs.fn(), + fatal: rs.fn(), + }), +})) + +const mockTrackComponentView = rs.fn().mockResolvedValue(undefined) + +const mockOptimization = { + trackComponentView: mockTrackComponentView, +} + +rs.mock('../context/OptimizationContext', () => ({ + useOptimization: () => mockOptimization, +})) + +let scrollContextValue: { scrollY: number; viewportHeight: number } | null = null + +rs.mock('../context/OptimizationScrollContext', () => ({ + useScrollContext: () => scrollContextValue, +})) + +type EffectFn = () => undefined | (() => void) +type CallbackFn = (...args: unknown[]) => unknown + +const effects: EffectFn[] = [] + +const refs = new Map() +let refCounter = 0 + +rs.mock('react', () => ({ + useState: (initial: unknown) => [initial, rs.fn()], + useEffect: (fn: EffectFn) => { + effects.push(fn) + fn() + }, + useCallback: (fn: CallbackFn) => fn, + useRef: (initial: unknown) => { + const id = refCounter++ + if (!refs.has(id)) { + refs.set(id, { current: initial }) + } + const ref = refs.get(id) + if (!ref) throw new Error('ref not found') + return ref + }, +})) + +function resetHookState(): void { + effects.length = 0 + refs.clear() + refCounter = 0 +} + +function createMockEntry(id: string): Entry { + return { + // @ts-expect-error -- partial mock for testing, missing publishedVersion + sys: { + id, + type: 'Entry', + contentType: { sys: { id: 'testType', type: 'Link', linkType: 'ContentType' } }, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + environment: { sys: { id: 'master', type: 'Link', linkType: 'Environment' } }, + space: { sys: { id: 'space1', type: 'Link', linkType: 'Space' } }, + revision: 1, + locale: 'en-US', + }, + fields: { title: 'Test Entry' }, + metadata: { tags: [] }, + } +} + +function createLayoutEvent(): LayoutChangeEvent { + // @ts-expect-error -- partial mock for testing + return { + nativeEvent: { layout: { x: 0, y: 0, width: 100, height: 100 } }, + } +} + +function getCallArg(callIndex: number): Record { + const result: Record = mockTrackComponentView.mock.calls[callIndex]?.[0] + return result +} + +describe('useViewportTracking', () => { + beforeEach(() => { + rs.clearAllMocks() + resetHookState() + scrollContextValue = { scrollY: 0, viewportHeight: 800 } + rs.useFakeTimers() + }) + + afterEach(() => { + rs.useRealTimers() + }) + + describe('initial event after dwell threshold', () => { + it('should fire initial trackComponentView after viewTimeMs of accumulated visible time', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('entry-1') + + const { onLayout } = useViewportTracking({ + entry, + viewTimeMs: 2000, + threshold: 0.5, + }) + + onLayout(createLayoutEvent()) + + expect(mockTrackComponentView).not.toHaveBeenCalled() + + rs.advanceTimersByTime(2000) + + expect(mockTrackComponentView).toHaveBeenCalledTimes(1) + const call = getCallArg(0) + expect(call.componentId).toBe('entry-1') + expect(call.viewDurationMs).toBeGreaterThanOrEqual(2000) + expect(call.componentViewId).toBeDefined() + expect(typeof call.componentViewId).toBe('string') + }) + + it('should not fire if visibility ends before dwell threshold', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('entry-2') + + useViewportTracking({ + entry, + viewTimeMs: 2000, + threshold: 0.8, + }) + + expect(mockTrackComponentView).not.toHaveBeenCalled() + + rs.advanceTimersByTime(3000) + + expect(mockTrackComponentView).not.toHaveBeenCalled() + }) + }) + + describe('periodic event scheduling', () => { + it('should fire periodic events at viewDurationUpdateIntervalMs after initial event', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('entry-3') + + const { onLayout } = useViewportTracking({ + entry, + viewTimeMs: 1000, + viewDurationUpdateIntervalMs: 2000, + threshold: 0.5, + }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(1000) + expect(mockTrackComponentView).toHaveBeenCalledTimes(1) + + rs.advanceTimersByTime(2000) + expect(mockTrackComponentView).toHaveBeenCalledTimes(2) + + rs.advanceTimersByTime(2000) + expect(mockTrackComponentView).toHaveBeenCalledTimes(3) + }) + + it('should send increasing viewDurationMs with each periodic event', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('entry-4') + + const { onLayout } = useViewportTracking({ + entry, + viewTimeMs: 1000, + viewDurationUpdateIntervalMs: 2000, + threshold: 0.5, + }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(1000) + const firstDuration = Number(getCallArg(0).viewDurationMs) + + rs.advanceTimersByTime(2000) + const secondDuration = Number(getCallArg(1).viewDurationMs) + + expect(secondDuration).toBeGreaterThan(firstDuration) + }) + }) + + describe('componentViewId lifecycle', () => { + it('should use the same componentViewId for all events within a visibility cycle', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('entry-5') + + const { onLayout } = useViewportTracking({ + entry, + viewTimeMs: 1000, + viewDurationUpdateIntervalMs: 2000, + threshold: 0.5, + }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(1000) + rs.advanceTimersByTime(2000) + + const firstViewId = getCallArg(0).componentViewId + const secondViewId = getCallArg(1).componentViewId + + expect(firstViewId).toBe(secondViewId) + }) + }) + + describe('real accumulated viewDurationMs', () => { + it('should send real accumulated duration instead of configured viewTimeMs', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('entry-6') + + const { onLayout } = useViewportTracking({ + entry, + viewTimeMs: 1000, + viewDurationUpdateIntervalMs: 5000, + threshold: 0.5, + }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(1000) + + const call = getCallArg(0) + expect(call.viewDurationMs).toBeGreaterThanOrEqual(1000) + expect(typeof call.viewDurationMs).toBe('number') + }) + }) + + describe('metadata extraction', () => { + it('should use entry.sys.id as componentId for baseline entries', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('baseline-123') + + const { onLayout } = useViewportTracking({ + entry, + viewTimeMs: 100, + threshold: 0.5, + }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(100) + + const call = getCallArg(0) + expect(call.componentId).toBe('baseline-123') + expect(call.experienceId).toBeUndefined() + expect(call.variantIndex).toBe(0) + }) + + it('should use personalization metadata when provided', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('variant-456') + + const personalization: SelectedPersonalization = { + experienceId: 'exp-1', + variantIndex: 2, + variants: { 'comp-base': 'variant-456' }, + } + const { onLayout } = useViewportTracking({ + entry, + personalization, + viewTimeMs: 100, + threshold: 0.5, + }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(100) + + const call = getCallArg(0) + expect(call.componentId).toBe('comp-base') + expect(call.experienceId).toBe('exp-1') + expect(call.variantIndex).toBe(2) + }) + }) + + describe('default options', () => { + it('should default threshold to 0.8, viewTimeMs to 2000, viewDurationUpdateIntervalMs to 5000', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('defaults-test') + + const { onLayout } = useViewportTracking({ entry }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(1999) + expect(mockTrackComponentView).not.toHaveBeenCalled() + + rs.advanceTimersByTime(1) + expect(mockTrackComponentView).toHaveBeenCalledTimes(1) + + rs.advanceTimersByTime(5000) + expect(mockTrackComponentView).toHaveBeenCalledTimes(2) + }) + }) + + describe('return value', () => { + it('should return isVisible and onLayout', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('return-test') + + const result = useViewportTracking({ entry }) + + expect(result).toHaveProperty('isVisible') + expect(result).toHaveProperty('onLayout') + expect(typeof result.onLayout).toBe('function') + expect(typeof result.isVisible).toBe('boolean') + }) + }) +}) diff --git a/packages/react-native-sdk/src/hooks/useViewportTracking.ts b/packages/react-native-sdk/src/hooks/useViewportTracking.ts index 21220c4d..3f3d3319 100644 --- a/packages/react-native-sdk/src/hooks/useViewportTracking.ts +++ b/packages/react-native-sdk/src/hooks/useViewportTracking.ts @@ -2,7 +2,7 @@ import type { SelectedPersonalization } from '@contentful/optimization-core/api- import { createScopedLogger } from '@contentful/optimization-core/logger' import type { Entry } from 'contentful' import { useCallback, useEffect, useRef, useState } from 'react' -import { Dimensions, type LayoutChangeEvent } from 'react-native' +import { AppState, Dimensions, type LayoutChangeEvent } from 'react-native' import { useOptimization } from '../context/OptimizationContext' import { useScrollContext } from '../context/OptimizationScrollContext' @@ -32,7 +32,7 @@ export interface UseViewportTrackingOptions { threshold?: number /** - * Minimum time (in milliseconds) the component must be visible before tracking fires. + * Minimum accumulated visible time (in milliseconds) before the first tracking event fires. * * @defaultValue 2000 */ @@ -46,6 +46,14 @@ export interface UseViewportTrackingOptions { * @defaultValue `true` */ enabled?: boolean + + /** + * Interval (in milliseconds) between periodic view duration update events + * after the initial event has fired. + * + * @defaultValue 5000 + */ + viewDurationUpdateIntervalMs?: number } /** @@ -64,6 +72,7 @@ export interface UseViewportTrackingReturn { const PERCENTAGE_MULTIPLIER = 100 const DEFAULT_THRESHOLD = 0.8 const DEFAULT_VIEW_TIME_MS = 2000 +const DEFAULT_VIEW_DURATION_UPDATE_INTERVAL_MS = 5000 const HEX_RADIX = 16 const createComponentViewId = (): string => { try { @@ -73,6 +82,53 @@ const createComponentViewId = (): string => { } } +/** + * Mutable state for a single visibility cycle. Stored in a ref to avoid + * triggering re-renders on every scroll tick. + */ +interface ViewCycleState { + componentViewId: string | null + visibleSince: number | null + accumulatedMs: number + attempts: number +} + +const createInitialCycleState = (): ViewCycleState => ({ + componentViewId: null, + visibleSince: null, + accumulatedMs: 0, + attempts: 0, +}) + +/** + * Flush elapsed visible time into accumulatedMs and reset visibleSince to `now`. + * Returns the updated accumulatedMs. + */ +function flushAccumulatedTime(cycle: ViewCycleState, now: number): number { + if (cycle.visibleSince !== null) { + cycle.accumulatedMs += now - cycle.visibleSince + cycle.visibleSince = now + } + return cycle.accumulatedMs +} + +/** + * Pause time accumulation without resetting the cycle. + */ +function pauseAccumulation(cycle: ViewCycleState, now: number): void { + if (cycle.visibleSince !== null) { + cycle.accumulatedMs += now - cycle.visibleSince + cycle.visibleSince = null + } +} + +function resetCycleState(cycle: ViewCycleState): void { + cycle.componentViewId = null + cycle.visibleSince = null + cycle.accumulatedMs = 0 + cycle.attempts = 0 +} + /** * Extracts tracking metadata from a resolved entry and optional personalization data. * @@ -90,14 +146,11 @@ export function extractTrackingMetadata( experienceId?: string variantIndex: number } { - // If personalization data exists, this is a variant entry if (personalization) { - // Extract componentId from variants object: find the key whose value matches this entry's ID const componentId = Object.keys(personalization.variants).find( (baselineId) => personalization.variants[baselineId] === resolvedEntry.sys.id, ) - // Fallback to entry.sys.id if variants mapping not found (shouldn't happen, but defensive) return { componentId: componentId ?? resolvedEntry.sys.id, experienceId: personalization.experienceId, @@ -105,7 +158,6 @@ export function extractTrackingMetadata( } } - // Baseline or non-personalized entry: no personalization, use entry.sys.id as componentId return { componentId: resolvedEntry.sys.id, experienceId: undefined, @@ -114,8 +166,30 @@ export function extractTrackingMetadata( } /** - * Tracks whether a component is visible in the viewport and fires a component view - * event when visibility and time thresholds are met. + * Compute remaining ms until the next event should fire, based on accumulated + * visible time and the number of events already emitted. + * + * Formula mirrors Web SDK `ElementViewObserver.getRemainingMsUntilNextFire`: + * requiredMs = dwellTimeMs + attempts * viewDurationUpdateIntervalMs + * remaining = requiredMs - accumulatedMs + */ +function getRemainingMsUntilNextFire( + cycle: ViewCycleState, + dwellTimeMs: number, + updateIntervalMs: number, +): number { + const requiredMs = dwellTimeMs + cycle.attempts * updateIntervalMs + return requiredMs - cycle.accumulatedMs +} + +/** + * Tracks whether a component is visible in the viewport and fires component view + * events with accumulated duration tracking. + * + * The hook implements a three-phase event lifecycle per visibility cycle: + * 1. **Initial event** after accumulated visible time reaches `viewTimeMs`. + * 2. **Periodic updates** every `viewDurationUpdateIntervalMs` while visible. + * 3. **Final event** when visibility ends (only if at least one event was already emitted). * * @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 @@ -124,7 +198,9 @@ export function extractTrackingMetadata( * * @remarks * Uses {@link useScrollContext} if available, otherwise falls back to screen dimensions. - * The hook tracks only once per component instance — subsequent visibility events are ignored. + * A new visibility cycle (with a fresh `componentViewId`) starts each time the component + * transitions from invisible to visible. Time accumulation pauses when the app moves + * to the background. * * @example * ```tsx @@ -151,18 +227,16 @@ export function useViewportTracking({ threshold = DEFAULT_THRESHOLD, viewTimeMs = DEFAULT_VIEW_TIME_MS, enabled = true, + viewDurationUpdateIntervalMs = DEFAULT_VIEW_DURATION_UPDATE_INTERVAL_MS, }: UseViewportTrackingOptions): UseViewportTrackingReturn { const optimization = useOptimization() - // We invoke useScrollContext here to check if the OptimizationScrollProvider is mounted and the scroll context is available. const scrollContext = useScrollContext() - // Extract tracking metadata from the entry and personalization data const { componentId, experienceId, variantIndex } = extractTrackingMetadata( entry, personalization, ) - // Fallback to screen dimensions when used outside OptimizationScrollProvider const [screenHeight, setScreenHeight] = useState(Dimensions.get('window').height) useEffect(() => { @@ -174,26 +248,28 @@ export function useViewportTracking({ } }, []) - // Use scroll context if available, otherwise use screen dimensions - const scrollY = scrollContext ? scrollContext.scrollY : 0 - const viewportHeight = scrollContext ? scrollContext.viewportHeight : screenHeight const dimensionsRef = useRef<{ y: number; height: number } | null>(null) const isVisibleRef = useRef(false) - const viewTimeoutRef = useRef(null) - const viewSessionIdRef = useRef(null) + const fireTimerRef = useRef(null) + const cycleRef = useRef(createInitialCycleState()) - // Store optimization in a ref to prevent unnecessary callback recreations const optimizationRef = useRef(optimization) optimizationRef.current = optimization + const componentIdRef = useRef(componentId) + componentIdRef.current = componentId + const experienceIdRef = useRef(experienceId) + experienceIdRef.current = experienceId + const variantIndexRef = useRef(variantIndex) + variantIndexRef.current = variantIndex + logger.debug( `Hook initialized for ${componentId} (experienceId: ${experienceId}, variantIndex: ${variantIndex})`, ) - // Log if hook is being re-created (potential React Strict Mode or unmount/remount issue) useEffect(() => { logger.debug(`Hook mounted/updated for ${componentId}`) return () => { @@ -201,50 +277,107 @@ export function useViewportTracking({ } }, []) - const startTrackingTimer = useCallback( - (visibilityPercent: number) => { - if (!enabled) return + const clearFireTimer = useCallback(() => { + if (fireTimerRef.current) { + clearTimeout(fireTimerRef.current) + fireTimerRef.current = null + } + }, []) - logger.info( - `Component ${componentId} became visible (${visibilityPercent.toFixed(1)}%), starting ${viewTimeMs}ms timer`, - ) - viewSessionIdRef.current = createComponentViewId() + const emitViewEvent = useCallback(() => { + const { current: cycle } = cycleRef + const now = Date.now() + flushAccumulatedTime(cycle, now) + + const viewId = cycle.componentViewId ?? createComponentViewId() + const durationMs = Math.max(0, Math.round(cycle.accumulatedMs)) + + cycle.attempts += 1 + + logger.info( + `Emitting view event #${cycle.attempts} for ${componentIdRef.current} (viewDurationMs=${durationMs}, componentViewId=${viewId})`, + ) + + void (async () => { + await optimizationRef.current.trackComponentView({ + componentId: componentIdRef.current, + componentViewId: viewId, + experienceId: experienceIdRef.current, + variantIndex: variantIndexRef.current, + viewDurationMs: durationMs, + }) + })() + }, []) - // Clear any existing timeout - if (viewTimeoutRef.current) { - logger.debug(`Clearing existing timer for ${componentId}`) - clearTimeout(viewTimeoutRef.current) + const scheduleNextFire = useCallback(() => { + clearFireTimer() + const { current: cycle } = cycleRef + + if (cycle.componentViewId === null || cycle.visibleSince === null) { + return + } + + const now = Date.now() + flushAccumulatedTime(cycle, now) + + const remainingMs = getRemainingMsUntilNextFire(cycle, viewTimeMs, viewDurationUpdateIntervalMs) + + if (remainingMs <= 0) { + emitViewEvent() + scheduleNextFire() + return + } + + logger.debug( + `Scheduling next fire for ${componentIdRef.current} in ${remainingMs}ms (attempt #${cycle.attempts + 1})`, + ) + + fireTimerRef.current = setTimeout(() => { + if (!isVisibleRef.current) { + return } + emitViewEvent() + scheduleNextFire() + }, remainingMs) + }, [clearFireTimer, emitViewEvent, viewTimeMs, viewDurationUpdateIntervalMs]) - viewTimeoutRef.current = setTimeout(() => { - const { current: isVisible } = isVisibleRef - - logger.debug(`Timer fired for ${componentId} - isVisible: ${isVisible}`) - - if (isVisible) { - logger.info(`Component ${componentId} visible for ${viewTimeMs}ms, initiating tracking`) - - // Use ref to get current optimization instance - const { current: currentOptimization } = optimizationRef - const componentViewId = viewSessionIdRef.current ?? createComponentViewId() - - // Track the component view - void (async () => { - await currentOptimization.trackComponentView({ - componentId, - componentViewId, - experienceId, - variantIndex, - viewDurationMs: viewTimeMs, - }) - })() - } else { - logger.debug(`Skipping track for ${componentId} - component no longer visible`) - } - }, viewTimeMs) - }, - [enabled, componentId, experienceId, variantIndex, viewTimeMs], - ) + const onVisibilityStart = useCallback(() => { + if (!enabled) return + + const { current: cycle } = cycleRef + const now = Date.now() + + resetCycleState(cycle) + cycle.componentViewId = createComponentViewId() + cycle.visibleSince = now + + logger.info( + `Visibility cycle started for ${componentIdRef.current} (id=${cycle.componentViewId})`, + ) + + scheduleNextFire() + }, [enabled, scheduleNextFire]) + + const onVisibilityEnd = useCallback(() => { + const { current: cycle } = cycleRef + const now = Date.now() + + clearFireTimer() + pauseAccumulation(cycle, now) + + if (cycle.componentViewId !== null && cycle.attempts > 0) { + logger.info( + `Visibility ended for ${componentIdRef.current} after ${cycle.attempts} events, emitting final`, + ) + emitViewEvent() + } else { + logger.debug( + `Visibility ended for ${componentIdRef.current} before dwell threshold, no final event`, + ) + } + + resetCycleState(cycle) + }, [clearFireTimer, emitViewEvent]) const canCheckVisibility = useCallback((): boolean => { const { current: dimensions } = dimensionsRef @@ -279,11 +412,9 @@ export function useViewportTracking({ const { y: elementY, height: elementHeight } = dimensions const elementBottom = elementY + elementHeight - // Calculate what portion of the element is visible in the viewport const viewportTop = scrollY const viewportBottom = scrollY + viewportHeight - // Calculate the intersection between element and viewport const visibleTop = Math.max(elementY, viewportTop) const visibleBottom = Math.min(elementBottom, viewportBottom) const visibleHeight = Math.max(0, visibleBottom - visibleTop) @@ -303,17 +434,12 @@ export function useViewportTracking({ if (isNowVisible && !wasVisible) { logger.info(`${componentId} transitioned from invisible to visible`) - startTrackingTimer(visibilityRatio * PERCENTAGE_MULTIPLIER) + onVisibilityStart() } else if (!isNowVisible && wasVisible) { logger.info( - `${componentId} became invisible (${(visibilityRatio * PERCENTAGE_MULTIPLIER).toFixed(1)}%), canceling timer`, + `${componentId} became invisible (${(visibilityRatio * PERCENTAGE_MULTIPLIER).toFixed(1)}%)`, ) - - if (viewTimeoutRef.current) { - clearTimeout(viewTimeoutRef.current) - viewTimeoutRef.current = null - } - viewSessionIdRef.current = null + onVisibilityEnd() } else if (!isNowVisible) { logger.debug( `${componentId} is not visible enough (${(visibilityRatio * PERCENTAGE_MULTIPLIER).toFixed(1)}%)`, @@ -329,7 +455,8 @@ export function useViewportTracking({ threshold, scrollY, viewportHeight, - startTrackingTimer, + onVisibilityStart, + onVisibilityEnd, scrollContext, ]) @@ -345,24 +472,56 @@ export function useViewportTracking({ ) dimensionsRef.current = { y, height } - // Check visibility immediately after layout is captured - // This ensures tracking works even if user never scrolls checkVisibility() }, [componentId, checkVisibility], ) - // Check visibility when scroll position or viewport changes useEffect(() => { checkVisibility() }, [scrollY, viewportHeight, checkVisibility]) - // Cleanup timeout on unmount + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextState) => { + const { current: cycle } = cycleRef + + if (nextState === 'background' || nextState === 'inactive') { + if (cycle.visibleSince !== null) { + const now = Date.now() + clearFireTimer() + pauseAccumulation(cycle, now) + + if (cycle.attempts > 0) { + logger.info(`App backgrounded, emitting final event for ${componentIdRef.current}`) + emitViewEvent() + resetCycleState(cycle) + isVisibleRef.current = false + } + } + } else if (nextState === 'active') { + if (dimensionsRef.current !== null) { + isVisibleRef.current = false + checkVisibility() + } + } + }) + + return () => { + subscription.remove() + } + }, [clearFireTimer, emitViewEvent, checkVisibility]) + useEffect( () => () => { - if (viewTimeoutRef.current) { - clearTimeout(viewTimeoutRef.current) + if (fireTimerRef.current) { + clearTimeout(fireTimerRef.current) + } + const { current: cycle } = cycleRef + if (cycle.componentViewId !== null && cycle.attempts > 0) { + pauseAccumulation(cycle, Date.now()) + emitViewEvent() } + resetCycleState(cycle) }, [], ) diff --git a/specs/023-react-native-hooks/spec.md b/specs/023-react-native-hooks/spec.md index c5f8925a..27b65eaf 100644 --- a/specs/023-react-native-hooks/spec.md +++ b/specs/023-react-native-hooks/spec.md @@ -11,20 +11,32 @@ ### User Story 1 - Track Component Views from Viewport Visibility (Priority: P1) As an SDK consumer, I need a hook that tracks when a Contentful entry stays sufficiently visible for -a configured dwell time so component view analytics can be emitted automatically. +a configured dwell time so component view analytics can be emitted automatically with accumulated +duration tracking. **Why this priority**: Automatic visibility-based component tracking is core package behavior. **Independent Test**: Attach `useViewportTracking` to a view, simulate layout + viewport updates, -and verify `trackComponentView` dispatch when threshold and dwell requirements are met. +and verify `trackComponentView` dispatch when threshold and dwell requirements are met, including +periodic updates while visible and a final event on visibility end. **Acceptance Scenarios**: 1. **Given** an entry becomes visible above threshold, **When** it remains visible for `viewTimeMs`, - **Then** `analytics.trackComponentView` is called with derived tracking metadata. -2. **Given** an entry becomes invisible before dwell timeout completes, **When** visibility drops, - **Then** pending timer is cancelled. -3. **Given** component unmount, **When** cleanup runs, **Then** active timers are cleared. + **Then** an initial `trackComponentView` event is dispatched with accumulated `viewDurationMs`. +2. **Given** an entry remains visible after the initial event, **When** each + `viewDurationUpdateIntervalMs` elapses, **Then** one additional component view event is + dispatched with an increased `viewDurationMs`. +3. **Given** an entry that already emitted at least one view event, **When** it leaves threshold + visibility, **Then** one final component view event is dispatched for that visibility cycle. +4. **Given** a visibility cycle that ends before dwell threshold is reached, **When** threshold + visibility stops, **Then** no component view event is dispatched for that cycle. +5. **Given** a single visibility cycle, **When** initial, periodic, and final events are emitted, + **Then** all events reuse the same `componentViewId`. +6. **Given** an entry becomes invisible before dwell timeout completes, **When** visibility drops, + **Then** pending timer is cancelled and cycle state is reset. +7. **Given** component unmount during an active visibility cycle with at least one emitted event, + **When** cleanup runs, **Then** a final event is emitted and active timers are cleared. --- @@ -49,6 +61,28 @@ context and verify viewport calculations and metadata extraction. --- +### User Story 2b - Maintain Deterministic View Lifecycle Under App State Changes (Priority: P1) + +As a mobile app developer, I need view tracking to pause/resume correctly when the app moves to the +background and foreground so duration measurements remain accurate. + +**Why this priority**: Mobile apps frequently background; duration accuracy depends on correct +pause/resume behavior. + +**Independent Test**: Simulate AppState transitions during active visibility cycles and verify +accumulation pauses, final events fire on background, and accumulation resumes on foreground. + +**Acceptance Scenarios**: + +1. **Given** a visible component accumulating dwell time, **When** the app moves to background, + **Then** accumulation pauses and a final event is emitted if at least one event was already sent. +2. **Given** an app that returns to foreground, **When** the component is still visible, **Then** + accumulation resumes from where it left off. +3. **Given** an app that backgrounds before the dwell threshold is reached, **When** background + occurs, **Then** no event is emitted and the cycle state is reset. + +--- + ### User Story 3 - Track Screens Automatically or Manually (Priority: P2) As a screen developer, I need a hook that can auto-track screen views on mount and also expose @@ -75,8 +109,19 @@ manual tracking so I can align screen events with lifecycle/data loading constra - Visibility checks should no-op until both element layout dimensions and non-zero viewport height are available. - Dimension listener cleanup must run on unmount. -- `useViewportTracking` may emit multiple view events across repeated visible/invisible cycles - because it is transition-based, not permanently one-shot. +- `useViewportTracking` emits multiple view events within a single visibility cycle: one initial + event after the dwell threshold, periodic updates at `viewDurationUpdateIntervalMs`, and one final + event when visibility ends. +- Across repeated visible/invisible cycles, each cycle generates a fresh `componentViewId`. +- `componentViewId` is generated per visibility cycle, reused for all events in that cycle, and + replaced on the next visibility cycle. +- `viewDurationMs` is emitted as rounded non-negative milliseconds derived from accumulated visible + time, not the configured dwell threshold. +- Dwell accumulation resets when a visibility cycle ends (no cross-cycle accumulation). +- AppState transitions to `background` or `inactive` MUST pause time accumulation and emit a final + event if at least one event was already emitted in the current cycle. +- AppState transition to `active` MUST resume accumulation if the component is still visible. +- On unmount during an active cycle with at least one emitted event, a final event MUST be emitted. - `useViewportTracking.isVisible` is ref-backed and does not itself trigger re-render updates. - `useScreenTracking` stores `name`/`properties` in refs for stable callback identity. @@ -85,8 +130,9 @@ manual tracking so I can align screen events with lifecycle/data loading constra ### Functional Requirements - **FR-001**: `useViewportTracking` MUST require an `entry` input and MAY accept optional - `personalization`, `threshold`, and `viewTimeMs`. -- **FR-002**: `useViewportTracking` MUST default `threshold` to `0.8` and `viewTimeMs` to `2000`. + `personalization`, `threshold`, `viewTimeMs`, and `viewDurationUpdateIntervalMs`. +- **FR-002**: `useViewportTracking` MUST default `threshold` to `0.8`, `viewTimeMs` to `2000`, and + `viewDurationUpdateIntervalMs` to `5000`. - **FR-003**: `useViewportTracking` MUST derive tracking metadata from entry/personalization data. - **FR-004**: With personalization input, metadata extraction MUST attempt to resolve `componentId` from `personalization.variants` mapping and fall back to `entry.sys.id` when unmatched. @@ -100,14 +146,25 @@ manual tracking so I can align screen events with lifecycle/data loading constra dimensions and triggers immediate visibility evaluation. - **FR-009**: Visibility evaluation MUST compute intersection ratio between element bounds and viewport bounds. -- **FR-010**: On transition from invisible to visible-above-threshold, hook MUST start a dwell - timer. -- **FR-011**: On transition from visible to below-threshold, hook MUST cancel pending dwell timer. -- **FR-012**: When dwell timer completes and element remains visible, hook MUST call - `optimization.trackComponentView` with derived metadata. +- **FR-010**: On transition from invisible to visible-above-threshold, hook MUST start a new + visibility cycle with a fresh `componentViewId` and begin accumulating visible time. +- **FR-011**: On transition from visible to below-threshold, hook MUST cancel pending fire timer and + emit a final event if at least one event was already emitted in the cycle. If no events were + emitted, the cycle MUST be reset silently. +- **FR-012**: When accumulated visible time reaches the dwell threshold (`viewTimeMs`), hook MUST + call `optimization.trackComponentView` with derived metadata including real accumulated + `viewDurationMs`. +- **FR-012a**: After the initial event, hook MUST continue scheduling periodic events at + `viewDurationUpdateIntervalMs` intervals while the component remains visible. +- **FR-012b**: The next-fire schedule MUST follow: + `requiredMs = viewTimeMs + attempts * viewDurationUpdateIntervalMs`. - **FR-013**: `useViewportTracking` MUST re-check visibility whenever scroll position or viewport height changes. -- **FR-014**: `useViewportTracking` MUST clear active timers on unmount. +- **FR-014**: `useViewportTracking` MUST clear active timers on unmount and emit a final event if + the component is mid-cycle with at least one event already emitted. +- **FR-014a**: `useViewportTracking` MUST listen to `AppState` changes. On `background`/`inactive`, + it MUST pause accumulation and emit a final event if applicable. On `active`, it MUST resume + accumulation if the component is still visible. - **FR-015**: `useViewportTracking` MUST return `{ isVisible, onLayout }`. - **FR-016**: `useScreenTracking` MUST accept `{ name, properties?, trackOnMount? }` options. - **FR-017**: `useScreenTracking` MUST default `properties` to an empty object. @@ -124,16 +181,25 @@ manual tracking so I can align screen events with lifecycle/data loading constra - **Viewport Tracking Metadata**: `{ componentId, experienceId?, variantIndex }` payload for component-view analytics. - **Viewport Geometry State**: Element layout + viewport bounds used to compute visibility ratio. -- **Dwell Timer State**: Timeout state controlling delayed tracking dispatch. +- **View Cycle State**: Per-cycle mutable state including `componentViewId`, `visibleSince` + timestamp, `accumulatedMs` total visible duration, and `attempts` event emission count. - **Screen Tracking Contract**: Hook return API and behavior for automatic/manual screen events. ## Success Criteria _(mandatory)_ ### Measurable Outcomes -- **SC-001**: Visibility tests confirm `trackComponentView` fires only after threshold and dwell - criteria are met. +- **SC-001**: Visibility tests confirm initial `trackComponentView` fires only after threshold and + accumulated dwell criteria are met with real `viewDurationMs`. +- **SC-001a**: Periodic event tests confirm additional events fire at `viewDurationUpdateIntervalMs` + intervals while visible, with increasing `viewDurationMs`. +- **SC-001b**: Final event tests confirm one final event fires when visibility ends after at least + one event was emitted, and no event fires when visibility ends before dwell threshold. +- **SC-001c**: `componentViewId` tests confirm stability within a cycle and uniqueness across + cycles. - **SC-002**: Scroll/non-scroll tests confirm viewport calculations remain correct in both layout modes. -- **SC-003**: Cleanup tests confirm timers and dimension listeners are removed on unmount. +- **SC-003**: Cleanup tests confirm timers and dimension listeners are removed on unmount and a + final event is emitted when applicable. +- **SC-003a**: AppState tests confirm pause/resume behavior and final event emission on background. - **SC-004**: Screen tracking tests confirm auto/manual behavior and failure-path return semantics.