diff --git a/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts b/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts index 38e91095..826eb753 100644 --- a/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts +++ b/packages/react-native-sdk/src/hooks/useViewportTracking.test.ts @@ -315,6 +315,112 @@ describe('useViewportTracking', () => { }) }) + describe('sticky dedupe by success', () => { + it('should emit sticky once after successful trackView for one rendered component', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('sticky-success-entry') + + const personalization: SelectedPersonalization = { + experienceId: 'exp-sticky-success', + variantIndex: 1, + variants: { 'sticky-success-component': 'sticky-success-entry' }, + sticky: true, + } + + mockTrackView.mockResolvedValue({}) + + const { onLayout } = useViewportTracking({ + entry, + personalization, + viewTimeMs: 100, + viewDurationUpdateIntervalMs: 200, + threshold: 0.5, + }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(100) + await Promise.resolve() + rs.advanceTimersByTime(200) + + expect(mockTrackView).toHaveBeenCalledTimes(2) + expect(getCallArg(0).sticky).toBe(true) + expect(getCallArg(1).sticky).toBeUndefined() + }) + + it('should retry sticky until trackView resolves with a value', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('sticky-retry-entry') + + const personalization: SelectedPersonalization = { + experienceId: 'exp-sticky-retry', + variantIndex: 1, + variants: { 'sticky-retry-component': 'sticky-retry-entry' }, + sticky: true, + } + + mockTrackView.mockResolvedValueOnce(undefined).mockResolvedValueOnce({}).mockResolvedValue({}) + + const { onLayout } = useViewportTracking({ + entry, + personalization, + viewTimeMs: 100, + viewDurationUpdateIntervalMs: 200, + threshold: 0.5, + }) + + onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(100) + await Promise.resolve() + rs.advanceTimersByTime(200) + await Promise.resolve() + rs.advanceTimersByTime(200) + + expect(mockTrackView).toHaveBeenCalledTimes(3) + expect(getCallArg(0).sticky).toBe(true) + expect(getCallArg(1).sticky).toBe(true) + expect(getCallArg(2).sticky).toBeUndefined() + }) + + it('should dedupe sticky independently per rendered component instance', async () => { + const { useViewportTracking } = await import('./useViewportTracking') + const entry = createMockEntry('sticky-shared-entry') + + const personalization: SelectedPersonalization = { + experienceId: 'exp-sticky-shared', + variantIndex: 1, + variants: { 'sticky-shared-component': 'sticky-shared-entry' }, + sticky: true, + } + + mockTrackView.mockResolvedValue({}) + + const first = useViewportTracking({ + entry, + personalization, + viewTimeMs: 100, + threshold: 0.5, + }) + + const second = useViewportTracking({ + entry, + personalization, + viewTimeMs: 100, + threshold: 0.5, + }) + + first.onLayout(createLayoutEvent()) + second.onLayout(createLayoutEvent()) + + rs.advanceTimersByTime(100) + + expect(mockTrackView).toHaveBeenCalledTimes(2) + expect(getCallArg(0).sticky).toBe(true) + expect(getCallArg(1).sticky).toBe(true) + }) + }) + describe('default options', () => { it('should default threshold to 0.8, viewTimeMs to 2000, viewDurationUpdateIntervalMs to 5000', async () => { const { useViewportTracking } = await import('./useViewportTracking') diff --git a/packages/react-native-sdk/src/hooks/useViewportTracking.ts b/packages/react-native-sdk/src/hooks/useViewportTracking.ts index cd726d90..4ab25ef4 100644 --- a/packages/react-native-sdk/src/hooks/useViewportTracking.ts +++ b/packages/react-native-sdk/src/hooks/useViewportTracking.ts @@ -145,6 +145,7 @@ export function extractTrackingMetadata( componentId: string experienceId?: string variantIndex: number + sticky?: boolean } { if (personalization) { const componentId = Object.keys(personalization.variants).find( @@ -155,6 +156,7 @@ export function extractTrackingMetadata( componentId: componentId ?? resolvedEntry.sys.id, experienceId: personalization.experienceId, variantIndex: personalization.variantIndex, + sticky: personalization.sticky, } } @@ -162,6 +164,7 @@ export function extractTrackingMetadata( componentId: resolvedEntry.sys.id, experienceId: undefined, variantIndex: 0, + sticky: undefined, } } @@ -233,7 +236,7 @@ export function useViewportTracking({ const scrollContext = useScrollContext() - const { componentId, experienceId, variantIndex } = extractTrackingMetadata( + const { componentId, experienceId, variantIndex, sticky } = extractTrackingMetadata( entry, personalization, ) @@ -266,6 +269,21 @@ export function useViewportTracking({ experienceIdRef.current = experienceId const variantIndexRef = useRef(variantIndex) variantIndexRef.current = variantIndex + const stickyRef = useRef(sticky) + stickyRef.current = sticky + const stickySuccessRef = useRef(false) + const stickyInFlightRef = useRef(false) + const stickyIdentityRef = useRef(null) + + const stickyIdentity = `${componentId}::${experienceId ?? ''}::${variantIndex}::${sticky === true ? '1' : '0'}` + + useEffect(() => { + if (stickyIdentityRef.current === stickyIdentity) return + + stickyIdentityRef.current = stickyIdentity + stickySuccessRef.current = false + stickyInFlightRef.current = false + }, [stickyIdentity]) logger.debug( `Hook initialized for ${componentId} (experienceId: ${experienceId}, variantIndex: ${variantIndex})`, @@ -299,14 +317,37 @@ export function useViewportTracking({ `Emitting view event #${cycle.attempts} for ${componentIdRef.current} (viewDurationMs=${durationMs}, viewId=${viewId})`, ) + const shouldSendSticky = + stickyRef.current === true && !stickySuccessRef.current && !stickyInFlightRef.current + + if (shouldSendSticky) { + stickyInFlightRef.current = true + } + void (async () => { - await optimizationRef.current.trackView({ - componentId: componentIdRef.current, - viewId, - experienceId: experienceIdRef.current, - variantIndex: variantIndexRef.current, - viewDurationMs: durationMs, - }) + try { + const data = await optimizationRef.current.trackView({ + componentId: componentIdRef.current, + viewId, + experienceId: experienceIdRef.current, + variantIndex: variantIndexRef.current, + viewDurationMs: durationMs, + sticky: shouldSendSticky ? true : undefined, + }) + + if (shouldSendSticky && data !== undefined) { + stickySuccessRef.current = true + } + } catch (error) { + logger.error( + `Failed to emit view event for ${componentIdRef.current} (viewId=${viewId})`, + error, + ) + } finally { + if (shouldSendSticky) { + stickyInFlightRef.current = false + } + } })() }, []) diff --git a/packages/universal/core-sdk/src/CoreBase.ts b/packages/universal/core-sdk/src/CoreBase.ts index b0d6599f..466bd31d 100644 --- a/packages/universal/core-sdk/src/CoreBase.ts +++ b/packages/universal/core-sdk/src/CoreBase.ts @@ -346,11 +346,15 @@ abstract class CoreBase implements ResolverMethods { async trackView( payload: ViewBuilderArgs & { profile?: PartialProfile }, ): Promise { + let result = undefined + if (payload.sticky) { - return await this._personalization.trackView(payload) + result = await this._personalization.trackView(payload) } await this._analytics.trackView(payload) + + return result } /** diff --git a/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.ts b/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.ts index e791a193..7abb77fe 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.ts @@ -37,6 +37,7 @@ interface CreateTimedEntryDetectorOptions< core: TCore, payload: NonNullable>, info: TInfo, + element: Element, ) => Promise } @@ -82,7 +83,7 @@ export function createTimedEntryDetector< const payload = resolveTrackingPayload(info.data, element) if (!payload) return - await track(runtimeCore, payload, info) + await track(runtimeCore, payload, info, element) } let observer: TObserver | undefined = undefined diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts index db37ec81..537b1674 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts @@ -252,6 +252,152 @@ describe('EntryViewTracker', () => { cleanup() }) + it('emits sticky only once per element across visibility cycles after success', async () => { + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-sticky-once' + entry.dataset.ctflSticky = 'true' + document.body.append(entry) + + const stickySuccess = {} + const trackView = rs.fn().mockResolvedValue(stickySuccess) + const core: EntryViewTrackingCore = { trackView } + const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core)) + + tracker.start({ dwellTimeMs: 0, viewDurationUpdateIntervalMs: 10_000 }) + + const instance = io.getLast() + + if (!instance) { + throw new Error('IntersectionObserver polyfill instance not found') + } + + instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 }) + await advance(0) + instance.trigger({ target: entry, isIntersecting: false, intersectionRatio: 0 }) + await Promise.resolve() + instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 }) + await advance(0) + + expect(trackView).toHaveBeenCalledTimes(2) + expect(trackView.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + componentId: 'entry-sticky-once', + sticky: true, + }), + ) + expect(trackView.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + componentId: 'entry-sticky-once', + sticky: undefined, + }), + ) + + cleanup() + }) + + it('retries sticky for the same element until a successful personalization response', async () => { + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-sticky-retry' + entry.dataset.ctflSticky = 'true' + document.body.append(entry) + + const stickySuccess = {} + const trackView = rs + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(stickySuccess) + .mockResolvedValue(stickySuccess) + const core: EntryViewTrackingCore = { trackView } + const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core)) + + tracker.start({ dwellTimeMs: 0, viewDurationUpdateIntervalMs: 10_000 }) + + const instance = io.getLast() + + if (!instance) { + throw new Error('IntersectionObserver polyfill instance not found') + } + + instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 }) + await advance(0) + instance.trigger({ target: entry, isIntersecting: false, intersectionRatio: 0 }) + await Promise.resolve() + instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 }) + await advance(0) + instance.trigger({ target: entry, isIntersecting: false, intersectionRatio: 0 }) + await Promise.resolve() + instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 }) + await advance(0) + + expect(trackView).toHaveBeenCalledTimes(3) + expect(trackView.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + componentId: 'entry-sticky-retry', + sticky: true, + }), + ) + expect(trackView.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + componentId: 'entry-sticky-retry', + sticky: true, + }), + ) + expect(trackView.mock.calls[2]?.[0]).toEqual( + expect.objectContaining({ + componentId: 'entry-sticky-retry', + sticky: undefined, + }), + ) + + cleanup() + }) + + it('treats separately rendered elements as distinct sticky dedupe targets', async () => { + const first = document.createElement('div') + first.dataset.ctflEntryId = 'entry-shared-component-id' + first.dataset.ctflSticky = 'true' + document.body.append(first) + + const second = document.createElement('div') + second.dataset.ctflEntryId = 'entry-shared-component-id' + second.dataset.ctflSticky = 'true' + document.body.append(second) + + const stickySuccess = {} + const trackView = rs.fn().mockResolvedValue(stickySuccess) + const core: EntryViewTrackingCore = { trackView } + const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core)) + + tracker.start({ dwellTimeMs: 0, viewDurationUpdateIntervalMs: 10_000 }) + + const instance = io.getLast() + + if (!instance) { + throw new Error('IntersectionObserver polyfill instance not found') + } + + instance.trigger({ target: first, isIntersecting: true, intersectionRatio: 1 }) + await advance(0) + instance.trigger({ target: second, isIntersecting: true, intersectionRatio: 1 }) + await advance(0) + + expect(trackView).toHaveBeenCalledTimes(2) + expect(trackView.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + componentId: 'entry-shared-component-id', + sticky: true, + }), + ) + expect(trackView.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + componentId: 'entry-shared-component-id', + sticky: true, + }), + ) + + cleanup() + }) + it('emits a final duration update when an observed entry leaves view', async () => { const entry = document.createElement('div') entry.dataset.ctflEntryId = 'entry-view-final' diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.ts index 40f537d5..35df29c1 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.ts @@ -28,6 +28,8 @@ export function createEntryViewDetector( EntryViewInteractionStartOptions | undefined, EntryViewInteractionElementOptions > { + const stickySuccessElements = new WeakSet() + return createTimedEntryDetector< EntryViewTrackingCore, EntryViewInteractionStartOptions, @@ -51,12 +53,21 @@ export function createEntryViewDetector( viewDurationUpdateIntervalMs, } }, - track: async (runtimeCore, payload, info: ElementViewCallbackInfo): Promise => { - await runtimeCore.trackView({ + track: async (runtimeCore, payload, info: ElementViewCallbackInfo, element): Promise => { + const stickyAlreadySucceeded = stickySuccessElements.has(element) + const shouldSendSticky = payload.sticky === true && !stickyAlreadySucceeded + const sticky = shouldSendSticky ? true : undefined + + const data = await runtimeCore.trackView({ ...payload, + sticky, viewId: info.viewId, viewDurationMs: Math.max(0, Math.round(info.totalVisibleMs)), }) + + if (shouldSendSticky && data !== undefined) { + stickySuccessElements.add(element) + } }, }) } diff --git a/specs/009-core-foundational-and-shared/spec.md b/specs/009-core-foundational-and-shared/spec.md index c3c3ef76..6893135e 100644 --- a/specs/009-core-foundational-and-shared/spec.md +++ b/specs/009-core-foundational-and-shared/spec.md @@ -53,7 +53,7 @@ and return behavior. 2. **Given** identify/page/screen/track payloads, **When** `identify`, `page`, `screen`, and `track` are called, **Then** each delegates to personalization and returns its async result. 3. **Given** `trackView` payload with `sticky: true`, **When** the method is called, **Then** it - delegates only to personalization and returns the personalization result. + delegates to personalization and analytics, and returns the personalization result. 4. **Given** `trackView` payload with `sticky` omitted or `false`, **When** the method is called, **Then** it delegates to analytics and resolves with `undefined`. 5. **Given** analytics interaction payloads, **When** `trackClick`, `trackHover`, or `trackFlagView` @@ -93,7 +93,8 @@ and subpath exports. - Analytics and personalization API base URLs remain isolated when only one scoped override is set. - Top-level `fetchOptions` are forwarded to shared API client config. -- `CoreBase.trackView` does not dual-send when `sticky` is truthy. +- `CoreBase.trackView` always sends analytics view events; when `sticky` is truthy, it also sends + personalization view events. - `InterceptorManager.run` returns the original input reference when no interceptors are registered. - `guardedBy` throws `TypeError` at call time when the configured predicate key is not callable. - Core root export surface has no package default export and does not expose logger/API client/API @@ -119,8 +120,9 @@ and subpath exports. `getMergeTagValue`) MUST delegate to personalization methods without reshaping outputs. - **FR-008**: `CoreBase.identify`, `page`, `screen`, and `track` MUST delegate to personalization and return delegated async results. -- **FR-009**: `CoreBase.trackView` MUST delegate to personalization only when `payload.sticky` is - truthy; otherwise it MUST delegate to analytics. +- **FR-009**: `CoreBase.trackView` MUST delegate to analytics for all payloads and MUST additionally + delegate to personalization when `payload.sticky` is truthy; it MUST return personalization data + for sticky payloads and `undefined` otherwise. - **FR-010**: `CoreBase.trackClick`, `trackHover`, and `trackFlagView` MUST delegate to analytics. - **FR-011**: `ProductBase` MUST default `allowedEventTypes` to `['identify', 'page', 'screen']` when unspecified. diff --git a/specs/010-core-stateless-environment-support/spec.md b/specs/010-core-stateless-environment-support/spec.md index a8fae930..239cec51 100644 --- a/specs/010-core-stateless-environment-support/spec.md +++ b/specs/010-core-stateless-environment-support/spec.md @@ -78,7 +78,7 @@ builder output, interceptor application, schema parsing, and one-batch send payl 3. **Given** optional partial profile payload, **When** analytics batch payload is built, **Then** outgoing shape is `[{ profile?: PartialProfile, events: [event] }]`. 4. **Given** core facade usage, **When** `CoreBase.trackView` is called, **Then** sticky payloads - route to personalization and non-sticky payloads route to analytics. + route to both personalization and analytics while non-sticky payloads route to analytics only. --- @@ -125,8 +125,8 @@ builder output, interceptor application, schema parsing, and one-batch send payl - **FR-015**: `AnalyticsStateless` outgoing batch payload MUST validate against `BatchInsightsEventArray` and use shape `[{ profile?: PartialProfile, events: [event] }]`. - **FR-016**: Core stateless facade methods MUST route as follows: `identify/page/screen/track` to - personalization; `trackView` to personalization only when `sticky` is truthy, otherwise to - analytics; `trackClick/trackHover/trackFlagView` to analytics. + personalization; `trackView` to analytics for all payloads and additionally to personalization + when `sticky` is truthy; `trackClick/trackHover/trackFlagView` to analytics. - **FR-017**: `CoreStateless` MUST expose resolver helpers (`getCustomFlag`, `getCustomFlags`, `personalizeEntry`, `getMergeTagValue`) without requiring mutable runtime state. - **FR-018**: `CoreStateless` MUST remain stateless-only and MUST NOT introduce stateful singleton, diff --git a/specs/023-react-native-hooks/spec.md b/specs/023-react-native-hooks/spec.md index 56407e23..f52f7d9d 100644 --- a/specs/023-react-native-hooks/spec.md +++ b/specs/023-react-native-hooks/spec.md @@ -33,6 +33,13 @@ verify initial, periodic, and final event behavior. same `viewId`. 6. **Given** visibility transitions into a new cycle, **When** a new cycle starts, **Then** a fresh `viewId` is generated. +7. **Given** sticky personalization metadata, **When** `trackView` returns personalization data for + a rendered hook instance, **Then** later emissions for that same instance omit `sticky`. +8. **Given** sticky personalization metadata, **When** a `trackView` attempt resolves `undefined` or + throws, **Then** later emissions for that same instance continue sending `sticky` until a + successful personalization response is returned. +9. **Given** two rendered hook instances with the same tracking metadata, **When** each instance + emits its first sticky view event, **Then** each event includes `sticky` independently. --- @@ -95,6 +102,10 @@ manual, and callback-based) so I can instrument interactions with minimal boiler - `isVisible` is ref-backed and returned as a non-reactive snapshot (`useViewportTracking` does not trigger rerenders on visibility changes). - `viewDurationMs` is emitted as rounded, non-negative, accumulated visible time. +- Sticky view emission is guarded per hook instance so only one sticky `trackView` request is + in-flight at a time. +- Sticky dedupe state is keyed by tracking identity (`componentId`, `experienceId`, `variantIndex`, + `sticky`) and resets when that identity changes. - `enabled=false` prevents cycle start/event emission but does not remove hook setup. - `useTapTracking` classifies taps by movement distance `< 10` points. - `useTapTracking` clears touch-start state on touch end regardless of outcome. @@ -112,9 +123,9 @@ manual, and callback-based) so I can instrument interactions with minimal boiler - **FR-003**: `useViewportTracking` MUST derive metadata via `extractTrackingMetadata`. - **FR-004**: With personalization, metadata extraction MUST resolve `componentId` from `personalization.variants` mapping to resolved entry ID, falling back to `entry.sys.id`; and MUST - include `experienceId`/`variantIndex` from personalization. + include `experienceId`/`variantIndex`/`sticky` from personalization. - **FR-005**: Without personalization, metadata extraction MUST resolve - `{ componentId: entry.sys.id, experienceId: undefined, variantIndex: 0 }`. + `{ componentId: entry.sys.id, experienceId: undefined, variantIndex: 0, sticky: undefined }`. - **FR-006**: `useViewportTracking` MUST subscribe to dimension changes and maintain fallback screen height. - **FR-007**: When scroll context exists, visibility checks MUST use context `scrollY` and @@ -128,8 +139,9 @@ manual, and callback-based) so I can instrument interactions with minimal boiler - **FR-011**: Next-fire scheduling MUST use `requiredMs = viewTimeMs + attempts * viewDurationUpdateIntervalMs`. - **FR-012**: Event emission MUST flush elapsed visible time, increment attempts, and call - `optimization.trackView` with `componentId`, `viewId`, `experienceId`, `variantIndex`, and rounded - non-negative `viewDurationMs`. + `optimization.trackView` with `componentId`, `viewId`, `experienceId`, `variantIndex`, rounded + non-negative `viewDurationMs`, and `sticky` when sticky metadata is present and no successful + sticky response has been observed for the current tracking identity. - **FR-013**: On visible-to-invisible transition, hook MUST clear timer, pause accumulation, emit a final event only when attempts > 0, and reset cycle state. - **FR-014**: Hook MUST re-check visibility whenever `scrollY` or `viewportHeight` dependencies @@ -168,10 +180,15 @@ manual, and callback-based) so I can instrument interactions with minimal boiler - **FR-035**: `useScreenTrackingCallback` MUST call `optimization.screen({ name, properties: properties ?? {}, screen: { name } })` without returning SDK result to caller. +- **FR-036**: For sticky metadata, `useViewportTracking` MUST treat a sticky attempt as successful + only when `optimization.trackView` resolves with a defined value; `undefined` or failed attempts + MUST remain eligible for sticky retry. +- **FR-037**: Sticky dedupe state MUST be scoped per rendered hook instance and MUST reset when the + tracking identity (`componentId`, `experienceId`, `variantIndex`, `sticky`) changes. ### Key Entities _(include if feature involves data)_ -- **Viewport Tracking Metadata**: `{ componentId, experienceId?, variantIndex }` used for +- **Viewport Tracking Metadata**: `{ componentId, experienceId?, variantIndex, sticky? }` used for `trackView` and `trackClick` payloads. - **View Cycle State**: Mutable cycle state (`viewId`, `visibleSince`, `accumulatedMs`, `attempts`) used for dwell and duration tracking. @@ -192,3 +209,5 @@ manual, and callback-based) so I can instrument interactions with minimal boiler - **SC-005**: Screen-hook tests confirm auto/manual tracking behavior and error-path return semantics. - **SC-006**: Callback-hook tests confirm dynamic screen tracking callback payload format. +- **SC-007**: Sticky-view tests confirm per-instance sticky dedupe on successful personalization, + sticky retry for undefined/failed responses, and identity-based dedupe reset. diff --git a/specs/026-web-automatic-view-tracking/spec.md b/specs/026-web-automatic-view-tracking/spec.md index 33a8d24f..a822febb 100644 --- a/specs/026-web-automatic-view-tracking/spec.md +++ b/specs/026-web-automatic-view-tracking/spec.md @@ -35,6 +35,15 @@ keep elements visible for periodic updates, then end visibility and assert initi 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 `viewId`. +6. **Given** a tracked entry payload with `sticky: true`, **When** the first successful + `core.trackView` call for an element returns personalization data, **Then** later emissions for + that element omit `sticky`. +7. **Given** a tracked entry payload with `sticky: true`, **When** `core.trackView` returns + `undefined` or rejects for an element, **Then** later emissions for that element continue sending + `sticky` until a successful personalization response is returned. +8. **Given** two tracked elements with the same component metadata and `sticky: true`, **When** each + element emits its first sticky view event, **Then** both emissions include `sticky` + independently. --- @@ -113,6 +122,8 @@ unobserve/disconnect calls, and orphan element cleanup sweeps. the next visibility cycle. - `viewDurationMs` is emitted as rounded non-negative milliseconds derived from accumulated visible time. +- Sticky dedupe is scoped per observed element and is marked successful only when + `core.trackView(...)` resolves with personalization data. - Callback failures are logged and do not permanently disable subsequent periodic updates while an element remains visible. - `disconnect()` removes timers, active state, and page visibility listeners. @@ -143,7 +154,7 @@ unobserve/disconnect calls, and orphan element cleanup sweeps. observation eligibility from attribute and auto-tracking state. - **FR-010**: Auto-tracking callback MUST resolve payload via `resolveTrackingPayload(info.data, element)` and MUST call `core.trackView` only when payload - resolution succeeds. + resolution succeeds, while preserving element context for sticky dedupe behavior. - **FR-011**: `ElementViewObserver` MUST initialize effective observer options with defaults and construct an `IntersectionObserver` with threshold `[0]` when `minVisibleRatio` is `0`, otherwise `[0, minVisibleRatio]`. @@ -182,6 +193,13 @@ unobserve/disconnect calls, and orphan element cleanup sweeps. - **FR-026**: `clearElement(element)` MUST clear only manual override state and MUST continue to honor attribute override state (including `ctflTrackViews` and parsed `ctflViewDurationUpdateIntervalMs`) for auto-discovered elements. +- **FR-027**: When resolved tracking payload includes `sticky: true`, detector callbacks MUST send + `sticky: true` until the first `core.trackView` call for that element resolves with a defined + personalization result. +- **FR-028**: After sticky success is recorded for an element, subsequent callbacks for that element + MUST omit `sticky` while continuing to emit analytics view events. +- **FR-029**: Sticky dedupe MUST be keyed by element identity, so separately rendered elements with + identical component metadata are treated as distinct sticky targets. ### Key Entities _(include if feature involves data)_ @@ -207,3 +225,6 @@ unobserve/disconnect calls, and orphan element cleanup sweeps. release state on unobserve/disconnect/orphan sweep. - **SC-005**: View override tests confirm `data-ctfl-track-views='false'` suppresses observation and `data-ctfl-track-views='true'` can force-enable observation when global auto mode is off. +- **SC-006**: Sticky-view tests confirm sticky is emitted until first successful personalization + response per element, retried after undefined/failed responses, and deduped independently across + separately rendered elements.