diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/pagePayload.test.ts b/packages/web/frameworks/react-web-sdk/src/auto-page/pagePayload.test.ts new file mode 100644 index 00000000..d07574ae --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/pagePayload.test.ts @@ -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' } }) + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/pagePayload.ts b/packages/web/frameworks/react-web-sdk/src/auto-page/pagePayload.ts index c8c79136..cfb1af2c 100644 --- a/packages/web/frameworks/react-web-sdk/src/auto-page/pagePayload.ts +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/pagePayload.ts @@ -1,4 +1,4 @@ -import type { AutoPagePayload } from './types' +import type { AutoPageEmissionContext, AutoPagePayload, AutoPagePayloadOptions } from './types' function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -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 { - return mergeRecords(staticPayload ?? {}, dynamicPayload ?? {}) as AutoPagePayload + return layers.reduce>( + (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( + routerPayload: AutoPagePayload, + consumerOptions: AutoPagePayloadOptions, + context: AutoPageEmissionContext, +): AutoPagePayload { + return composePagePayload( + routerPayload, + consumerOptions.pagePayload, + consumerOptions.getPagePayload?.(context), + ) } diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx index ff47aece..03b9dd72 100644 --- a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx @@ -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 @@ -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 @@ -118,36 +109,75 @@ describe('useAutoPageEmitter', () => { const rendered = await renderWithOptimizationProviders( ({ - 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( + , + sdk, + ) + + await first.unmount() + + const second = await renderWithOptimizationProviders( + , + 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( + , + sdk, + ) + + await first.unmount() + + const second = await renderWithOptimizationProviders( + , + 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() diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts index b353815c..a2ef3e48 100644 --- a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts @@ -1,30 +1,49 @@ 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() -function mergePagePayload( - options: AutoPagePayloadOptions, - context: AutoPageEmissionContext, -): ReturnType { - return composePagePayload(options.pagePayload, options.getPagePayload?.(context)) +export interface AutoPageEmissionMetadata { + readonly isInitialEmission: boolean } -export interface UseAutoPageEmitterArgs< - TRouteContext, -> extends AutoPagePayloadOptions { +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 + /** + * 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({ +/** + * 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): void { + routeKey, + buildPayload, +}: UseAutoPageEmitterArgs): void { const sdk = useOptimization() useEffect(() => { @@ -32,25 +51,17 @@ export function useAutoPageEmitter({ 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 { diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index 0361251b..5d5acb44 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -27,7 +27,8 @@ const testConfig = { function TestAutoPageEmitter(): null { useAutoPageEmitter({ enabled: true, - route: { routeKey: '/', context: undefined }, + routeKey: '/', + buildPayload: () => ({}), }) return null diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx index dfbfdedc..47063093 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx @@ -99,7 +99,14 @@ describe('NextAppAutoPageTracker', () => { const rendered = await renderTracker(, sdk) expect(page).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledWith({}) + expect(page).toHaveBeenCalledWith({ + properties: { + path: '/', + query: {}, + search: '', + url: `${window.location.origin}/`, + }, + }) await rendered.unmount() }) @@ -121,7 +128,14 @@ describe('NextAppAutoPageTracker', () => { await rendered.rerender() expect(page).toHaveBeenCalledTimes(2) - expect(page).toHaveBeenNthCalledWith(2, {}) + expect(page).toHaveBeenNthCalledWith(2, { + properties: { + path: '/products', + query: { tab: 'featured' }, + search: '?tab=featured', + url: `${window.location.origin}/products?tab=featured`, + }, + }) await rendered.unmount() }) @@ -182,9 +196,12 @@ describe('NextAppAutoPageTracker', () => { expect(page).toHaveBeenCalledWith({ locale: 'en-US', properties: { + campaign: 'spring', path: '/products?tab=featured', + query: { tab: 'featured' }, + search: '?tab=featured', source: 'dynamic', - campaign: 'spring', + url: `${window.location.origin}/products?tab=featured`, }, }) expect(getPagePayload).toHaveBeenCalledWith({ diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx index 9772a6f8..4b2ab87f 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx @@ -1,9 +1,9 @@ 'use client' import { usePathname, useRouter, useSearchParams } from 'next/navigation' -import type { ReactElement } from 'react' -import { useMemo } from 'react' -import type { AutoPagePayloadOptions } from '../auto-page/types' +import { useCallback, useMemo, type ReactElement } from 'react' +import { buildAutoPagePayload } from '../auto-page/pagePayload' +import type { AutoPagePayload, AutoPagePayloadOptions } from '../auto-page/types' import { useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' function toSearch(searchParams: ReturnType): string { @@ -11,6 +11,23 @@ function toSearch(searchParams: ReturnType): string { return value.length > 0 ? `?${value}` : '' } +function toQueryDictionary( + searchParams: ReturnType, +): Record { + return Object.fromEntries(searchParams) +} + +function resolveAbsoluteUrl(href: string): string { + if (typeof window === 'undefined') { + return href + } + try { + return new URL(href, window.location.origin).toString() + } catch { + return href + } +} + export interface NextAppAutoPageContext { readonly routeKey: string readonly pathname: string @@ -30,28 +47,46 @@ export function NextAppAutoPageTracker({ const router = useRouter() const searchParams = useSearchParams() - const route = useMemo(() => { - const search = toSearch(searchParams) + const search = useMemo(() => toSearch(searchParams), [searchParams]) + const routeKey = `${pathname}${search}` - return { - routeKey: `${pathname}${search}`, - context: { - routeKey: `${pathname}${search}`, - pathname, - router, + // Hash intentionally omitted: Next.js App Router does not expose it; the + // SDK's getPageProperties will read window.location.hash, which is not + // subject to the same staleness as pathname/search. + const routerPayload = useMemo( + () => ({ + properties: { + path: pathname, + query: toQueryDictionary(searchParams), search, - searchParams, - url: `${pathname}${search}`, + url: resolveAbsoluteUrl(routeKey), }, - } - }, [pathname, router, searchParams]) - - useAutoPageEmitter({ - enabled: true, - route, - pagePayload, - getPagePayload, - }) + }), + [pathname, routeKey, search, searchParams], + ) + + const buildPayload = useCallback( + ({ isInitialEmission }: { isInitialEmission: boolean }): AutoPagePayload => + buildAutoPagePayload( + routerPayload, + { pagePayload, getPagePayload }, + { + isInitialEmission, + routeKey, + context: { + routeKey, + pathname, + router, + search, + searchParams, + url: routeKey, + }, + }, + ), + [getPagePayload, pagePayload, pathname, routeKey, router, routerPayload, search, searchParams], + ) + + useAutoPageEmitter({ enabled: true, routeKey, buildPayload }) return null } diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx index 1aa1ceb8..f324204e 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx @@ -85,7 +85,14 @@ describe('NextPagesAutoPageTracker', () => { const rendered = await renderTracker(, sdk) expect(page).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledWith({}) + expect(page).toHaveBeenCalledWith({ + properties: { + path: '/', + query: {}, + search: '', + url: `${window.location.origin}/`, + }, + }) await rendered.unmount() }) @@ -98,14 +105,21 @@ describe('NextPagesAutoPageTracker', () => { const sdk = createOptimizationSdk({ page }) const rendered = await renderTracker(, sdk) - routerState.asPath = '/products' + routerState.asPath = '/products?tab=featured' routerState.pathname = '/products' - routerState.query = { slug: 'products' } + routerState.query = { tab: 'featured' } await rendered.rerender() expect(page).toHaveBeenCalledTimes(2) - expect(page).toHaveBeenNthCalledWith(2, {}) + expect(page).toHaveBeenNthCalledWith(2, { + properties: { + path: '/products', + query: { tab: 'featured' }, + search: '?tab=featured', + url: `${window.location.origin}/products?tab=featured`, + }, + }) await rendered.unmount() }) @@ -184,9 +198,12 @@ describe('NextPagesAutoPageTracker', () => { expect(page).toHaveBeenCalledWith({ locale: 'en-US', properties: { + campaign: 'spring', path: '/', + query: {}, + search: '', source: 'dynamic', - campaign: 'spring', + url: `${window.location.origin}/`, }, }) expect(getPagePayload).toHaveBeenCalledWith({ diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx index 13e9f138..566ccc18 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx @@ -1,9 +1,41 @@ import { useRouter, type NextRouter } from 'next/router' -import type { ReactElement } from 'react' -import { useMemo } from 'react' -import type { AutoPagePayloadOptions } from '../auto-page/types' +import { useCallback, useMemo, type ReactElement } from 'react' +import { buildAutoPagePayload } from '../auto-page/pagePayload' +import type { AutoPagePayload, AutoPagePayloadOptions } from '../auto-page/types' import { useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' +function splitAsPath(asPath: string): { path: string; search: string } { + const queryIndex = asPath.indexOf('?') + if (queryIndex === -1) { + return { path: asPath, search: '' } + } + return { path: asPath.slice(0, queryIndex), search: asPath.slice(queryIndex) } +} + +function flattenQuery(query: NextRouter['query']): Record { + const entries = Object.entries(query).flatMap<[string, string]>(([key, value]) => { + if (typeof value === 'string') { + return [[key, value]] + } + if (Array.isArray(value) && value.length > 0) { + return [[key, value.join(',')]] + } + return [] + }) + return Object.fromEntries(entries) +} + +function resolveAbsoluteUrl(href: string): string { + if (typeof window === 'undefined') { + return href + } + try { + return new URL(href, window.location.origin).toString() + } catch { + return href + } +} + export interface NextPagesAutoPageContext { readonly routeKey: string readonly pathname: string @@ -19,27 +51,42 @@ export function NextPagesAutoPageTracker({ getPagePayload, }: NextPagesAutoPageTrackerProps): ReactElement | null { const router = useRouter() + const { asPath, pathname, query, isReady } = router + const routeKey = asPath - const route = useMemo( - () => ({ - routeKey: router.asPath, - context: { - routeKey: router.asPath, - pathname: router.pathname, - asPath: router.asPath, - query: router.query, - router, + const routerPayload = useMemo(() => { + const { path, search } = splitAsPath(asPath) + return { + properties: { + path, + query: flattenQuery(query), + search, + url: resolveAbsoluteUrl(asPath), }, - }), - [router, router.asPath, router.pathname, router.query], + } + }, [asPath, query]) + + const buildPayload = useCallback( + ({ isInitialEmission }: { isInitialEmission: boolean }): AutoPagePayload => + buildAutoPagePayload( + routerPayload, + { pagePayload, getPagePayload }, + { + isInitialEmission, + routeKey, + context: { + routeKey, + asPath, + pathname, + query, + router, + }, + }, + ), + [asPath, getPagePayload, pagePayload, pathname, query, routeKey, router, routerPayload], ) - useAutoPageEmitter({ - enabled: router.isReady, - route, - pagePayload, - getPagePayload, - }) + useAutoPageEmitter({ enabled: isReady, routeKey, buildPayload }) return null } diff --git a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx index 1fc40358..de8cc5bd 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx @@ -78,7 +78,7 @@ describe('ReactRouterAutoPageTracker', () => { expect(ReactRouterAutoPageTracker).toBeTypeOf('function') }) - it('emits on initial render and route changes', async () => { + it('emits router-derived URL data on initial render and route changes', async () => { const page = rs.fn(async () => { await Promise.resolve() return undefined @@ -87,7 +87,15 @@ describe('ReactRouterAutoPageTracker', () => { const rendered = await renderTracker(, sdk) expect(page).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenNthCalledWith(1, {}) + expect(page).toHaveBeenNthCalledWith(1, { + properties: { + hash: '', + path: '/', + query: {}, + search: '', + url: `${window.location.origin}/`, + }, + }) locationState.pathname = '/products' locationState.search = '?tab=featured' @@ -98,7 +106,15 @@ describe('ReactRouterAutoPageTracker', () => { await rendered.rerender() expect(page).toHaveBeenCalledTimes(2) - expect(page).toHaveBeenNthCalledWith(2, {}) + expect(page).toHaveBeenNthCalledWith(2, { + properties: { + hash: '#hero', + path: '/products', + query: { tab: 'featured' }, + search: '?tab=featured', + url: `${window.location.origin}/products?tab=featured#hero`, + }, + }) await rendered.unmount() }) @@ -181,9 +197,13 @@ describe('ReactRouterAutoPageTracker', () => { locale: 'en-US', properties: { campaign: 'spring', + hash: '#hero', matchCount: 2, path: '/products?tab=featured#hero', + query: { tab: 'featured' }, + search: '?tab=featured', source: 'dynamic', + url: `${window.location.origin}/products?tab=featured#hero`, }, }) expect(getPagePayload).toHaveBeenCalledWith({ diff --git a/packages/web/frameworks/react-web-sdk/src/router/react-router.tsx b/packages/web/frameworks/react-web-sdk/src/router/react-router.tsx index d3121e6f..73a1cfc5 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/react-router.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/react-router.tsx @@ -1,12 +1,43 @@ -import type { ReactElement } from 'react' -import { type Location, type UIMatch, useLocation, useMatches } from 'react-router-dom' -import type { AutoPagePayloadOptions } from '../auto-page/types' +import { useCallback, useMemo, type ReactElement } from 'react' +import { useLocation, useMatches, type Location, type UIMatch } from 'react-router-dom' +import { buildAutoPagePayload } from '../auto-page/pagePayload' +import type { AutoPagePayload, AutoPagePayloadOptions } from '../auto-page/types' import { useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' function toRouteKey(location: Pick): string { return `${location.pathname}${location.search}${location.hash}` } +function buildQueryDictionary(searchStr: string): Record { + return Object.fromEntries(new URLSearchParams(searchStr)) +} + +function resolveAbsoluteUrl(href: string): string { + if (typeof window === 'undefined') { + return href + } + try { + return new URL(href, window.location.origin).toString() + } catch { + return href + } +} + +function buildRouterPayload( + location: Pick, +): AutoPagePayload { + const href = `${location.pathname}${location.search}${location.hash}` + return { + properties: { + hash: location.hash, + path: location.pathname, + query: buildQueryDictionary(location.search), + search: location.search, + url: resolveAbsoluteUrl(href), + }, + } +} + export interface ReactRouterAutoPageContext { readonly hash: string readonly location: Location @@ -26,24 +57,46 @@ export function ReactRouterAutoPageTracker({ const location = useLocation() const matches = useMatches() const routeKey = toRouteKey(location) + const { hash, pathname, search } = location + + const routerPayload = useMemo( + () => buildRouterPayload({ hash, pathname, search }), + [hash, pathname, search], + ) - useAutoPageEmitter({ - enabled: true, - route: { + const buildPayload = useCallback( + ({ isInitialEmission }: { isInitialEmission: boolean }): AutoPagePayload => + buildAutoPagePayload( + routerPayload, + { pagePayload, getPagePayload }, + { + isInitialEmission, + routeKey, + context: { + hash, + location, + matches, + pathname, + routeKey, + search, + url: routeKey, + }, + }, + ), + [ + getPagePayload, + hash, + location, + matches, + pagePayload, + pathname, routeKey, - context: { - hash: location.hash, - location, - matches, - pathname: location.pathname, - routeKey, - search: location.search, - url: routeKey, - }, - }, - pagePayload, - getPagePayload, - }) + routerPayload, + search, + ], + ) + + useAutoPageEmitter({ enabled: true, routeKey, buildPayload }) return null } diff --git a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx index cc0863d5..88a522d8 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx @@ -111,7 +111,7 @@ describe('TanStackRouterAutoPageTracker', () => { expect(TanStackRouterAutoPageTracker).toBeTypeOf('function') }) - it('emits on initial render and route changes', async () => { + it('emits router-derived URL data on initial render and route changes', async () => { const page = rs.fn(async () => { await Promise.resolve() return undefined @@ -121,12 +121,28 @@ describe('TanStackRouterAutoPageTracker', () => { const rendered = await renderRouter() expect(page).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenNthCalledWith(1, {}) + expect(page).toHaveBeenNthCalledWith(1, { + properties: { + hash: '', + path: '/', + query: {}, + search: '', + url: `${window.location.origin}/`, + }, + }) await navigateTo(router, '/products?tab=featured#hero') expect(page).toHaveBeenCalledTimes(2) - expect(page).toHaveBeenNthCalledWith(2, {}) + expect(page).toHaveBeenNthCalledWith(2, { + properties: { + hash: '#hero', + path: '/products', + query: { tab: 'featured' }, + search: '?tab=featured', + url: `${window.location.origin}/products?tab=featured#hero`, + }, + }) await rendered.unmount() }) @@ -206,9 +222,13 @@ describe('TanStackRouterAutoPageTracker', () => { locale: 'en-US', properties: { campaign: 'spring', + hash: `#${location.hash}`, matchCount: matches.length, path: location.href, + query: { tab: 'featured' }, + search: location.searchStr, source: 'dynamic', + url: `${window.location.origin}${location.href}`, }, }) if (captured.current === undefined) { diff --git a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.tsx b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.tsx index 8e35208b..01f38457 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.tsx @@ -1,6 +1,7 @@ -import { type AnyRouter, type RouterState, useRouter, useRouterState } from '@tanstack/react-router' -import type { ReactElement } from 'react' -import type { AutoPagePayloadOptions } from '../auto-page/types' +import { useRouter, useRouterState, type AnyRouter, type RouterState } from '@tanstack/react-router' +import { useCallback, useMemo, type ReactElement } from 'react' +import { buildAutoPagePayload } from '../auto-page/pagePayload' +import type { AutoPagePayload, AutoPagePayloadOptions } from '../auto-page/types' import { useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' type TanStackLocation = RouterState['location'] @@ -19,6 +20,47 @@ export interface TanStackRouterAutoPageContext { export interface TanStackRouterAutoPageTrackerProps extends AutoPagePayloadOptions {} +function buildQueryDictionary(searchStr: string): Record { + return Object.fromEntries(new URLSearchParams(searchStr)) +} + +function resolveAbsoluteUrl(href: string): string { + if (typeof window === 'undefined') { + return href + } + try { + return new URL(href, window.location.origin).toString() + } catch { + return href + } +} + +function normalizeHash(hash: string): string { + if (hash === '' || hash.startsWith('#')) { + return hash + } + return `#${hash}` +} + +interface RouterUrlSnapshot { + readonly hash: string + readonly href: string + readonly pathname: string + readonly searchStr: string +} + +function buildRouterPayload(snapshot: RouterUrlSnapshot): AutoPagePayload { + return { + properties: { + hash: normalizeHash(snapshot.hash), + path: snapshot.pathname, + query: buildQueryDictionary(snapshot.searchStr), + search: snapshot.searchStr, + url: resolveAbsoluteUrl(snapshot.href), + }, + } +} + export function TanStackRouterAutoPageTracker({ pagePayload, getPagePayload, @@ -27,27 +69,55 @@ export function TanStackRouterAutoPageTracker({ const location = useRouterState({ select: (state) => state.location, }) - const matches = useRouterState({ select: (state) => state.matches }) + const matches = useRouterState({ + select: (state) => state.matches, + }) const { href: routeKey } = location + const { hash, pathname, searchStr } = location - useAutoPageEmitter({ - enabled: true, - route: { + // Memoize on the URL primitives that describe the route. Re-renders that + // produce a new `location` reference but the same URL must not invalidate + // this memo, otherwise the emitter effect would re-run unnecessarily. + const routerPayload = useMemo( + () => buildRouterPayload({ hash, href: routeKey, pathname, searchStr }), + [hash, pathname, routeKey, searchStr], + ) + + const buildPayload = useCallback( + ({ isInitialEmission }: { isInitialEmission: boolean }): AutoPagePayload => + buildAutoPagePayload( + routerPayload, + { pagePayload, getPagePayload }, + { + isInitialEmission, + routeKey, + context: { + hash, + location, + matches, + pathname, + routeKey, + router, + search: searchStr, + url: routeKey, + }, + }, + ), + [ + getPagePayload, + hash, + location, + matches, + pagePayload, + pathname, routeKey, - context: { - hash: location.hash, - location, - matches, - pathname: location.pathname, - routeKey, - router, - search: location.searchStr, - url: routeKey, - }, - }, - pagePayload, - getPagePayload, - }) + router, + routerPayload, + searchStr, + ], + ) + + useAutoPageEmitter({ enabled: true, routeKey, buildPayload }) return null }