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 f0b57ce96..4def0fe24 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" + ); + // The no-retry-timer property is owned by the executable suite + // (gpt_bootstrap.test.ts asserts adInit runs exactly once); a + // `!contains("setTimeout")` over the whole joined head-insert output + // would misattribute any future unrelated timer to the scheduler. + } + #[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 599e8fd18..a6408b693 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -79,12 +79,49 @@ 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. + // + // Hidden documents: rAF is not serviced while the document is hidden, so a + // background-tab load holds the initial adInit until first view. Intended, + // and deliberately identical to the bundle scheduler — the impression is + // spent on a viewed tab, and the post-hydration guarantee holds whenever + // the request is actually issued. + 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 2f4a59dad..f600a5979 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3313,8 +3313,45 @@ 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`, pinned to navigation + // generation 0 so a faster SPA navigation cancels it — 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; + // gpt_bootstrap.js installs a minimal head-injected fallback so a failed + // bundle load still initializes initial ads. + // + // The deferral is deliberately unconditional — every publisher, every + // page — even though only hydrating React publishers exhibit the #418 + // failure. Uniform behavior keeps one code path to reason about and + // avoids a framework-detection or config surface that must be kept + // truthful per publisher; the cost is that non-React pages also move the + // initial request from parse time to window load. The agreed follow-up + // (branch 958-adinit-hydration-chunk-gate, spec in docs/superpowers/ + // specs/2026-07-24-adinit-hydration-gate-design.md) narrows the gate to + // the Next.js hydration chunks with `load` as the can't-hang fallback, + // which recovers most of that latency without a new config surface. + // + // The bids payload is handed to the scheduler instead of being assigned + // here: an SPA navigation that committed while this document was still + // streaming has already replaced `tsjs.bids`, and an unconditional + // assignment would clobber the live route's bids with the stale SSR + // payload. Only when no scheduler exists at all (GPT integration active + // without its head bootstrap — not an expected deployment) does the script + // fall back to a plain assignment, where no SPA hook exists to race with. format!( - "", + "", escaped ) } @@ -6049,7 +6086,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}" ); }); @@ -6115,7 +6152,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}" ); }); @@ -6408,7 +6445,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}" ); } @@ -6450,7 +6487,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}" ); } @@ -6890,7 +6927,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)..] ); @@ -8068,15 +8105,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("t.scheduleInitialAdInit"), + "should hand off bids to the deferred adInit scheduler" ); assert!( !script.contains("setTimeout"), @@ -8088,6 +8125,54 @@ 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`, 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("else t.bids=b"), + "should fall back to a plain bids assignment without a scheduler" + ); + assert!( + !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. + 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 a0654a529..73494ff19 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -141,4 +141,29 @@ 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`]) is pinned to + * generation 0 (the SSR document) and no-ops when a navigation has + * committed — before it was called, or 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`. 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?: (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 a5c5e27b9..30cfbbbdd 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -493,19 +493,89 @@ 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. + * + * 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. + * + * Hidden documents: browsers do not service `requestAnimationFrame` while a + * document is hidden, so a background-tab load (Cmd+click, open-in-new-tab) + * holds the initial `adInit()` until the tab is first viewed. This is + * intended, not an oversight: the initial ad request then spends its + * impression on a tab someone is actually looking at instead of firing — + * unviewable — at parse time in a tab that may never be foregrounded, and + * riding rAF keeps a single code path whose post-hydration-commit guarantee + * holds whenever the request is actually issued. + */ +function installScheduleInitialAdInit(ts: TsjsApi): void { + ts.scheduleInitialAdInit = function (initialBids?: Record) { + if ((ts.navGeneration ?? 0) !== 0) return; + if (initialBids) ts.bids = initialBids; + const runUnlessNavigated = (): void => { + if ((ts.navGeneration ?? 0) !== 0) 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. // 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[]); @@ -829,6 +899,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 @@ -869,6 +946,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/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts new file mode 100644 index 000000000..812543a9a --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -0,0 +1,233 @@ +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