Skip to content
Open
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
60 changes: 56 additions & 4 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3314,8 +3314,25 @@ 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`, 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!(
"<script>(window.tsjs=window.tsjs||{{}}).bids=JSON.parse(\"{}\");(function(){{var f=window.tsjs.adInit;if(typeof f===\"function\")f();}})();</script>",
"<script>(window.tsjs=window.tsjs||{{}}).bids=JSON.parse(\"{}\");\
(function(){{\
var s=window.tsjs.scheduleInitialAdInit;\
if(typeof s===\"function\")s();\
}})();</script>",
escaped
)
}
Expand Down Expand Up @@ -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"),
Expand All @@ -7925,6 +7942,41 @@ 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`, 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();
Expand Down
19 changes: 19 additions & 0 deletions crates/trusted-server-js/lib/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `</body>` bids
* script; lives in the bundle so the lifecycle is executable under test and
* shares [`navGeneration`] with the SPA auction hook.
*/
scheduleInitialAdInit?: () => void;
}
53 changes: 53 additions & 0 deletions crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,9 +449,54 @@ function installInitialLoadDetector(ts: TsjsApi): void {
});
}

/**
* Install `window.tsjs.scheduleInitialAdInit`.
*
* The server-injected `</body>` 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.
Expand Down Expand Up @@ -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
Expand All @@ -730,6 +782,7 @@ export function installSpaAuctionHook(): void {
async function onNavigate(path: string): Promise<void> {
if (path === currentPath) return;
currentPath = path;
ts.navGeneration = (ts.navGeneration ?? 0) + 1;
inflight?.abort();
const controller = new AbortController();
inflight = controller;
Expand Down
Loading
Loading