Skip to content

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

Merged
aram356 merged 12 commits into
mainfrom
fix/react418-hydration-safety
Jul 30, 2026
Merged

Defer initial adInit until after React hydration on Next.js App Router#945
aram356 merged 12 commits into
mainfrom
fix/react418-hydration-safety

Conversation

@aram356

@aram356aram356 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:

ConditionReact #418
Pure publisher (TS inactive)0
TS active1–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.

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 assigns tsjs.bids and 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:

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

The gate's timing (window load is dominated by page weight — ~52s on heavy publishers) is addressed in the agreed stacked follow-up 958-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 keeps load as 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

  • Stale deferred callback vs. SPA navigation — the deferred callback now captures 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-only replaceState before load no longer cancels the initial adInit, and an /a → /b → /a round trip (URL compares equal again) now correctly stands the stale callback down.
  • Executable lifecycle coverage — the bootstrap moved out of the Rust format string into the bundle, so its lifecycle is executed (not string-matched) under Vitest: test/integrations/gpt/schedule_initial_ad_init.test.ts covers load + two-frame ordering, already-complete documents, exactly-once invocation, query-only history changes, /a → /b → /a cancellation, and a publisher-displayed slot receiving setTargeting before the refresh that delivers it. The Rust tests pin the inline script's delegation and XSS safety.
  • Browser globals shadowing — the scheduler window-qualifies requestAnimationFrame / addEventListener.
  • 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; 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 with ts_initial=1 and that publisher-owned slots are distinct. Residual risk remains in principle for a publisher whose GPT activation precedes window load; the follow-up chunk gate narrows that window to seconds.

Second round (c3e8df3)

  • Stale SSR payload adopted after a streaming-window navigation — the </body> script no longer assigns tsjs.bids; it hands the payload to scheduleInitialAdInit, which applies it and runs adInit() only while the page is still on navigation generation 0 (the SSR document). A navigation that commits before — or after — scheduling drops the payload and cancels the initial run, so it can neither clobber the live route's bids nor re-run it.
  • Queued GPT mutation outliving its generation checkadInit() now rechecks its captured generation as the first act of the queued googletag.cmd callback, so a navigation that commits before GPT drains the queue (e.g. consent-gated GPT) cancels the stale slot work. Applied in the bundle and the head bootstrap.
  • Bundle-failure degradation path restoredgpt_bootstrap.js installs a minimal fallback scheduleInitialAdInit (same generation-0 + load + double-rAF semantics), so a failed TSJS bundle load still initializes initial ads via the bootstrap's fallback adInit. The shipped bootstrap file is now evaluated verbatim under Vitest (gpt_bootstrap.test.ts).

Third round (756cfaf)

  • Hidden-tab behavior documented as intended — rAF is unserviced while a document is hidden, so a background-tab load holds the initial adInit() until first view, spending the impression on a viewed tab; docblocks in the bundle scheduler and gpt_bootstrap.js now say so, and both executable suites cover the hidden case.
  • Uniform-deferral tradeoff stated in-code — the deferral is deliberately unconditional (one code path, no framework-detection/config surface); the chunk-gate follow-up recovers the non-React latency.
  • Doc fix in types.ts (generation-0 pinning, not counter capture) and the whole-blob !setTimeout assertion in gpt.rs dropped in favor of the executable no-retry coverage.

Verification

  • Executable Vitest lifecycle suite schedule_initial_ad_init.test.ts (6 tests) plus a navGeneration accounting test in spa_hook.test.ts; full JS suite 432 passed, 0 failed, format clean, bundles build.
  • Rust: cargo test-fastly1734 passed, 0 failed (+ 21 adapter tests), cargo test-axum green, parity suite green, cargo fmt clean, clippy clean on trusted-server-core.
  • 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

@aram356aram356 self-assigned this Jul 21, 2026
@aram356aram356 changed the title Fix React #418 hydration mismatch from ad-slot injection on Next.js App RouterFix React 418 hydration mismatch from ad-slot injection on Next.js App RouterJul 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
aram356force-pushed the fix/react418-hydration-safety branch from 6a1db09 to 3ebcf8fCompareJuly 21, 2026 18:05
@aram356aram356 changed the title Fix React 418 hydration mismatch from ad-slot injection on Next.js App RouterDefer initial adInit until after React hydration on Next.js App RouterJul 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 ChristianPavilonisJuly 21, 2026 20:49
@ChristianPavilonis
ChristianPavilonis self-requested a review July 21, 2026 21:49

