Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions lib/e2e-web/e2e/tracking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/web/web-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
}
Expand Down
36 changes: 36 additions & 0 deletions packages/web/web-sdk/src/ContentfulOptimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 =>
Expand Down
1 change: 1 addition & 0 deletions packages/web/web-sdk/src/ContentfulOptimization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ class ContentfulOptimization extends CoreStateful {
})

this.cleanupVisibilityListener = createVisibilityChangeListener(async () => {
this.entryInteractionRuntime.flushActiveInteractions()
await this.flushQueues({ force: true, beacon: beaconHandler })
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ export interface EntryInteractionDetector<TStartOptions = never, TElementOptions
enableElement?: (element: Element, options?: TElementOptions) => void
disableElement?: (element: Element) => void
clearElement?: (element: Element) => void
flushActive?: () => void
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface DetectorMocks<
enableElement: ReturnType<typeof rs.fn>
disableElement: ReturnType<typeof rs.fn>
clearElement: ReturnType<typeof rs.fn>
flushActive: ReturnType<typeof rs.fn>
}

const createDetectorMocks = <TStartOptions, TElementOptions>(): DetectorMocks<
Expand All @@ -32,6 +33,7 @@ const createDetectorMocks = <TStartOptions, TElementOptions>(): DetectorMocks<
enableElement: rs.fn(),
disableElement: rs.fn(),
clearElement: rs.fn(),
flushActive: rs.fn(),
})

function createRuntime(
Expand Down Expand Up @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ interface TestObserver {
observe: ReturnType<typeof rs.fn>
unobserve: ReturnType<typeof rs.fn>
disconnect: ReturnType<typeof rs.fn>
flushActive: ReturnType<typeof rs.fn>
}

const makeObserver = (): TestObserver => ({
observe: rs.fn(),
unobserve: rs.fn(),
disconnect: rs.fn(),
flushActive: rs.fn(),
})

const makeEntryElement = (id = 'entry-1'): EntryElement => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface TimedObserver<TElementOptions> {
observe: (element: Element, options?: TElementOptions) => void
unobserve: (element: Element) => void
disconnect: () => void
flushActive: () => void
}

interface ElementOverride<TOptions> {
Expand Down Expand Up @@ -200,5 +201,8 @@ export function createTimedEntryDetector<

applyElementObservation(element)
},
flushActive: (): void => {
observer?.flushActive()
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>>().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<void>>().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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>>().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<void>>().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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down