Skip to content

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

Description

@jevansnyc

Summary

On a Next.js App Router publisher, Trusted Server's injection is applied to the server-rendered HTML after SSR but before React hydrates. React compares the mutated DOM against its RSC/flight payload, they disagree, and it throws React #418 (Hydration failed because the server rendered HTML didn't match the client), regenerating the affected subtree — visible as flashes/reflow. Core functionality still works because React recovers by client-rendering, but the hydration break is real.

Confirmed root cause: adInit mutates ad-slot subtrees during hydration

The publisher SSR-renders each ad slot as <div id="ad-…-container"> wrapping an inner <div id="ad-…"> (present in both the SSR HTML and the RSC flight). TS's adInit calls googletag.defineSlot(…, "${divId}-container") on the wrapper; GPT then renders into the container and removes/replaces the publisher's inner slot div. The </body> bids bootstrap invoked adInit() synchronously at parse time, so that mutation landed inside React's hydration window.

Evidence

A live A/B toggling TS on the same page via the tester cookie, plus builds isolating each candidate change:

Build head append flight rewrite defer adInit React #418
Pure publisher (TS inactive) 0
TS active, deployed 1, 0
TS active 1, 1, 2
TS active 0, 0, 0, 0
TS active 0, 0, 0

The last two rows are decisive: deferring adInit alone drives the count to 0, and the other two changes neither fix it nor are needed. The count tracks whether adInit processes ad slots, not the injection position.

Fix

PR #945 — defer the initial adInit() until after hydration: gate on window load, then a double requestAnimationFrame. Run-once, no retry timer. The deferred callback captures the route it was scheduled for and no-ops if an SPA navigation has since committed a different route, so it cannot re-run adInit against the newer route's live slots/bids.

Verified end to end through the dev proxy against a live App Router publisher: #418 = 0 across runs, with services enabled, TS container slots defined, ads rendering, and the first ad request carrying ts_initial / hb_pb / hb_bidder targeting.

Identified but unconfirmed mechanisms

Two further mismatch sources were identified and prototyped, but neither changed the observed #418 count, and both prototypes were withdrawn as defective. They are not fixed. If either is ever shown to fire, a correct implementation must avoid the defects found:

  1. Head injection position — the tsjs bundle is prepended to the React-owned <head>, shifting the origin's first child. Moving it to the end of <head> reorders 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. Any change here needs to separate ordering-sensitive bootstraps from hydration-sensitive trailing injections, with an execution-order test using a blocking origin GPT script.

  2. Attribute-rewrite asymmetry — TS rewrites third-party integration hosts on rendered tags to first-party /integrations/... paths, but the RSC/flight rewriter is anchored to the origin host only, so the DOM and the flight disagree on those tags. A correct fix must: emit raw (non-escaped) bytes, since HTML-escaping a <script> payload corrupts it and desyncs recomputed T-chunk lengths; be stateful across lol_html text-chunk and cross-script boundaries, since a T-row split across flight scripts corrupts the stream; and target actual script src values / __next_s descriptors rather than every URL in flight data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions