diff --git a/lib/e2e-web/e2e/tracking.spec.ts b/lib/e2e-web/e2e/tracking.spec.ts index 156c083f..010b29bc 100644 --- a/lib/e2e-web/e2e/tracking.spec.ts +++ b/lib/e2e-web/e2e/tracking.spec.ts @@ -187,6 +187,42 @@ test.describe('Tracking', () => { expect(finalHoverDurationMs).toBeGreaterThan(firstHoverDurationMs) }) + + test('emits a final hover heartbeat when the page becomes hidden while still hovered', async ({ + page, + }) => { + await page.getByTestId('consent-button').click() + + const target = page.getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`) + await target.scrollIntoViewIfNeeded() + await target.hover() + + const hoverEvents = page.locator('[data-hover-id]') + await expect(hoverEvents.first()).toBeVisible() + + const hoverId = await readHoverEventId(page) + expect(hoverId).toBeTruthy() + if (!hoverId) return + + await expect + .poll(async () => await readHoverDurationMs(page, hoverId)) + .toBeGreaterThanOrEqual(1000) + const beforeHiddenDurationMs = await readHoverDurationMs(page, hoverId) + + await page.waitForTimeout(300) + + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }) + document.dispatchEvent(new Event('visibilitychange')) + }) + + await expect + .poll(async () => await readHoverDurationMs(page, hoverId)) + .toBeGreaterThan(beforeHiddenDurationMs) + }) }) test.describe('Consent Gating', () => { diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index 6ed2e2e1..9d7fc4f7 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -131,7 +131,7 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "contentful-optimization-web.umd.js": 35500, + "contentful-optimization-web.umd.js": 35700, "index.cjs": 12500, "index.mjs": 12500, "bridge-support.cjs": 1200, @@ -143,7 +143,7 @@ "presentation.mjs": 3200, "tracking-attributes.cjs": 1200, "tracking-attributes.mjs": 1200, - "web-components.cjs": 16200, + "web-components.cjs": 16300, "web-components.mjs": 17500 } } diff --git a/packages/web/web-sdk/src/ContentfulOptimization.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.test.ts index 45205bd4..166acb4a 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.test.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.test.ts @@ -10,6 +10,7 @@ import { } from '@contentful/optimization-core/constants' import ContentfulOptimization from './ContentfulOptimization' import { OPTIMIZATION_WEB_SDK_NAME } from './constants' +import { EntryInteractionRuntime } from './entry-tracking/EntryInteractionRuntime' import { getCookie, removeCookie, setCookie } from './lib/cookies' const CLIENT_ID = 'key_123' @@ -625,6 +626,41 @@ describe('ContentfulOptimization', () => { expect(sendBeacon).toHaveBeenCalledWith('/collect', '[]') }) + it('flushes active entry interactions before the lifecycle Insights flush', async () => { + const web = new ContentfulOptimization({ + ...config, + defaults: { consent: true, profile: DEFAULT_PROFILE }, + }) + + const runtime: unknown = Reflect.get(web, 'entryInteractionRuntime') + if (!(runtime instanceof EntryInteractionRuntime)) { + throw new Error('entryInteractionRuntime is unavailable') + } + + const invocations: string[] = [] + const flushActiveInteractions = rs + .spyOn(runtime, 'flushActiveInteractions') + .mockImplementation(() => { + invocations.push('flushActiveInteractions') + }) + const sendBatchEvents = rs + .spyOn(web.api.insights, 'sendBatchEvents') + .mockImplementation(async () => { + invocations.push('sendBatchEvents') + await Promise.resolve() + return true + }) + + await web.trackClick({ componentId: 'hero-banner' }) + window.dispatchEvent(new Event('pagehide')) + await Promise.resolve() + await Promise.resolve() + + expect(flushActiveInteractions).toHaveBeenCalledTimes(1) + expect(sendBatchEvents).toHaveBeenCalledTimes(1) + expect(invocations).toEqual(['flushActiveInteractions', 'sendBatchEvents']) + }) + it('allows creating a new instance after destroy', () => { const first = new ContentfulOptimization(config) const createSecondOptimization = (): ContentfulOptimization => diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index 7e6d7110..e68bd849 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -314,6 +314,7 @@ class ContentfulOptimization extends CoreStateful { }) this.cleanupVisibilityListener = createVisibilityChangeListener(async () => { + this.entryInteractionRuntime.flushActiveInteractions() await this.flushQueues({ force: true, beacon: beaconHandler }) }) diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionDetector.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionDetector.ts index f3115637..131d478f 100644 --- a/packages/web/web-sdk/src/entry-tracking/EntryInteractionDetector.ts +++ b/packages/web/web-sdk/src/entry-tracking/EntryInteractionDetector.ts @@ -18,4 +18,5 @@ export interface EntryInteractionDetector void disableElement?: (element: Element) => void clearElement?: (element: Element) => void + flushActive?: () => void } diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.test.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.test.ts index e24b5502..3aa4bdef 100644 --- a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.test.ts @@ -17,6 +17,7 @@ interface DetectorMocks< enableElement: ReturnType disableElement: ReturnType clearElement: ReturnType + flushActive: ReturnType } const createDetectorMocks = (): DetectorMocks< @@ -32,6 +33,7 @@ const createDetectorMocks = (): DetectorMocks< enableElement: rs.fn(), disableElement: rs.fn(), clearElement: rs.fn(), + flushActive: rs.fn(), }) function createRuntime( @@ -391,4 +393,28 @@ describe('EntryInteractionRuntime', () => { expect(viewDetector.stop).toHaveBeenCalledTimes(1) expect(Reflect.get(runtime, 'entryElementObserver')).toBeUndefined() }) + + it('flushActiveInteractions asks running view and hover detectors to flush', () => { + const { clickDetector, hoverDetector, runtime, viewDetector } = createRuntime() + + runtime.tracking.enable('views') + runtime.tracking.enable('hovers') + + runtime.flushActiveInteractions() + + expect(viewDetector.flushActive).toHaveBeenCalledTimes(1) + expect(hoverDetector.flushActive).toHaveBeenCalledTimes(1) + expect(clickDetector.flushActive).not.toHaveBeenCalled() + }) + + it('flushActiveInteractions skips detectors that are not running', () => { + const { hoverDetector, runtime, viewDetector } = createRuntime() + + runtime.tracking.enable('views') + + runtime.flushActiveInteractions() + + expect(viewDetector.flushActive).toHaveBeenCalledTimes(1) + expect(hoverDetector.flushActive).not.toHaveBeenCalled() + }) }) diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts index 01d82f96..9615186d 100644 --- a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts +++ b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts @@ -234,10 +234,16 @@ export class EntryInteractionRuntime { this.reconcileAllInteractions() } + public flushActiveInteractions(): void { + for (const interaction of ENTRY_INTERACTIONS) { + if (!this.isInteractionRunning[interaction]) continue + const { flushActive: fn, onError } = this.getDetector(interaction) + if (fn) safeCall(fn, onError) + } + } + private reconcileAllInteractions(): void { - ENTRY_INTERACTIONS.forEach((interaction) => { - this.reconcileInteraction(interaction) - }) + for (const i of ENTRY_INTERACTIONS) this.reconcileInteraction(i) } private reconcileInteraction(interaction: EntryInteraction, restart = false): void { @@ -310,9 +316,7 @@ export class EntryInteractionRuntime { } private stopAllEntryInteractions(): void { - ENTRY_INTERACTIONS.forEach((interaction) => { - this.stopEntryInteraction(interaction) - }) + for (const i of ENTRY_INTERACTIONS) this.stopEntryInteraction(i) } private ensureEntryElementObservation(): void { diff --git a/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.test.ts index 60cf27f9..8d2dec62 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.test.ts @@ -13,12 +13,14 @@ interface TestObserver { observe: ReturnType unobserve: ReturnType disconnect: ReturnType + flushActive: ReturnType } const makeObserver = (): TestObserver => ({ observe: rs.fn(), unobserve: rs.fn(), disconnect: rs.fn(), + flushActive: rs.fn(), }) const makeEntryElement = (id = 'entry-1'): EntryElement => { 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 d14aa142..6cc049b2 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/createTimedEntryDetector.ts @@ -9,6 +9,7 @@ interface TimedObserver { observe: (element: Element, options?: TElementOptions) => void unobserve: (element: Element) => void disconnect: () => void + flushActive: () => void } interface ElementOverride { @@ -200,5 +201,8 @@ export function createTimedEntryDetector< applyElementObservation(element) }, + flushActive: (): void => { + observer?.flushActive() + }, } } diff --git a/packages/web/web-sdk/src/entry-tracking/events/hover/ElementHoverObserver.test.ts b/packages/web/web-sdk/src/entry-tracking/events/hover/ElementHoverObserver.test.ts index 394ae50d..b70019ea 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/hover/ElementHoverObserver.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/hover/ElementHoverObserver.test.ts @@ -379,4 +379,51 @@ describe('ElementHoverObserver', () => { expect(cb).not.toHaveBeenCalled() }) + + it('flushActive emits a latest-duration heartbeat for active hovers past dwell', async () => { + const el = makeElement() + const cb = rs.fn<(e: Element, m: Meta) => Promise>().mockResolvedValue(undefined) + + const obs = new ElementHoverObserver(cb, { + dwellTimeMs: 1000, + hoverDurationUpdateIntervalMs: 10_000, + }) + obs.observe(el) + + dispatchHoverEnter(el) + + await advance(1000) + expect(cb).toHaveBeenCalledTimes(1) + + await advance(750) + obs.flushActive() + await Promise.resolve() + + expect(cb).toHaveBeenCalledTimes(2) + + const firstMeta = cb.mock.calls[0]?.[1] + const secondMeta = cb.mock.calls[1]?.[1] + if (!isMeta(firstMeta) || !isMeta(secondMeta)) { + throw new Error('Unexpected callback payload') + } + + expect(secondMeta.hoverId).toBe(firstMeta.hoverId) + expect(secondMeta.totalHoverMs).toBe(1750) + }) + + it('flushActive does not emit for hovers that have not passed dwell yet', async () => { + const el = makeElement() + const cb = rs.fn<(e: Element, m: Meta) => Promise>().mockResolvedValue(undefined) + + const obs = new ElementHoverObserver(cb, { dwellTimeMs: 1000 }) + obs.observe(el) + + dispatchHoverEnter(el) + + await advance(500) + obs.flushActive() + await Promise.resolve() + + expect(cb).not.toHaveBeenCalled() + }) }) diff --git a/packages/web/web-sdk/src/entry-tracking/events/hover/ElementHoverObserver.ts b/packages/web/web-sdk/src/entry-tracking/events/hover/ElementHoverObserver.ts index 1f272c87..0e14c532 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/hover/ElementHoverObserver.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/hover/ElementHoverObserver.ts @@ -126,6 +126,17 @@ class ElementHoverObserver { this.stopSweeper() } + public flushActive(): void { + const now = NOW() + + for (const state of this.activeStates) { + if (state.done || state.inFlight || state.hoverId === null || state.attempts === 0) continue + if (!state.isHovered) continue + + this.trigger(state, now) + } + } + private static initOptions(options?: ElementHoverObserverOptions): EffectiveObserverOptions { return { dwellTimeMs: nonNegativeNumber(options?.dwellTimeMs, DEFAULTS.DWELL_MS), diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.test.ts b/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.test.ts index 393e029e..186722fb 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.test.ts @@ -435,4 +435,53 @@ describe('ElementViewObserver', () => { expect(rs.getTimerCount()).toBe(0) expect(removeSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) }) + + it('flushActive emits a latest-duration heartbeat for active views past dwell', async () => { + const el = makeElement() + const cb = rs.fn<(e: Element, m: Meta) => Promise>().mockResolvedValue(undefined) + + const obs = new ElementViewObserver(cb, { + dwellTimeMs: 1000, + viewDurationUpdateIntervalMs: 10_000, + }) + obs.observe(el) + + const inst = mustGetIO() + inst.trigger({ target: el, isIntersecting: true, intersectionRatio: 1 }) + + await advance(1000) + expect(cb).toHaveBeenCalledTimes(1) + + await advance(750) + obs.flushActive() + await Promise.resolve() + + expect(cb).toHaveBeenCalledTimes(2) + + const firstMeta = cb.mock.calls[0]?.[1] + const secondMeta = cb.mock.calls[1]?.[1] + if (!isMeta(firstMeta) || !isMeta(secondMeta)) { + throw new Error('Unexpected callback payload') + } + + expect(secondMeta.viewId).toBe(firstMeta.viewId) + expect(secondMeta.totalVisibleMs).toBe(1750) + }) + + it('flushActive does not emit for views that have not passed dwell yet', async () => { + const el = makeElement() + const cb = rs.fn<(e: Element, m: Meta) => Promise>().mockResolvedValue(undefined) + + const obs = new ElementViewObserver(cb, { dwellTimeMs: 1000 }) + obs.observe(el) + + const inst = mustGetIO() + inst.trigger({ target: el, isIntersecting: true, intersectionRatio: 1 }) + + await advance(500) + obs.flushActive() + await Promise.resolve() + + expect(cb).not.toHaveBeenCalled() + }) }) diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.ts b/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.ts index ee9dc07c..d5ba961e 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/ElementViewObserver.ts @@ -133,6 +133,17 @@ class ElementViewObserver { this.stopSweeper() } + public flushActive(): void { + const now = NOW() + + for (const state of this.activeStates) { + if (state.done || state.inFlight || state.viewId === null || state.attempts === 0) continue + if (!state.lastKnownVisible) continue + + this.trigger(state, now) + } + } + private onPageVisibilityChange(): void { const now = NOW() const hidden = !isPageVisible()