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/4] 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/4] 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 = '
'; From c3e8df3fd565c5ebe2691fd4df642b6822162e94 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:22:00 -0700 Subject: [PATCH 3/4] Pin the initial adInit pass to generation 0 and restore the bundle fallback Address the second-round review findings on PR #945: - scheduleInitialAdInit now receives the SSR bids payload from the script and applies it only while the page is still on navigation generation 0 (the SSR document). Capturing the counter at body end adopted a navigation that committed while the HTML was still streaming, and the unconditional tsjs.bids assignment clobbered the live route's bids with the stale SSR payload. - adInit captures its generation and rechecks it as the queued googletag.cmd callback's first act, so a navigation that commits between the invocation and GPT's queue drain (e.g. consent-gated GPT) cancels the stale slot mutation. Applied in both the bundle and the head bootstrap. - gpt_bootstrap.js installs a minimal fallback scheduleInitialAdInit so a failed TSJS bundle load still initializes initial server-side ads through the head bootstrap's fallback adInit, restoring the degradation path the scheduler delegation had bypassed. - New executable coverage: navigation before scheduling, an applied page-bids response before scheduling, deferred command-queue drain cancellation, and a gpt_bootstrap.test.ts suite that evaluates the shipped bootstrap verbatim (fallback scheduler and adInit, generation cancellation, targeting and display through the command queue). --- .../trusted-server-core/src/html_processor.rs | 4 +- .../src/integrations/gpt.rs | 40 ++++ .../src/integrations/gpt_bootstrap.js | 31 +++ crates/trusted-server-core/src/publisher.rs | 78 ++++--- .../trusted-server-js/lib/src/core/types.ts | 15 +- .../lib/src/integrations/gpt/index.ts | 42 ++-- .../integrations/gpt/gpt_bootstrap.test.ts | 209 ++++++++++++++++++ .../gpt/schedule_initial_ad_init.test.ts | 123 ++++++++++- 8 files changed, 492 insertions(+), 50 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index ad69e51c5..e906ad79a 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -1637,7 +1637,7 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains(".bids=JSON.parse(\"{}\")"), + html.contains("JSON.parse(\"{}\")"), "should inject empty bids fallback when auction produced nothing" ); } @@ -1663,7 +1663,7 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - !html.contains(".bids=JSON.parse"), + !html.contains("JSON.parse"), "should NOT inject tsjs.bids when no slots matched" ); } diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index e21058a21..75a9f60b2 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -1196,6 +1196,46 @@ mod tests { ); } + #[test] + fn head_inserts_bootstrap_installs_fallback_scheduler() { + // The `` bids script hands its payload to + // `tsjs.scheduleInitialAdInit`. The bundle installs the real scheduler, + // but when the bundle fails to load this head bootstrap must provide + // the degradation path — otherwise a failed bundle request would leave + // initial server-side ads uninitialized. Executable coverage of the + // fallback lives in the Vitest suite (gpt_bootstrap.test.ts). + let config = test_config(); + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let combined = integration.head_inserts(&ctx).join(""); + assert!( + combined.contains("ts.scheduleInitialAdInit"), + "should install the fallback scheduler for bundle-load failures" + ); + assert!( + combined.contains("navGeneration"), + "fallback scheduler should honor the navigation-generation guard" + ); + assert!( + combined.contains("requestAnimationFrame"), + "fallback scheduler should defer past hydration frames" + ); + assert!( + combined.contains("\"load\""), + "fallback scheduler should gate on window load" + ); + assert!( + !combined.contains("setTimeout"), + "fallback scheduler must not retry on a timer" + ); + } + #[test] fn head_inserts_bootstrap_uses_css_safe_div_prefix_lookup() { let config = test_config(); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index cc4c5c00c..fba395fe1 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -42,12 +42,43 @@ pubads.__tsInitialLoadHooked = true; }); + // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's + // hydration-safe scheduler in + // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the + // bids script hands the SSR bids payload to this scheduler, which applies + // it and runs adInit only while the page is still on navigation + // generation 0 (the SSR document), after window load plus a double + // requestAnimationFrame so the call lands outside React's hydration + // window. Keeps initial server-side ads working when the main TSJS bundle + // fails to load; the bundle overwrites this with the full implementation. + ts.scheduleInitialAdInit = function (initialBids) { + if ((ts.navGeneration || 0) !== 0) return; + if (initialBids) ts.bids = initialBids; + var fire = function () { + if ((ts.navGeneration || 0) !== 0) return; + if (typeof ts.adInit === "function") ts.adInit(); + }; + var afterFrames = function () { + window.requestAnimationFrame(function () { + window.requestAnimationFrame(fire); + }); + }; + if (document.readyState === "complete") afterFrames(); + else window.addEventListener("load", afterFrames, { once: true }); + }; + ts.adInit = function () { var slots = ts.adSlots || []; var bids = ts.bids || {}; var divToSlotId = {}; + // Generation this invocation belongs to. The slot work below is queued on + // googletag.cmd, which drains only when GPT loads; recheck first inside + // the queued callback so a navigation committed in the gap cancels the + // stale mutation — mirrors the bundle's adInit. + var generation = ts.navGeneration || 0; googletag.cmd.push(function () { + if ((ts.navGeneration || 0) !== generation) return; // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. var newSlots = []; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index a9c51455f..016328566 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3318,20 +3318,29 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map(window.tsjs=window.tsjs||{{}}).bids=JSON.parse(\"{}\");\ -(function(){{\ -var s=window.tsjs.scheduleInitialAdInit;\ -if(typeof s===\"function\")s();\ + "", escaped ) @@ -5960,7 +5969,7 @@ mod tests { "should still inject ad slots. Got: {html}" ); assert!( - html.contains(".bids=JSON.parse"), + html.contains("var b=JSON.parse("), "should collect auction and inject bids before body close. Got: {html}" ); }); @@ -6026,7 +6035,7 @@ mod tests { "should decode the second gzip member that a single-member decoder drops. Got: {html}" ); assert!( - html.contains(".bids=JSON.parse"), + html.contains("var b=JSON.parse("), "should inject bids before the carried in the second member. Got: {html}" ); }); @@ -6319,7 +6328,7 @@ mod tests { "prefix must carry the injected (rewritten) head before EOF. Got: {html}" ); assert!( - !html.contains(".bids=JSON.parse"), + !html.contains("var b=JSON.parse("), "bids inject only at after collection, which the first poll must not wait for. Got: {html}" ); } @@ -6361,7 +6370,7 @@ mod tests { "first poll must emit the decoded document prefix of a small gzip page. Got: {decoded}" ); assert!( - !decoded.contains(".bids=JSON.parse"), + !decoded.contains("var b=JSON.parse("), "bids inject only at after collection, which the first poll must not wait for. Got: {decoded}" ); } @@ -6801,7 +6810,7 @@ mod tests { let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); assert!( - html.contains(".bids=JSON.parse"), + html.contains("var b=JSON.parse("), "should collect the held auction and inject bids. Got tail: {}", &html[html.len().saturating_sub(200)..] ); @@ -7929,8 +7938,8 @@ mod tests { let script = build_bids_script(&map); assert!( - script.contains("window.tsjs.scheduleInitialAdInit"), - "should hand off bids to the bundle's deferred adInit scheduler" + script.contains("t.scheduleInitialAdInit"), + "should hand off bids to the deferred adInit scheduler" ); assert!( !script.contains("setTimeout"), @@ -7953,17 +7962,30 @@ mod tests { // `-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. + // double `requestAnimationFrame`, generation-0 pinning via + // `tsjs.navGeneration`) lives in the GPT bundle module (with a + // head-injected fallback in gpt_bootstrap.js) 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=t.scheduleInitialAdInit"), + "should delegate deferral to the installed scheduler" + ); + // The bids payload is handed to the scheduler (which applies it only + // while the page is still on navigation generation 0) instead of + // being assigned unconditionally, so a faster SPA navigation's live + // bids cannot be clobbered by the stale SSR payload. + assert!( + script.contains("if(typeof s===\"function\")s(b)"), + "should pass the SSR bids payload to the scheduler" + ); assert!( - script.contains("var s=window.tsjs.scheduleInitialAdInit"), - "should delegate deferral to the bundle scheduler" + script.contains("else t.bids=b"), + "should fall back to a plain bids assignment without a scheduler" ); assert!( - script.contains("if(typeof s===\"function\")s()"), - "should guard the scheduler call for pages without the GPT module" + !script.contains(".bids=JSON.parse"), + "should not assign the SSR payload unconditionally" ); // The one hydration-unsafe thing this script could do is invoke // adInit synchronously at body-parse time — it must not. diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index f71bf3666..1a3bc9134 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -152,10 +152,15 @@ export interface TsjsApi { 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. + * then a double `requestAnimationFrame`. Called by the server-injected + * `` bids script with the SSR bids payload. The whole initial pass + * is pinned to navigation generation 0 (the SSR document): if an SPA + * navigation has already committed — or commits while the deferred callback + * is pending — the payload is dropped and `adInit()` is not run, so a stale + * SSR bootstrap can neither clobber the live route's bids nor re-run it. + * Lives in the bundle so the lifecycle is executable under test and shares + * [`navGeneration`] with the SPA auction hook; `gpt_bootstrap.js` installs + * a minimal fallback for pages where the bundle fails to load. */ - scheduleInitialAdInit?: () => void; + scheduleInitialAdInit?: (initialBids?: Record) => 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 fb510a66c..812cbe192 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -462,22 +462,30 @@ function installInitialLoadDetector(ts: TsjsApi): void { * `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. + * The SSR document is navigation generation 0 by definition, so the scheduler + * pins the whole initial pass to generation 0 rather than capturing whatever + * the counter reads when the `` script runs: the SPA hook is installed + * by the synchronous head bundle, so a navigation can commit while the HTML + * is still streaming, and capturing that advanced value would adopt the stale + * SSR bootstrap as current. For the same reason the initial bids payload is + * passed in and applied here, generation-guarded — assigning it + * unconditionally at body end would clobber the live bids a faster SPA + * navigation already applied. When a navigation has committed since — or + * commits while the deferred callback is pending — the SSR payload is + * dropped and `adInit()` is not run: running anyway would re-run 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; + ts.scheduleInitialAdInit = function (initialBids?: Record) { + if ((ts.navGeneration ?? 0) !== 0) return; + if (initialBids) ts.bids = initialBids; const runUnlessNavigated = (): void => { - if ((ts.navGeneration ?? 0) !== generation) return; + if ((ts.navGeneration ?? 0) !== 0) return; ts.adInit?.(); }; const afterHydrationFrames = (): void => { @@ -503,10 +511,18 @@ export function installTsAdInit(): void { // The slotRenderEnded listener below reads ts.bids live so SPA navigation // updates (new ts.bids injected before ) are picked up at render time. const bids = ts.bids ?? {}; + // Generation this invocation belongs to. The destructive slot work below + // is queued on googletag.cmd, which only drains when GPT itself loads — + // possibly much later (e.g. consent-gated GPT). A navigation can commit + // in that gap, so the queued callback rechecks the generation as its + // first act and stands down rather than applying this invocation's + // slots/bids to the newer route's DOM and double-requesting it. + const generation = ts.navGeneration ?? 0; const g = (window as GptWindow).googletag; if (!g) return; g.cmd?.push(() => { + if ((ts.navGeneration ?? 0) !== generation) return; // Destroy previously defined TS slots before redefining for the new page. if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { g.destroySlots?.(ts.prevGptSlots as GoogleTagSlot[]); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts new file mode 100644 index 000000000..9eb104fbd --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -0,0 +1,209 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Executable coverage for the edge-injected `gpt_bootstrap.js` — the + * head-inline fallback that keeps initial server-side ads working when the + * main TSJS bundle fails to load. The file ships from + * `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` and is + * evaluated here verbatim, so the degradation path (fallback `adInit` and + * fallback `scheduleInitialAdInit`) is executed, not string-matched. + * + * Vitest runs with the lib directory as cwd (the vitest.config.ts root), so + * the bootstrap is resolved relative to it rather than via import.meta.url, + * which the jsdom environment rewrites to a non-file scheme. + */ +const BOOTSTRAP_SOURCE = readFileSync( + path.resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' +); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyRecord = Record; + +type TestWindow = Window & { + googletag?: AnyRecord; + tsjs?: AnyRecord; +}; + +function runBootstrap(): void { + // Evaluate in the jsdom global scope, exactly as an inline