diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 34909efe..ad8151fd 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2180,8 +2180,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 ) } @@ -4709,6 +4732,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();