From 8cebb74c6d5c47c974f18f57ba6b7783e94a14af Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Thu, 23 Jul 2026 17:28:10 +0200 Subject: [PATCH 1/2] fix(web): flush active view and hover heartbeats before lifecycle Insights flush --- lib/e2e-web/e2e/tracking.spec.ts | 36 ++++++++++++++ .../src/ContentfulOptimization.test.ts | 36 ++++++++++++++ .../web/web-sdk/src/ContentfulOptimization.ts | 1 + .../EntryInteractionDetector.ts | 1 + .../EntryInteractionRuntime.test.ts | 26 ++++++++++ .../entry-tracking/EntryInteractionRuntime.ts | 16 +++--- .../events/createTimedEntryDetector.test.ts | 2 + .../events/createTimedEntryDetector.ts | 4 ++ .../events/hover/ElementHoverObserver.test.ts | 47 ++++++++++++++++++ .../events/hover/ElementHoverObserver.ts | 11 +++++ .../events/view/ElementViewObserver.test.ts | 49 +++++++++++++++++++ .../events/view/ElementViewObserver.ts | 11 +++++ 12 files changed, 234 insertions(+), 6 deletions(-) diff --git a/lib/e2e-web/e2e/tracking.spec.ts b/lib/e2e-web/e2e/tracking.spec.ts index 156c083f6..010b29bc5 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/src/ContentfulOptimization.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.test.ts index 45205bd44..166acb4a5 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 7e6d7110f..e68bd8490 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 f3115637e..131d478f8 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 e24b5502d..3aa4bdefa 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 01d82f966..9615186d6 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 60cf27f95..8d2dec62e 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 d14aa1422..6cc049b20 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 394ae50d5..b70019eae 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 1f272c872..0e14c5328 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 393e029e1..186722fb8 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 ee9dc07c8..d5ba961eb 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() From a513939bb5fc3a811064d6eb9ebbb751068b23ce Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Thu, 23 Jul 2026 18:11:51 +0200 Subject: [PATCH 2/2] chore(web): raise umd and web-components.cjs gzip budgets for lifecycle heartbeat flush --- packages/web/web-sdk/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index 6ed2e2e1e..9d7fc4f77 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 } }