@ChristianPavilonisChristianPavilonis 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.

Comment threadcrates/trusted-server-core/src/publisher.rs Outdated
Comment threadcrates/trusted-server-core/src/publisher.rs Outdated
Comment threadcrates/trusted-server-core/src/publisher.rs

@prk-Jrprk-Jr 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.

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

Comment threadcrates/trusted-server-core/src/publisher.rs Outdated
Comment threadcrates/trusted-server-core/src/publisher.rs Outdated
Comment threadcrates/trusted-server-core/src/publisher.rs
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.
ChristianPavilonis added a commit that referenced this pull request Jul 28, 2026
# Conflicts:
#	crates/trusted-server-core/src/publisher.rs
#	crates/trusted-server-js/lib/src/integrations/gpt/index.ts

@ChristianPavilonisChristianPavilonis 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.

Summary

Reviewed the hydration deferral, navigation-generation lifecycle, GPT command-queue behavior, head/body injection ordering, fallback bootstrap, and executable coverage. CI is green and the earlier URL-equality and string-only-test concerns were addressed. I found two high-priority lifecycle race windows and one medium-priority fallback regression, detailed inline.

Comment threadcrates/trusted-server-js/lib/src/integrations/gpt/index.ts Outdated
Comment threadcrates/trusted-server-js/lib/src/integrations/gpt/index.ts Outdated
Comment threadcrates/trusted-server-core/src/publisher.rs Outdated
aram356and others added 3 commits July 29, 2026 22:22
…llback
Address the second-round review findings on PR #945:
- scheduleInitialAdInit now receives the SSR bids payload from the </body>
script and applies it only while the page is still on navigation
generation 0 (the SSR document). Capturing the counter at body end
adopted a navigation that committed while the HTML was still streaming,
and the unconditional tsjs.bids assignment clobbered the live route's
bids with the stale SSR payload.
- adInit captures its generation and rechecks it as the queued
googletag.cmd callback's first act, so a navigation that commits between
the invocation and GPT's queue drain (e.g. consent-gated GPT) cancels
the stale slot mutation. Applied in both the bundle and the head
bootstrap.
- gpt_bootstrap.js installs a minimal fallback scheduleInitialAdInit so a
failed TSJS bundle load still initializes initial server-side ads
through the head bootstrap's fallback adInit, restoring the degradation
path the scheduler delegation had bypassed.
- New executable coverage: navigation before scheduling, an applied
page-bids response before scheduling, deferred command-queue drain
cancellation, and a gpt_bootstrap.test.ts suite that evaluates the
shipped bootstrap verbatim (fallback scheduler and adInit, generation
cancellation, targeting and display through the command queue).

@prk-Jrprk-Jr 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.

Summary

The </body> bids script stops calling adInit() synchronously and hands the SSR payload to tsjs.scheduleInitialAdInit, which applies bids and runs adInit() after window load plus a double requestAnimationFrame, pinned to navigation generation 0. The new tsjs.navGeneration counter is bumped synchronously in the SPA hook and rechecked inside adInit's googletag.cmd callback, so work queued before a navigation stands down instead of mutating the newer route's DOM.

The design is sound and the reasoning is unusually well documented at every layer — including why a counter beats URL comparison, why the pass pins to generation 0 rather than capturing, and why a failed navigation deliberately does not roll the counter back. Approving: nothing here blocks merge. The questions below are about consequences I could not verify from the diff, not defects.

Blocking

None.

Non-blocking

