From 3ebcf8fe3f3362c45667fa0b4ebc4af5fbe7be79 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:50:57 -0700 Subject: [PATCH 1/2] Defer initial adInit until after React hydration on Next.js App Router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Next.js App Router publisher, `adInit()` defines GPT slots on the publisher's `-container` wrappers, mutating those ad-slot subtrees. The `` bids bootstrap called it synchronously at parse time, landing that mutation inside React's hydration window, so React threw #418 and re-rendered the affected subtrees (visible flashes/reflow). A live A/B — toggling TS on the same page via the tester cookie — isolated the trigger: the pure publisher throws 0 #418, TS activation introduces it, and the count tracks whether adInit processes ad slots (not the injection position). adInit is now deferred to after hydration: gated on window `load`, then a double `requestAnimationFrame`. Run-once, no retry timer. Deferring opens a window in which an SPA navigation can commit a new route (and run its own adInit via the SPA auction hook) before the callback fires, so the callback captures the route it was scheduled for and no-ops when the route has changed — otherwise it would re-run adInit against the newer route's live slots/bids, destroying and redefining that route's TS slots and refreshing it twice. Browser globals are window-qualified so a page-level lexical binding cannot shadow them. Verified end to end through the dev proxy against a live App Router publisher: #418 goes from 1-2 to 0 with TS still defining its container slots and ads rendering. Refs #938 --- crates/trusted-server-core/src/publisher.rs | 77 ++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 85abef617..c9d7183d7 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2169,8 +2169,31 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map should be infallible"); let escaped = html_escape_for_script(&json); + // adInit() defines GPT slots on the publisher's `-container` wrappers, which + // mutates those ad-slot subtrees. Calling it synchronously here (this script + // runs at body-parse time) lands those mutations inside React's hydration + // window and trips a #418 hydration mismatch. Defer adInit until after the + // page has hydrated: gate on window `load` (client bundles that hydrate the + // tree have executed by then), then a double `requestAnimationFrame` so it + // runs after React has committed. Not a retry timer — a single deferred call. + // + // Deferring opens a window in which an SPA navigation can commit a new route + // (and run its own `adInit` via the SPA auction hook) before this callback + // fires. The callback therefore captures the route it was scheduled for and + // no-ops when the route has since changed; without that guard it would run + // `adInit` a second time against the newer route's live slots/bids, which + // destroys and redefines that route's TS slots and refreshes it twice. format!( - "", + "", escaped ) } @@ -4695,6 +4718,58 @@ mod tests { ); } + #[test] + fn bids_script_defers_ad_init_until_after_hydration() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + + let script = build_bids_script(&map); + + // adInit() mutates ad-slot subtrees (GPT defineSlot on the + // `-container` wrapper). Running it synchronously at body-parse time + // lands those mutations inside React's hydration window and trips a + // #418 hydration mismatch. The bootstrap must defer adInit until + // after hydration: gate on window `load`, then a `requestAnimationFrame`. + assert!( + script.contains("requestAnimationFrame"), + "should defer adInit to a post-hydration animation frame" + ); + assert!( + script.contains("\"load\""), + "should gate adInit on the window load event" + ); + // Deferral must not regress into a retry timer. + assert!( + !script.contains("setTimeout"), + "should not retry adInit on a timer" + ); + assert!( + script.contains("window.tsjs.adInit"), + "should still hand off bids to adInit" + ); + + // Browser globals are window-qualified so a page-level lexical + // binding of the same name cannot shadow them. + assert!( + script.contains("window.requestAnimationFrame"), + "should qualify requestAnimationFrame on window" + ); + assert!( + script.contains("window.addEventListener"), + "should qualify addEventListener on window" + ); + + // Deferring opens a window in which an SPA navigation can commit a + // new route (and its own adInit) before this callback fires. The + // callback must capture the route it was scheduled for and no-op if + // the route has since changed, otherwise it re-runs adInit against + // the newer route's live slots/bids and double-refreshes it. + assert!( + script.contains("location.pathname"), + "should capture route identity to discard a stale deferred callback" + ); + } + #[test] fn auction_request_without_ec_id_omits_user_id_and_uses_non_ec_request_id() { let slot = make_slot(); From 6f9dbff78f70b2bf2dc61011c414cd329a49542d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:02:34 -0700 Subject: [PATCH 2/2] Guard deferred adInit with a navigation generation and make it testable Address PR #945 review findings: - Replace the URL-equality stale-route guard with a monotonic navigation generation (tsjs.navGeneration) maintained synchronously by the SPA auction hook. URL equality diverged from the hook's pathname-only route identity: a query-only replaceState before load cancelled the initial adInit entirely, and an /a -> /b -> /a round trip defeated the guard and double-ran adInit against the round-tripped route's live slots. - Move the deferral bootstrap (window load, double requestAnimationFrame, stale-navigation cancellation) from the Rust-emitted inline script into the GPT bundle module as tsjs.scheduleInitialAdInit, where the lifecycle is executable under Vitest and the navigation generation is shared with the SPA hook. The bids script now only assigns tsjs.bids and delegates to the scheduler; the GPT module ships in the synchronous head bundle, so it has always run by the time the inline script executes. - Add executable lifecycle coverage (schedule_initial_ad_init.test.ts): load plus two-frame ordering, already-complete documents, exactly-once invocation across duplicate load events, query-only history changes, /a -> /b -> /a cancellation, and a publisher-displayed slot receiving setTargeting before the refresh that delivers it. The Rust tests now pin only the inline script's delegation and XSS safety. --- crates/trusted-server-core/src/publisher.rs | 83 +++--- .../trusted-server-js/lib/src/core/types.ts | 19 ++ .../lib/src/integrations/gpt/index.ts | 53 ++++ .../gpt/schedule_initial_ad_init.test.ts | 247 ++++++++++++++++++ .../test/integrations/gpt/spa_hook.test.ts | 25 ++ 5 files changed, 374 insertions(+), 53 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index bd929d894..a9c51455f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3317,27 +3317,21 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map(window.tsjs=window.tsjs||{{}}).bids=JSON.parse(\"{}\");\ (function(){{\ -var p=location.pathname+location.search;\ -var f=function(){{\ -if(location.pathname+location.search!==p)return;\ -var a=window.tsjs.adInit;if(typeof a===\"function\")a();}};\ -var d=function(){{window.requestAnimationFrame(function(){{window.requestAnimationFrame(f);}});}};\ -if(document.readyState===\"complete\")d();\ -else window.addEventListener(\"load\",d,{{once:true}});\ +var s=window.tsjs.scheduleInitialAdInit;\ +if(typeof s===\"function\")s();\ }})();", escaped ) @@ -7928,15 +7922,15 @@ mod tests { } #[test] - fn bids_script_calls_ad_init_without_retry_timer() { + fn bids_script_schedules_ad_init_without_retry_timer() { let mut map = serde_json::Map::new(); map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); let script = build_bids_script(&map); assert!( - script.contains("window.tsjs.adInit"), - "should hand off bids to adInit" + script.contains("window.tsjs.scheduleInitialAdInit"), + "should hand off bids to the bundle's deferred adInit scheduler" ); assert!( !script.contains("setTimeout"), @@ -7958,45 +7952,28 @@ mod tests { // adInit() mutates ad-slot subtrees (GPT defineSlot on the // `-container` wrapper). Running it synchronously at body-parse time // lands those mutations inside React's hydration window and trips a - // #418 hydration mismatch. The bootstrap must defer adInit until - // after hydration: gate on window `load`, then a `requestAnimationFrame`. + // #418 hydration mismatch. The deferral lifecycle (window `load`, + // double `requestAnimationFrame`, stale-navigation cancellation via + // `tsjs.navGeneration`) lives in the GPT bundle module where it is + // executable under Vitest (schedule_initial_ad_init.test.ts); this + // inline script must only delegate to that scheduler. assert!( - script.contains("requestAnimationFrame"), - "should defer adInit to a post-hydration animation frame" + script.contains("var s=window.tsjs.scheduleInitialAdInit"), + "should delegate deferral to the bundle scheduler" ); assert!( - script.contains("\"load\""), - "should gate adInit on the window load event" + script.contains("if(typeof s===\"function\")s()"), + "should guard the scheduler call for pages without the GPT module" ); - // Deferral must not regress into a retry timer. + // The one hydration-unsafe thing this script could do is invoke + // adInit synchronously at body-parse time — it must not. assert!( - !script.contains("setTimeout"), - "should not retry adInit on a timer" + !script.contains("adInit()"), + "should not invoke adInit synchronously at parse time" ); assert!( - script.contains("window.tsjs.adInit"), - "should still hand off bids to adInit" - ); - - // Browser globals are window-qualified so a page-level lexical - // binding of the same name cannot shadow them. - assert!( - script.contains("window.requestAnimationFrame"), - "should qualify requestAnimationFrame on window" - ); - assert!( - script.contains("window.addEventListener"), - "should qualify addEventListener on window" - ); - - // Deferring opens a window in which an SPA navigation can commit a - // new route (and its own adInit) before this callback fires. The - // callback must capture the route it was scheduled for and no-op if - // the route has since changed, otherwise it re-runs adInit against - // the newer route's live slots/bids and double-refreshes it. - assert!( - script.contains("location.pathname"), - "should capture route identity to discard a stale deferred callback" + !script.contains("setTimeout"), + "should not retry adInit on a timer" ); } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 6de6959db..f71bf3666 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -139,4 +139,23 @@ export interface TsjsApi { gptInitialLoadDisabled?: boolean; /** Guards SPA pushState hook installation. */ spaHookInstalled?: boolean; + /** + * Monotonic count of committed SPA navigations, incremented synchronously by + * the SPA auction hook the moment it accepts a route change. The deferred + * initial-adInit bootstrap ([`scheduleInitialAdInit`]) captures this counter + * and no-ops when a navigation committed while it was pending. A counter is + * used instead of a URL comparison so the guard cannot diverge from the + * auction path: a query-only history change (which the hook deliberately + * ignores) leaves the counter unchanged, and an `/a → /b → /a` round trip + * (where the URL compares equal again) advances it. + */ + navGeneration?: number; + /** + * Defers the initial `adInit()` until after React hydration: window `load`, + * then a double `requestAnimationFrame`, cancelled when an SPA navigation + * committed while pending. Called by the server-injected `` bids + * script; lives in the bundle so the lifecycle is executable under test and + * shares [`navGeneration`] with the SPA auction hook. + */ + scheduleInitialAdInit?: () => void; } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 1f5aa14d6..fb510a66c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -449,9 +449,54 @@ function installInitialLoadDetector(ts: TsjsApi): void { }); } +/** + * Install `window.tsjs.scheduleInitialAdInit`. + * + * The server-injected `` bids script calls this to run the initial + * `adInit()` after React hydration instead of synchronously at body-parse + * time. `adInit()` defines GPT slots on the publisher's `-container` + * wrappers, mutating those ad-slot subtrees; on a Next.js App Router page a + * synchronous call lands that mutation inside React's hydration window and + * trips a #418 hydration mismatch. Deferral: gate on window `load` (the + * client bundles that hydrate the tree have executed by then), then a double + * `requestAnimationFrame` so the call runs after React has committed. A + * single deferred call — no retry timer. + * + * Deferring opens a window in which an SPA navigation can commit a new route + * (and run its own `adInit()` via the SPA auction hook) before the deferred + * callback fires. The callback captures `tsjs.navGeneration` at schedule time + * and no-ops when a navigation has committed since — running anyway would + * re-run `adInit()` against the newer route's live slots/bids, destroying and + * redefining that route's TS slots and double-refreshing it. The generation + * counter (not a URL comparison) keeps this guard aligned with the SPA + * auction hook's own navigation identity: a query-only history change the + * hook ignores must not cancel the initial call, while an `/a → /b → /a` + * round trip — where the URL compares equal again — must. + */ +function installScheduleInitialAdInit(ts: TsjsApi): void { + ts.scheduleInitialAdInit = function () { + const generation = ts.navGeneration ?? 0; + const runUnlessNavigated = (): void => { + if ((ts.navGeneration ?? 0) !== generation) return; + ts.adInit?.(); + }; + const afterHydrationFrames = (): void => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(runUnlessNavigated); + }); + }; + if (document.readyState === 'complete') { + afterHydrationFrames(); + } else { + window.addEventListener('load', afterHydrationFrames, { once: true }); + } + }; +} + export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); installInitialLoadDetector(ts); + installScheduleInitialAdInit(ts); ts.adInit = function () { const slots = ts.adSlots ?? []; // Snapshot bids at adInit() call time — correct for targeting setup. @@ -713,6 +758,13 @@ export function installSpaAuctionHook(): void { const ts = (window.tsjs ??= {} as TsjsApi); if (ts.spaHookInstalled) return; ts.spaHookInstalled = true; + // Navigation identity for the deferred initial-adInit bootstrap (see + // installScheduleInitialAdInit). Initialized here, and incremented + // synchronously in onNavigate the moment a route change is accepted, so the + // counter can never lag the auction decision. Deliberately NOT rolled back + // when a navigation later fails: the framework has already swapped the + // route's DOM by then, so a pending initial callback must still stand down. + ts.navGeneration ??= 0; let inflight: AbortController | null = null; // Last path an auction was run for. popstate fires for hash-only and @@ -730,6 +782,7 @@ export function installSpaAuctionHook(): void { async function onNavigate(path: string): Promise { if (path === currentPath) return; currentPath = path; + ts.navGeneration = (ts.navGeneration ?? 0) + 1; inflight?.abort(); const controller = new AbortController(); inflight = controller; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts new file mode 100644 index 000000000..80d2b462e --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import type { TsjsApi } from '../../../src/core/types'; + +type TestWindow = Window & { + googletag?: unknown; + tsjs?: TsjsApi; +}; + +const originalPushState = history.pushState.bind(history); +const originalReplaceState = history.replaceState.bind(history); + +/** + * Executable lifecycle coverage for `tsjs.scheduleInitialAdInit` — the + * deferred initial-adInit bootstrap the server's `` bids script hands + * off to. These tests run the real scheduler (and, where noted, the real + * `adInit()` and SPA auction hook) instead of string-matching the emitted + * script, so post-load ordering, two-frame deferral, exactly-once invocation, + * and stale-navigation cancellation are all exercised, not just spelled. + */ +describe('scheduleInitialAdInit', () => { + let rafQueue: FrameRequestCallback[]; + let readyState: DocumentReadyState; + let fetchStub: ReturnType; + let popstateHandlers: EventListenerOrEventListenerObject[] = []; + const realAddEventListener = window.addEventListener.bind(window); + + /** Run every queued animation-frame callback (one frame's worth). */ + function flushFrame(): void { + const queued = [...rafQueue]; + rafQueue.length = 0; + queued.forEach((cb) => cb(0)); + } + + /** Flush the microtask/timer queue so the SPA hook's awaits settle. */ + async function flushAsync(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + async function importGptModule() { + return import('../../../src/integrations/gpt/index'); + } + + beforeEach(() => { + vi.resetModules(); + delete (window as TestWindow).tsjs; + delete (window as TestWindow).googletag; + // Restore unwrapped history methods so each module import wraps exactly + // once — without this, wrappers from prior imports accumulate. + history.pushState = originalPushState; + history.replaceState = originalReplaceState; + fetchStub = vi.fn(); + vi.stubGlobal('fetch', fetchStub); + popstateHandlers = []; + vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { + if (type === 'popstate' && listener) popstateHandlers.push(listener); + return realAddEventListener(type, listener, options); + }); + // Manual animation-frame queue: the scheduler must be observed frame by + // frame, so frames only run when a test flushes them explicitly. + rafQueue = []; + ( + window as { requestAnimationFrame: typeof window.requestAnimationFrame } + ).requestAnimationFrame = ((cb: FrameRequestCallback) => { + rafQueue.push(cb); + return rafQueue.length; + }) as typeof window.requestAnimationFrame; + // Controllable document.readyState (jsdom reports 'complete' by default; + // the scheduler branches on it). + readyState = 'loading'; + Object.defineProperty(document, 'readyState', { + configurable: true, + get: () => readyState, + }); + }); + + afterEach(() => { + history.pushState = originalPushState; + history.replaceState = originalReplaceState; + // Reset jsdom location back to root for the next test. + originalReplaceState({}, '', '/'); + document.body.innerHTML = ''; + popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); + popstateHandlers = []; + // Remove the instance property so the prototype getter is visible again. + delete (document as unknown as Record).readyState; + delete (window as unknown as Record).requestAnimationFrame; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('defers adInit until window load plus two animation frames', async () => { + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + ts.scheduleInitialAdInit!(); + expect(adInit).not.toHaveBeenCalled(); + + // load alone must not run it — React commits after the load-time frame. + window.dispatchEvent(new Event('load')); + expect(adInit).not.toHaveBeenCalled(); + + // One frame is not enough: the double rAF exists so the call lands after + // React's post-hydration commit, not inside the load-event frame. + flushFrame(); + expect(adInit).not.toHaveBeenCalled(); + + flushFrame(); + expect(adInit).toHaveBeenCalledTimes(1); + }); + + it('runs after two frames without a load event when the document is already complete', async () => { + readyState = 'complete'; + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + ts.scheduleInitialAdInit!(); + // Still never synchronous — even past load, adInit waits two frames. + expect(adInit).not.toHaveBeenCalled(); + + flushFrame(); + expect(adInit).not.toHaveBeenCalled(); + flushFrame(); + expect(adInit).toHaveBeenCalledTimes(1); + }); + + it('invokes adInit exactly once even across duplicate load events and extra frames', async () => { + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + ts.scheduleInitialAdInit!(); + window.dispatchEvent(new Event('load')); + window.dispatchEvent(new Event('load')); + flushFrame(); + flushFrame(); + flushFrame(); + + expect(adInit).toHaveBeenCalledTimes(1); + }); + + it('still runs after a query-only history change before load', async () => { + // The SPA auction hook identifies routes by pathname only, so a query-only + // replaceState is not a navigation: it must neither trigger an auction nor + // cancel the pending initial adInit. (A URL-equality guard would abort + // here and leave the initial ads uninitialized.) + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + ts.scheduleInitialAdInit!(); + history.replaceState({}, '', '/?utm_source=newsletter'); + await flushAsync(); + expect(fetchStub).not.toHaveBeenCalled(); + + window.dispatchEvent(new Event('load')); + flushFrame(); + flushFrame(); + expect(adInit).toHaveBeenCalledTimes(1); + }); + + it('cancels the initial run after an /a → /b → /a round trip before load', async () => { + // Both navigations commit and return to the original URL, so a URL + // comparison would see "unchanged" and run adInit a second time against + // the round-tripped route's live state. The navigation generation counts + // both commits and stands the initial callback down. + fetchStub.mockResolvedValue({ + ok: true, + json: async () => ({ slots: [], bids: {} }), + }); + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + ts.scheduleInitialAdInit!(); + history.pushState({}, '', '/b'); + await flushAsync(); + history.pushState({}, '', '/'); + await flushAsync(); + expect(ts.navGeneration).toBe(2); + + window.dispatchEvent(new Event('load')); + flushFrame(); + flushFrame(); + expect(adInit).not.toHaveBeenCalled(); + }); + + it('applies server targeting to a publisher-displayed slot before its refresh', async () => { + // A publisher that defined and displayed its GPT slot before window load + // still gets the server-side targeting applied before the refresh that + // delivers it — the deferred run must order setTargeting ahead of the ad + // request it triggers. + const div = document.createElement('div'); + div.id = 'div-atf-sidebar'; + document.body.appendChild(div); + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn(), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + ts.adSlots = [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ]; + ts.bids = { atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' } }; + + ts.scheduleInitialAdInit!(); + window.dispatchEvent(new Event('load')); + flushFrame(); + flushFrame(); + + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + const targetingOrder = mockSlot.setTargeting.mock.invocationCallOrder[0]!; + const refreshOrder = mockPubads.refresh.mock.invocationCallOrder[0]!; + expect(targetingOrder).toBeLessThan(refreshOrder); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 9a08defcb..8aac870c1 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -58,6 +58,31 @@ describe('installSpaAuctionHook', () => { vi.unstubAllGlobals(); }); + it('increments navGeneration only when a pathname navigation is accepted', async () => { + // The deferred initial-adInit bootstrap keys off this counter, so it must + // move in lockstep with the hook's own navigation identity: bumped + // synchronously for each accepted pathname change, untouched by the + // query-only and same-path history calls the hook ignores. + fetchStub.mockResolvedValue({ + ok: true, + json: async () => ({ slots: [], bids: {} }), + }); + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + expect(ts.navGeneration).toBe(0); + + history.pushState({}, '', '/next-page'); + expect(ts.navGeneration).toBe(1); + + history.replaceState({}, '', '/next-page?utm_source=x'); + expect(ts.navGeneration).toBe(1); + + history.pushState({}, '', '/next-page'); + expect(ts.navGeneration).toBe(1); + await flushAsync(); + }); + it('fetches page-bids on pushState and applies slots/bids via adInit', async () => { // The route's ad container already exists, so bids apply immediately. document.body.innerHTML = '
';