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
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { buildAutoPagePayload, composePagePayload } from './pagePayload'
import type { AutoPagePayload } from './types'

describe('composePagePayload', () => {
it('returns an empty object when no layers are supplied', () => {
expect(composePagePayload()).toEqual({})
})

it('skips undefined layers', () => {
const result = composePagePayload(undefined, { properties: { a: 1 } }, undefined)

expect(result).toEqual({ properties: { a: 1 } })
})

it('deep-merges layers with later precedence on conflict', () => {
const result = composePagePayload(
{ properties: { a: 1, b: 2 } },
{ properties: { b: 99 } },
{ properties: { c: 3 } },
)

expect(result).toEqual({ properties: { a: 1, b: 99, c: 3 } })
})

it('replaces non-object values rather than merging them', () => {
const result = composePagePayload(
{ properties: { tags: ['a', 'b'] } },
{ properties: { tags: ['c'] } },
)

expect(result).toEqual({ properties: { tags: ['c'] } })
})
})

describe('buildAutoPagePayload', () => {
const context = {
isInitialEmission: true,
routeKey: '/x',
context: { foo: 'bar' },
}

it('returns the router payload when no consumer overrides are supplied', () => {
const routerPayload: AutoPagePayload = {
properties: { path: '/x', url: 'https://example.com/x' },
}

const result = buildAutoPagePayload(routerPayload, {}, context)

expect(result).toEqual(routerPayload)
})

it('lets consumer static pagePayload override router payload keys', () => {
const routerPayload: AutoPagePayload = {
properties: { path: '/x', source: 'router' },
}

const result = buildAutoPagePayload(
routerPayload,
{ pagePayload: { properties: { source: 'static' } } },
context,
)

expect(result).toEqual({ properties: { path: '/x', source: 'static' } })
})

it('lets consumer dynamic getPagePayload override both router and static layers', () => {
const routerPayload: AutoPagePayload = {
properties: { path: '/x', source: 'router' },
}

const result = buildAutoPagePayload(
routerPayload,
{
pagePayload: { properties: { source: 'static' } },
getPagePayload: () => ({ properties: { source: 'dynamic' } }),
},
context,
)

expect(result).toEqual({ properties: { path: '/x', source: 'dynamic' } })
})

it('forwards the emission context to getPagePayload', () => {
const routerPayload: AutoPagePayload = { properties: {} }
let captured: typeof context | undefined

buildAutoPagePayload(
routerPayload,
{
getPagePayload: (received) => {
captured = received as typeof context
return undefined
},
},
context,
)

expect(captured).toBe(context)
})

it('treats getPagePayload returning undefined as no override', () => {
const routerPayload: AutoPagePayload = { properties: { source: 'router' } }

const result = buildAutoPagePayload(routerPayload, { getPagePayload: () => undefined }, context)

expect(result).toEqual({ properties: { source: 'router' } })
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AutoPagePayload } from './types'
import type { AutoPageEmissionContext, AutoPagePayload, AutoPagePayloadOptions } from './types'

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
Expand All @@ -20,9 +20,44 @@ function mergeRecords(
return result
}

/**
* Compose page payload layers from lowest to highest precedence.
*
* Each subsequent layer deep-merges over the previous one; later arguments win
* on key conflicts. `undefined` layers are skipped.
*
* @internal
*/
export function composePagePayload(
staticPayload: AutoPagePayload | undefined,
dynamicPayload: AutoPagePayload | undefined,
...layers: ReadonlyArray<AutoPagePayload | undefined>
): AutoPagePayload {
return mergeRecords(staticPayload ?? {}, dynamicPayload ?? {}) as AutoPagePayload
return layers.reduce<Record<string, unknown>>(
(accumulator, layer) => (layer ? mergeRecords(accumulator, layer) : accumulator),
{},
) as AutoPagePayload
}

/**
* Compose the final auto-page-tracker payload from the three sources of truth:
*
* 1. The router-derived payload (URL data sourced from the router's React state).
* 2. The consumer-supplied static `pagePayload` prop.
* 3. The consumer-supplied dynamic `getPagePayload` callback evaluated against
* the current emission context.
*
* Later sources deep-merge over earlier ones. Adapters use this helper to
* build the finished payload they hand to `useAutoPageEmitter`.
*
* @internal
*/
export function buildAutoPagePayload<TRouteContext>(
routerPayload: AutoPagePayload,
consumerOptions: AutoPagePayloadOptions<TRouteContext>,
context: AutoPageEmissionContext<TRouteContext>,
): AutoPagePayload {
return composePagePayload(
routerPayload,
consumerOptions.pagePayload,
consumerOptions.getPagePayload?.(context),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,18 @@ import { resetAutoPageEmitterState, useAutoPageEmitter } from './useAutoPageEmit
function TestAutoPageEmitter({
enabled = true,
routeKey,
pagePayload,
getPagePayload,
payload,
buildPayload,
}: {
enabled?: boolean
routeKey: string
pagePayload?: AutoPagePayload
getPagePayload?: (context: { routeKey: string; isInitialEmission: boolean }) => AutoPagePayload
payload?: AutoPagePayload
buildPayload?: (metadata: { isInitialEmission: boolean }) => AutoPagePayload
}): null {
useAutoPageEmitter({
enabled,
route: {
routeKey,
context: {
routeKey,
},
},
pagePayload,
getPagePayload: getPagePayload
? ({ routeKey: currentRouteKey, isInitialEmission }) =>
getPagePayload({ routeKey: currentRouteKey, isInitialEmission })
: undefined,
routeKey,
buildPayload: buildPayload ?? ((): AutoPagePayload => payload ?? {}),
})

return null
Expand Down Expand Up @@ -109,7 +100,7 @@ describe('useAutoPageEmitter', () => {
await rendered.unmount()
})

it('merges static and dynamic payloads with dynamic precedence', async () => {
it('passes the finished payload through to sdk.page', async () => {
const page = rs.fn(async () => {
await Promise.resolve()
return undefined
Expand All @@ -118,36 +109,75 @@ describe('useAutoPageEmitter', () => {
const rendered = await renderWithOptimizationProviders(
<TestAutoPageEmitter
routeKey="/checkout"
pagePayload={{
locale: 'fr-FR',
properties: {
source: 'static',
stableKey: 'yes',
},
}}
getPagePayload={({ routeKey, isInitialEmission }) => ({
locale: isInitialEmission ? 'en-US' : 'de-DE',
properties: {
routeKey,
source: 'dynamic',
},
})}
payload={{ locale: 'en-US', properties: { source: 'test', path: '/checkout' } }}
/>,
sdk,
)

expect(page).toHaveBeenCalledWith({
locale: 'en-US',
properties: {
source: 'dynamic',
stableKey: 'yes',
routeKey: '/checkout',
},
properties: { source: 'test', path: '/checkout' },
})

await rendered.unmount()
})

it('signals isInitialEmission to buildPayload on the first emission only', async () => {
const page = rs.fn(async () => {
await Promise.resolve()
return undefined
})
const sdk = createOptimizationSdk({ page })
const buildPayload = rs.fn(
({ isInitialEmission }: { isInitialEmission: boolean }): AutoPagePayload => ({
properties: { initial: isInitialEmission ? 'yes' : 'no' },
}),
)

const first = await renderWithOptimizationProviders(
<TestAutoPageEmitter routeKey="/" buildPayload={buildPayload} />,
sdk,
)

await first.unmount()

const second = await renderWithOptimizationProviders(
<TestAutoPageEmitter routeKey="/products" buildPayload={buildPayload} />,
sdk,
)

expect(page).toHaveBeenNthCalledWith(1, { properties: { initial: 'yes' } })
expect(page).toHaveBeenNthCalledWith(2, { properties: { initial: 'no' } })

await second.unmount()
})

it('does not call buildPayload when deduplicated', async () => {
const page = rs.fn(async () => {
await Promise.resolve()
return undefined
})
const sdk = createOptimizationSdk({ page })
const buildPayload = rs.fn((): AutoPagePayload => ({}))

const first = await renderWithOptimizationProviders(
<TestAutoPageEmitter routeKey="/" buildPayload={buildPayload} />,
sdk,
)

await first.unmount()

const second = await renderWithOptimizationProviders(
<TestAutoPageEmitter routeKey="/" buildPayload={buildPayload} />,
sdk,
)

expect(buildPayload).toHaveBeenCalledTimes(1)
expect(page).toHaveBeenCalledTimes(1)

await second.unmount()
})

it('does not emit when disabled', async () => {
const page = rs.fn(async () => {
await Promise.resolve()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,56 +1,67 @@
import { useEffect } from 'react'
import { useOptimization } from '../hooks/useOptimization'
import { composePagePayload } from './pagePayload'
import type { AutoPageEmissionContext, AutoPagePayloadOptions, AutoPageRouteState } from './types'
import type { AutoPagePayload } from './types'

let lastEmittedRouteKeyBySdk = new WeakMap<object, string>()

function mergePagePayload<TRouteContext>(
options: AutoPagePayloadOptions<TRouteContext>,
context: AutoPageEmissionContext<TRouteContext>,
): ReturnType<typeof composePagePayload> {
return composePagePayload(options.pagePayload, options.getPagePayload?.(context))
export interface AutoPageEmissionMetadata {
readonly isInitialEmission: boolean
}

export interface UseAutoPageEmitterArgs<
TRouteContext,
> extends AutoPagePayloadOptions<TRouteContext> {
export interface UseAutoPageEmitterArgs {
/**
* When `false` the emitter is inert. Adapters that depend on a router being
* ready (e.g. Next.js Pages router) should gate on their readiness signal
* here.
*/
readonly enabled: boolean
readonly route: AutoPageRouteState<TRouteContext>
/**
* Stable string identity for the current route. Consecutive emissions with
* the same `routeKey` are deduplicated, which also suppresses StrictMode's
* double-effect invocations.
*/
readonly routeKey: string
/**
* Builds the page event payload to emit. Called only when an emission would
* actually happen (after the dedup check), so it never runs more than once
* per route change. Receives `isInitialEmission` to pass through to
* consumer callbacks if the adapter exposes one.
*/
readonly buildPayload: (metadata: AutoPageEmissionMetadata) => AutoPagePayload
}

export function useAutoPageEmitter<TRouteContext>({
/**
* Emit a page event when the route changes.
*
* The hook is intentionally narrow: it owns dedup and emission only. Each
* router adapter is responsible for building the finished payload and passing
* it through `buildPayload`.
*
* @internal
*/
export function useAutoPageEmitter({
enabled,
route,
pagePayload,
getPagePayload,
}: UseAutoPageEmitterArgs<TRouteContext>): void {
routeKey,
buildPayload,
}: UseAutoPageEmitterArgs): void {
const sdk = useOptimization()

useEffect(() => {
if (!enabled) {
return
}

const isInitialEmission = !lastEmittedRouteKeyBySdk.has(sdk)
const previousRouteKey = lastEmittedRouteKeyBySdk.get(sdk)

if (previousRouteKey === route.routeKey) {
if (previousRouteKey === routeKey) {
return
}

lastEmittedRouteKeyBySdk.set(sdk, route.routeKey)

void sdk.page(
mergePagePayload(
{ pagePayload, getPagePayload },
{
...route,
isInitialEmission,
},
),
)
}, [enabled, getPagePayload, pagePayload, route, sdk])
const isInitialEmission = !lastEmittedRouteKeyBySdk.has(sdk)
lastEmittedRouteKeyBySdk.set(sdk, routeKey)

void sdk.page(buildPayload({ isInitialEmission }))
}, [buildPayload, enabled, routeKey, sdk])
}

export function resetAutoPageEmitterState(): void {
Expand Down
Loading