Skip to content

TS adInit overwrites publisher ads and APS replacements remain 1x1 #1078

Description

@ChristianPavilonis

Summary

Two independent TSJS defects that combine into one severe user-visible failure:

  1. adInit() can refresh a publisher-owned GPT slot after the publisher has already auctioned or rendered that slot. GPT removes the working creative and starts a second impression request.
  2. When that second request selects a Trusted Server APS bid through GAM and Prebid Universal Creative, the TS render bridge suppresses Prebid's handler but does not replace Prebid's outer-frame resize. The APS creative renders inside the 1x1 GAM shell and remains effectively invisible.

The common sequence is therefore: a publisher ad paints quickly, Trusted Server replaces it a few seconds later, and the replacement appears blank even though the APS creative loaded successfully.

flowchart LR
    A[Publisher auction] --> B[728x90 publisher creative paints]
    B --> C[Late TS adInit refreshes same slot]
    C --> D[GAM selects TS APS line item]
    D --> E[PUC sends Prebid Request]
    E --> F[TS bridge intercepts and renders APS]
    F --> G[Inner APS creative loads]
    G --> H[Outer GAM iframe stays 1x1]
    H --> I[Placement appears blank]
Loading

Both defects need fixes. Preventing the duplicate request protects publisher-rendered impressions, but legitimate TS-owned APS impressions would still be invisible. Restoring shell resize makes APS visible, but the unnecessary replacement would still discard a valid publisher creative.

evidence

Duplicate first-impression request

Instrumentation installed before page scripts observed this homepage sequence:

  1. The publisher's Prebid wrapper auctioned the slot.
  2. GPT painted a real 728x90 AdX creative.
  3. Nine milliseconds after the publisher paint, tsjs-unified called pubads().refresh() on the same slots.
  4. GPT replaced the visible creative about 1.7 seconds later.

Independent matched-arm browser runs reproduced the same overwrite with both diagnostics consoles disabled, with Google Publisher Console enabled, and with Trusted Server diagnostics enabled. The consoles are not the trigger.

SPA article navigation widens the delay. /_ts/page-bids must return, and the current slot resolver may wait up to two seconds for matching elements. The resulting overwrite commonly happens two to four seconds after first paint, matching the report.

Trusted Server diagnostics captured the inverse SPA ordering as:

  • request 1: prebid_refresh
  • request 2: trusted_server_direct
  • request 2: replacedRequestNumber: 1

Some sampled second requests had trustedServerOpportunity: no_candidate and creativeChanged: false. Even with no TS candidate, adInit() removed a valid creative and issued another GPT request.

Successful APS render trapped in one pixel

A separate capture followed a TS APS win through the complete render path:

  • Prebid emitted bidWon.
  • Amazon's prebid-creative.js loaded.
  • The real APS creative iframe was present.
  • Its document contained about 26.7 KB of content.
  • The outer google_ads_iframe remained width=1 and height=1 indefinitely.

The ad was not absent. It was rendered inside one pixel.

Publisher bids rendered through the publisher's own Prebid path resized and painted correctly. That is the control showing that the page and GAM placement support the winning dimensions and that the missing behavior is specific to the TS bridge.

Defect 1: existing-slot reuse lacks request ownership

The previous duplicate-slot work in #944 and #966 made Trusted Server reuse a publisher GPT slot instead of defining a competing container slot. That solved duplicate slot definitions, but it did not establish ownership of the first request.

In the current adInit() existing-slot path, every matching existing slot receives Trusted Server targeting and enters slotsToRefresh. The later refresh block calls pubads().refresh() without checking whether the current navigation's first impression is already owned or consumed.

There is no behavioral check for:

  • a publisher Prebid auction already in flight;
  • a publisher GPT request already issued;
  • a creative already rendered for this navigation; or
  • a no-candidate page-bids response arriving after delivery.

The Prebid integration has delivery-correlation state, but it creates the useful registration inside bidsBackHandler and keeps it private to the module. That is too late for the homepage race, where delayed adInit() can run while the publisher auction is still pending.

The SPA generation guard protects route identity. It does not protect impression ownership.

The fallback GPT bootstrap carries the same unconditional existing-slot behavior, so a bundle-only change would leave the fallback path broken.

Defect 2: the TS render bridge bypasses outer-frame resize

The bridge documents and implements this behavior:

  1. Receive PUC's Prebid Request.
  2. Resolve and validate the TS-owned bid.
  3. Reply over the transferred port with Prebid Response.
  4. Call stopImmediatePropagation() so the publisher's Prebid handler does not process the request.