❓ question

  • Hidden-tab rAF suspension is an unstated behavior change: load fires in background tabs but requestAnimationFrame does not run while hidden, so an open-in-new-tab load now makes zero initial ad requests until first view (gpt/index.ts:493, mirrored in gpt_bootstrap.js:63). Intended, or does the hidden case need its own path?
  • The deferral is global; the bug is Next.js App Router-specific: every publisher's initial ad request moves from body-parse to window load, including those with no React #418 exposure (publisher.rs:3341). Was gating considered?
  • Widened untargeted-request window vs. SSAT single-shot attribution: the gap between the publisher's initial untargeted request and TS's targeted refresh() grows from GPT-ready to window load, and only the first render per slot can claim ssat (schedule_initial_ad_init.test.ts:198). Do the deployed numbers hold?

🤔 thinking

  • A third-party pushState before load permanently cancels the initial pass (gpt/index.ts:895): recovery then depends entirely on that navigation's page-bids succeeding, and the !data branch returns without adInit(). Pre-change the SSR pass had already rendered, so this failure mode is new.

⛏ nitpick

  • Doc/impl mismatch (core/types.ts:145): says the scheduler "captures this counter"; it pins to generation 0. The installScheduleInitialAdInit docblock has it right.
  • Over-broad negative assertion (integrations/gpt.rs:1234): !contains("setTimeout") spans all GPT head inserts, so an unrelated future timer fails it with a misleading message.

👍 praise

  • gpt_bootstrap.test.ts evaluates the shipped bootstrap verbatim — the head-injected degradation path is now executed rather than string-matched from Rust, generation cancellation across the googletag.cmd gap included.
  • Generation counter over URL comparison, with the query-only change and the /a → /b → /a round trip both covered as executable tests.

CI Status

  • fmt: PASS
  • clippy / cargo check (fastly, axum, cloudflare native + wasm, spin native + wasm): PASS
  • rust tests (fastly, axum, cloudflare, spin, ts CLI, cross-adapter parity): PASS
  • js tests (vitest) + format-typescript + format-docs: PASS
  • integration tests (browser, Fastly EC lifecycle): pending at time of review

