Skip to content

Defer initial adInit until after React hydration on Next.js App Router#945

Open
aram356 wants to merge 1 commit into
mainfrom
fix/react418-hydration-safety
Open

Defer initial adInit until after React hydration on Next.js App Router#945
aram356 wants to merge 1 commit into
mainfrom
fix/react418-hydration-safety

Conversation

@aram356

@aram356 aram356 commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

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 — 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:

Condition React #418
Pure publisher (TS inactive) 0
TS active 1–2

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.

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:

  • Appending the tsjs head bundle instead of prepending — reordered origin blocking scripts ahead of TS's bootstraps, which breaks the GPT shim's first-party rewrite of dynamically loaded scripts and can install the disableInitialLoad detector too late.
  • Rewriting integration hosts in the RSC flight — as written it HTML-escaped the serialized payload (corrupting bytes and desyncing recomputed T-chunk lengths), was not safe across lol_html text-chunk or cross-script boundaries, and rewrote every matching URL in flight data rather than only script src values.

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.

Review findings addressed

  • Stale deferred callback re-running a newer SPA route — the callback now captures the route it was scheduled for (location.pathname + location.search) and no-ops if the route changed, so it cannot re-run adInit against a newer route's live slots/bids and double-refresh it.
  • Browser globals shadowingwindow.requestAnimationFrame / window.addEventListener are now window-qualified.
  • Possible loss of the first targeted impression — measured rather than assumed. Instrumenting real GPT ad requests, the first request (t≈3.7s) carries ts_initial=1, hb_pb, and hb_bidder, so server-side targeting is applied to the first impression on the tested publisher. Residual risk remains in principle for a publisher whose GPT activation precedes window load; load + rAF is a pragmatic signal, as React exposes no hydration-complete event.

Verification

  • New test bids_script_defers_ad_init_until_after_hydration covers the deferral, the route guard, the qualified globals, and the absence of a retry timer.
  • Full trusted-server-core lib suite: 1646 passed, 0 failed. cargo fmt clean.
  • End to end through the dev proxy against a live App Router publisher: JS bundle hash recomputed on every HTML response instead of cached #418 = 0, servicesEnabled: true, TS container slot defined, ads rendering, and the first ad request fully targeted.

Refs #938

@aram356 aram356 self-assigned this Jul 21, 2026
@aram356 aram356 changed the title Fix React #418 hydration mismatch from ad-slot injection on Next.js App Router Fix React 418 hydration mismatch from ad-slot injection on Next.js App Router Jul 21, 2026
aram356 added a commit that referenced this pull request Jul 21, 2026
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
@aram356
aram356 force-pushed the fix/react418-hydration-safety branch from 6a1db09 to 3ebcf8f Compare July 21, 2026 18:05
@aram356 aram356 changed the title Fix React 418 hydration mismatch from ad-slot injection on Next.js App Router Defer initial adInit until after React hydration on Next.js App Router Jul 21, 2026
aram356 added a commit that referenced this pull request Jul 21, 2026
…uly"

This reverts commit eaa8fed, reversing
changes made to 41345fc.
@aram356
aram356 requested review from ChristianPavilonis and prk-Jr and removed request for ChristianPavilonis July 21, 2026 20:49
@ChristianPavilonis
ChristianPavilonis self-requested a review July 21, 2026 21:49

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;\

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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/replaceState before load makes this callback abort, but the SPA hook also declines to auction because the pathname is unchanged. Initial adInit() is therefore never called.
  • An /a → /b → /a transition before this callback defeats the stale-route check because the URL equals /a again. The SPA hook auctions the new /a route and calls adInit(), 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.

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);}});}};\

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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.

}

#[test]
fn bids_script_defers_ad_init_until_after_hydration() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants