From 25cb1e73796e45434e48a9b43806976964511686 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:51:21 +0200 Subject: [PATCH 1/4] refactor(react-web-sdk): make useAutoPageEmitter a generic emitter and add buildAutoPagePayload helper Shrink useAutoPageEmitter to a router-agnostic emitter that owns dedup and emission only. The hook now accepts { enabled, routeKey, buildPayload } and no longer knows about routers, route contexts, payload merging, or the consumer's pagePayload/getPagePayload props. Move payload composition into a new buildAutoPagePayload helper that merges three explicit layers (router-derived, consumer static, consumer dynamic) and forwards the emission context to getPagePayload. composePagePayload is extended to accept any number of layers so the helper can compose them cleanly; the previous two-arg call sites continue to work. These changes prepare the four router adapters to build their own finished page payloads. The hook becomes trivial to test without a router; the merge precedence becomes one explicit data flow inside the helper rather than inline logic in the hook. --- .../src/auto-page/pagePayload.ts | 43 +++++++++-- .../src/auto-page/useAutoPageEmitter.ts | 71 +++++++++++-------- 2 files changed, 80 insertions(+), 34 deletions(-) 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.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 { From 701f4b6be1bdcec4179c7ab6e0e11bd816a99aef Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:51:40 +0200 Subject: [PATCH 2/4] refactor(react-web-sdk): each router adapter builds its own page payload All four router adapters (TanStack, React Router, Next.js App Router, Next.js Pages Router) now follow the same five-step structure: 1. Read router state via the router's React API. 2. Build a router-derived payload (path, url, search, hash, query) from authoritative router state, not a window.location read at send time. 3. Build the emission context for the consumer's getPagePayload callback. 4. Compose router payload + consumer static + consumer dynamic via the shared buildAutoPagePayload helper, inside a buildPayload callback. 5. Hand routeKey + buildPayload to useAutoPageEmitter. The router-derived payload becomes the bottom layer of the merge stack. Consumer-supplied pagePayload and getPagePayload still take precedence on key conflicts, so existing consumer code works unchanged. This restructure also fixes a class of stale-URL bugs across all four adapters (originally reported for TanStack Router): page event payloads now reflect the route React just rendered, not whatever window.location shows at emission time. The Next.js App Router adapter intentionally omits hash, since Next does not expose it; getPageProperties() supplies it from window.location.hash, which is not subject to the same staleness as pathname/search. --- .../react-web-sdk/src/router/next-app.tsx | 83 +++++++++---- .../react-web-sdk/src/router/next-pages.tsx | 86 ++++++++++--- .../react-web-sdk/src/router/react-router.tsx | 96 +++++++++++--- .../src/router/tanstack-router.tsx | 117 ++++++++++++++---- 4 files changed, 300 insertions(+), 82 deletions(-) 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..c4e70aca 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,27 @@ function toSearch(searchParams: ReturnType): string { return value.length > 0 ? `?${value}` : '' } +function toQueryDictionary( + searchParams: ReturnType, +): Record { + const query: Record = {} + for (const [key, value] of searchParams) { + query[key] = value + } + return query +} + +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 +51,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.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx index 13e9f138..b8e35782 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,40 @@ 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 flattened: Record = {} + for (const [key, value] of Object.entries(query)) { + if (typeof value === 'string') { + flattened[key] = value + } else if (Array.isArray(value) && value.length > 0) { + flattened[key] = value.join(',') + } + } + return flattened +} + +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 +50,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.tsx b/packages/web/frameworks/react-web-sdk/src/router/react-router.tsx index d3121e6f..656e8d43 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,48 @@ -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 { + const params = new URLSearchParams(searchStr) + const query: Record = {} + for (const [key, value] of params) { + query[key] = value + } + return query +} + +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 +62,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.tsx b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.tsx index 8e35208b..c038c1e7 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,52 @@ export interface TanStackRouterAutoPageContext { export interface TanStackRouterAutoPageTrackerProps extends AutoPagePayloadOptions {} +function buildQueryDictionary(searchStr: string): Record { + const params = new URLSearchParams(searchStr) + const query: Record = {} + for (const [key, value] of params) { + query[key] = value + } + return query +} + +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 +74,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 } From 9412962ed5ec38bea1cfc7ac0f18df9d4f519e0d Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:51:59 +0200 Subject: [PATCH 3/4] test(react-web-sdk): cover the new helper and adapter payload changes - Add pagePayload.test.ts: standalone tests for composePagePayload's variadic merge semantics and buildAutoPagePayload's three-layer precedence + context forwarding. Pure helper, no React or routers. - Rewrite useAutoPageEmitter.test.tsx for the slim hook surface ({ enabled, routeKey, buildPayload }). Adds tests that buildPayload only runs when an emission would actually happen (i.e. not on dedup short-circuit) and that isInitialEmission is signalled correctly across emissions. - Update each router adapter test (tanstack-router, react-router, next-app, next-pages) to assert the new payload shape that includes the router-derived path / url / search / hash / query keys, alongside whatever static or dynamic consumer overrides each test supplies. - Update OptimizationProvider.onStatesReady.test.tsx for the new hook call shape. --- .../src/auto-page/pagePayload.test.ts | 108 ++++++++++++++++++ .../src/auto-page/useAutoPageEmitter.test.tsx | 100 ++++++++++------ ...ptimizationProvider.onStatesReady.test.tsx | 3 +- .../src/router/next-app.test.tsx | 23 +++- .../src/router/next-pages.test.tsx | 27 ++++- .../src/router/react-router.test.tsx | 26 ++++- .../src/router/tanstack-router.test.tsx | 26 ++++- 7 files changed, 263 insertions(+), 50 deletions(-) create mode 100644 packages/web/frameworks/react-web-sdk/src/auto-page/pagePayload.test.ts 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/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/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-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/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/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) { From b64ed811d562e3a87fffdf259794dbd69ff2ebce Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:07:01 +0200 Subject: [PATCH 4/4] fix(react-web-sdk): prevent prototype pollution in router query parsing CodeQL flagged remote property injection in the four router adapters where user-controlled query keys were assigned to plain {} objects via `obj[key] = value`. A malicious URL containing keys like __proto__ or constructor could mutate the prototype chain, affecting downstream code that reads from these query dictionaries. Replace the imperative for-loop assignments with Object.fromEntries, which uses [[DefineOwnProperty]] semantics and is not subject to the same prototype-setter trap. This satisfies CodeQL's remote-property- injection sink check without an unsafe type assertion. --- .../react-web-sdk/src/router/next-app.tsx | 6 +----- .../react-web-sdk/src/router/next-pages.tsx | 15 ++++++++------- .../react-web-sdk/src/router/react-router.tsx | 7 +------ .../react-web-sdk/src/router/tanstack-router.tsx | 7 +------ 4 files changed, 11 insertions(+), 24 deletions(-) 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 c4e70aca..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 @@ -14,11 +14,7 @@ function toSearch(searchParams: ReturnType): string { function toQueryDictionary( searchParams: ReturnType, ): Record { - const query: Record = {} - for (const [key, value] of searchParams) { - query[key] = value - } - return query + return Object.fromEntries(searchParams) } function resolveAbsoluteUrl(href: string): string { 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 b8e35782..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 @@ -13,15 +13,16 @@ function splitAsPath(asPath: string): { path: string; search: string } { } function flattenQuery(query: NextRouter['query']): Record { - const flattened: Record = {} - for (const [key, value] of Object.entries(query)) { + const entries = Object.entries(query).flatMap<[string, string]>(([key, value]) => { if (typeof value === 'string') { - flattened[key] = value - } else if (Array.isArray(value) && value.length > 0) { - flattened[key] = value.join(',') + return [[key, value]] } - } - return flattened + if (Array.isArray(value) && value.length > 0) { + return [[key, value.join(',')]] + } + return [] + }) + return Object.fromEntries(entries) } function resolveAbsoluteUrl(href: string): string { 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 656e8d43..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 @@ -9,12 +9,7 @@ function toRouteKey(location: Pick): s } function buildQueryDictionary(searchStr: string): Record { - const params = new URLSearchParams(searchStr) - const query: Record = {} - for (const [key, value] of params) { - query[key] = value - } - return query + return Object.fromEntries(new URLSearchParams(searchStr)) } function resolveAbsoluteUrl(href: string): string { 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 c038c1e7..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 @@ -21,12 +21,7 @@ export interface TanStackRouterAutoPageContext { export interface TanStackRouterAutoPageTrackerProps extends AutoPagePayloadOptions {} function buildQueryDictionary(searchStr: string): Record { - const params = new URLSearchParams(searchStr) - const query: Record = {} - for (const [key, value] of params) { - query[key] = value - } - return query + return Object.fromEntries(new URLSearchParams(searchStr)) } function resolveAbsoluteUrl(href: string): string {