Comment threadcrates/trusted-server-js/lib/src/integrations/gpt/index.ts
Comment threadcrates/trusted-server-core/src/integrations/gpt_bootstrap.js
Comment threadcrates/trusted-server-core/src/publisher.rs
Comment threadcrates/trusted-server-js/lib/src/integrations/gpt/index.ts
Comment threadcrates/trusted-server-js/lib/src/core/types.ts Outdated
Comment threadcrates/trusted-server-core/src/integrations/gpt.rs Outdated
prk-Jr added a commit that referenced this pull request Jul 30, 2026
… docs
Review follow-ups on the __next_f adInit gate:
- scheduleInitialAdInit now checks the runtime signal once before installing
the 50ms interval. This script runs at </body>, and on a streamed App Router
page the chunks can already have executed by then, so the poll idled a tick
for nothing. The load listener stays registered but is inert (once: true plus
the fired guard).
- Document __next_f as a Next.js App Router internal: Pages Router publishers
and any future release that renames the flight global never patch it and take
the window.load path, so the failure mode is slow rather than broken. Worth
re-checking on a gated publisher's major Next upgrade.
- Cover the reverse signal order (load before the runtime signal) and the
already-patched synchronous path, completing the signal-order matrix.
- build_bids_script's doc comment still described PR #945's window.load-only
gate. Comment only; the emitted script is unchanged.
- Both docs' UPDATE banners now record the gate's move out of publisher.rs into
gpt/index.ts, since the superseded sections still call publisher.rs the only
production change. The spec also records requestIdleCallback-after-signal as
considered and deferred, with the condition that would adopt it.
prk-Jr added a commit that referenced this pull request Jul 30, 2026
TsjsApi.scheduleInitialAdInit still documented PR #945's window.load-only
deferral, which the __next_f runtime-signal gate replaced.
…etails
Address the post-approval review comments on PR #945:
- Document the hidden-tab behavior as intended in the bundle scheduler and
the gpt_bootstrap.js fallback: rAF is not serviced while a document is
hidden, so a background-tab load holds the initial adInit until first
view, spending the impression on a viewed tab. Executable coverage added
in both suites (frames queued but unserviced while hidden; adInit fires
when frames are first serviced, still exactly once, no timer path).
- State the uniform-deferral tradeoff in build_bids_script: the deferral is
deliberately unconditional (one code path, no per-publisher framework
detection or config surface), and the hydration-chunk-gate follow-up
recovers the non-React latency without new configuration.
- Fix the navGeneration docblock in types.ts: the initial bootstrap is
pinned to generation 0, it does not capture the current counter.
- Drop the whole-blob !setTimeout assertion from the gpt.rs fallback
scheduler test; the no-retry property is owned by the executable
gpt_bootstrap.test.ts suite, and the string match would misattribute any
future unrelated timer in the head inserts to the scheduler.
@aram356
aram356 merged commit 430305d into mainJul 30, 2026
19 checks passed
@aram356
aram356 deleted the fix/react418-hydration-safety branch July 30, 2026 17:48
aram356 added a commit that referenced this pull request Jul 30, 2026
Reconciles the squash-merged #945 (deferred initial adInit) with the RC's
own evolution of the same files:
- build_bids_script, the bundle scheduler, and the gpt_bootstrap.js
fallback take main's final form: the SSR bids payload is handed to
scheduleInitialAdInit, which applies it and runs adInit only on
navigation generation 0; adInit rechecks its generation inside the
queued googletag.cmd callback; the head bootstrap keeps the
bundle-failure fallback scheduler.
- The RC's pathname-plus-query route identity is kept: docblocks and the
query-change tests follow the RC semantics (a query change requests
fresh page bids and cancels the pending initial pass), and the RC's
slot-handoff/initial-request-gate machinery is preserved alongside the
new scheduler.
- gpt_bootstrap.test.ts is adapted to the RC bootstrap: assertions target
the pre-patch spies (the handoff patcher wraps defineSlot/display/
refresh) and expect the TS fallback slot on the publisher's actual
inner div per the RC handoff design.
prk-Jr added a commit that referenced this pull request Jul 30, 2026
…chunk-gate
Main landed #945, which defers the initial adInit past React hydration by
gating on window `load` plus a double requestAnimationFrame. This branch
replaces that gate with first-signal-wins — the Next.js App Router runtime
patching `window.__next_f`, or `load` as the fallback and the only signal on
non-Next publishers — so the two implementations collided across the
scheduler, its documentation, and both test files (add/add on each).
Resolution:
- Keep this branch's scheduler. It registers the same `load` listener as its
fallback, so main's behavior is preserved as the non-Next path.
- Keep main's hidden-document reasoning in both the bundle scheduler docs and
the gpt_bootstrap.js fallback, and record that the runtime poll fires
independently of rAF while `afterHydrationFrames` still gates the call, so
hidden-tab behavior is unchanged.
- Take main's `navGeneration` doc wording: it describes the generation-0 pin
this branch already implements, where this branch's text was stale.
- Take main's removal of the `!contains("setTimeout")` assertion in gpt.rs.
The no-retry-timer property is covered by gpt_bootstrap.test.ts, and a
substring check over the joined head-inserts would misattribute any future
unrelated timer to the scheduler.
- Port main's hidden-document tests into both test files on top of this
branch's runtime-signal cases, along with the `document.hidden` cleanup.
- Drop main's publisher.rs pointer to this branch as pending follow-up work,
since it lands here.
aram356 added a commit that referenced this pull request Jul 31, 2026
Adopts the gating revert and the hardened GPT slot handoff from #978:
- Removes the publisher initial-request gate (initialRequestGate,
heldPublisherRequests, GptInitialRequestGate) that #978 reverted
- Takes matchingHandoff/displayTargetElementId and the responsive-slot
helpers with ambiguous-hydration protection
- Keeps rc-only content intact: gpt_diagnostics types, the #948
disableInitialLoad sync (syncInitialLoadDisabled wired into the
refresh-selection path), and the #945 scheduleInitialAdInit coverage
- Drops the obsolete held-display test from schedule_initial_ad_init
and ports the two #948 setConfig tests to the new zero-arg
runGptBootstrap harness
@aram356aram356 added this to the 202607 milestone Aug 13, 2026
Sign up for freeto 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.

TS HTML injection breaks React hydration on Next.js App Router (React #418)

3 participants

@aram356@ChristianPavilonis@prk-Jr