Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/trusted-server-core/src/html_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Expand All @@ -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"
);
}
Expand Down
40 changes: 40 additions & 0 deletions crates/trusted-server-core/src/integrations/gpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,46 @@ mod tests {
);
}

#[test]
fn head_inserts_bootstrap_installs_fallback_scheduler() {
// The `</body>` 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();
Expand Down
37 changes: 37 additions & 0 deletions crates/trusted-server-core/src/integrations/gpt_bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 </body>
// 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);
Comment thread
aram356 marked this conversation as resolved.
});
};
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 = [];
Expand Down
103 changes: 94 additions & 9 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3313,8 +3313,45 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map<String, serde_json::Va
let json = serde_json::to_string(bid_map)
.expect("serde_json::to_string of Map<String,Value> 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!(
"<script>(window.tsjs=window.tsjs||{{}}).bids=JSON.parse(\"{}\");(function(){{var f=window.tsjs.adInit;if(typeof f===\"function\")f();}})();</script>",
"<script>(function(){{\
var t=window.tsjs=window.tsjs||{{}};\
var b=JSON.parse(\"{}\");\
var s=t.scheduleInitialAdInit;\
Comment thread
aram356 marked this conversation as resolved.
if(typeof s===\"function\")s(b);\
else t.bids=b;\
}})();</script>",
escaped
)
}
Expand Down Expand Up @@ -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}"
);
});
Expand Down Expand Up @@ -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 </body> carried in the second member. Got: {html}"
);
});
Expand Down Expand Up @@ -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 </body> after collection, which the first poll must not wait for. Got: {html}"
);
}
Expand Down Expand Up @@ -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 </body> after collection, which the first poll must not wait for. Got: {decoded}"
);
}
Expand Down Expand Up @@ -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)..]
);
Expand Down Expand Up @@ -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"),
Expand All @@ -8088,6 +8125,54 @@ mod tests {
);
}

#[test]
fn bids_script_defers_ad_init_until_after_hydration() {
Comment thread
aram356 marked this conversation as resolved.
Comment thread
aram356 marked this conversation as resolved.
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();
Expand Down
25 changes: 25 additions & 0 deletions crates/trusted-server-js/lib/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `</body>` 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<string, AuctionBidData>) => void;
}
Loading
Loading