Defer initial adInit until after React hydration on Next.js App Router - #945
Defer initial adInit until after React hydration on Next.js App Router#945aram356 wants to merge 6 commits into
Conversation
Brings the three hydration-safety changes: defer the initial adInit() until after hydration, append the tsjs head bundle at the end of <head>, and rewrite integration hosts in the streamed RSC flight. Merged cleanly with no conflicts.
On a Next.js App Router publisher, `adInit()` defines GPT slots on the publisher's `-container` wrappers, mutating those ad-slot subtrees. The `</body>` 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
6a1db09 to
3ebcf8f
Compare
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Review summary
🔧 Requesting changes. The diff is narrow, but it changes initial ad initialization for every matched-slot publisher and introduces high-risk navigation and GPT request-ordering regressions. CI is green, but the current automated coverage does not exercise these lifecycle paths.
| "<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 p=location.pathname+location.search;\ |
There was a problem hiding this comment.
🔧 P1 — URL equality is not a safe navigation identity
The guard compares pathname + search, while the SPA auction hook intentionally identifies routes using only pathname (crates/trusted-server-js/lib/src/integrations/gpt/index.ts:715-724). This creates two failures:
- A query-only
pushState/replaceStatebeforeloadmakes this callback abort, but the SPA hook also declines to auction because the pathname is unchanged. InitialadInit()is therefore never called. - An
/a → /b → /atransition before this callback defeats the stale-route check because the URL equals/aagain. The SPA hook auctions the new/aroute and callsadInit(), after which this initial callback can call it again, destroying/redefining slots and issuing duplicate requests.
This can leave all initial ads uninitialized after query normalization or double-refresh inventory after a rapid round trip. Please use a shared monotonic navigation generation maintained synchronously by the SPA hook, capture that generation here, and run only if it is unchanged. Keep query-only changes ignored unless /__ts/page-bids and the SPA hook are deliberately changed to treat them as auction-relevant.
There was a problem hiding this comment.
Implemented in 6f9dbff. The SPA auction hook now maintains tsjs.navGeneration — a monotonic counter incremented synchronously the moment a pathname navigation is accepted (before the page-bids fetch) — and the deferred initial-adInit callback captures it at schedule time and stands down if it has moved.
Query-only history changes remain ignored by both the hook and the guard, so the initial adInit() still runs after a query-only replaceState; an /a → /b → /a round trip advances the counter twice, so the stale callback no-ops. Both scenarios have executable coverage in schedule_initial_ad_init.test.ts, plus a counter-accounting test in spa_hook.test.ts.
| 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);}});}};\ |
There was a problem hiding this comment.
🔧 P1 — The global load gate moves TS targeting behind normal publisher GPT requests
This generic bootstrap now waits for every load-blocking resource and then two animation frames. A publisher that defines/displays GPT before or during window.load sends its first request before TS targeting is applied. adInit() subsequently finds the publisher-owned slot (crates/trusted-server-js/lib/src/integrations/gpt/index.ts:512-518) and refreshes it (index.ts:621-633), turning the TS-targeted request into a second impression. This behavior applies to every creative-opportunity page, not only the tested Next.js App Router publisher.
That can lose the server-auction bid on the first impression, create a duplicate request, delay TS-owned slots behind unrelated resources, and age the auction result. Testing one publisher whose first request happened after this callback does not establish compatibility with other supported initialization patterns.
Please preserve pre-request targeting rather than delaying the entire operation: defer only the hydration-unsafe TS-owned slot definition/display work while intercepting or targeting existing publisher slots before their first display(). If that cannot be done safely here, make delayed initialization an explicit App Router/publisher opt-in and retain synchronous behavior elsewhere.
There was a problem hiding this comment.
Two separable points here.
Refreshing a publisher-owned slot to deliver TS targeting is pre-existing behavior, not introduced by this PR — before this change adInit() ran synchronously at body-parse time and refreshed publisher-owned slots the same way whenever the publisher's initial load had already fired. This PR changes only how long that window stays open. On the live App Router publisher this was measured against, the TS slot's first GPT request (adindex 0) carries ts_initial=1 + hb_*, and the wire investigation for #958 confirmed the publisher's own slots are distinct from TS's slots (the early pre-SSAT fetch belonged to the publisher).
The gate timing is addressed in the agreed stacked follow-up (958-adinit-hydration-chunk-gate, spec at docs/superpowers/specs/2026-07-24-adinit-hydration-gate-design.md): wait for the async /_next/static/chunks/ hydration scripts (a few seconds) instead of window.load (~52s on heavy pages), with load kept as the can't-hang fallback — effectively the App Router-specific fast path this comment asks for, with no new config surface. That spec's PR strategy is to land #945 with the conservative gate and make the timing change in its own PR with its own #418 A/B evidence, so the gate here is unchanged. A sync-targeting/deferred-DOM split of adInit() is the spec's explicit non-goal (the "hydration-safe adInit rewrite" — a larger change, separate effort).
What did land here for this finding's test ask: the deferral now lives in the GPT bundle (tsjs.scheduleInitialAdInit, commit 6f9dbff), and an executable test pins that a publisher-displayed slot receives setTargeting before the refresh that delivers it.
| } | ||
|
|
||
| #[test] | ||
| fn bids_script_defers_ad_init_until_after_hydration() { |
There was a problem hiding this comment.
🔧 P2 — This test does not execute the lifecycle being changed
The assertions only check for substrings such as requestAnimationFrame, "load", and location.pathname. The test would still pass if adInit() ran synchronously, if only one frame were used, or with both navigation bugs noted above. The central guarantees—post-load ordering, exactly-once invocation, stale-navigation cancellation, and interaction with the SPA hook—remain unprotected.
Please add an executable browser/JavaScript test covering loading and already-complete documents, two-frame ordering, query-only history changes, /a → /b → /a, and a publisher GPT display() during load. Assert both the adInit() count and first-request targeting.
There was a problem hiding this comment.
Addressed in 6f9dbff. The bootstrap moved out of the Rust-emitted inline string into the GPT bundle (scheduleInitialAdInit in gpt/index.ts); the </body> bids script now only assigns tsjs.bids and delegates to it.
The lifecycle now runs for real under Vitest/jsdom in test/integrations/gpt/schedule_initial_ad_init.test.ts — the real scheduler plus, where relevant, the real adInit() and SPA auction hook: load + 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.
Real first-request GPT targeting stays verified by the live A/B in the PR description — an actual GPT ad request is not reachable from jsdom.
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
The hydration mitigation is narrow, but it changes initial ad initialization for every matched-slot publisher and leaves navigation and GPT request-ordering regressions. CI is green; the current automated coverage does not execute these lifecycle paths.
Blocking
- 🔧 Use a real navigation generation instead of URL equality.
- 🔧 Preserve first-request GPT targeting instead of globally delaying all initialization.
- 🔧 Add executable lifecycle coverage for the deferred bootstrap.
CI Status
- fmt: PASS
- clippy/checks: PASS
- rust tests: PASS
- JavaScript tests: PASS
- browser/integration tests: PASS
| "<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 p=location.pathname+location.search;\ |
There was a problem hiding this comment.
🔧 wrench — URL equality is not a safe navigation identity
This guard uses pathname + search, while the SPA auction hook intentionally identifies routes using only pathname (crates/trusted-server-js/lib/src/integrations/gpt/index.ts:715-724). That produces two failures:
- A query-only
pushState/replaceStatebeforeloadmakes this callback abort, but the SPA hook also declines to auction because the pathname is unchanged. InitialadInit()is never called. - An
/a → /b → /atransition before this callback defeats the stale-route check because the URL equals/aagain. After the SPA hook applies the new/astate and callsadInit(), this callback can call it again, destroying/redefining slots and issuing duplicate requests.
Please use a shared monotonic navigation generation maintained synchronously by the SPA hook, capture it here, and run only if it remains unchanged. Query-only changes should remain ignored unless the SPA auction path is deliberately changed to treat them as auction-relevant.
There was a problem hiding this comment.
Implemented in 6f9dbff. The SPA auction hook now maintains tsjs.navGeneration — a monotonic counter incremented synchronously the moment a pathname navigation is accepted (before the page-bids fetch) — and the deferred initial-adInit callback captures it at schedule time and stands down if it has moved.
Query-only history changes remain ignored by both the hook and the guard, so the initial adInit() still runs after a query-only replaceState; an /a → /b → /a round trip advances the counter twice, so the stale callback no-ops. Both scenarios have executable coverage in schedule_initial_ad_init.test.ts, plus a counter-accounting test in spa_hook.test.ts.
| 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);}});}};\ |
There was a problem hiding this comment.
🔧 wrench — The global load gate can move targeting behind the first GPT request
This generic bootstrap now waits for every load-blocking resource and two animation frames. A publisher that defines/displays GPT before or during window.load can send its first request before TS targeting is applied. The later adInit() finds the publisher-owned slot (crates/trusted-server-js/lib/src/integrations/gpt/index.ts:512-518) and refreshes it (index.ts:621-633), making the TS-targeted request a second impression.
That can lose the server-auction bid on the first impression, create a duplicate request, delay TS-owned slots behind unrelated resources, and age the auction result. Please preserve pre-request targeting—e.g. defer only hydration-unsafe TS-owned slot definition/display work—or make delayed initialization an explicit App Router/publisher opt-in while retaining synchronous behavior elsewhere.
There was a problem hiding this comment.
Two separable points here.
Refreshing a publisher-owned slot to deliver TS targeting is pre-existing behavior, not introduced by this PR — before this change adInit() ran synchronously at body-parse time and refreshed publisher-owned slots the same way whenever the publisher's initial load had already fired. This PR changes only how long that window stays open. On the live App Router publisher this was measured against, the TS slot's first GPT request (adindex 0) carries ts_initial=1 + hb_*, and the wire investigation for #958 confirmed the publisher's own slots are distinct from TS's slots (the early pre-SSAT fetch belonged to the publisher).
The gate timing is addressed in the agreed stacked follow-up (958-adinit-hydration-chunk-gate, spec at docs/superpowers/specs/2026-07-24-adinit-hydration-gate-design.md): wait for the async /_next/static/chunks/ hydration scripts (a few seconds) instead of window.load (~52s on heavy pages), with load kept as the can't-hang fallback — effectively the App Router-specific fast path this comment asks for, with no new config surface. That spec's PR strategy is to land #945 with the conservative gate and make the timing change in its own PR with its own #418 A/B evidence, so the gate here is unchanged. A sync-targeting/deferred-DOM split of adInit() is the spec's explicit non-goal (the "hydration-safe adInit rewrite" — a larger change, separate effort).
What did land here for this finding's test ask: the deferral now lives in the GPT bundle (tsjs.scheduleInitialAdInit, commit 6f9dbff), and an executable test pins that a publisher-displayed slot receives setTargeting before the refresh that delivers it.
| } | ||
|
|
||
| #[test] | ||
| fn bids_script_defers_ad_init_until_after_hydration() { |
There was a problem hiding this comment.
🔧 wrench — This test does not execute the lifecycle being changed
These assertions only check for substrings such as requestAnimationFrame, "load", and location.pathname. They would still pass if adInit() ran synchronously, if only one frame were used, or with both navigation failures above. The core guarantees—post-load ordering, exactly-once invocation, stale-navigation cancellation, and interaction with publisher GPT—remain unprotected.
Please add executable browser/JavaScript coverage for loading and already-complete documents, two-frame ordering, query-only history changes, /a → /b → /a, and a publisher GPT display() during load. Assert both the adInit() count and first-request targeting.
There was a problem hiding this comment.
Addressed in 6f9dbff. The bootstrap moved out of the Rust-emitted inline string into the GPT bundle (scheduleInitialAdInit in gpt/index.ts); the </body> bids script now only assigns tsjs.bids and delegates to it.
The lifecycle now runs for real under Vitest/jsdom in test/integrations/gpt/schedule_initial_ad_init.test.ts — the real scheduler plus, where relevant, the real adInit() and SPA auction hook: load + 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.
Real first-request GPT targeting stays verified by the live A/B in the PR description — an actual GPT ad request is not reachable from jsdom.
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 </body> 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.
Summary
On a Next.js App Router publisher,
adInit()defines GPT slots on the publisher's-containerwrappers, mutating those ad-slot subtrees. The</body>bids bootstrap called it synchronously at parse time, landing that mutation inside React's hydration window — 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 count tracks whether
adInitprocesses ad slots — not the injection position.adInitis now deferred to after hydration: gated on windowload, then a doublerequestAnimationFrame. Run-once, no retry timer.The deferral lifecycle lives in the GPT bundle module as
tsjs.scheduleInitialAdInit(crates/trusted-server-js/lib/src/integrations/gpt/index.ts); the server-emitted</body>bids script assignstsjs.bidsand delegates to it. The GPT module ships in the synchronous head bundle, so it has always executed before the inline script runs.Scope
Deliberately reduced to the single change with a measured effect on #418. Two other hydration-safety changes were considered and dropped from this PR, because neither moved the observed #418 count and review surfaced real defects in both:
disableInitialLoaddetector too late.lol_htmltext-chunk or cross-script boundaries, and rewrote every matching URL in flight data rather than only scriptsrcvalues.A correct flight rewrite needs the existing RSC stream state machine, raw (non-escaping) insertion, and
src-targeted parsing — that belongs in its own PR.The gate's timing (window
loadis dominated by page weight — ~52s on heavy publishers) is addressed in the agreed stacked follow-up958-adinit-hydration-chunk-gate(spec:docs/superpowers/specs/2026-07-24-adinit-hydration-gate-design.md), which waits on the async/_next/static/chunks/hydration scripts and keepsloadas the can't-hang fallback. Per that spec's PR strategy, this PR lands the conservative gate; the timing change gets its own PR and its own #418 A/B.Review findings addressed
tsjs.navGeneration, a monotonic counter the SPA auction hook increments synchronously the moment it accepts a pathname navigation, and no-ops if it moved. Unlike the earlier URL-equality guard, this cannot diverge from the hook's pathname-only route identity: a query-onlyreplaceStatebeforeloadno longer cancels the initialadInit, and an/a → /b → /around trip (URL compares equal again) now correctly stands the stale callback down.test/integrations/gpt/schedule_initial_ad_init.test.tscovers load + two-frame ordering, already-complete documents, exactly-once invocation, query-only history changes,/a → /b → /acancellation, and a publisher-displayed slot receivingsetTargetingbefore the refresh that delivers it. The Rust tests pin the inline script's delegation and XSS safety.requestAnimationFrame/addEventListener.ts_initial=1,hb_pb, andhb_bidder, so server-side targeting is applied to the first impression on the tested publisher; the Investigate refresh too quickly after SSAT with client side auction (timing issue with GAM) #958 wire investigation confirmed the TS slot's first fetch is adindex 0 withts_initial=1and that publisher-owned slots are distinct. Residual risk remains in principle for a publisher whose GPT activation precedes windowload; the follow-up chunk gate narrows that window to seconds.Verification
schedule_initial_ad_init.test.ts(6 tests) plus anavGenerationaccounting test inspa_hook.test.ts; full JS suite 432 passed, 0 failed, format clean, bundles build.cargo test-fastly1734 passed, 0 failed (+ 21 adapter tests),cargo test-axumgreen, parity suite green,cargo fmtclean, clippy clean ontrusted-server-core.servicesEnabled: true, TS container slot defined, ads rendering, and the first ad request fully targeted.Refs #938