See the bridge contract and interception and the APS response path.

Prebid's normal top-page handler owns resizeRemoteCreative(). Suppressing that handler also suppresses its resize of the outer GAM iframe and immediate parent.

The TS response includes the correct width and height. The current APS dynamic renderer applies those dimensions only to the nested renderer iframe. Neither installTsRenderBridge() nor APS_UNIVERSAL_CREATIVE_RENDERER resizes the authenticated outer google_ads_iframe. The generic TS display renderer has the same structural gap.

PR #998 was intended to fix PUC rendering and collapsed GAM shells. An earlier implementation included a guarded resizeCollapsedCreativeFrame() helper and real-PUC browser coverage, but neither rc/202608 nor current main contains that helper or an equivalent, the shell-resize guarantee is absent. This should be treated as an incomplete or regressed part of #998 rather than as an APS no-bid.

The newer publisher_native experiment follows a different render path. It does not repair the deployed Universal Creative bridge, and switching modes is not a safe hotfix without the security, reporting, CSP, and APS-account validation required by #999 and #1042.

Impact

  • Valid publisher creatives disappear after first paint.
  • GPT sends unnecessary requests for the same user-visible impression.
  • TS APS winners can load and bill while remaining visually 1x1.
  • Viewability, impression ownership, bidder accounting, and revenue attribution may disagree.
  • A no-candidate TS cycle can still destroy a filled publisher slot.
  • Diagnostics may show the replacement generation as pending while the page looks blank.
  • The failure is timing-sensitive and appears intermittent even though both code paths are deterministic once their ordering is known.

Proposed fix 1: first-claimant ownership per navigation

Adopt this immediate contract:

The first valid claimant owns the current navigation's first impression. Trusted Server may mark and request ts_initial=1 only while that claim is unconsumed. After a publisher auction, GPT request, or render claims the impression, late page-bids data is stale for that impression and must not mutate or refresh the slot.

Strict unconditional TS-first ownership would require holding publisher delivery while page-bids settles and choosing between two auction winners. That is a larger product and auction-arbitration change and should not be hidden inside adInit().

Shared ownership state

Add bounded, shared state usable by the GPT and Prebid bundles. Track at least:

  • navigation generation;
  • exact slot element identity or unambiguous publisher ad-unit code;
  • owner such as publisher_prebid or trusted_server_direct;
  • phase such as auctioning, delivery_pending, requested, or rendered;
  • a registration token; and
  • a bounded expiry.

Use registration tokens rather than booleans so nested or overlapping auctions cannot clear each other's claims.

Claim publisher slots at requestBids() entry

For real publisher auctions, register the claim before invoking native requestBids().

  • Move the same registration to delivery-pending in bidsBackHandler.
  • Attach returned ad IDs to that registration.
  • Consume it only when the correlated GPT delivery is forwarded.
  • Clear it on synchronous throw, navigation invalidation, removal, or bounded timeout.
  • Do not create publisher claims for synthetic TS refresh auctions or while adInitRefreshInProgress is set.

This closes the homepage window that callback-time delivery registration misses.

Observe GPT lifecycle independently of diagnostics

Install early listeners for slotRequested and slotRenderEnded. Store only slot identity and navigation generation needed for ownership.

Behavioral correctness must not depend on the optional diagnostics recorder.

Classify before changing targeting

Before clearing or applying any targeting, adInit() should classify each slot:

Slot state Required behavior
New TS-owned slot Preserve current define and display behavior
New TS-owned slot with initial load disabled Preserve the one required explicit refresh
Existing untouched slot with no publisher claim Atomically claim for TS, apply targeting, set ts_initial=1, and refresh once
Existing slot in a publisher auction Do not change targeting, set ts_initial, map a winning creative, or refresh
Existing slot requested or rendered this generation Treat page-bids data as stale for that impression

Clear prior-generation TS targeting at navigation transition and again before publisher targeting is applied. Late adInit() cleanup must not alter an already-rendered current-generation slot.

Publisher claims should be leases. If a publisher auction never reaches GPT, allow one per-slot TS fallback only after the lease expires and the code rechecks generation, DOM identity, and ownership. Do not rerun whole-page adInit() as a fallback.

Mirror the same ownership contract in gpt_bootstrap.js.

Why TS demand is preserved

The Prebid wrapper injects the trustedServer adapter into publisher ad units. When publisher Prebid claims first, server-side TS demand still participates in that auction.

A separate page-bids response arriving after GPT has requested the impression is no longer eligible for that impression. Rendering it creates a second impression rather than improving the first.

