diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 600441f95..a9c51455f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3314,8 +3314,25 @@ 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. The deferral — gate on window + // `load`, then a double `requestAnimationFrame`, cancelled when an SPA + // navigation committed in between — lives in the GPT bundle module as + // `tsjs.scheduleInitialAdInit` (crates/trusted-server-js/lib/src/ + // integrations/gpt/index.ts), where the lifecycle is executable under + // Vitest (schedule_initial_ad_init.test.ts) and the navigation-generation + // guard is shared with the SPA auction hook. The GPT module ships in the + // synchronous head bundle, so it has always executed by the time this + // inline script runs; when the GPT integration is disabled the scheduler is + // absent — but so is `adInit`, so there is nothing to schedule. format!( - "", + "", escaped ) } @@ -7905,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"), @@ -7925,6 +7942,41 @@ 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 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("var s=window.tsjs.scheduleInitialAdInit"), + "should delegate deferral to the bundle scheduler" + ); + assert!( + script.contains("if(typeof s===\"function\")s()"), + "should guard the scheduler call for pages without the GPT module" + ); + // The one hydration-unsafe thing this script could do is invoke + // adInit synchronously at body-parse time — it must not. + assert!( + !script.contains("adInit()"), + "should not invoke adInit synchronously at parse time" + ); + assert!( + !script.contains("setTimeout"), + "should not retry adInit on a timer" + ); + } + #[test] fn auction_request_without_ec_id_omits_user_id_and_uses_non_ec_request_id() { let slot = make_slot(); 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 = '
';