This depends on the TS Prebid wrapper installing before the publisher auction. Verify and monitor that ordering during rollout.

Proposed fix 2: restore authenticated collapsed-shell resizing

Mirror Prebid's outer-frame resize after the bridge has validated the exact message source, slot ownership, bid identity, and winning dimensions.

The resize helper should:

  1. Resolve the exact iframe whose contentWindow equals MessageEvent.source under the already-resolved slot root.
  2. Require a connected ordinary display iframe that is still collapsed to 1x1 in both attributes and computed geometry.
  3. Reject invalid, zero, non-finite, or unbounded dimensions.
  4. Exclude interstitials, anchors, fixed or sticky frames, and ambiguous source matches.
  5. Set the outer iframe's width and height attributes and styles to the validated winner dimensions.
  6. Resize the immediate shell parent only under the same guarded conditions, matching the minimum behavior of Prebid's resizeRemoteCreative().
  7. Perform the resize only after port.postMessage() succeeds, with explicit rollback or no mutation on failed response delivery.
  8. Apply equivalent handling to APS, inline adm, and PBS Cache bridge responses where the TS bridge suppresses native Prebid handling.

The earlier guarded helper from the #998 work is a useful starting point, but it must be reconciled with current source matching, APS capability consumption, refresh lifecycle, diagnostics, and the publisher_native branch.

Regression coverage

Request ownership

Add deterministic tests for:

  1. Homepage: publisher auction starts, delayed scheduler runs, then publisher delivery occurs. GPT receives one first-impression request.
  2. SPA: publisher refresh renders, page-bids returns, and no second request or iframe removal occurs.
  3. Reverse race: TS claims an untouched slot first, and a later genuine publisher refresh is allowed only as the next impression.
  4. Same-generation slotRequested and slotRenderEnded both suppress late adInit() targeting and refresh.
  5. Prior-generation lifecycle state does not suppress a valid new navigation claim.
  6. An abandoned publisher claim permits exactly one per-slot fallback after expiry.
  7. A no-candidate response never replaces an already-rendered publisher slot.
  8. New TS-owned and both legacy and modern initial-load-disabled paths still request exactly once.
  9. Bundle and bootstrap behavior match.

Creative shell geometry

Use real Prebid Universal Creative in a browser fixture:

  1. Start with an outer GAM iframe and shell parent at 1x1.
  2. Deliver a TS APS winner through the bridge.
  3. Confirm the bridge authenticates the exact source and the APS creative loads.
  4. Assert that the outer iframe and required parent geometry become the winning dimensions, such as 728x90 or 300x250.
  5. Repeat for inline adm and cache-backed responses.
  6. Confirm wrong-source, replayed, ambiguous, interstitial, anchor, fixed, sticky, already-expanded, and invalid-size cases do not resize.
  7. Verify refresh and navigation cleanup do not let a stale completion resize a newer creative.
  8. Assert terminal renderer and diagnostics events, not merely runner-script load.

Acceptance criteria

  • A publisher first impression produces one GPT request and one committed creative.
  • Late initial-page or SPA adInit() work cannot overwrite a publisher-claimed or rendered slot.
  • ts_initial=1 appears only on a request that TS actually owns.
  • Skipped page-bids results do not alter hb_* targeting or creative mappings.
  • Legitimate TS-owned slots, including initial-load-disabled slots, still request exactly once.
  • A TS APS winner delivered through a 1x1 PUC shell becomes visible at its validated dimensions.
  • The inner APS creative, outer GAM iframe, and immediate shell parent all have expected geometry.
  • Publisher-native, direct /auction, interstitial, anchor, refresh, and navigation behavior do not regress.
  • Focused Vitest, real-PUC browser tests, TSJS typecheck/build, Rust bootstrap tests, formatting, and target-specific clippy suites pass.

Immediate mitigation and rollback

Until both fixes are deployed, disable server-side creative opportunities for the affected cohort:

[creative_opportunities]
enabled = false

This stops initial HTML and SPA page-bids template delivery while preserving direct /auction behavior and the publisher's normal Prebid flow. A narrower mitigation can remove the overlapping slot rules or page patterns, or disable the APS provider while keeping the duplicate-request risk visible for non-APS candidates.

Do not switch to publisher_native as an emergency workaround without the validation required by #999 and #1042.

Keep the creative-opportunities switch available through rollout. Monitor GPT request counts per slot and navigation, replacement generations, outer iframe geometry, visible-fill rate, and TS APS render acknowledgements. Roll back through the same configuration gate if duplicate requests or collapsed shells reappear.

Related work

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions