From 76c56263fa780dbd7b6d0f1fd9e32099516e9f6e Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 13:09:53 -0500 Subject: [PATCH 01/16] Prevent duplicate GPT slot requests --- .../src/integrations/gpt.rs | 29 +++ .../src/integrations/gpt_bootstrap.js | 160 +++++++++++-- .../trusted-server-js/lib/src/core/types.ts | 18 ++ .../lib/src/integrations/gpt/index.ts | 164 ++++++++++++- .../lib/test/integrations/gpt/ad_init.test.ts | 174 +++++++++++++- ...-24-prevent-duplicate-gpt-slot-requests.md | 219 ++++++++++++++++++ ...vent-duplicate-gpt-slot-requests-design.md | 165 +++++++++++++ 7 files changed, 900 insertions(+), 29 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md create mode 100644 docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index e21058a21..f53a21cf0 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -1222,6 +1222,35 @@ mod tests { ); } + #[test] + fn head_inserts_bootstrap_installs_inner_div_slot_handoff() { + let integration = GptIntegration::new(test_config()); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let combined = integration.head_inserts(&ctx).join(""); + assert!( + combined.contains("gptSlotHandoffs"), + "bootstrap should keep late publisher slot handoff state on window.tsjs" + ); + assert!( + combined.contains("__tsSlotHandoffPatched"), + "bootstrap should install idempotent GPT handoff wrappers" + ); + assert!( + combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), + "bootstrap should define the TS fallback on the actual inner div" + ); + assert!( + !combined.contains("actualDivId + \"-container\""), + "bootstrap must not define a competing outer-container GPT slot" + ); + } + #[test] fn head_inserts_bootstrap_guards_enable_services_with_idempotency_flag() { let config = test_config(); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index cc4c5c00c..0c2697357 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -42,6 +42,127 @@ pubads.__tsInitialLoadHooked = true; }); + function findSlotByElementId(pubads, elementId) { + var slots = pubads.getSlots ? pubads.getSlots() : []; + return ( + slots.find(function (slot) { + return slot.getSlotElementId() === elementId; + }) || null + ); + } + + function runHandoffInternal(callback) { + var wasInternal = ts.gptSlotHandoffInternal; + ts.gptSlotHandoffInternal = true; + try { + return callback(); + } finally { + ts.gptSlotHandoffInternal = wasInternal; + } + } + + // TS cannot wait an arbitrary amount of time for a framework to define a + // slot: publishers that never define one would render blank. Instead, TS + // defines its fallback on the actual inner div and aliases only a later + // publisher defineSlot() for that exact div to the same GPT slot. + function installSlotHandoff() { + window.googletag.cmd.push(function () { + var tag = window.googletag; + var pubads = tag.pubads && tag.pubads(); + if (!tag.defineSlot || !tag.display || !pubads) return; + + if (!tag.defineSlot.__tsSlotHandoffPatched) { + var originalDefineSlot = tag.defineSlot.bind(tag); + var patchedDefineSlot = function (adUnitPath, formats, elementId) { + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if (!ts.gptSlotHandoffInternal && handoff) { + var existingSlot = findSlotByElementId(pubads, elementId); + if (existingSlot) { + if (!handoff.publisherClaimed) { + handoff.publisherClaimed = true; + handoff.suppressPublisherDisplay = true; + handoff.suppressPublisherRefresh = + ts.gptInitialLoadDisabled === true; + ts.prevGptSlots = (ts.prevGptSlots || []).filter( + function (ownedSlot) { + return ownedSlot !== existingSlot; + }, + ); + if ( + handoff.gamUnitPath !== adUnitPath || + JSON.stringify(handoff.formats) !== JSON.stringify(formats) + ) { + ts.log && + ts.log.warn && + ts.log.warn( + "GPT slot handoff: publisher definition differs from TS configuration", + elementId, + ); + } + } + return existingSlot; + } + } + return originalDefineSlot(adUnitPath, formats, elementId); + }; + patchedDefineSlot.__tsSlotHandoffPatched = true; + tag.defineSlot = patchedDefineSlot; + } + + if (!tag.display.__tsSlotHandoffPatched) { + var originalDisplay = tag.display.bind(tag); + var patchedDisplay = function (elementId) { + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if ( + !ts.gptSlotHandoffInternal && + handoff && + handoff.suppressPublisherDisplay + ) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(elementId); + }; + patchedDisplay.__tsSlotHandoffPatched = true; + tag.display = patchedDisplay; + } + + if (!pubads.refresh.__tsSlotHandoffPatched) { + var originalRefresh = pubads.refresh.bind(pubads); + var patchedRefresh = function (requestedSlots) { + if (ts.gptSlotHandoffInternal) { + originalRefresh(requestedSlots); + return; + } + var slots = + requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); + if (!slots) { + originalRefresh(requestedSlots); + return; + } + var suppressed = false; + var remainingSlots = slots.filter(function (slot) { + var handoff = + ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; + if (!handoff || !handoff.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + }); + if (!suppressed) { + originalRefresh(requestedSlots); + } else if (remainingSlots.length > 0) { + originalRefresh(remainingSlots); + } + }; + patchedRefresh.__tsSlotHandoffPatched = true; + pubads.refresh = patchedRefresh; + } + }); + } + + installSlotHandoff(); + ts.adInit = function () { var slots = ts.adSlots || []; var bids = ts.bids || {}; @@ -88,15 +209,26 @@ }) || null; var tsOwned = false; if (!s) { - // Use outer container div for TS's slot when publisher hasn't defined - // theirs yet — keeps both slots on separate divs so publisher's - // later defineSlot on the inner div doesn't conflict. - var containerEl = document.getElementById(actualDivId + "-container"); - var slotDivId = containerEl ? containerEl.id : actualDivId; - s = googletag.defineSlot(slot.gam_unit_path, slot.formats, slotDivId); + // Define TS's fallback on the publisher's actual div. The scoped + // handoff wrapper returns this slot if the publisher defines it later. + s = runHandoffInternal(function () { + return googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + actualDivId, + ); + }); if (!s) return; s.addService(googletag.pubads()); tsOwned = true; + ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; + ts.gptSlotHandoffs[actualDivId] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; } Object.entries(slot.targeting || {}).forEach(function (e) { @@ -113,11 +245,9 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); - // Map both the inner div and the GPT slot's element ID (the - // "-container" div when TS defined the slot there) into divToSlotId. - // This bootstrap fires no beacons and registers no slotRenderEnded - // listener; the map is consumed by the bundle's render bridge (index.ts) - // once it loads, which reports the GPT slot element ID. + // Map the resolved inner div to the slot ID. This bootstrap fires no + // beacons and registers no slotRenderEnded listener; the map is consumed + // by the bundle's render bridge (index.ts) once it loads. divToSlotId[actualDivId] = slot.id; var slotElementId = s.getSlotElementId(); if (slotElementId && slotElementId !== actualDivId) { @@ -143,7 +273,9 @@ // impression. Runs after enableServices(); on SPA navigation services are // already enabled, so this runs unconditionally for new slots. slotsToDisplay.forEach(function (divId) { - googletag.display(divId); + runHandoffInternal(function () { + googletag.display(divId); + }); }); // Reused publisher-owned slots always need a refresh to pick up the // server-side targeting. TS-defined slots are fetched by display() above @@ -161,7 +293,9 @@ // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. ts.adInitRefreshInProgress = true; try { - googletag.pubads().refresh(slotsNeedingRefresh); + runHandoffInternal(function () { + googletag.pubads().refresh(slotsNeedingRefresh); + }); } finally { ts.adInitRefreshInProgress = false; } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 360e2aa49..cd25133d1 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -63,6 +63,20 @@ export interface AuctionBidData { debug_bid?: AuctionDebugBidData; } +/** + * Lifecycle state for a GPT slot TS created before its publisher declares it. + * + * Stored on `window.tsjs` so the head bootstrap and the full TSJS bundle share + * one handoff protocol. + */ +export interface GptSlotHandoff { + gamUnitPath: string; + formats: Array<[number, number]>; + publisherClaimed: boolean; + suppressPublisherDisplay: boolean; + suppressPublisherRefresh: boolean; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -121,6 +135,10 @@ export interface TsjsApi { * defined slots so they are not left blank. */ gptInitialLoadDisabled?: boolean; + /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ + gptSlotHandoffs?: Record; + /** True only while TS calls a GPT function that the handoff wrappers observe. */ + gptSlotHandoffInternal?: boolean; /** Guards SPA pushState hook installation. */ spaHookInstalled?: boolean; } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index ca4689684..8853997c3 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; +import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -445,9 +445,143 @@ function installInitialLoadDetector(ts: TsjsApi): void { }); } +interface HandoffPatchedFunction { + __tsSlotHandoffPatched?: boolean; +} + +function findGptSlotByElementId( + pubads: GoogleTagPubAdsService, + elementId: string +): GoogleTagSlot | undefined { + return pubads.getSlots?.().find((slot) => slot.getSlotElementId() === elementId); +} + +function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | undefined { + return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; +} + +function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { + const wasInternal = ts.gptSlotHandoffInternal; + ts.gptSlotHandoffInternal = true; + try { + return callback(); + } finally { + ts.gptSlotHandoffInternal = wasInternal; + } +} + +/** + * Reuse a TS-created inner-div slot when its publisher defines that div later. + * + * TS cannot wait an arbitrary amount of time for framework hydration: doing so + * would leave placements blank when no publisher slot is ever defined. Instead, + * TS creates its fallback on the publisher's actual div and aliases only a later + * `defineSlot()` for that exact div. The first duplicate publisher request is + * suppressed because TS has already issued the initial request with TS targeting. + */ +function installLatePublisherSlotHandoff(ts: TsjsApi): void { + const win = window as GptWindow; + const cmd = win.googletag?.cmd; + if (!cmd) return; + + cmd.push(() => { + const g = win.googletag; + const pubads = g?.pubads?.(); + if (!g?.defineSlot || !g.display || !pubads) return; + + const defineSlot = g.defineSlot; + if (!(defineSlot as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalDefineSlot = defineSlot.bind(g); + const patchedDefineSlot = ( + adUnitPath: string, + formats: Array, + elementId: string + ): GoogleTagSlot | null => { + const handoff = ts.gptSlotHandoffs?.[elementId]; + if (!ts.gptSlotHandoffInternal && handoff) { + const existingSlot = findGptSlotByElementId(pubads, elementId); + if (existingSlot) { + if (!handoff.publisherClaimed) { + handoff.publisherClaimed = true; + handoff.suppressPublisherDisplay = true; + handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; + ts.prevGptSlots = (ts.prevGptSlots ?? []).filter( + (ownedSlot) => ownedSlot !== existingSlot + ); + if ( + handoff.gamUnitPath !== adUnitPath || + JSON.stringify(handoff.formats) !== JSON.stringify(formats) + ) { + log.warn('GPT slot handoff: publisher definition differs from TS configuration', { + elementId, + tsGamUnitPath: handoff.gamUnitPath, + publisherGamUnitPath: adUnitPath, + }); + } + } + return existingSlot; + } + } + return originalDefineSlot(adUnitPath, formats, elementId); + }; + (patchedDefineSlot as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + g.defineSlot = patchedDefineSlot; + } + + const display = g.display; + if (!(display as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalDisplay = display.bind(g); + const patchedDisplay = (elementId: string): void => { + const handoff = ts.gptSlotHandoffs?.[elementId]; + if (!ts.gptSlotHandoffInternal && handoff?.suppressPublisherDisplay) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(elementId); + }; + (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + g.display = patchedDisplay; + } + + const refresh = pubads.refresh; + if (!(refresh as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalRefresh = refresh.bind(pubads); + const patchedRefresh = (requestedSlots?: GoogleTagSlot[]): void => { + if (ts.gptSlotHandoffInternal) { + originalRefresh(requestedSlots); + return; + } + + const slots = requestedSlots ?? pubads.getSlots?.(); + if (!slots) { + originalRefresh(requestedSlots); + return; + } + + let suppressed = false; + const remainingSlots = slots.filter((slot) => { + const handoff = handoffForSlot(ts, slot); + if (!handoff?.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + }); + if (!suppressed) { + originalRefresh(requestedSlots); + } else if (remainingSlots.length > 0) { + originalRefresh(remainingSlots); + } + }; + (patchedRefresh as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + pubads.refresh = patchedRefresh; + } + }); +} + export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); installInitialLoadDetector(ts); + installLatePublisherSlotHandoff(ts); ts.adInit = function () { const slots = ts.adSlots ?? []; // Snapshot bids at adInit() call time — correct for targeting setup. @@ -517,16 +651,23 @@ export function installTsAdInit(): void { if (existingSlot) { gptSlot = existingSlot; } else { - // Use outer container div for TS's slot when publisher hasn't defined - // theirs yet — keeps both slots on separate divs so publisher's - // later defineSlot on the inner div doesn't conflict. - const containerEl = document.getElementById(`${actualDivId}-container`); - const slotDivId = containerEl?.id ?? actualDivId; - const defined = g.defineSlot?.(slot.gam_unit_path, slot.formats, slotDivId); + // Define TS's fallback on the publisher's actual div. A late publisher + // defineSlot() for this div is handed the same slot by the scoped GPT + // wrapper, preventing a competing container-slot request. + const defined = withGptSlotHandoffInternal(ts, () => + g.defineSlot?.(slot.gam_unit_path, slot.formats, actualDivId) + ); if (!defined) return; defined.addService(g.pubads!()); gptSlot = defined; tsOwned = true; + (ts.gptSlotHandoffs ??= {})[actualDivId] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; } const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; @@ -541,9 +682,8 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); - // Map both inner div and container div → slot ID so slotRenderEnded - // (which reports the GPT slot's div, i.e. slotDivId/container) can look up - // the slot, while adm injection (which targets the inner div) also works. + // Map the resolved inner div to the slot ID so slotRenderEnded and ADM + // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; if (slotDivId2 !== actualDivId) divToSlotId[slotDivId2] = slot.id; const slotTargetingKeys = Object.keys(slot.targeting ?? {}); @@ -607,7 +747,7 @@ export function installTsAdInit(): void { // called without a matching display call") and misses its impression. // Must run after enableServices(); on SPA navigation services are already // enabled, so this runs unconditionally for any newly-defined slots. - slotsToDisplay.forEach((divId) => g.display?.(divId)); + slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); // Slots needing an explicit ad request via refresh(). Reused // publisher-owned slots always need one to pick up the just-applied @@ -630,7 +770,7 @@ export function installTsAdInit(): void { // the same slots still go through the wrapper normally. ts.adInitRefreshInProgress = true; try { - g.pubads!().refresh(slotsNeedingRefresh); + withGptSlotHandoffInternal(ts, () => g.pubads!().refresh(slotsNeedingRefresh)); } finally { ts.adInitRefreshInProgress = false; } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 4a6368768..f99d90fa7 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -145,12 +145,13 @@ describe('installTsAdInit', () => { getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue([]), }; + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), // Publisher has not defined this slot, so TS defines (owns) it. getSlots: vi.fn().mockReturnValue([]), addEventListener: vi.fn(), - refresh: vi.fn(), + refresh: nativeRefresh, }; const defineSlotMock = vi.fn().mockReturnValue(mockSlot); const displayMock = vi.fn(); @@ -184,7 +185,171 @@ describe('installTsAdInit', () => { expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); // TS-owned slots are displayed, not refreshed (refresh() no-ops for a slot // that was never displayed). - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); + }); + + it('hands a late publisher definition the TS inner-div slot without a second request', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const slots = new Map(); + const requests: string[] = []; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: vi.fn((requestedSlots?: FakeSlot[]) => { + (requestedSlots ?? Array.from(slots.values())).forEach((slot) => + requests.push(slot.getSlotElementId()) + ); + }), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); + const destroySlots = vi.fn(); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + destroySlots, + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + const publisherDefineSlot = googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot; + const publisherDisplay = googletag.display as unknown as (elementId: string) => void; + const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); + publisherSlot.addService(pubads); + publisherDisplay('div-atf-sidebar'); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['div-atf-sidebar']); + expect((window as TestWindow).tsjs!.prevGptSlots).toEqual([]); + + (window as TestWindow).tsjs!.adSlots = []; + (window as TestWindow).tsjs!.adInit!(); + expect(destroySlots).not.toHaveBeenCalled(); + }); + + it('suppresses only the claimed slot from the first disabled-load publisher refresh', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const slots = new Map(); + const requests: string[] = []; + let initialLoadDisabled = false; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: vi.fn((requestedSlots?: FakeSlot[]) => { + (requestedSlots ?? Array.from(slots.values())).forEach((slot) => + requests.push(slot.getSlotElementId()) + ); + }), + disableInitialLoad: vi.fn(() => { + initialLoadDisabled = true; + }), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((elementId: string) => { + if (!initialLoadDisabled) requests.push(elementId); + }); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + pubads.disableInitialLoad(); + (window as TestWindow).tsjs!.adInit!(); + + const publisherDefineSlot = googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot; + const publisherDisplay = googletag.display as unknown as (elementId: string) => void; + const publisherRefresh = pubads.refresh as unknown as () => void; + const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); + publisherSlot.addService(pubads); + publisherDisplay('div-atf-sidebar'); + slots.set('div-unrelated', makeSlot('div-unrelated')); + publisherRefresh(); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(requests.filter((elementId) => elementId === 'div-atf-sidebar')).toHaveLength(1); + expect(requests).toContain('div-unrelated'); }); it('refreshes TS-defined slots when the publisher disabled GPT initial load', async () => { @@ -197,12 +362,13 @@ describe('installTsAdInit', () => { getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue([]), }; + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), // Publisher has not defined this slot, so TS defines (owns) it. getSlots: vi.fn().mockReturnValue([]), addEventListener: vi.fn(), - refresh: vi.fn(), + refresh: nativeRefresh, disableInitialLoad: vi.fn(), }; const displayMock = vi.fn(); @@ -240,7 +406,7 @@ describe('installTsAdInit', () => { // The slot is still registered via display(), and additionally refreshed so // it actually requests an ad under disableInitialLoad(). expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); }); it('sets adInitRefreshInProgress only for the duration of the internal refresh', async () => { diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md new file mode 100644 index 000000000..4699e3c42 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -0,0 +1,219 @@ +# Prevent Duplicate GPT Slot Requests — Implementation Plan + +> **Status:** Implemented locally; production-like browser validation remains pending. +> +> **Spec:** `docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md` + +**Goal:** Ensure one GPT slot and one initial request per configured placement when +TS `adInit()` runs before a publisher later defines the placement's inner GPT div. + +**Architecture:** TS creates its fallback on the resolved inner div and records a +handoff claim. Narrow, idempotent wrappers around GPT's `defineSlot`, `display`, and +`pubads().refresh` alias a matching late publisher definition to that slot and +suppress only the duplicate initial publisher request. A successful handoff transfers +SPA-destruction ownership to the publisher. The head bootstrap and full TSJS bundle +share this runtime protocol through `window.tsjs`. + +**Primary files:** + +- `crates/trusted-server-js/lib/src/core/types.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` +- `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- `crates/trusted-server-core/src/integrations/gpt.rs` + +## Preconditions + +- [ ] Confirm with the issue owner that the intended late-owner behavior is slot + handoff (publisher receives the existing inner-div slot), not a hydration-delay + policy. +- [ ] Capture representative publisher call sequences for normal initial load and + `disableInitialLoad()` before changing wrappers. The expected sequence is + `defineSlot` → `addService` → `display`; initial-load-disabled pages additionally + call `refresh`. +- [ ] Establish an automated fake-GPT request counter: calling native `display` with + initial load enabled, or native `refresh` with initial load disabled, records a + request. Assertions must use this counter rather than only `getSlots()`. + +## Task 1: Add the shared handoff state and typed GPT wrapper surface + +**Files:** + +- Modify `crates/trusted-server-js/lib/src/core/types.ts` +- Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` + +- [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must + retain serializable lifecycle flags: TS-created, ownership-transferred, initial + request made, and one-shot publisher display/refresh suppression state. +- [ ] Add only the minimal optional/internal type surface needed for idempotence + markers on GPT functions and `pubads`. Do not weaken the public GPT types with + `any`. +- [ ] Add helper functions in `index.ts` to: + - find a live GPT slot by exact element ID; + - register and retrieve a claim; + - remove a transferred slot from `ts.prevGptSlots`; + - run an internal TS GPT call behind a short-lived guard; + - filter a requested refresh list (including no-argument/global refresh) by the + entries whose one-shot publisher refresh must be suppressed. +- [ ] Keep the registry on `window.tsjs`, not in module scope, so the bootstrap state + survives bundle loading. + +**Focused checks:** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index.test.ts +``` + +## Task 2: Install scoped idempotent handoff wrappers + +**File:** `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` + +- [ ] From the GPT command queue, install wrappers once GPT exposes the real methods. + Mark the wrapped functions/service so a later `installTsAdInit()` call or the + bootstrap-to-bundle handoff cannot stack wrappers. +- [ ] `defineSlot` wrapper: + - pass through TS-internal calls and IDs absent from the registry; + - for a late publisher call on a claimed inner div, find and return the existing + slot without calling native `defineSlot`; + - mark ownership transferred and remove that slot from `prevGptSlots` before + returning it; + - log, but do not create a second slot, if publisher arguments differ from the TS + configuration. +- [ ] `display` wrapper: consume the one permitted publisher post-handoff display + call without invoking native `display`; pass every other call through unchanged. +- [ ] `refresh` wrapper: when initial load was disabled, consume the one permitted + post-handoff refresh for each claimed slot. If called with no slot list, expand + `getSlots()`, filter only the claimed slots, and forward the remaining slots + explicitly. Preserve all unrelated refreshes. +- [ ] Ensure wrapper installation precedes the fallback definition path and does not + change existing publisher-owned-slot behavior. + +**Focused checks:** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt/ad_init.test.ts +``` + +## Task 3: Change fallback creation to the actual inner div + +**File:** `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` + +- [ ] Delete the `${actualDivId}-container` fallback selection. When no existing + publisher slot is found, call `defineSlot` with `actualDivId`. +- [ ] Register the handoff claim immediately after successful TS definition. +- [ ] Keep `display()` for TS-created slots; with initial load disabled, retain the + single TS `refresh()` that makes the required initial request. +- [ ] Simplify `divToSlotId` and `prevSlotTargetingKeys` to the actual inner div; + remove only mappings that existed exclusively for the container fallback. +- [ ] On SPA navigation, destroy only claims that remain TS-owned. A transferred + claim must participate in stale-targeting cleanup but never be passed to + `destroySlots()`. +- [ ] Retain exact match then prefix-based dynamic-ID lookup; do not interpolate + publisher-provided IDs into CSS selectors. + +## Task 4: Add request-level regression coverage for the full bundle + +**Files:** + +- Modify `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` if the + shared wrapper setup belongs there + +- [ ] Introduce a reusable fake GPT fixture that models slots by element ID and + records native `defineSlot`, `display`, `refresh`, and request events. Its + `getSlots()` result must update when a slot is defined so the test cannot pass by + asserting a stale static array. +- [ ] Add a failing regression test for the critical sequence: + 1. TS finds the inner div and runs `adInit()` before publisher setup; + 2. TS defines/displays the inner div and makes one request; + 3. publisher calls `defineSlot(innerDiv).addService(...); display(innerDiv)`; + 4. assert native `defineSlot` was called once, there is one slot, and there is one + request. +- [ ] Add the same sequence with `disableInitialLoad()`: TS display plus its refresh + makes one request; the publisher's first refresh cannot make a second request. +- [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert + the claimed slot is suppressed once and the unrelated slot is refreshed. +- [ ] Add an already publisher-owned test proving TS does not install a claim, applies + targeting, and refreshes that slot. +- [ ] Add a no-publisher test proving TS still creates, displays, and requests its + inner-div slot exactly once. +- [ ] Add a SPA handoff test: after late publisher claim, the next `adInit()` does not + destroy the transferred slot, clears old TS keys, and reapplies current-route + targeting. +- [ ] Retain or extend the dynamic prefix-ID test to prove a resolved runtime ID is + the handoff key. + +## Task 5: Mirror the runtime protocol in the head bootstrap + +**Files:** + +- Modify `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- Modify `crates/trusted-server-core/src/integrations/gpt.rs` + +- [ ] Port the same actual-inner-div fallback, registry names, lifecycle flags, and + idempotence markers to the plain-JavaScript bootstrap. +- [ ] Use the existing bootstrap `window.tsjs` properties exactly so `index.ts` can + adopt the initial claim after the bundle loads. +- [ ] Ensure its internal definition/display/refresh calls use the same guards as the + bundle; bootstrap must not transfer or suppress its own operations. +- [ ] Extend the `gpt.rs` head-insert tests to assert that the bootstrap contains the + inner-div handoff protocol and no longer contains the container fallback. +- [ ] Add an executable bootstrap behavior test if practical by evaluating the + injected script against the same fake GPT fixture. If the test setup cannot execute + the included asset without duplication, record that limitation and keep the Rust + source-contract assertion plus identical bundle lifecycle tests as the minimum + coverage. + +## Task 6: Validate, inspect, and ship + +- [ ] Run focused request-level tests: + + ```bash + cd crates/trusted-server-js/lib + npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index.test.ts + ``` + +- [ ] Run all TSJS tests and formatting: + + ```bash + cd crates/trusted-server-js/lib + npx vitest run + npm run format + ``` + +- [ ] Run the target-matched Rust tests that cover the embedded bootstrap, followed by + project formatting and linting: + + ```bash + cargo test-axum + cargo fmt --all -- --check + cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare + ``` + +- [ ] Before PR handoff, run the full required CI gates from `CLAUDE.md`, including + Fastly, Axum, Cloudflare, Spin, integration parity, JS build/tests/format, and docs + format. +- [ ] Review the diff specifically for bootstrap/bundle protocol drift and for any + use of container IDs in GPT slot creation. +- [ ] In a controlled production-like browser capture, verify one initial request for + each affected visible placement and independently verify an unrelated placement + remains requestable. +- [ ] Update issue #944 with the ownership-handoff decision, test evidence, and + browser-capture result. + +## Stop conditions + +Stop and return to design review instead of adding heuristics if any of these occur: + +- A publisher relies on a late `defineSlot` with materially different path or size + arguments and cannot accept the existing TS slot. +- The publisher's first initial-load-disabled refresh cannot be identified without + suppressing unrelated legitimate refreshes. +- A cross-bundle bootstrap handoff requires module-local identity that cannot be + represented safely through `window.tsjs`. +- Browser validation shows a second request despite native `defineSlot`/`display`/ + `refresh` suppression; capture the GPT event ordering before choosing another + strategy. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md new file mode 100644 index 000000000..770718199 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -0,0 +1,165 @@ +# Prevent Duplicate GPT Slot Requests — Design Specification + +## Problem + +When `tsjs.adInit()` executes before a publisher's framework later calls +`googletag.defineSlot()` for the same placement, TS currently defines and displays a +slot on the outer `-container` element. The publisher subsequently defines and +displays an inner-div slot. These are distinct GPT slots, so they make separate GAM +requests for one visible placement. + +The affected paths are deliberately duplicated today: + +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` is the full bundle + implementation used after the TSJS bundle loads. +- `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` is the head-injected + implementation that can make the initial request before the bundle loads. + +A fix must keep both implementations in sync. + +## Goals + +1. A configured placement has at most one initial GPT slot and ad request when TS + runs before a publisher defines its inner div. +2. Apply TS targeting and the `ts_initial=1` marker before that single initial + request. +3. Continue reusing a slot that the publisher has already defined. +4. Keep the TS-only fallback: if the publisher never defines the placement, TS still + displays it and makes exactly one initial request. +5. Preserve `disableInitialLoad()`, SPA targeting cleanup, and the rule that TS does + not destroy genuinely publisher-owned slots. +6. Keep dynamic div-ID prefix resolution intact. + +## Non-goals + +- Deduplicating by GAM ad-unit path. Multiple visible placements may validly share a + path. +- Changing publisher GAM configuration, line items, or refresh policy. +- Delaying the initial TS request while waiting an arbitrary amount of time for + framework hydration. A time-based grace period cannot distinguish a slow + publisher-owned slot from a placement that the publisher will never define. +- General interception of unrelated GPT slots. + +## Decision: one inner-div slot with late-definition handoff + +TS will define its fallback slot on the **actual inner div**, never on its outer +`-container` element. It will record a narrowly scoped handoff claim keyed by that +inner div ID. A `googletag.defineSlot` wrapper then recognizes a later publisher +request for that exact div and returns the existing TS slot rather than invoking +GPT's native `defineSlot` again. + +GPT requires a one-to-one slot-to-div relationship and documents that a slot should +be displayed only once. Sharing the initial inner-div slot therefore avoids both the +competing container slot and an invalid duplicate definition. + +### Lifecycle + +1. **Already publisher-owned** — `getSlots()` finds a slot for the resolved inner + div. TS applies targeting, records it as publisher-owned, and refreshes it as it + does today. +2. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, + enables services when needed, and displays it. When initial load is disabled, TS + performs its existing one explicit refresh. TS records this slot as TS-owned and + handoff-eligible. +3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded + inner-div claim, returns the existing slot, and transfers ownership: it removes + the slot from TS's future `destroySlots()` set. The publisher's setup continues + against that same slot. +4. **Publisher's first request call** — the wrapper suppresses the duplicate + publisher `display()` call. With `disableInitialLoad()`, it instead suppresses + only the publisher's first refresh for the transferred slot, because TS has + already issued the required initial refresh. For a no-argument/global refresh, + the wrapper must expand `getSlots()`, remove only the one-shot suppressed slots, + and forward the remaining slots explicitly so unrelated slots still refresh. +5. **Later refreshes and SPA navigation** — after the one-shot suppression is + consumed, publisher refreshes are untouched. On navigation, TS clears its + targeting from the shared slot and may reuse it for the next route; it must not + destroy a slot after ownership has transferred. + +The wrapper is not a global deduplicator. It only handles IDs present in TS's +handoff registry and must preserve native `defineSlot`, `display`, and `refresh` +behavior for every other placement. + +## Implementation shape + +### Shared runtime state + +Add a small, serializable `window.tsjs` registry that both initial implementations +can read after the bundle replaces the bootstrap implementation. It is keyed by the +resolved actual div ID and records at least: + +- whether TS created the slot and whether ownership has transferred; +- whether one publisher `display()` or initial-load-disabled `refresh()` remains to + suppress. + +Do not rely only on module-local state: the bootstrap can define the initial slot +before `index.ts` is loaded. Look up the live slot by element ID through +`pubads().getSlots()` when a wrapper needs it. + +Install idempotent markers on the wrapped GPT functions/services so the bootstrap and +bundle do not stack wrappers. Each wrapper must retain and call the original bound +function for non-claimed slots. Internal TS calls need a short-lived guard so the +wrappers do not mistake TS's own `defineSlot`, `display`, or `refresh` for a +publisher handoff. + +### Full bundle + +In `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: + +- Replace the container fallback with `actualDivId`. +- Add the typed handoff-registry state to `TsjsApi` in + `crates/trusted-server-js/lib/src/core/types.ts`. +- Install the idempotent `defineSlot`, `display`, and `pubads().refresh` handoff + wrappers from the GPT command queue before `adInit()` can create a fallback slot. +- When a late publisher definition is aliased to the existing slot, remove it from + `prevGptSlots` and mark it transferred before returning it. +- Keep targeting cleanup keyed by the real inner div. Remove the old dual + inner/container mappings because the slot element ID is now the inner div. + +### Head bootstrap + +Mirror the same ownership registry and wrappers in +`crates/trusted-server-core/src/integrations/gpt_bootstrap.js`. The bootstrap must +leave the registry and idempotence markers in `window.tsjs` so the full bundle adopts +rather than re-wraps or reclaims the initial slot. + +This duplication is intentional for now: the head bootstrap is needed to apply +server-side targeting before the normal bundle becomes available. The regression +suite must exercise both implementations' observable contract. + +## Compatibility rules and risks + +| Risk | Mitigation | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Publisher passes a different ad-unit path or sizes in its late `defineSlot` call | Return the existing claimed slot but log a diagnostic. Do not define a second slot. Treat the TS configuration and publisher configuration mismatch as an integration error to resolve separately. | +| Publisher invokes global `refresh()` after `disableInitialLoad()` | Filter the one-shot claimed slot from the expanded slot list and refresh all remaining slots. A no-argument refresh must not be silently dropped. | +| Publisher calls a legitimate refresh without an initial display | The one-shot suppression is consumed only immediately after a successful late handoff. Document and test the standard publisher sequence (`defineSlot` → `addService` → `display`, with `refresh` when initial load is disabled). Escalate unusual publisher lifecycle requirements rather than adding a time heuristic. | +| Publisher-owned slot is destroyed on SPA navigation | Transfer ownership synchronously in the `defineSlot` wrapper and remove the slot from `prevGptSlots`. | +| Bootstrap and bundle diverge | Give both paths the same black-box regression cases; retain a Rust source-contract assertion for bootstrap-specific sentinels. | +| A framework creates the inner element only after `adInit()` | TS still skips an absent element, as it does today; when the publisher owns that later-created slot it will not be duplicated. Supplying TS targeting to such a slot is a separate readiness problem, not part of this duplicate-request fix. | + +## Acceptance criteria + +- A late `defineSlot(innerDiv)` aliases the already-created inner-div TS slot; native + `defineSlot` is not called a second time for that placement. +- Request instrumentation records one initial request for the placement in normal and + initial-load-disabled modes. +- The late publisher `display()` (and its first initial-load-disabled refresh) cannot + create a second request, while unrelated slots retain their normal calls. +- Existing publisher slots are still reused and receive TS targeting. +- A slot that no publisher claims is displayed and requested once by TS. +- A transferred slot is absent from TS's SPA `destroySlots()` argument; targeting is + still cleared and reapplied correctly on the next route. +- Dynamic resolved div IDs work without constructing a CSS selector from the ID. +- Bootstrap and bundle paths pass the same ownership/request assertions. + +## Validation + +1. Add focused Vitest lifecycle tests with a fake GPT that records native + `defineSlot`, `display`, `refresh`, and synthetic request events. +2. Run the focused GPT test files, then the full TSJS Vitest suite and formatter. +3. Run the target-matched Rust test suite so the included bootstrap and its source + assertions compile and pass. +4. In a controlled browser capture, verify that one configured header and one + configured fixed placement each produce one initial slot request, while a distinct + in-content placement remains independently requestable. From b65e1aedd33440a281cf28443c0ffd6e46b9de02 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 14:14:54 -0500 Subject: [PATCH 02/16] Gate publisher GPT requests until targeting is ready --- .../src/integrations/gpt.rs | 7 +- .../src/integrations/gpt_bootstrap.js | 108 +++++++++++--- .../trusted-server-js/lib/src/core/types.ts | 11 ++ .../lib/src/integrations/gpt/index.ts | 133 ++++++++++++----- .../lib/test/integrations/gpt/ad_init.test.ts | 134 +++++++++++++++++- ...-24-prevent-duplicate-gpt-slot-requests.md | 50 ++++--- ...vent-duplicate-gpt-slot-requests-design.md | 60 +++++--- 7 files changed, 398 insertions(+), 105 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index f53a21cf0..20e4a9492 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -474,7 +474,8 @@ impl IntegrationHeadInjector for GptIntegration { /// ## Scroll / refresh handoff contract (Phase 1) /// /// `tsjs.adInit` handles **initial render only**: it wires server-side bid - /// targeting into GPT slots and refreshes them. Win/billing beacons fire + /// targeting into GPT slots and replays only publisher requests held until + /// that targeting was available. Win/billing beacons fire /// only from the TS render bridge in the JS bundle, where a matching /// Prebid Universal Creative request proves the TS creative rendered. /// It does **not** trigger refresh auctions or handle GPT slot refresh events. @@ -1241,6 +1242,10 @@ mod tests { combined.contains("__tsSlotHandoffPatched"), "bootstrap should install idempotent GPT handoff wrappers" ); + assert!( + combined.contains("gptInitialRequestGate") && combined.contains("pendingDisplays"), + "bootstrap should hold configured publisher requests until initial targeting is applied" + ); assert!( combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), "bootstrap should define the TS fallback on the actual inner div" diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 0c2697357..257da5908 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -10,8 +10,8 @@ // - Both implementations must set `window.tsjs.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a // subsequent call becomes a no-op. -// - `refresh()` is called only for the slots defined in this pass, -// never the global slot list. +// - `refresh()` is called only for TS-defined slots in this pass and +// publisher requests the initial gate held, never the global slot list. // // Only installed if `window.tsjs.adInit` isn't already defined. (function () { @@ -51,6 +51,45 @@ ); } + function configuredSlotForElementId(elementId) { + return (ts.adSlots || []).find(function (slot) { + return ( + slot.div_id && + (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && + !elementId.endsWith("-container") + ); + }); + } + + function initialRequestGate() { + if (!ts.gptInitialRequestGate) { + ts.gptInitialRequestGate = { + pendingDisplays: {}, + pendingRefreshes: {}, + released: false, + }; + } + return ts.gptInitialRequestGate; + } + + function takeInitialPublisherRequests(pubads) { + var gate = initialRequestGate(); + if (gate.released) return { displayIds: [], refreshSlots: [] }; + + gate.released = true; + var displayIds = Object.keys(gate.pendingDisplays); + var refreshIds = Object.keys(gate.pendingRefreshes); + gate.pendingDisplays = {}; + gate.pendingRefreshes = {}; + var slots = pubads.getSlots ? pubads.getSlots() : []; + return { + displayIds: displayIds, + refreshSlots: slots.filter(function (slot) { + return refreshIds.includes(slot.getSlotElementId()); + }), + }; + } + function runHandoffInternal(callback) { var wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -121,6 +160,15 @@ handoff.suppressPublisherDisplay = false; return; } + var gate = initialRequestGate(); + if ( + !ts.gptSlotHandoffInternal && + !gate.released && + configuredSlotForElementId(elementId) + ) { + gate.pendingDisplays[elementId] = true; + return; + } originalDisplay(elementId); }; patchedDisplay.__tsSlotHandoffPatched = true; @@ -141,13 +189,22 @@ return; } var suppressed = false; + var gate = initialRequestGate(); var remainingSlots = slots.filter(function (slot) { var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; - if (!handoff || !handoff.suppressPublisherRefresh) return true; - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; + if (handoff && handoff.suppressPublisherRefresh) { + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + } + var elementId = slot.getSlotElementId(); + if (!gate.released && configuredSlotForElementId(elementId)) { + gate.pendingRefreshes[elementId] = true; + suppressed = true; + return false; + } + return true; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -172,13 +229,15 @@ // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. var newSlots = []; - // Publisher-owned slots TS reused — refreshed to pick up server-side - // targeting. The publisher already display()ed these. + // Publisher-owned slots can be refreshed on SPA navigation. On initial + // load their first request is held until the targeting below is applied. var slotsToRefresh = []; + var isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself. GPT requires display() to // register/render a freshly-defined slot; refresh() alone no-ops for a // slot that was never displayed, so these are display()ed instead. var slotsToDisplay = []; + var hasAppliedTargeting = false; slots.forEach(function (slot) { // Resolve actual div ID: exact match first, then safe prefix scan. // div_id in config may be a stable prefix (e.g. "ad-header-0-") when @@ -245,6 +304,7 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); + hasAppliedTargeting = true; // Map the resolved inner div to the slot ID. This bootstrap fires no // beacons and registers no slotRenderEnded listener; the map is consumed // by the bundle's render bridge (index.ts) once it loads. @@ -257,34 +317,36 @@ newSlots.push(s); var displayId = s.getSlotElementId() || actualDivId; slotsToDisplay.push(displayId); - } else { + } else if (!isInitialAdInit) { slotsToRefresh.push(s); } }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; - if (!ts.servicesEnabled) { + var heldPublisherRequests = isInitialAdInit + ? takeInitialPublisherRequests(googletag.pubads()) + : { displayIds: [], refreshSlots: [] }; + ts.gptInitialAdInitCompleted = true; + if (!ts.servicesEnabled && (hasAppliedTargeting || heldPublisherRequests.displayIds.length > 0 || heldPublisherRequests.refreshSlots.length > 0)) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); ts.servicesEnabled = true; } - // Register and render TS-defined slots. GPT requires display() for a - // freshly-defined slot; without it the slot no-ops and misses its - // impression. Runs after enableServices(); on SPA navigation services are - // already enabled, so this runs unconditionally for new slots. - slotsToDisplay.forEach(function (divId) { + // Register/render TS-defined slots and replay publisher displays held + // before server-side bids were available. The replay is the publisher's + // one initial request, not a later TS refresh. + heldPublisherRequests.displayIds.concat(slotsToDisplay).forEach(function (divId) { runHandoffInternal(function () { googletag.display(divId); }); }); - // Reused publisher-owned slots always need a refresh to pick up the - // server-side targeting. TS-defined slots are fetched by display() above - // unless the publisher disabled initial load, in which case display() only - // registers them and refresh() must request the ad — otherwise they render - // blank. Only add them in that case to avoid double-requesting. - var slotsNeedingRefresh = ts.gptInitialLoadDisabled - ? slotsToRefresh.concat(newSlots) - : slotsToRefresh; + // Replay held publisher refreshes after targeting. On SPA navigation TS + // refreshes reused publisher slots as before; TS-defined slots need a + // refresh only when initial load was disabled. + var slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( + slotsToRefresh, + ts.gptInitialLoadDisabled ? newSlots : [], + ); if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied // server-side targeting to GAM. If slim-Prebid has already wrapped diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index cd25133d1..2ced11086 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -77,6 +77,13 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } +/** Publisher requests held until initial TS targeting has been applied. */ +export interface GptInitialRequestGate { + pendingDisplays: Record; + pendingRefreshes: Record; + released: boolean; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -137,6 +144,10 @@ export interface TsjsApi { gptInitialLoadDisabled?: boolean; /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ gptSlotHandoffs?: Record; + /** Publisher initial requests held until TS has applied server-side targeting. */ + gptInitialRequestGate?: GptInitialRequestGate; + /** True after the first page-load `adInit()` has handled publisher slots. */ + gptInitialAdInitCompleted?: boolean; /** True only while TS calls a GPT function that the handoff wrappers observe. */ gptSlotHandoffInternal?: boolean; /** Guards SPA pushState hook installation. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 8853997c3..effae7ada 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,5 +1,11 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; +import type { + AuctionSlot, + AuctionBidData, + GptInitialRequestGate, + GptSlotHandoff, + TsjsApi, +} from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -460,6 +466,41 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } +function configuredSlotForElementId(ts: TsjsApi, elementId: string): AuctionSlot | undefined { + return ts.adSlots?.find( + (slot) => + !!slot.div_id && + (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && + !elementId.endsWith('-container') + ); +} + +function initialRequestGate(ts: TsjsApi): GptInitialRequestGate { + return (ts.gptInitialRequestGate ??= { + pendingDisplays: {}, + pendingRefreshes: {}, + released: false, + }); +} + +function takeInitialPublisherRequests( + ts: TsjsApi, + pubads: GoogleTagPubAdsService +): { displayIds: string[]; refreshSlots: GoogleTagSlot[] } { + const gate = initialRequestGate(ts); + if (gate.released) return { displayIds: [], refreshSlots: [] }; + + gate.released = true; + const displayIds = Object.keys(gate.pendingDisplays); + const refreshIds = new Set(Object.keys(gate.pendingRefreshes)); + gate.pendingDisplays = {}; + gate.pendingRefreshes = {}; + const refreshSlots = (pubads.getSlots?.() ?? []).filter((slot) => + refreshIds.has(slot.getSlotElementId()) + ); + return { displayIds, refreshSlots }; +} + function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -537,6 +578,15 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { handoff.suppressPublisherDisplay = false; return; } + const gate = initialRequestGate(ts); + if ( + !ts.gptSlotHandoffInternal && + !gate.released && + configuredSlotForElementId(ts, elementId) + ) { + gate.pendingDisplays[elementId] = true; + return; + } originalDisplay(elementId); }; (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; @@ -559,12 +609,21 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { } let suppressed = false; + const gate = initialRequestGate(ts); const remainingSlots = slots.filter((slot) => { const handoff = handoffForSlot(ts, slot); - if (!handoff?.suppressPublisherRefresh) return true; - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; + if (handoff?.suppressPublisherRefresh) { + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + } + const elementId = slot.getSlotElementId(); + if (!gate.released && configuredSlotForElementId(ts, elementId)) { + gate.pendingRefreshes[elementId] = true; + suppressed = true; + return false; + } + return true; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -601,14 +660,17 @@ export function installTsAdInit(): void { // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. const newSlots: GoogleTagSlot[] = []; - // Publisher-owned slots TS reused — refreshed to pick up server-side - // targeting. The publisher already display()ed these. + // Publisher-owned slots can be refreshed on SPA navigation. On initial + // load their first request is held by the head-installed gate and replayed + // only after the targeting below has been applied. const slotsToRefresh: GoogleTagSlot[] = []; + const isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself this call. GPT requires a // display() call to register/render a freshly-defined slot; refresh() // alone no-ops for a slot that was never displayed, so these are // display()ed instead of refreshed. const slotsToDisplay: string[] = []; + let hasAppliedTargeting = false; const divToSlotId: Record = {}; const prevSlotTargetingKeys = ts.prevSlotTargetingKeys ?? {}; const nextSlotTargetingKeys: Record = {}; @@ -682,6 +744,7 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); + hasAppliedTargeting = true; // Map the resolved inner div to the slot ID so slotRenderEnded and ADM // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; @@ -692,7 +755,7 @@ export function installTsAdInit(): void { if (tsOwned) { newSlots.push(gptSlot); slotsToDisplay.push(slotDivId2); - } else { + } else if (!isInitialAdInit) { slotsToRefresh.push(gptSlot); } @@ -709,11 +772,20 @@ export function installTsAdInit(): void { // Replace (not merge) so destroyed slots from previous navigation don't linger. ts.divToSlotId = divToSlotId; ts.prevSlotTargetingKeys = nextSlotTargetingKeys; - - // Whether this call produced any TS slot to render. A gated page-bids - // response (auction kill switch or consent denial) returns no slots, so - // the loops above leave these empty. - const hasRenderableWork = slotsToDisplay.length > 0 || slotsToRefresh.length > 0; + const heldPublisherRequests = isInitialAdInit + ? takeInitialPublisherRequests(ts, g.pubads!()) + : { displayIds: [], refreshSlots: [] }; + ts.gptInitialAdInitCompleted = true; + + // Whether this call produced a request to make. A gated page-bids response + // (auction kill switch or consent denial) returns no slots, so the loops + // above leave these empty. + const hasRenderableWork = + slotsToDisplay.length > 0 || + slotsToRefresh.length > 0 || + heldPublisherRequests.displayIds.length > 0 || + heldPublisherRequests.refreshSlots.length > 0 || + hasAppliedTargeting; // enableSingleRequest and enableServices must only be called once per page // load. Skip activating GPT services when TS has nothing to display or @@ -742,25 +814,22 @@ export function installTsAdInit(): void { }); } - // Register and render TS-defined slots. GPT requires display() for a - // freshly-defined slot — without it the slot no-ops ("defineSlot was - // called without a matching display call") and misses its impression. - // Must run after enableServices(); on SPA navigation services are already - // enabled, so this runs unconditionally for any newly-defined slots. - slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); - - // Slots needing an explicit ad request via refresh(). Reused - // publisher-owned slots always need one to pick up the just-applied - // server-side targeting. TS-defined slots are normally fetched by the - // display() above — but when the publisher called - // pubads().disableInitialLoad(), display() only registers the slot and the - // ad request must come from refresh(). Without this, a TS-owned - // first-impression slot renders blank on initial-load-disabled pages. Only - // add them in that case; otherwise display() + refresh() would - // double-request the impression. - const slotsNeedingRefresh = ts.gptInitialLoadDisabled - ? slotsToRefresh.concat(newSlots) - : slotsToRefresh; + // Register/render TS-defined slots and replay publisher displays held + // before the server-side bids were available. The gate is released only + // after targeting has been applied, so this remains the publisher's one + // initial request rather than a later TS refresh. + heldPublisherRequests.displayIds + .concat(slotsToDisplay) + .forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); + + // Slots needing an explicit ad request via refresh(). Publisher refreshes + // held on the initial page load are replayed after targeting. On SPA + // navigation TS refreshes reused publisher slots as before. TS-defined + // slots need a refresh only when the publisher disabled initial load. + const slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( + slotsToRefresh, + ts.gptInitialLoadDisabled ? newSlots : [] + ); if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index f99d90fa7..bdfc7123b 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -77,7 +77,7 @@ describe('installTsAdInit', () => { document.getElementById("ad'prefix-real")?.remove(); }); - it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { + it('reads window.tsjs.bids synchronously without re-requesting an existing publisher slot', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -133,11 +133,130 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); fetchSpy.mockRestore(); }); + it('holds and replays a publisher display once after applying initial targeting', async () => { + const requests: string[] = []; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); + const nativeRefresh = vi.fn(); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: nativeRefresh, + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ], + bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + googletag.display('div-atf-sidebar'); + expect(nativeDisplay).not.toHaveBeenCalled(); + + (window as TestWindow).tsjs!.adInit!(); + + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['div-atf-sidebar']); + expect(nativeRefresh).not.toHaveBeenCalled(); + }); + + it('holds and replays a disabled-load publisher refresh once after targeting', async () => { + const requests: string[] = []; + let initialLoadDisabled = false; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const unrelatedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), + }; + const nativeDisplay = vi.fn((elementId: string) => { + if (!initialLoadDisabled) requests.push(elementId); + }); + const nativeRefresh = vi.fn((slots?: Array) => { + (slots ?? [mockSlot, unrelatedSlot]).forEach((slot) => + requests.push(slot.getSlotElementId()) + ); + }); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot, unrelatedSlot]), + addEventListener: vi.fn(), + refresh: nativeRefresh, + disableInitialLoad: vi.fn(() => { + initialLoadDisabled = true; + }), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + mockPubads.disableInitialLoad(); + googletag.display('div-atf-sidebar'); + mockPubads.refresh(); + expect(nativeDisplay).not.toHaveBeenCalled(); + expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); + + (window as TestWindow).tsjs!.adInit!(); + + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(nativeRefresh).toHaveBeenCalledTimes(2); + expect(nativeRefresh).toHaveBeenLastCalledWith([mockSlot]); + expect(requests).toEqual(['div-unrelated', 'div-atf-sidebar']); + }); + it('displays TS-defined slots and does not include them in refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -444,6 +563,9 @@ describe('installTsAdInit', () => { }, ], bids: {}, + // This models a route update: existing publisher slots are refreshed on + // SPA navigation, while initial-load publisher slots are not re-requested. + gptInitialAdInitCompleted: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -589,7 +711,7 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(mockPubads.refresh).not.toHaveBeenCalled(); }); it('does not fire win/billing beacons from slotRenderEnded targeting alone', async () => { @@ -914,7 +1036,7 @@ describe('installTsAdInit', () => { delete (window as TestWindow).apstag; }); - it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { + it('does not re-request an existing publisher slot when tsjs.bids is empty', async () => { const emptyTestSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -953,7 +1075,7 @@ describe('installTsAdInit', () => { installTsAdInit(); (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); }); it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { @@ -996,7 +1118,7 @@ describe('installTsAdInit', () => { installTsAdInit(); expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(mockPubads.refresh).toHaveBeenCalledWith([dynamicSlot]); + expect(mockPubads.refresh).not.toHaveBeenCalled(); }); }); diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md index 4699e3c42..24879c8e4 100644 --- a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -1,6 +1,7 @@ # Prevent Duplicate GPT Slot Requests — Implementation Plan -> **Status:** Implemented locally; production-like browser validation remains pending. +> **Status:** Revised after production-like validation found a second request for +> publisher-owned slots when hydration-safe scheduling defers `adInit()`. > > **Spec:** `docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md` @@ -8,10 +9,11 @@ TS `adInit()` runs before a publisher later defines the placement's inner GPT div. **Architecture:** TS creates its fallback on the resolved inner div and records a -handoff claim. Narrow, idempotent wrappers around GPT's `defineSlot`, `display`, and -`pubads().refresh` alias a matching late publisher definition to that slot and -suppress only the duplicate initial publisher request. A successful handoff transfers -SPA-destruction ownership to the publisher. The head bootstrap and full TSJS bundle +handoff claim. Narrow, idempotent GPT wrappers also gate a configured publisher +slot's first `display`/`refresh` while the server auction result is unavailable. At +`adInit()`, TS applies targeting to that same publisher slot and replays the held +native request once; it does not issue a second TS refresh. Late-definition handoff +and SPA ownership transfer remain unchanged. The head bootstrap and full TSJS bundle share this runtime protocol through `window.tsjs`. **Primary files:** @@ -43,9 +45,9 @@ share this runtime protocol through `window.tsjs`. - Modify `crates/trusted-server-js/lib/src/core/types.ts` - Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` -- [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must - retain serializable lifecycle flags: TS-created, ownership-transferred, initial - request made, and one-shot publisher display/refresh suppression state. +- [ ] Add `TsjsApi` state for both the div-ID-keyed late-handoff registry and an + initial publisher-request gate. The gate records held display/refresh IDs and + a released marker so it applies only once per page load. - [ ] Add only the minimal optional/internal type surface needed for idempotence markers on GPT functions and `pubads`. Do not weaken the public GPT types with `any`. @@ -81,14 +83,14 @@ npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index returning it; - log, but do not create a second slot, if publisher arguments differ from the TS configuration. -- [ ] `display` wrapper: consume the one permitted publisher post-handoff display - call without invoking native `display`; pass every other call through unchanged. -- [ ] `refresh` wrapper: when initial load was disabled, consume the one permitted - post-handoff refresh for each claimed slot. If called with no slot list, expand - `getSlots()`, filter only the claimed slots, and forward the remaining slots - explicitly. Preserve all unrelated refreshes. -- [ ] Ensure wrapper installation precedes the fallback definition path and does not - change existing publisher-owned-slot behavior. +- [ ] `display` wrapper: consume the one permitted post-handoff display; before the + first `adInit()`, also hold a configured publisher slot's native display. +- [ ] `refresh` wrapper: consume one permitted post-handoff disabled-load refresh; + before the first `adInit()`, hold configured publisher refreshes and forward + all unrelated slots explicitly, including a no-argument/global refresh. +- [ ] At initial `adInit()`, apply targeting then replay held native calls; never + refresh an existing publisher-owned slot that has already requested. +- [ ] Ensure wrapper installation precedes publisher setup and fallback creation. **Focused checks:** @@ -136,8 +138,9 @@ npx vitest run test/integrations/gpt/ad_init.test.ts makes one request; the publisher's first refresh cannot make a second request. - [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert the claimed slot is suppressed once and the unrelated slot is refreshed. -- [ ] Add an already publisher-owned test proving TS does not install a claim, applies - targeting, and refreshes that slot. +- [ ] Add publisher-owned tests proving TS holds normal and disabled-load initial + requests, applies targeting, and replays exactly one native request. Also prove + an already-requested publisher slot is not refreshed again. - [ ] Add a no-publisher test proving TS still creates, displays, and requests its inner-div slot exactly once. - [ ] Add a SPA handoff test: after late publisher claim, the next `adInit()` does not @@ -153,8 +156,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts - Modify `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` - Modify `crates/trusted-server-core/src/integrations/gpt.rs` -- [ ] Port the same actual-inner-div fallback, registry names, lifecycle flags, and - idempotence markers to the plain-JavaScript bootstrap. +- [ ] Port the same initial-request gate, actual-inner-div fallback, registry names, + lifecycle flags, and idempotence markers to the plain-JavaScript bootstrap. - [ ] Use the existing bootstrap `window.tsjs` properties exactly so `index.ts` can adopt the initial claim after the bundle loads. - [ ] Ensure its internal definition/display/refresh calls use the same guards as the @@ -198,9 +201,10 @@ npx vitest run test/integrations/gpt/ad_init.test.ts format. - [ ] Review the diff specifically for bootstrap/bundle protocol drift and for any use of container IDs in GPT slot creation. -- [ ] In a controlled production-like browser capture, verify one initial request for - each affected visible placement and independently verify an unrelated placement - remains requestable. +- [ ] In a controlled production-like browser capture with the hydration-safe + deferred `adInit()` path, verify one targeted initial request for each affected + visible placement and independently verify an unrelated placement remains + requestable. - [ ] Update issue #944 with the ownership-handoff decision, test evidence, and browser-capture result. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index 770718199..c94e25b2c 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -8,6 +8,12 @@ slot on the outer `-container` element. The publisher subsequently defines and displays an inner-div slot. These are distinct GPT slots, so they make separate GAM requests for one visible placement. +A production deployment also exposed the inverse ordering: the hydration-safe +body bootstrap delays `adInit()` until after `window.load`, so publisher code can +already have defined **and requested** its inner-div slot. In that ordering, +reusing the slot and refreshing it applies targeting too late and creates a second +SRA request. + The affected paths are deliberately duplicated today: - `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` is the full bundle @@ -40,7 +46,7 @@ A fix must keep both implementations in sync. publisher-owned slot from a placement that the publisher will never define. - General interception of unrelated GPT slots. -## Decision: one inner-div slot with late-definition handoff +## Decision: inner-div fallback, late-definition handoff, and an initial request gate TS will define its fallback slot on the **actual inner div**, never on its outer `-container` element. It will record a narrowly scoped handoff claim keyed by that @@ -54,31 +60,36 @@ competing container slot and an invalid duplicate definition. ### Lifecycle -1. **Already publisher-owned** — `getSlots()` finds a slot for the resolved inner - div. TS applies targeting, records it as publisher-owned, and refreshes it as it - does today. -2. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, +1. **Publisher-owned before bids are available** — a scoped head-installed gate + holds the configured placement's first publisher `display()` or `refresh()`. + At `adInit()`, TS finds the publisher slot, applies targeting, and replays that + held native call exactly once. It never adds a second TS refresh. +2. **Already-requested publisher-owned slot** — if a configured publisher request + was not observed by the gate, TS applies targeting for later lifecycle work but + does not re-request the already-served initial impression. +3. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, enables services when needed, and displays it. When initial load is disabled, TS performs its existing one explicit refresh. TS records this slot as TS-owned and handoff-eligible. -3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded +4. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded inner-div claim, returns the existing slot, and transfers ownership: it removes the slot from TS's future `destroySlots()` set. The publisher's setup continues against that same slot. -4. **Publisher's first request call** — the wrapper suppresses the duplicate +5. **Publisher's first request call after a late handoff** — the wrapper suppresses the duplicate publisher `display()` call. With `disableInitialLoad()`, it instead suppresses only the publisher's first refresh for the transferred slot, because TS has already issued the required initial refresh. For a no-argument/global refresh, the wrapper must expand `getSlots()`, remove only the one-shot suppressed slots, and forward the remaining slots explicitly so unrelated slots still refresh. -5. **Later refreshes and SPA navigation** — after the one-shot suppression is +6. **Later refreshes and SPA navigation** — after the one-shot suppression is consumed, publisher refreshes are untouched. On navigation, TS clears its targeting from the shared slot and may reuse it for the next route; it must not destroy a slot after ownership has transferred. -The wrapper is not a global deduplicator. It only handles IDs present in TS's -handoff registry and must preserve native `defineSlot`, `display`, and `refresh` -behavior for every other placement. +The wrappers are not global deduplicators. The initial request gate only holds the +first `display`/`refresh` for a configured placement until initial TS targeting is +available; handoff suppression only handles IDs present in TS's handoff registry. +All unrelated GPT calls retain native behavior. ## Implementation shape @@ -89,8 +100,10 @@ can read after the bundle replaces the bootstrap implementation. It is keyed by resolved actual div ID and records at least: - whether TS created the slot and whether ownership has transferred; -- whether one publisher `display()` or initial-load-disabled `refresh()` remains to - suppress. +- whether one post-handoff publisher `display()` or initial-load-disabled `refresh()` + remains to suppress; +- configured publisher displays and refreshes held before initial targeting, plus a + released marker so the gate applies only once per page load. Do not rely only on module-local state: the bootstrap can define the initial slot before `index.ts` is loaded. Look up the live slot by element ID through @@ -109,8 +122,12 @@ In `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: - Replace the container fallback with `actualDivId`. - Add the typed handoff-registry state to `TsjsApi` in `crates/trusted-server-js/lib/src/core/types.ts`. -- Install the idempotent `defineSlot`, `display`, and `pubads().refresh` handoff - wrappers from the GPT command queue before `adInit()` can create a fallback slot. +- Install idempotent `defineSlot`, `display`, and `pubads().refresh` wrappers from + the GPT command queue before publisher setup. The latter two also hold the first + configured publisher request until `adInit()` has applied initial targeting. +- Replay held initial publisher displays/refreshes after targeting rather than + refreshing an existing publisher-owned slot. Retain reused-slot refreshes only for + later SPA navigations. - When a late publisher definition is aliased to the existing slot, remove it from `prevGptSlots` and mark it transferred before returning it. - Keep targeting cleanup keyed by the real inner div. Remove the old dual @@ -132,7 +149,7 @@ suite must exercise both implementations' observable contract. | Risk | Mitigation | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Publisher passes a different ad-unit path or sizes in its late `defineSlot` call | Return the existing claimed slot but log a diagnostic. Do not define a second slot. Treat the TS configuration and publisher configuration mismatch as an integration error to resolve separately. | -| Publisher invokes global `refresh()` after `disableInitialLoad()` | Filter the one-shot claimed slot from the expanded slot list and refresh all remaining slots. A no-argument refresh must not be silently dropped. | +| Publisher invokes global `refresh()` before bids after `disableInitialLoad()` | Filter only configured held slots from the expanded list, forward unrelated slots immediately, then replay the held slots once after targeting. A no-argument refresh must not be silently dropped. | | Publisher calls a legitimate refresh without an initial display | The one-shot suppression is consumed only immediately after a successful late handoff. Document and test the standard publisher sequence (`defineSlot` → `addService` → `display`, with `refresh` when initial load is disabled). Escalate unusual publisher lifecycle requirements rather than adding a time heuristic. | | Publisher-owned slot is destroyed on SPA navigation | Transfer ownership synchronously in the `defineSlot` wrapper and remove the slot from `prevGptSlots`. | | Bootstrap and bundle diverge | Give both paths the same black-box regression cases; retain a Rust source-contract assertion for bootstrap-specific sentinels. | @@ -146,7 +163,9 @@ suite must exercise both implementations' observable contract. initial-load-disabled modes. - The late publisher `display()` (and its first initial-load-disabled refresh) cannot create a second request, while unrelated slots retain their normal calls. -- Existing publisher slots are still reused and receive TS targeting. +- A configured publisher slot whose first request occurs before the deferred + `adInit()` is held, receives TS targeting, and makes exactly one replayed native + request. An already-requested publisher slot is never re-requested by TS. - A slot that no publisher claims is displayed and requested once by TS. - A transferred slot is absent from TS's SPA `destroySlots()` argument; targeting is still cleared and reapplied correctly on the next route. @@ -160,6 +179,7 @@ suite must exercise both implementations' observable contract. 2. Run the focused GPT test files, then the full TSJS Vitest suite and formatter. 3. Run the target-matched Rust test suite so the included bootstrap and its source assertions compile and pass. -4. In a controlled browser capture, verify that one configured header and one - configured fixed placement each produce one initial slot request, while a distinct - in-content placement remains independently requestable. +4. In a controlled browser capture with deferred `adInit()`, verify that one + configured header and one configured fixed placement each produce one initial + slot request with TS targeting, while a distinct in-content placement remains + independently requestable. From f3c1e6bcbefcfc20144d874945d5277587612de1 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 16:17:31 -0500 Subject: [PATCH 03/16] Revert "Gate publisher GPT requests until targeting is ready" This reverts commit b65e1aedd33440a281cf28443c0ffd6e46b9de02. --- .../src/integrations/gpt.rs | 7 +- .../src/integrations/gpt_bootstrap.js | 108 +++----------- .../trusted-server-js/lib/src/core/types.ts | 11 -- .../lib/src/integrations/gpt/index.ts | 133 +++++------------ .../lib/test/integrations/gpt/ad_init.test.ts | 134 +----------------- ...-24-prevent-duplicate-gpt-slot-requests.md | 50 +++---- ...vent-duplicate-gpt-slot-requests-design.md | 60 +++----- 7 files changed, 105 insertions(+), 398 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 20e4a9492..f53a21cf0 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -474,8 +474,7 @@ impl IntegrationHeadInjector for GptIntegration { /// ## Scroll / refresh handoff contract (Phase 1) /// /// `tsjs.adInit` handles **initial render only**: it wires server-side bid - /// targeting into GPT slots and replays only publisher requests held until - /// that targeting was available. Win/billing beacons fire + /// targeting into GPT slots and refreshes them. Win/billing beacons fire /// only from the TS render bridge in the JS bundle, where a matching /// Prebid Universal Creative request proves the TS creative rendered. /// It does **not** trigger refresh auctions or handle GPT slot refresh events. @@ -1242,10 +1241,6 @@ mod tests { combined.contains("__tsSlotHandoffPatched"), "bootstrap should install idempotent GPT handoff wrappers" ); - assert!( - combined.contains("gptInitialRequestGate") && combined.contains("pendingDisplays"), - "bootstrap should hold configured publisher requests until initial targeting is applied" - ); assert!( combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), "bootstrap should define the TS fallback on the actual inner div" diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 257da5908..0c2697357 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -10,8 +10,8 @@ // - Both implementations must set `window.tsjs.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a // subsequent call becomes a no-op. -// - `refresh()` is called only for TS-defined slots in this pass and -// publisher requests the initial gate held, never the global slot list. +// - `refresh()` is called only for the slots defined in this pass, +// never the global slot list. // // Only installed if `window.tsjs.adInit` isn't already defined. (function () { @@ -51,45 +51,6 @@ ); } - function configuredSlotForElementId(elementId) { - return (ts.adSlots || []).find(function (slot) { - return ( - slot.div_id && - (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && - !elementId.endsWith("-container") - ); - }); - } - - function initialRequestGate() { - if (!ts.gptInitialRequestGate) { - ts.gptInitialRequestGate = { - pendingDisplays: {}, - pendingRefreshes: {}, - released: false, - }; - } - return ts.gptInitialRequestGate; - } - - function takeInitialPublisherRequests(pubads) { - var gate = initialRequestGate(); - if (gate.released) return { displayIds: [], refreshSlots: [] }; - - gate.released = true; - var displayIds = Object.keys(gate.pendingDisplays); - var refreshIds = Object.keys(gate.pendingRefreshes); - gate.pendingDisplays = {}; - gate.pendingRefreshes = {}; - var slots = pubads.getSlots ? pubads.getSlots() : []; - return { - displayIds: displayIds, - refreshSlots: slots.filter(function (slot) { - return refreshIds.includes(slot.getSlotElementId()); - }), - }; - } - function runHandoffInternal(callback) { var wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -160,15 +121,6 @@ handoff.suppressPublisherDisplay = false; return; } - var gate = initialRequestGate(); - if ( - !ts.gptSlotHandoffInternal && - !gate.released && - configuredSlotForElementId(elementId) - ) { - gate.pendingDisplays[elementId] = true; - return; - } originalDisplay(elementId); }; patchedDisplay.__tsSlotHandoffPatched = true; @@ -189,22 +141,13 @@ return; } var suppressed = false; - var gate = initialRequestGate(); var remainingSlots = slots.filter(function (slot) { var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; - if (handoff && handoff.suppressPublisherRefresh) { - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; - } - var elementId = slot.getSlotElementId(); - if (!gate.released && configuredSlotForElementId(elementId)) { - gate.pendingRefreshes[elementId] = true; - suppressed = true; - return false; - } - return true; + if (!handoff || !handoff.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -229,15 +172,13 @@ // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. var newSlots = []; - // Publisher-owned slots can be refreshed on SPA navigation. On initial - // load their first request is held until the targeting below is applied. + // Publisher-owned slots TS reused — refreshed to pick up server-side + // targeting. The publisher already display()ed these. var slotsToRefresh = []; - var isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself. GPT requires display() to // register/render a freshly-defined slot; refresh() alone no-ops for a // slot that was never displayed, so these are display()ed instead. var slotsToDisplay = []; - var hasAppliedTargeting = false; slots.forEach(function (slot) { // Resolve actual div ID: exact match first, then safe prefix scan. // div_id in config may be a stable prefix (e.g. "ad-header-0-") when @@ -304,7 +245,6 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); - hasAppliedTargeting = true; // Map the resolved inner div to the slot ID. This bootstrap fires no // beacons and registers no slotRenderEnded listener; the map is consumed // by the bundle's render bridge (index.ts) once it loads. @@ -317,36 +257,34 @@ newSlots.push(s); var displayId = s.getSlotElementId() || actualDivId; slotsToDisplay.push(displayId); - } else if (!isInitialAdInit) { + } else { slotsToRefresh.push(s); } }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; - var heldPublisherRequests = isInitialAdInit - ? takeInitialPublisherRequests(googletag.pubads()) - : { displayIds: [], refreshSlots: [] }; - ts.gptInitialAdInitCompleted = true; - if (!ts.servicesEnabled && (hasAppliedTargeting || heldPublisherRequests.displayIds.length > 0 || heldPublisherRequests.refreshSlots.length > 0)) { + if (!ts.servicesEnabled) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); ts.servicesEnabled = true; } - // Register/render TS-defined slots and replay publisher displays held - // before server-side bids were available. The replay is the publisher's - // one initial request, not a later TS refresh. - heldPublisherRequests.displayIds.concat(slotsToDisplay).forEach(function (divId) { + // Register and render TS-defined slots. GPT requires display() for a + // freshly-defined slot; without it the slot no-ops and misses its + // impression. Runs after enableServices(); on SPA navigation services are + // already enabled, so this runs unconditionally for new slots. + slotsToDisplay.forEach(function (divId) { runHandoffInternal(function () { googletag.display(divId); }); }); - // Replay held publisher refreshes after targeting. On SPA navigation TS - // refreshes reused publisher slots as before; TS-defined slots need a - // refresh only when initial load was disabled. - var slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( - slotsToRefresh, - ts.gptInitialLoadDisabled ? newSlots : [], - ); + // Reused publisher-owned slots always need a refresh to pick up the + // server-side targeting. TS-defined slots are fetched by display() above + // unless the publisher disabled initial load, in which case display() only + // registers them and refresh() must request the ad — otherwise they render + // blank. Only add them in that case to avoid double-requesting. + var slotsNeedingRefresh = ts.gptInitialLoadDisabled + ? slotsToRefresh.concat(newSlots) + : slotsToRefresh; if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied // server-side targeting to GAM. If slim-Prebid has already wrapped diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 2ced11086..cd25133d1 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -77,13 +77,6 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } -/** Publisher requests held until initial TS targeting has been applied. */ -export interface GptInitialRequestGate { - pendingDisplays: Record; - pendingRefreshes: Record; - released: boolean; -} - export interface TsjsApi { version: string; que: Array<() => void>; @@ -144,10 +137,6 @@ export interface TsjsApi { gptInitialLoadDisabled?: boolean; /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ gptSlotHandoffs?: Record; - /** Publisher initial requests held until TS has applied server-side targeting. */ - gptInitialRequestGate?: GptInitialRequestGate; - /** True after the first page-load `adInit()` has handled publisher slots. */ - gptInitialAdInitCompleted?: boolean; /** True only while TS calls a GPT function that the handoff wrappers observe. */ gptSlotHandoffInternal?: boolean; /** Guards SPA pushState hook installation. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index effae7ada..8853997c3 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,11 +1,5 @@ import { log } from '../../core/log'; -import type { - AuctionSlot, - AuctionBidData, - GptInitialRequestGate, - GptSlotHandoff, - TsjsApi, -} from '../../core/types'; +import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -466,41 +460,6 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } -function configuredSlotForElementId(ts: TsjsApi, elementId: string): AuctionSlot | undefined { - return ts.adSlots?.find( - (slot) => - !!slot.div_id && - (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && - !elementId.endsWith('-container') - ); -} - -function initialRequestGate(ts: TsjsApi): GptInitialRequestGate { - return (ts.gptInitialRequestGate ??= { - pendingDisplays: {}, - pendingRefreshes: {}, - released: false, - }); -} - -function takeInitialPublisherRequests( - ts: TsjsApi, - pubads: GoogleTagPubAdsService -): { displayIds: string[]; refreshSlots: GoogleTagSlot[] } { - const gate = initialRequestGate(ts); - if (gate.released) return { displayIds: [], refreshSlots: [] }; - - gate.released = true; - const displayIds = Object.keys(gate.pendingDisplays); - const refreshIds = new Set(Object.keys(gate.pendingRefreshes)); - gate.pendingDisplays = {}; - gate.pendingRefreshes = {}; - const refreshSlots = (pubads.getSlots?.() ?? []).filter((slot) => - refreshIds.has(slot.getSlotElementId()) - ); - return { displayIds, refreshSlots }; -} - function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -578,15 +537,6 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { handoff.suppressPublisherDisplay = false; return; } - const gate = initialRequestGate(ts); - if ( - !ts.gptSlotHandoffInternal && - !gate.released && - configuredSlotForElementId(ts, elementId) - ) { - gate.pendingDisplays[elementId] = true; - return; - } originalDisplay(elementId); }; (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; @@ -609,21 +559,12 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { } let suppressed = false; - const gate = initialRequestGate(ts); const remainingSlots = slots.filter((slot) => { const handoff = handoffForSlot(ts, slot); - if (handoff?.suppressPublisherRefresh) { - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; - } - const elementId = slot.getSlotElementId(); - if (!gate.released && configuredSlotForElementId(ts, elementId)) { - gate.pendingRefreshes[elementId] = true; - suppressed = true; - return false; - } - return true; + if (!handoff?.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -660,17 +601,14 @@ export function installTsAdInit(): void { // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. const newSlots: GoogleTagSlot[] = []; - // Publisher-owned slots can be refreshed on SPA navigation. On initial - // load their first request is held by the head-installed gate and replayed - // only after the targeting below has been applied. + // Publisher-owned slots TS reused — refreshed to pick up server-side + // targeting. The publisher already display()ed these. const slotsToRefresh: GoogleTagSlot[] = []; - const isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself this call. GPT requires a // display() call to register/render a freshly-defined slot; refresh() // alone no-ops for a slot that was never displayed, so these are // display()ed instead of refreshed. const slotsToDisplay: string[] = []; - let hasAppliedTargeting = false; const divToSlotId: Record = {}; const prevSlotTargetingKeys = ts.prevSlotTargetingKeys ?? {}; const nextSlotTargetingKeys: Record = {}; @@ -744,7 +682,6 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); - hasAppliedTargeting = true; // Map the resolved inner div to the slot ID so slotRenderEnded and ADM // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; @@ -755,7 +692,7 @@ export function installTsAdInit(): void { if (tsOwned) { newSlots.push(gptSlot); slotsToDisplay.push(slotDivId2); - } else if (!isInitialAdInit) { + } else { slotsToRefresh.push(gptSlot); } @@ -772,20 +709,11 @@ export function installTsAdInit(): void { // Replace (not merge) so destroyed slots from previous navigation don't linger. ts.divToSlotId = divToSlotId; ts.prevSlotTargetingKeys = nextSlotTargetingKeys; - const heldPublisherRequests = isInitialAdInit - ? takeInitialPublisherRequests(ts, g.pubads!()) - : { displayIds: [], refreshSlots: [] }; - ts.gptInitialAdInitCompleted = true; - - // Whether this call produced a request to make. A gated page-bids response - // (auction kill switch or consent denial) returns no slots, so the loops - // above leave these empty. - const hasRenderableWork = - slotsToDisplay.length > 0 || - slotsToRefresh.length > 0 || - heldPublisherRequests.displayIds.length > 0 || - heldPublisherRequests.refreshSlots.length > 0 || - hasAppliedTargeting; + + // Whether this call produced any TS slot to render. A gated page-bids + // response (auction kill switch or consent denial) returns no slots, so + // the loops above leave these empty. + const hasRenderableWork = slotsToDisplay.length > 0 || slotsToRefresh.length > 0; // enableSingleRequest and enableServices must only be called once per page // load. Skip activating GPT services when TS has nothing to display or @@ -814,22 +742,25 @@ export function installTsAdInit(): void { }); } - // Register/render TS-defined slots and replay publisher displays held - // before the server-side bids were available. The gate is released only - // after targeting has been applied, so this remains the publisher's one - // initial request rather than a later TS refresh. - heldPublisherRequests.displayIds - .concat(slotsToDisplay) - .forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); - - // Slots needing an explicit ad request via refresh(). Publisher refreshes - // held on the initial page load are replayed after targeting. On SPA - // navigation TS refreshes reused publisher slots as before. TS-defined - // slots need a refresh only when the publisher disabled initial load. - const slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( - slotsToRefresh, - ts.gptInitialLoadDisabled ? newSlots : [] - ); + // Register and render TS-defined slots. GPT requires display() for a + // freshly-defined slot — without it the slot no-ops ("defineSlot was + // called without a matching display call") and misses its impression. + // Must run after enableServices(); on SPA navigation services are already + // enabled, so this runs unconditionally for any newly-defined slots. + slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); + + // Slots needing an explicit ad request via refresh(). Reused + // publisher-owned slots always need one to pick up the just-applied + // server-side targeting. TS-defined slots are normally fetched by the + // display() above — but when the publisher called + // pubads().disableInitialLoad(), display() only registers the slot and the + // ad request must come from refresh(). Without this, a TS-owned + // first-impression slot renders blank on initial-load-disabled pages. Only + // add them in that case; otherwise display() + refresh() would + // double-request the impression. + const slotsNeedingRefresh = ts.gptInitialLoadDisabled + ? slotsToRefresh.concat(newSlots) + : slotsToRefresh; if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index bdfc7123b..f99d90fa7 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -77,7 +77,7 @@ describe('installTsAdInit', () => { document.getElementById("ad'prefix-real")?.remove(); }); - it('reads window.tsjs.bids synchronously without re-requesting an existing publisher slot', async () => { + it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -133,130 +133,11 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalled(); fetchSpy.mockRestore(); }); - it('holds and replays a publisher display once after applying initial targeting', async () => { - const requests: string[] = []; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ], - bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - googletag.display('div-atf-sidebar'); - expect(nativeDisplay).not.toHaveBeenCalled(); - - (window as TestWindow).tsjs!.adInit!(); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(requests).toEqual(['div-atf-sidebar']); - expect(nativeRefresh).not.toHaveBeenCalled(); - }); - - it('holds and replays a disabled-load publisher refresh once after targeting', async () => { - const requests: string[] = []; - let initialLoadDisabled = false; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const unrelatedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), - }; - const nativeDisplay = vi.fn((elementId: string) => { - if (!initialLoadDisabled) requests.push(elementId); - }); - const nativeRefresh = vi.fn((slots?: Array) => { - (slots ?? [mockSlot, unrelatedSlot]).forEach((slot) => - requests.push(slot.getSlotElementId()) - ); - }); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot, unrelatedSlot]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - disableInitialLoad: vi.fn(() => { - initialLoadDisabled = true; - }), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - mockPubads.disableInitialLoad(); - googletag.display('div-atf-sidebar'); - mockPubads.refresh(); - expect(nativeDisplay).not.toHaveBeenCalled(); - expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); - - (window as TestWindow).tsjs!.adInit!(); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(nativeRefresh).toHaveBeenCalledTimes(2); - expect(nativeRefresh).toHaveBeenLastCalledWith([mockSlot]); - expect(requests).toEqual(['div-unrelated', 'div-atf-sidebar']); - }); - it('displays TS-defined slots and does not include them in refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -563,9 +444,6 @@ describe('installTsAdInit', () => { }, ], bids: {}, - // This models a route update: existing publisher slots are refreshed on - // SPA navigation, while initial-load publisher slots are not re-requested. - gptInitialAdInitCompleted: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -711,7 +589,7 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); }); it('does not fire win/billing beacons from slotRenderEnded targeting alone', async () => { @@ -1036,7 +914,7 @@ describe('installTsAdInit', () => { delete (window as TestWindow).apstag; }); - it('does not re-request an existing publisher slot when tsjs.bids is empty', async () => { + it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { const emptyTestSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -1075,7 +953,7 @@ describe('installTsAdInit', () => { installTsAdInit(); (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalled(); }); it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { @@ -1118,7 +996,7 @@ describe('installTsAdInit', () => { installTsAdInit(); expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalledWith([dynamicSlot]); }); }); diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md index 24879c8e4..4699e3c42 100644 --- a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -1,7 +1,6 @@ # Prevent Duplicate GPT Slot Requests — Implementation Plan -> **Status:** Revised after production-like validation found a second request for -> publisher-owned slots when hydration-safe scheduling defers `adInit()`. +> **Status:** Implemented locally; production-like browser validation remains pending. > > **Spec:** `docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md` @@ -9,11 +8,10 @@ TS `adInit()` runs before a publisher later defines the placement's inner GPT div. **Architecture:** TS creates its fallback on the resolved inner div and records a -handoff claim. Narrow, idempotent GPT wrappers also gate a configured publisher -slot's first `display`/`refresh` while the server auction result is unavailable. At -`adInit()`, TS applies targeting to that same publisher slot and replays the held -native request once; it does not issue a second TS refresh. Late-definition handoff -and SPA ownership transfer remain unchanged. The head bootstrap and full TSJS bundle +handoff claim. Narrow, idempotent wrappers around GPT's `defineSlot`, `display`, and +`pubads().refresh` alias a matching late publisher definition to that slot and +suppress only the duplicate initial publisher request. A successful handoff transfers +SPA-destruction ownership to the publisher. The head bootstrap and full TSJS bundle share this runtime protocol through `window.tsjs`. **Primary files:** @@ -45,9 +43,9 @@ share this runtime protocol through `window.tsjs`. - Modify `crates/trusted-server-js/lib/src/core/types.ts` - Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` -- [ ] Add `TsjsApi` state for both the div-ID-keyed late-handoff registry and an - initial publisher-request gate. The gate records held display/refresh IDs and - a released marker so it applies only once per page load. +- [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must + retain serializable lifecycle flags: TS-created, ownership-transferred, initial + request made, and one-shot publisher display/refresh suppression state. - [ ] Add only the minimal optional/internal type surface needed for idempotence markers on GPT functions and `pubads`. Do not weaken the public GPT types with `any`. @@ -83,14 +81,14 @@ npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index returning it; - log, but do not create a second slot, if publisher arguments differ from the TS configuration. -- [ ] `display` wrapper: consume the one permitted post-handoff display; before the - first `adInit()`, also hold a configured publisher slot's native display. -- [ ] `refresh` wrapper: consume one permitted post-handoff disabled-load refresh; - before the first `adInit()`, hold configured publisher refreshes and forward - all unrelated slots explicitly, including a no-argument/global refresh. -- [ ] At initial `adInit()`, apply targeting then replay held native calls; never - refresh an existing publisher-owned slot that has already requested. -- [ ] Ensure wrapper installation precedes publisher setup and fallback creation. +- [ ] `display` wrapper: consume the one permitted publisher post-handoff display + call without invoking native `display`; pass every other call through unchanged. +- [ ] `refresh` wrapper: when initial load was disabled, consume the one permitted + post-handoff refresh for each claimed slot. If called with no slot list, expand + `getSlots()`, filter only the claimed slots, and forward the remaining slots + explicitly. Preserve all unrelated refreshes. +- [ ] Ensure wrapper installation precedes the fallback definition path and does not + change existing publisher-owned-slot behavior. **Focused checks:** @@ -138,9 +136,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts makes one request; the publisher's first refresh cannot make a second request. - [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert the claimed slot is suppressed once and the unrelated slot is refreshed. -- [ ] Add publisher-owned tests proving TS holds normal and disabled-load initial - requests, applies targeting, and replays exactly one native request. Also prove - an already-requested publisher slot is not refreshed again. +- [ ] Add an already publisher-owned test proving TS does not install a claim, applies + targeting, and refreshes that slot. - [ ] Add a no-publisher test proving TS still creates, displays, and requests its inner-div slot exactly once. - [ ] Add a SPA handoff test: after late publisher claim, the next `adInit()` does not @@ -156,8 +153,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts - Modify `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` - Modify `crates/trusted-server-core/src/integrations/gpt.rs` -- [ ] Port the same initial-request gate, actual-inner-div fallback, registry names, - lifecycle flags, and idempotence markers to the plain-JavaScript bootstrap. +- [ ] Port the same actual-inner-div fallback, registry names, lifecycle flags, and + idempotence markers to the plain-JavaScript bootstrap. - [ ] Use the existing bootstrap `window.tsjs` properties exactly so `index.ts` can adopt the initial claim after the bundle loads. - [ ] Ensure its internal definition/display/refresh calls use the same guards as the @@ -201,10 +198,9 @@ npx vitest run test/integrations/gpt/ad_init.test.ts format. - [ ] Review the diff specifically for bootstrap/bundle protocol drift and for any use of container IDs in GPT slot creation. -- [ ] In a controlled production-like browser capture with the hydration-safe - deferred `adInit()` path, verify one targeted initial request for each affected - visible placement and independently verify an unrelated placement remains - requestable. +- [ ] In a controlled production-like browser capture, verify one initial request for + each affected visible placement and independently verify an unrelated placement + remains requestable. - [ ] Update issue #944 with the ownership-handoff decision, test evidence, and browser-capture result. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index c94e25b2c..770718199 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -8,12 +8,6 @@ slot on the outer `-container` element. The publisher subsequently defines and displays an inner-div slot. These are distinct GPT slots, so they make separate GAM requests for one visible placement. -A production deployment also exposed the inverse ordering: the hydration-safe -body bootstrap delays `adInit()` until after `window.load`, so publisher code can -already have defined **and requested** its inner-div slot. In that ordering, -reusing the slot and refreshing it applies targeting too late and creates a second -SRA request. - The affected paths are deliberately duplicated today: - `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` is the full bundle @@ -46,7 +40,7 @@ A fix must keep both implementations in sync. publisher-owned slot from a placement that the publisher will never define. - General interception of unrelated GPT slots. -## Decision: inner-div fallback, late-definition handoff, and an initial request gate +## Decision: one inner-div slot with late-definition handoff TS will define its fallback slot on the **actual inner div**, never on its outer `-container` element. It will record a narrowly scoped handoff claim keyed by that @@ -60,36 +54,31 @@ competing container slot and an invalid duplicate definition. ### Lifecycle -1. **Publisher-owned before bids are available** — a scoped head-installed gate - holds the configured placement's first publisher `display()` or `refresh()`. - At `adInit()`, TS finds the publisher slot, applies targeting, and replays that - held native call exactly once. It never adds a second TS refresh. -2. **Already-requested publisher-owned slot** — if a configured publisher request - was not observed by the gate, TS applies targeting for later lifecycle work but - does not re-request the already-served initial impression. -3. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, +1. **Already publisher-owned** — `getSlots()` finds a slot for the resolved inner + div. TS applies targeting, records it as publisher-owned, and refreshes it as it + does today. +2. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, enables services when needed, and displays it. When initial load is disabled, TS performs its existing one explicit refresh. TS records this slot as TS-owned and handoff-eligible. -4. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded +3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded inner-div claim, returns the existing slot, and transfers ownership: it removes the slot from TS's future `destroySlots()` set. The publisher's setup continues against that same slot. -5. **Publisher's first request call after a late handoff** — the wrapper suppresses the duplicate +4. **Publisher's first request call** — the wrapper suppresses the duplicate publisher `display()` call. With `disableInitialLoad()`, it instead suppresses only the publisher's first refresh for the transferred slot, because TS has already issued the required initial refresh. For a no-argument/global refresh, the wrapper must expand `getSlots()`, remove only the one-shot suppressed slots, and forward the remaining slots explicitly so unrelated slots still refresh. -6. **Later refreshes and SPA navigation** — after the one-shot suppression is +5. **Later refreshes and SPA navigation** — after the one-shot suppression is consumed, publisher refreshes are untouched. On navigation, TS clears its targeting from the shared slot and may reuse it for the next route; it must not destroy a slot after ownership has transferred. -The wrappers are not global deduplicators. The initial request gate only holds the -first `display`/`refresh` for a configured placement until initial TS targeting is -available; handoff suppression only handles IDs present in TS's handoff registry. -All unrelated GPT calls retain native behavior. +The wrapper is not a global deduplicator. It only handles IDs present in TS's +handoff registry and must preserve native `defineSlot`, `display`, and `refresh` +behavior for every other placement. ## Implementation shape @@ -100,10 +89,8 @@ can read after the bundle replaces the bootstrap implementation. It is keyed by resolved actual div ID and records at least: - whether TS created the slot and whether ownership has transferred; -- whether one post-handoff publisher `display()` or initial-load-disabled `refresh()` - remains to suppress; -- configured publisher displays and refreshes held before initial targeting, plus a - released marker so the gate applies only once per page load. +- whether one publisher `display()` or initial-load-disabled `refresh()` remains to + suppress. Do not rely only on module-local state: the bootstrap can define the initial slot before `index.ts` is loaded. Look up the live slot by element ID through @@ -122,12 +109,8 @@ In `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: - Replace the container fallback with `actualDivId`. - Add the typed handoff-registry state to `TsjsApi` in `crates/trusted-server-js/lib/src/core/types.ts`. -- Install idempotent `defineSlot`, `display`, and `pubads().refresh` wrappers from - the GPT command queue before publisher setup. The latter two also hold the first - configured publisher request until `adInit()` has applied initial targeting. -- Replay held initial publisher displays/refreshes after targeting rather than - refreshing an existing publisher-owned slot. Retain reused-slot refreshes only for - later SPA navigations. +- Install the idempotent `defineSlot`, `display`, and `pubads().refresh` handoff + wrappers from the GPT command queue before `adInit()` can create a fallback slot. - When a late publisher definition is aliased to the existing slot, remove it from `prevGptSlots` and mark it transferred before returning it. - Keep targeting cleanup keyed by the real inner div. Remove the old dual @@ -149,7 +132,7 @@ suite must exercise both implementations' observable contract. | Risk | Mitigation | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Publisher passes a different ad-unit path or sizes in its late `defineSlot` call | Return the existing claimed slot but log a diagnostic. Do not define a second slot. Treat the TS configuration and publisher configuration mismatch as an integration error to resolve separately. | -| Publisher invokes global `refresh()` before bids after `disableInitialLoad()` | Filter only configured held slots from the expanded list, forward unrelated slots immediately, then replay the held slots once after targeting. A no-argument refresh must not be silently dropped. | +| Publisher invokes global `refresh()` after `disableInitialLoad()` | Filter the one-shot claimed slot from the expanded slot list and refresh all remaining slots. A no-argument refresh must not be silently dropped. | | Publisher calls a legitimate refresh without an initial display | The one-shot suppression is consumed only immediately after a successful late handoff. Document and test the standard publisher sequence (`defineSlot` → `addService` → `display`, with `refresh` when initial load is disabled). Escalate unusual publisher lifecycle requirements rather than adding a time heuristic. | | Publisher-owned slot is destroyed on SPA navigation | Transfer ownership synchronously in the `defineSlot` wrapper and remove the slot from `prevGptSlots`. | | Bootstrap and bundle diverge | Give both paths the same black-box regression cases; retain a Rust source-contract assertion for bootstrap-specific sentinels. | @@ -163,9 +146,7 @@ suite must exercise both implementations' observable contract. initial-load-disabled modes. - The late publisher `display()` (and its first initial-load-disabled refresh) cannot create a second request, while unrelated slots retain their normal calls. -- A configured publisher slot whose first request occurs before the deferred - `adInit()` is held, receives TS targeting, and makes exactly one replayed native - request. An already-requested publisher slot is never re-requested by TS. +- Existing publisher slots are still reused and receive TS targeting. - A slot that no publisher claims is displayed and requested once by TS. - A transferred slot is absent from TS's SPA `destroySlots()` argument; targeting is still cleared and reapplied correctly on the next route. @@ -179,7 +160,6 @@ suite must exercise both implementations' observable contract. 2. Run the focused GPT test files, then the full TSJS Vitest suite and formatter. 3. Run the target-matched Rust test suite so the included bootstrap and its source assertions compile and pass. -4. In a controlled browser capture with deferred `adInit()`, verify that one - configured header and one configured fixed placement each produce one initial - slot request with TS targeting, while a distinct in-content placement remains - independently requestable. +4. In a controlled browser capture, verify that one configured header and one + configured fixed placement each produce one initial slot request, while a distinct + in-content placement remains independently requestable. From 9b1985c8b65a578be3aa25ff1a07d115e6b77d5a Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 27 Jul 2026 13:59:39 -0500 Subject: [PATCH 04/16] Harden GPT slot handoff --- .../src/integrations/gpt_bootstrap.js | 59 +++- .../trusted-server-js/lib/src/core/types.ts | 4 + .../lib/src/integrations/gpt/index.ts | 79 ++++- .../lib/test/integrations/gpt/ad_init.test.ts | 301 ++++++++++++++++++ ...-24-prevent-duplicate-gpt-slot-requests.md | 25 +- ...vent-duplicate-gpt-slot-requests-design.md | 17 +- 6 files changed, 446 insertions(+), 39 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 0c2697357..996c0e800 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -51,6 +51,33 @@ ); } + function matchingHandoff(pubads, adUnitPath, formats, elementId) { + var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if (exact) return exact; + + var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( + function (handoff, index, allHandoffs) { + return ( + allHandoffs.indexOf(handoff) === index && + !handoff.publisherClaimed && + elementId.startsWith(handoff.divIdPrefix) && + handoff.gamUnitPath === adUnitPath && + JSON.stringify(handoff.formats) === JSON.stringify(formats) && + findSlotByElementId(pubads, handoff.slotElementId) + ); + }, + ); + return candidates.length === 1 ? candidates[0] : null; + } + + function displayTargetElementId(target) { + if (typeof target === "string") return target; + if (target && typeof target.getSlotElementId === "function") { + return target.getSlotElementId(); + } + return target && target.id ? target.id : null; + } + function runHandoffInternal(callback) { var wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -74,11 +101,12 @@ if (!tag.defineSlot.__tsSlotHandoffPatched) { var originalDefineSlot = tag.defineSlot.bind(tag); var patchedDefineSlot = function (adUnitPath, formats, elementId) { - var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); if (!ts.gptSlotHandoffInternal && handoff) { - var existingSlot = findSlotByElementId(pubads, elementId); + var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); if (existingSlot) { if (!handoff.publisherClaimed) { + ts.gptSlotHandoffs[elementId] = handoff; handoff.publisherClaimed = true; handoff.suppressPublisherDisplay = true; handoff.suppressPublisherRefresh = @@ -111,8 +139,10 @@ if (!tag.display.__tsSlotHandoffPatched) { var originalDisplay = tag.display.bind(tag); - var patchedDisplay = function (elementId) { - var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + var patchedDisplay = function (target) { + var elementId = displayTargetElementId(target); + var handoff = + elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; if ( !ts.gptSlotHandoffInternal && handoff && @@ -121,7 +151,7 @@ handoff.suppressPublisherDisplay = false; return; } - originalDisplay(elementId); + originalDisplay(target); }; patchedDisplay.__tsSlotHandoffPatched = true; tag.display = patchedDisplay; @@ -129,15 +159,22 @@ if (!pubads.refresh.__tsSlotHandoffPatched) { var originalRefresh = pubads.refresh.bind(pubads); - var patchedRefresh = function (requestedSlots) { + var callRefresh = function (slots, options) { + if (options === undefined) { + originalRefresh(slots); + } else { + originalRefresh(slots, options); + } + }; + var patchedRefresh = function (requestedSlots, options) { if (ts.gptSlotHandoffInternal) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); return; } var slots = requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); if (!slots) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); return; } var suppressed = false; @@ -150,9 +187,9 @@ return false; }); if (!suppressed) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); } else if (remainingSlots.length > 0) { - originalRefresh(remainingSlots); + callRefresh(remainingSlots, options); } }; patchedRefresh.__tsSlotHandoffPatched = true; @@ -225,6 +262,8 @@ ts.gptSlotHandoffs[actualDivId] = { gamUnitPath: slot.gam_unit_path, formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: actualDivId, publisherClaimed: false, suppressPublisherDisplay: false, suppressPublisherRefresh: false, diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index cd25133d1..8cf1a80de 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -72,6 +72,10 @@ export interface AuctionBidData { export interface GptSlotHandoff { gamUnitPath: string; formats: Array<[number, number]>; + /** Stable configured prefix used to safely bridge framework-generated IDs. */ + divIdPrefix: string; + /** Element ID GPT received when TS created the fallback slot. */ + slotElementId: string; publisherClaimed: boolean; suppressPublisherDisplay: boolean; suppressPublisherRefresh: boolean; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 8853997c3..15468ac88 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -99,16 +99,22 @@ function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable): void { } } +interface GoogleTagRefreshOptions { + changeCorrelator?: boolean; +} + interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; - refresh(slots?: GoogleTagSlot[]): void; + refresh(slots?: GoogleTagSlot[], options?: GoogleTagRefreshOptions): void; getSlots?(): GoogleTagSlot[]; disableInitialLoad?(): void; } +type GoogleTagDisplayTarget = string | Element | GoogleTagSlot; + interface GoogleTag { cmd: Array<() => void>; pubads(): GoogleTagPubAdsService; @@ -119,7 +125,7 @@ interface GoogleTag { ): GoogleTagSlot | null; destroySlots(slots?: GoogleTagSlot[]): boolean; enableServices(): void; - display(elementId: string): void; + display(target: GoogleTagDisplayTarget): void; _loaded_?: boolean; } @@ -460,6 +466,38 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } +function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefined { + if (typeof target === 'string') return target; + if (target instanceof Element) return target.id || undefined; + return target.getSlotElementId(); +} + +function matchingHandoff( + ts: TsjsApi, + pubads: GoogleTagPubAdsService, + adUnitPath: string, + formats: Array, + elementId: string +): GptSlotHandoff | undefined { + const exact = ts.gptSlotHandoffs?.[elementId]; + if (exact) return exact; + + const candidates = new Set(Object.values(ts.gptSlotHandoffs ?? {})).values(); + const matching = Array.from(candidates).filter( + (handoff) => + !handoff.publisherClaimed && + elementId.startsWith(handoff.divIdPrefix) && + handoff.gamUnitPath === adUnitPath && + JSON.stringify(handoff.formats) === JSON.stringify(formats) && + findGptSlotByElementId(pubads, handoff.slotElementId) + ); + return matching.length === 1 ? matching[0] : undefined; +} + +function registerHandoffAlias(ts: TsjsApi, elementId: string, handoff: GptSlotHandoff): void { + (ts.gptSlotHandoffs ??= {})[elementId] = handoff; +} + function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -497,11 +535,12 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { formats: Array, elementId: string ): GoogleTagSlot | null => { - const handoff = ts.gptSlotHandoffs?.[elementId]; + const handoff = matchingHandoff(ts, pubads, adUnitPath, formats, elementId); if (!ts.gptSlotHandoffInternal && handoff) { - const existingSlot = findGptSlotByElementId(pubads, elementId); + const existingSlot = findGptSlotByElementId(pubads, handoff.slotElementId); if (existingSlot) { if (!handoff.publisherClaimed) { + registerHandoffAlias(ts, elementId, handoff); handoff.publisherClaimed = true; handoff.suppressPublisherDisplay = true; handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; @@ -531,13 +570,14 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { const display = g.display; if (!(display as HandoffPatchedFunction).__tsSlotHandoffPatched) { const originalDisplay = display.bind(g); - const patchedDisplay = (elementId: string): void => { - const handoff = ts.gptSlotHandoffs?.[elementId]; + const patchedDisplay = (target: GoogleTagDisplayTarget): void => { + const elementId = displayTargetElementId(target); + const handoff = elementId ? ts.gptSlotHandoffs?.[elementId] : undefined; if (!ts.gptSlotHandoffInternal && handoff?.suppressPublisherDisplay) { handoff.suppressPublisherDisplay = false; return; } - originalDisplay(elementId); + originalDisplay(target); }; (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; g.display = patchedDisplay; @@ -546,15 +586,28 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { const refresh = pubads.refresh; if (!(refresh as HandoffPatchedFunction).__tsSlotHandoffPatched) { const originalRefresh = refresh.bind(pubads); - const patchedRefresh = (requestedSlots?: GoogleTagSlot[]): void => { + const callRefresh = ( + slots: GoogleTagSlot[] | undefined, + options: GoogleTagRefreshOptions | undefined + ): void => { + if (options === undefined) { + originalRefresh(slots); + } else { + originalRefresh(slots, options); + } + }; + const patchedRefresh = ( + requestedSlots?: GoogleTagSlot[], + options?: GoogleTagRefreshOptions + ): void => { if (ts.gptSlotHandoffInternal) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); return; } const slots = requestedSlots ?? pubads.getSlots?.(); if (!slots) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); return; } @@ -567,9 +620,9 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { return false; }); if (!suppressed) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); } else if (remainingSlots.length > 0) { - originalRefresh(remainingSlots); + callRefresh(remainingSlots, options); } }; (patchedRefresh as HandoffPatchedFunction).__tsSlotHandoffPatched = true; @@ -664,6 +717,8 @@ export function installTsAdInit(): void { (ts.gptSlotHandoffs ??= {})[actualDivId] = { gamUnitPath: slot.gam_unit_path, formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: actualDivId, publisherClaimed: false, suppressPublisherDisplay: false, suppressPublisherRefresh: false, diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index f99d90fa7..cec4c6d1f 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; // Track every 'message' EventListener added to window across the entire test @@ -74,6 +77,7 @@ describe('installTsAdInit', () => { afterEach(() => { document.getElementById('div-atf-sidebar')?.remove(); + document.getElementById('ad-header-0-_r_1_')?.remove(); document.getElementById("ad'prefix-real")?.remove(); }); @@ -268,6 +272,303 @@ describe('installTsAdInit', () => { expect(destroySlots).not.toHaveBeenCalled(); }); + it.each(['slot', 'element'] as const)( + 'hands a hydrated publisher ID off when it displays by %s', + async (displayMode) => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const ssrDiv = document.getElementById('div-atf-sidebar')!; + ssrDiv.id = 'ad-header-0-_R_0_'; + const hydratedId = 'ad-header-0-_r_1_'; + const slots = new Map(); + const requests: string[] = []; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((target: string | Element | FakeSlot) => { + if (typeof target === 'string') { + requests.push(target); + } else if ('getSlotElementId' in target) { + requests.push(target.getSlotElementId()); + } else { + requests.push(target.id); + } + }); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'header_ad', + gam_unit_path: '/123/header', + div_id: 'ad-header-0-', + formats: [[970, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + ssrDiv.id = hydratedId; + + const publisherSlot = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot + )('/123/header', [[970, 250]], hydratedId); + publisherSlot.addService(pubads); + const publisherDisplay = googletag.display as unknown as ( + target: string | Element | FakeSlot + ) => void; + publisherDisplay(displayMode === 'slot' ? publisherSlot : ssrDiv); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['ad-header-0-_R_0_']); + expect((window as TestWindow).tsjs!.gptSlotHandoffs[hydratedId]).toBe( + (window as TestWindow).tsjs!.gptSlotHandoffs['ad-header-0-_R_0_'] + ); + } + ); + + it('does not transfer an ambiguous hydrated publisher definition', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const firstSlot = makeSlot('ad-header-0-_R_0_'); + const secondSlot = makeSlot('ad-header-0-_R_1_'); + const nativeDefineSlot = vi.fn((_adUnitPath: string, _formats: number[][], elementId: string) => + makeSlot(elementId) + ); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => [firstSlot, secondSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + const firstHandoff = { + gamUnitPath: '/123/header', + formats: [[970, 250]], + divIdPrefix: 'ad-header-0-', + slotElementId: 'ad-header-0-_R_0_', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + const secondHandoff = { ...firstHandoff, slotElementId: 'ad-header-0-_R_1_' }; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'ad-header-0-_R_0_': firstHandoff, + 'ad-header-0-_R_1_': secondHandoff, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const defined = ( + (window as TestWindow).googletag as { + defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot; + } + ).defineSlot('/123/header', [[970, 250]], 'ad-header-0-_r_1_'); + + expect(nativeDefineSlot).toHaveBeenCalledOnce(); + expect(defined).not.toBe(firstSlot); + expect(defined).not.toBe(secondSlot); + expect(firstHandoff.publisherClaimed).toBe(false); + expect(secondHandoff.publisherClaimed).toBe(false); + }); + + it('runs the embedded bootstrap handoff for a hydrated publisher ID', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + }; + const ssrDiv = document.getElementById('div-atf-sidebar')!; + ssrDiv.id = 'ad-header-0-_R_0_'; + const slots = new Map(); + const requests: string[] = []; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + refresh: vi.fn(), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((target: string | FakeSlot) => { + requests.push(typeof target === 'string' ? target : target.getSlotElementId()); + }); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'header_ad', + gam_unit_path: '/123/header', + div_id: 'ad-header-0-', + formats: [[970, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const bootstrap = readFileSync( + resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' + ); + window.eval(bootstrap); + (window as TestWindow).tsjs!.adInit!(); + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ssrDiv.id = 'ad-header-0-_r_1_'; + + const googletag = (window as TestWindow).googletag as { + defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot; + display(target: FakeSlot): void; + }; + const publisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); + googletag.display(publisherSlot); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['ad-header-0-_R_0_']); + }); + + it('preserves refresh options while filtering a claimed disabled-load slot', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const slots = new Map(); + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const nativeRefresh = vi.fn(); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: nativeRefresh, + disableInitialLoad: vi.fn(), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + pubads.disableInitialLoad(); + (window as TestWindow).tsjs!.adInit!(); + + const publisherSlot = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot + )('/123/atf', [[300, 250]], 'div-atf-sidebar'); + const unrelatedSlot = makeSlot('div-unrelated'); + const refreshOptions = { changeCorrelator: false }; + ( + pubads.refresh as unknown as ( + requestedSlots: FakeSlot[], + options: { changeCorrelator: boolean } + ) => void + )([publisherSlot, unrelatedSlot], refreshOptions); + + expect(nativeRefresh).toHaveBeenLastCalledWith([unrelatedSlot], refreshOptions); + }); + it('suppresses only the claimed slot from the first disabled-load publisher refresh', async () => { type FakeSlot = { addService(service: unknown): FakeSlot; diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md index 4699e3c42..dc2c7426c 100644 --- a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -44,8 +44,9 @@ share this runtime protocol through `window.tsjs`. - Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` - [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must - retain serializable lifecycle flags: TS-created, ownership-transferred, initial - request made, and one-shot publisher display/refresh suppression state. + retain serializable lifecycle flags, the configured stable div-ID prefix, the + original GPT slot element ID, ownership transfer state, and one-shot publisher + display/refresh suppression state. - [ ] Add only the minimal optional/internal type surface needed for idempotence markers on GPT functions and `pubads`. Do not weaken the public GPT types with `any`. @@ -76,7 +77,9 @@ npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index - [ ] `defineSlot` wrapper: - pass through TS-internal calls and IDs absent from the registry; - for a late publisher call on a claimed inner div, find and return the existing - slot without calling native `defineSlot`; + slot without calling native `defineSlot`; for hydration-generated ID changes, + permit this only for one live, unclaimed fallback with identical path/formats and + the configured div-ID prefix; - mark ownership transferred and remove that slot from `prevGptSlots` before returning it; - log, but do not create a second slot, if publisher arguments differ from the TS @@ -131,11 +134,14 @@ npx vitest run test/integrations/gpt/ad_init.test.ts 2. TS defines/displays the inner div and makes one request; 3. publisher calls `defineSlot(innerDiv).addService(...); display(innerDiv)`; 4. assert native `defineSlot` was called once, there is one slot, and there is one - request. + request. Repeat with the SSR-generated ID changed to the publisher's hydrated + ID, and assert an ambiguous prefix does not transfer ownership. - [ ] Add the same sequence with `disableInitialLoad()`: TS display plus its refresh makes one request; the publisher's first refresh cannot make a second request. -- [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert - the claimed slot is suppressed once and the unrelated slot is refreshed. +- [ ] Add no-argument and explicit-slot publisher refresh tests containing an + unrelated slot. Assert the claimed slot is suppressed once, the unrelated slot + is refreshed, and `changeCorrelator` options are preserved. Cover string, + element, and slot-object `display()` calls. - [ ] Add an already publisher-owned test proving TS does not install a claim, applies targeting, and refreshes that slot. - [ ] Add a no-publisher test proving TS still creates, displays, and requests its @@ -161,11 +167,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts bundle; bootstrap must not transfer or suppress its own operations. - [ ] Extend the `gpt.rs` head-insert tests to assert that the bootstrap contains the inner-div handoff protocol and no longer contains the container fallback. -- [ ] Add an executable bootstrap behavior test if practical by evaluating the - injected script against the same fake GPT fixture. If the test setup cannot execute - the included asset without duplication, record that limitation and keep the Rust - source-contract assertion plus identical bundle lifecycle tests as the minimum - coverage. +- [ ] Add an executable bootstrap behavior test by evaluating the included asset + against the fake GPT fixture, including a bootstrap-to-bundle adoption check. ## Task 6: Validate, inspect, and ship diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index 770718199..422eb8ef8 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -46,7 +46,11 @@ TS will define its fallback slot on the **actual inner div**, never on its outer `-container` element. It will record a narrowly scoped handoff claim keyed by that inner div ID. A `googletag.defineSlot` wrapper then recognizes a later publisher request for that exact div and returns the existing TS slot rather than invoking -GPT's native `defineSlot` again. +GPT's native `defineSlot` again. Framework hydration can change generated suffixes +between the first TS request and the publisher definition; in that case, TS may +alias the new ID only when exactly one unclaimed fallback has the configured div-ID +prefix, identical GAM path, identical formats, and a live GPT slot. Ambiguous or +mismatched definitions remain native GPT calls. GPT requires a one-to-one slot-to-div relationship and documents that a slot should be displayed only once. Sharing the initial inner-div slot therefore avoids both the @@ -62,9 +66,9 @@ competing container slot and an invalid duplicate definition. performs its existing one explicit refresh. TS records this slot as TS-owned and handoff-eligible. 3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded - inner-div claim, returns the existing slot, and transfers ownership: it removes - the slot from TS's future `destroySlots()` set. The publisher's setup continues - against that same slot. + inner-div claim (or the uniquely matching hydrated-ID claim), returns the existing + slot, and transfers ownership: it removes the slot from TS's future + `destroySlots()` set. The publisher's setup continues against that same slot. 4. **Publisher's first request call** — the wrapper suppresses the duplicate publisher `display()` call. With `disableInitialLoad()`, it instead suppresses only the publisher's first refresh for the transferred slot, because TS has @@ -77,8 +81,9 @@ competing container slot and an invalid duplicate definition. destroy a slot after ownership has transferred. The wrapper is not a global deduplicator. It only handles IDs present in TS's -handoff registry and must preserve native `defineSlot`, `display`, and `refresh` -behavior for every other placement. +handoff registry, or one uniquely safe hydrated-ID match, and must preserve native +`defineSlot`, all supported `display()` argument forms, and both `refresh()` +arguments for every other placement. ## Implementation shape From 0fdd13e7dfdebdee48088d9732995a6db8b5adab Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 30 Jul 2026 12:41:37 -0500 Subject: [PATCH 05/16] Preserve native GPT slot behavior --- .../src/integrations/gpt_bootstrap.js | 20 +- .../lib/src/integrations/gpt/index.ts | 40 +++- .../lib/test/integrations/gpt/ad_init.test.ts | 222 +++++++++++++++++- 3 files changed, 259 insertions(+), 23 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 996c0e800..082bfa1b7 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -53,7 +53,7 @@ function matchingHandoff(pubads, adUnitPath, formats, elementId) { var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; - if (exact) return exact; + if (exact) return exact.publisherClaimed ? null : exact; var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( function (handoff, index, allHandoffs) { @@ -101,13 +101,15 @@ if (!tag.defineSlot.__tsSlotHandoffPatched) { var originalDefineSlot = tag.defineSlot.bind(tag); var patchedDefineSlot = function (adUnitPath, formats, elementId) { - var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); - if (!ts.gptSlotHandoffInternal && handoff) { - var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); - if (existingSlot) { - if (!handoff.publisherClaimed) { + if (!ts.gptSlotHandoffInternal && typeof elementId === "string") { + var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); + if (handoff) { + var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); + if (existingSlot) { ts.gptSlotHandoffs[elementId] = handoff; handoff.publisherClaimed = true; + // The supported publisher lifecycle is defineSlot → addService → display. + // Intentionally wait for that display instead of applying a time heuristic. handoff.suppressPublisherDisplay = true; handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; @@ -127,11 +129,13 @@ elementId, ); } + return existingSlot; } - return existingSlot; } } - return originalDefineSlot(adUnitPath, formats, elementId); + return elementId === undefined + ? originalDefineSlot(adUnitPath, formats) + : originalDefineSlot(adUnitPath, formats, elementId); }; patchedDefineSlot.__tsSlotHandoffPatched = true; tag.defineSlot = patchedDefineSlot; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index dd7051ea6..00b324952 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -121,7 +121,7 @@ interface GoogleTag { defineSlot( adUnitPath: string, size: Array, - elementId: string + elementId?: string ): GoogleTagSlot | null; destroySlots(slots?: GoogleTagSlot[]): boolean; enableServices(): void; @@ -472,8 +472,10 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefined { if (typeof target === 'string') return target; - if (target instanceof Element) return target.id || undefined; - return target.getSlotElementId(); + if (typeof (target as GoogleTagSlot).getSlotElementId === 'function') { + return (target as GoogleTagSlot).getSlotElementId(); + } + return (target as Element).id || undefined; } function matchingHandoff( @@ -484,7 +486,7 @@ function matchingHandoff( elementId: string ): GptSlotHandoff | undefined { const exact = ts.gptSlotHandoffs?.[elementId]; - if (exact) return exact; + if (exact) return exact.publisherClaimed ? undefined : exact; const candidates = new Set(Object.values(ts.gptSlotHandoffs ?? {})).values(); const matching = Array.from(candidates).filter( @@ -537,15 +539,17 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { const patchedDefineSlot = ( adUnitPath: string, formats: Array, - elementId: string + elementId?: string ): GoogleTagSlot | null => { - const handoff = matchingHandoff(ts, pubads, adUnitPath, formats, elementId); - if (!ts.gptSlotHandoffInternal && handoff) { - const existingSlot = findGptSlotByElementId(pubads, handoff.slotElementId); - if (existingSlot) { - if (!handoff.publisherClaimed) { + if (!ts.gptSlotHandoffInternal && typeof elementId === 'string') { + const handoff = matchingHandoff(ts, pubads, adUnitPath, formats, elementId); + if (handoff) { + const existingSlot = findGptSlotByElementId(pubads, handoff.slotElementId); + if (existingSlot) { registerHandoffAlias(ts, elementId, handoff); handoff.publisherClaimed = true; + // The supported publisher lifecycle is defineSlot → addService → display. + // Intentionally wait for that display instead of applying a time heuristic. handoff.suppressPublisherDisplay = true; handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; ts.prevGptSlots = (ts.prevGptSlots ?? []).filter( @@ -561,11 +565,13 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { publisherGamUnitPath: adUnitPath, }); } + return existingSlot; } - return existingSlot; } } - return originalDefineSlot(adUnitPath, formats, elementId); + return elementId === undefined + ? originalDefineSlot(adUnitPath, formats) + : originalDefineSlot(adUnitPath, formats, elementId); }; (patchedDefineSlot as HandoffPatchedFunction).__tsSlotHandoffPatched = true; g.defineSlot = patchedDefineSlot; @@ -651,7 +657,17 @@ export function installTsAdInit(): void { g.cmd?.push(() => { // Destroy previously defined TS slots before redefining for the new page. if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { + const destroyedSlotElementIds = new Set( + (ts.prevGptSlots as GoogleTagSlot[]).map((slot) => slot.getSlotElementId()) + ); g.destroySlots?.(ts.prevGptSlots as GoogleTagSlot[]); + if (ts.gptSlotHandoffs) { + for (const [elementId, handoff] of Object.entries(ts.gptSlotHandoffs)) { + if (destroyedSlotElementIds.has(handoff.slotElementId)) { + delete ts.gptSlotHandoffs[elementId]; + } + } + } ts.prevGptSlots = []; } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 64867c77d..4a3719e9b 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -220,6 +220,7 @@ describe('installTsAdInit', () => { }; const nativeDefineSlot = vi.fn( (_adUnitPath: string, _formats: number[][], elementId: string) => { + if (slots.has(elementId)) return null; const slot = makeSlot(elementId); slots.set(elementId, slot); return slot; @@ -268,6 +269,10 @@ describe('installTsAdInit', () => { expect(requests).toEqual(['div-atf-sidebar']); expect((window as TestWindow).tsjs!.prevGptSlots).toEqual([]); + const duplicatePublisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); + expect(duplicatePublisherSlot).toBeNull(); + expect(nativeDefineSlot).toHaveBeenCalledTimes(2); + (window as TestWindow).tsjs!.adSlots = []; (window as TestWindow).tsjs!.adInit!(); expect(destroySlots).not.toHaveBeenCalled(); @@ -425,6 +430,162 @@ describe('installTsAdInit', () => { expect(secondHandoff.publisherClaimed).toBe(false); }); + it('delegates a div-less publisher definition with an unclaimed bundle handoff', async () => { + const fallbackSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-ts-fallback'), + }; + const nativeDefineSlot = vi.fn().mockReturnValue(null); + const pubads = { + getSlots: vi.fn().mockReturnValue([fallbackSlot]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + const handoff = { + gamUnitPath: '/123/fallback', + formats: [[300, 250]], + divIdPrefix: 'div-ts-', + slotElementId: 'div-ts-fallback', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { 'div-ts-fallback': handoff }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + expect(() => + ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId?: string + ) => unknown + )('/123/unrelated', [[728, 90]]) + ).not.toThrow(); + expect(nativeDefineSlot).toHaveBeenCalledWith('/123/unrelated', [[728, 90]]); + expect(handoff.publisherClaimed).toBe(false); + }); + + it('prunes destroyed TS-owned handoffs and their aliases on SPA navigation', async () => { + const slots = new Map< + string, + { + addService(service: unknown): unknown; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + setTargeting(key: string, value: string | string[]): unknown; + } + >(); + const makeSlot = (elementId: string) => ({ + addService: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + setTargeting: vi.fn().mockReturnThis(), + }); + const destroySlots = vi.fn(); + const pubads = { + addEventListener: vi.fn(), + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn((_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + }), + destroySlots, + display: vi.fn(), + enableServices: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + const handoff = (window as TestWindow).tsjs!.gptSlotHandoffs['div-atf-sidebar']; + (window as TestWindow).tsjs!.gptSlotHandoffs['div-atf-sidebar-hydrated'] = handoff; + (window as TestWindow).tsjs!.gptSlotHandoffs.unrelated = { + ...handoff, + slotElementId: 'div-unrelated', + }; + const ownedSlot = slots.get('div-atf-sidebar')!; + + (window as TestWindow).tsjs!.adSlots = []; + (window as TestWindow).tsjs!.adInit!(); + + expect(destroySlots).toHaveBeenCalledWith([ownedSlot]); + expect((window as TestWindow).tsjs!.gptSlotHandoffs).toEqual({ + unrelated: expect.objectContaining({ slotElementId: 'div-unrelated' }), + }); + }); + + it('suppresses a cross-realm element display without throwing', async () => { + const nativeDisplay = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn(), + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + }; + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const crossRealmElement = iframe.contentDocument!.createElement('div'); + crossRealmElement.id = 'div-cross-realm'; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'div-cross-realm': { + gamUnitPath: '/123/cross-realm', + formats: [[300, 250]], + divIdPrefix: 'div-cross-realm', + slotElementId: 'div-cross-realm', + publisherClaimed: true, + suppressPublisherDisplay: true, + suppressPublisherRefresh: false, + }, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + expect(() => + (googletag.display as unknown as (target: Element) => void)(crossRealmElement) + ).not.toThrow(); + expect(nativeDisplay).not.toHaveBeenCalled(); + iframe.remove(); + }); + it('runs the embedded bootstrap handoff for a hydrated publisher ID', async () => { type FakeSlot = { addService(service: unknown): FakeSlot; @@ -432,6 +593,7 @@ describe('installTsAdInit', () => { getSlotElementId(): string; }; const ssrDiv = document.getElementById('div-atf-sidebar')!; + const hydratedId = 'ad-header-0-_r_1_'; ssrDiv.id = 'ad-header-0-_R_0_'; const slots = new Map(); const requests: string[] = []; @@ -447,6 +609,7 @@ describe('installTsAdInit', () => { }; const nativeDefineSlot = vi.fn( (_adUnitPath: string, _formats: number[][], elementId: string) => { + if (slots.has(elementId) || elementId === hydratedId) return null; const slot = makeSlot(elementId); slots.set(elementId, slot); return slot; @@ -483,17 +646,70 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); installTsAdInit(); - ssrDiv.id = 'ad-header-0-_r_1_'; + ssrDiv.id = hydratedId; const googletag = (window as TestWindow).googletag as { - defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot; + defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot | null; display(target: FakeSlot): void; }; const publisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); - googletag.display(publisherSlot); + expect(publisherSlot).not.toBeNull(); + googletag.display(publisherSlot!); expect(nativeDefineSlot).toHaveBeenCalledTimes(1); expect(requests).toEqual(['ad-header-0-_R_0_']); + + const duplicatePublisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); + expect(duplicatePublisherSlot).toBeNull(); + expect(nativeDefineSlot).toHaveBeenCalledTimes(2); + }); + + it('delegates a div-less publisher definition with an unclaimed bootstrap handoff', () => { + const fallbackSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-ts-fallback'), + }; + const nativeDefineSlot = vi.fn().mockReturnValue(null); + const pubads = { + getSlots: vi.fn().mockReturnValue([fallbackSlot]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + const handoff = { + gamUnitPath: '/123/fallback', + formats: [[300, 250]], + divIdPrefix: 'div-ts-', + slotElementId: 'div-ts-fallback', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { 'div-ts-fallback': handoff }, + }; + + const bootstrap = readFileSync( + resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' + ); + window.eval(bootstrap); + + expect(() => + ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId?: string + ) => unknown + )('/123/unrelated', [[728, 90]]) + ).not.toThrow(); + expect(nativeDefineSlot).toHaveBeenCalledWith('/123/unrelated', [[728, 90]]); + expect(handoff.publisherClaimed).toBe(false); }); it('preserves refresh options while filtering a claimed disabled-load slot', async () => { From b200be53c08b09064821e13d91eb78bae2e4d888 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 31 Jul 2026 12:15:13 -0500 Subject: [PATCH 06/16] Fix GPT slot handoff matching --- .../src/integrations/gpt_bootstrap.js | 36 ++- .../lib/src/integrations/gpt/index.ts | 23 +- .../lib/test/integrations/gpt/ad_init.test.ts | 269 ++++++++++++++++++ 3 files changed, 316 insertions(+), 12 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 082bfa1b7..ba2d51417 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -51,6 +51,22 @@ ); } + function normalizedGptFormats(formats) { + return formats.length === 2 && + formats.every(function (format) { + return typeof format === "number"; + }) + ? [formats] + : formats; + } + + function handoffFormatsMatch(handoff, formats) { + return ( + JSON.stringify(handoff.formats) === + JSON.stringify(normalizedGptFormats(formats)) + ); + } + function matchingHandoff(pubads, adUnitPath, formats, elementId) { var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; if (exact) return exact.publisherClaimed ? null : exact; @@ -60,9 +76,10 @@ return ( allHandoffs.indexOf(handoff) === index && !handoff.publisherClaimed && + !document.getElementById(handoff.slotElementId) && elementId.startsWith(handoff.divIdPrefix) && handoff.gamUnitPath === adUnitPath && - JSON.stringify(handoff.formats) === JSON.stringify(formats) && + handoffFormatsMatch(handoff, formats) && findSlotByElementId(pubads, handoff.slotElementId) ); }, @@ -91,7 +108,8 @@ // TS cannot wait an arbitrary amount of time for a framework to define a // slot: publishers that never define one would render blank. Instead, TS // defines its fallback on the actual inner div and aliases only a later - // publisher defineSlot() for that exact div to the same GPT slot. + // publisher defineSlot() for that exact div, or a hydration-renamed replacement + // after the original div is gone, to the same GPT slot. function installSlotHandoff() { window.googletag.cmd.push(function () { var tag = window.googletag; @@ -102,9 +120,17 @@ var originalDefineSlot = tag.defineSlot.bind(tag); var patchedDefineSlot = function (adUnitPath, formats, elementId) { if (!ts.gptSlotHandoffInternal && typeof elementId === "string") { - var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); + var handoff = matchingHandoff( + pubads, + adUnitPath, + formats, + elementId, + ); if (handoff) { - var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); + var existingSlot = findSlotByElementId( + pubads, + handoff.slotElementId, + ); if (existingSlot) { ts.gptSlotHandoffs[elementId] = handoff; handoff.publisherClaimed = true; @@ -120,7 +146,7 @@ ); if ( handoff.gamUnitPath !== adUnitPath || - JSON.stringify(handoff.formats) !== JSON.stringify(formats) + !handoffFormatsMatch(handoff, formats) ) { ts.log && ts.log.warn && diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 00b324952..c8e3236d2 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -478,6 +478,16 @@ function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefi return (target as Element).id || undefined; } +function normalizedGptFormats(formats: Array): Array { + return formats.length === 2 && formats.every((format) => typeof format === 'number') + ? [formats as number[]] + : formats; +} + +function handoffFormatsMatch(handoff: GptSlotHandoff, formats: Array): boolean { + return JSON.stringify(handoff.formats) === JSON.stringify(normalizedGptFormats(formats)); +} + function matchingHandoff( ts: TsjsApi, pubads: GoogleTagPubAdsService, @@ -492,9 +502,10 @@ function matchingHandoff( const matching = Array.from(candidates).filter( (handoff) => !handoff.publisherClaimed && + !document.getElementById(handoff.slotElementId) && elementId.startsWith(handoff.divIdPrefix) && handoff.gamUnitPath === adUnitPath && - JSON.stringify(handoff.formats) === JSON.stringify(formats) && + handoffFormatsMatch(handoff, formats) && findGptSlotByElementId(pubads, handoff.slotElementId) ); return matching.length === 1 ? matching[0] : undefined; @@ -520,8 +531,9 @@ function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { * TS cannot wait an arbitrary amount of time for framework hydration: doing so * would leave placements blank when no publisher slot is ever defined. Instead, * TS creates its fallback on the publisher's actual div and aliases only a later - * `defineSlot()` for that exact div. The first duplicate publisher request is - * suppressed because TS has already issued the initial request with TS targeting. + * `defineSlot()` for that exact div, or for a hydration-renamed replacement after + * the original div is gone. The first duplicate publisher request is suppressed + * because TS has already issued the initial request with TS targeting. */ function installLatePublisherSlotHandoff(ts: TsjsApi): void { const win = window as GptWindow; @@ -555,10 +567,7 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { ts.prevGptSlots = (ts.prevGptSlots ?? []).filter( (ownedSlot) => ownedSlot !== existingSlot ); - if ( - handoff.gamUnitPath !== adUnitPath || - JSON.stringify(handoff.formats) !== JSON.stringify(formats) - ) { + if (handoff.gamUnitPath !== adUnitPath || !handoffFormatsMatch(handoff, formats)) { log.warn('GPT slot handoff: publisher definition differs from TS configuration', { elementId, tsGamUnitPath: handoff.gamUnitPath, diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 4a3719e9b..41356be98 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -53,6 +53,22 @@ type TestWindow = Window & { tsjs?: any; }; +type HandoffImplementation = 'bootstrap' | 'bundle'; + +async function installHandoff(implementation: HandoffImplementation): Promise { + if (implementation === 'bootstrap') { + const bootstrap = readFileSync( + resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' + ); + window.eval(bootstrap); + return; + } + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); +} + describe('installTsAdInit', () => { beforeEach(() => { vi.resetModules(); @@ -77,6 +93,8 @@ describe('installTsAdInit', () => { afterEach(() => { document.getElementById('div-atf-sidebar')?.remove(); + document.getElementById('div-atf-sidebar-2')?.remove(); + document.getElementById('div-size-hydrated')?.remove(); document.getElementById('ad-header-0-_r_1_')?.remove(); document.getElementById("ad'prefix-real")?.remove(); }); @@ -712,6 +730,257 @@ describe('installTsAdInit', () => { expect(handoff.publisherClaimed).toBe(false); }); + it.each(['bootstrap', 'bundle'] as const)( + 'does not hand a sibling slot to a TS fallback through the %s prefix path', + async (implementation) => { + const fallbackSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + }; + const siblingSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar-2'), + }; + const nativeDefineSlot = vi.fn().mockReturnValue(siblingSlot); + const nativeDisplay = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([fallbackSlot]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + }; + const handoff = { + gamUnitPath: '/123/mpu', + formats: [[300, 250]], + divIdPrefix: 'div-atf-sidebar', + slotElementId: 'div-atf-sidebar', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + const siblingElement = document.createElement('div'); + siblingElement.id = 'div-atf-sidebar-2'; + document.body.appendChild(siblingElement); + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { 'div-atf-sidebar': handoff }, + }; + + await installHandoff(implementation); + + const publisherSlot = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => typeof siblingSlot + )('/123/mpu', [[300, 250]], siblingElement.id); + (googletag.display as unknown as (target: string) => void)(siblingElement.id); + + expect(publisherSlot).toBe(siblingSlot); + expect(nativeDefineSlot).toHaveBeenCalledOnce(); + expect(nativeDisplay).toHaveBeenCalledWith(siblingElement.id); + expect(handoff.publisherClaimed).toBe(false); + expect(handoff.suppressPublisherDisplay).toBe(false); + } + ); + + it.each(['bootstrap', 'bundle'] as const)( + 'hands a publisher shorthand size to the TS fallback through the %s prefix path', + async (implementation) => { + const fallbackSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-size-original'), + }; + const nativeDefineSlot = vi.fn(); + const nativeDisplay = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([fallbackSlot]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + }; + const handoff = { + gamUnitPath: '/123/size', + formats: [[300, 250]], + divIdPrefix: 'div-size-', + slotElementId: 'div-size-original', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + const hydratedElement = document.createElement('div'); + hydratedElement.id = 'div-size-hydrated'; + document.body.appendChild(hydratedElement); + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { 'div-size-original': handoff }, + }; + + await installHandoff(implementation); + + const publisherSlot = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[], + elementId: string + ) => typeof fallbackSlot + )('/123/size', [300, 250], hydratedElement.id); + (googletag.display as unknown as (target: string) => void)(hydratedElement.id); + + expect(publisherSlot).toBe(fallbackSlot); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + expect(nativeDisplay).not.toHaveBeenCalled(); + expect(handoff.publisherClaimed).toBe(true); + expect(handoff.suppressPublisherDisplay).toBe(false); + } + ); + + it('filters only the claimed slot from the first bootstrap global refresh', () => { + const claimedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-claimed'), + }; + const unrelatedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), + }; + const nativeRefresh = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([claimedSlot, unrelatedSlot]), + refresh: nativeRefresh, + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn(), + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'div-claimed': { + gamUnitPath: '/123/claimed', + formats: [[300, 250]], + divIdPrefix: 'div-claimed', + slotElementId: 'div-claimed', + publisherClaimed: true, + suppressPublisherDisplay: false, + suppressPublisherRefresh: true, + }, + }, + }; + + return installHandoff('bootstrap').then(() => { + (pubads.refresh as () => void)(); + + expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); + expect((window as TestWindow).tsjs!.gptSlotHandoffs['div-claimed']).toEqual( + expect.objectContaining({ suppressPublisherRefresh: false }) + ); + }); + }); + + it('preserves refresh options while filtering a claimed bootstrap slot', () => { + const claimedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-claimed'), + }; + const unrelatedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), + }; + const nativeRefresh = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([claimedSlot, unrelatedSlot]), + refresh: nativeRefresh, + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn(), + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'div-claimed': { + gamUnitPath: '/123/claimed', + formats: [[300, 250]], + divIdPrefix: 'div-claimed', + slotElementId: 'div-claimed', + publisherClaimed: true, + suppressPublisherDisplay: false, + suppressPublisherRefresh: true, + }, + }, + }; + const refreshOptions = { changeCorrelator: false }; + + return installHandoff('bootstrap').then(() => { + (pubads.refresh as (slots: (typeof claimedSlot)[], options: typeof refreshOptions) => void)( + [claimedSlot, unrelatedSlot], + refreshOptions + ); + + expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot], refreshOptions); + }); + }); + + it('does not transfer an ambiguous hydrated publisher definition through bootstrap', () => { + const firstSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-prefix-original-a'), + }; + const secondSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-prefix-original-b'), + }; + const nativeDefineSlot = vi.fn().mockReturnValue(null); + const pubads = { + getSlots: vi.fn().mockReturnValue([firstSlot, secondSlot]), + refresh: vi.fn(), + }; + const firstHandoff = { + gamUnitPath: '/123/prefix', + formats: [[300, 250]], + divIdPrefix: 'div-prefix-', + slotElementId: 'div-prefix-original-a', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + const secondHandoff = { + ...firstHandoff, + slotElementId: 'div-prefix-original-b', + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'div-prefix-original-a': firstHandoff, + 'div-prefix-original-b': secondHandoff, + }, + }; + + return installHandoff('bootstrap').then(() => { + const defined = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => null + )('/123/prefix', [[300, 250]], 'div-prefix-hydrated'); + + expect(defined).toBeNull(); + expect(nativeDefineSlot).toHaveBeenCalledOnce(); + expect(firstHandoff.publisherClaimed).toBe(false); + expect(secondHandoff.publisherClaimed).toBe(false); + }); + }); + it('preserves refresh options while filtering a claimed disabled-load slot', async () => { type FakeSlot = { addService(service: unknown): FakeSlot; From 659d5182e0426dcf5d7b02534dc0a45615c48b77 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 13:09:53 -0500 Subject: [PATCH 07/16] Prevent duplicate GPT slot requests --- .../src/integrations/gpt.rs | 29 +++ .../src/integrations/gpt_bootstrap.js | 160 +++++++++++-- .../trusted-server-js/lib/src/core/types.ts | 18 ++ .../lib/src/integrations/gpt/index.ts | 164 ++++++++++++- .../lib/test/integrations/gpt/ad_init.test.ts | 174 +++++++++++++- ...-24-prevent-duplicate-gpt-slot-requests.md | 219 ++++++++++++++++++ ...vent-duplicate-gpt-slot-requests-design.md | 165 +++++++++++++ 7 files changed, 900 insertions(+), 29 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md create mode 100644 docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 4def0fe24..fae701756 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -1262,6 +1262,35 @@ mod tests { ); } + #[test] + fn head_inserts_bootstrap_installs_inner_div_slot_handoff() { + let integration = GptIntegration::new(test_config()); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let combined = integration.head_inserts(&ctx).join(""); + assert!( + combined.contains("gptSlotHandoffs"), + "bootstrap should keep late publisher slot handoff state on window.tsjs" + ); + assert!( + combined.contains("__tsSlotHandoffPatched"), + "bootstrap should install idempotent GPT handoff wrappers" + ); + assert!( + combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), + "bootstrap should define the TS fallback on the actual inner div" + ); + assert!( + !combined.contains("actualDivId + \"-container\""), + "bootstrap must not define a competing outer-container GPT slot" + ); + } + #[test] fn head_inserts_bootstrap_guards_enable_services_with_idempotency_flag() { let config = test_config(); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index a6408b693..7dfb541fa 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -110,6 +110,127 @@ else window.addEventListener("load", afterFrames, { once: true }); }; + function findSlotByElementId(pubads, elementId) { + var slots = pubads.getSlots ? pubads.getSlots() : []; + return ( + slots.find(function (slot) { + return slot.getSlotElementId() === elementId; + }) || null + ); + } + + function runHandoffInternal(callback) { + var wasInternal = ts.gptSlotHandoffInternal; + ts.gptSlotHandoffInternal = true; + try { + return callback(); + } finally { + ts.gptSlotHandoffInternal = wasInternal; + } + } + + // TS cannot wait an arbitrary amount of time for a framework to define a + // slot: publishers that never define one would render blank. Instead, TS + // defines its fallback on the actual inner div and aliases only a later + // publisher defineSlot() for that exact div to the same GPT slot. + function installSlotHandoff() { + window.googletag.cmd.push(function () { + var tag = window.googletag; + var pubads = tag.pubads && tag.pubads(); + if (!tag.defineSlot || !tag.display || !pubads) return; + + if (!tag.defineSlot.__tsSlotHandoffPatched) { + var originalDefineSlot = tag.defineSlot.bind(tag); + var patchedDefineSlot = function (adUnitPath, formats, elementId) { + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if (!ts.gptSlotHandoffInternal && handoff) { + var existingSlot = findSlotByElementId(pubads, elementId); + if (existingSlot) { + if (!handoff.publisherClaimed) { + handoff.publisherClaimed = true; + handoff.suppressPublisherDisplay = true; + handoff.suppressPublisherRefresh = + ts.gptInitialLoadDisabled === true; + ts.prevGptSlots = (ts.prevGptSlots || []).filter( + function (ownedSlot) { + return ownedSlot !== existingSlot; + }, + ); + if ( + handoff.gamUnitPath !== adUnitPath || + JSON.stringify(handoff.formats) !== JSON.stringify(formats) + ) { + ts.log && + ts.log.warn && + ts.log.warn( + "GPT slot handoff: publisher definition differs from TS configuration", + elementId, + ); + } + } + return existingSlot; + } + } + return originalDefineSlot(adUnitPath, formats, elementId); + }; + patchedDefineSlot.__tsSlotHandoffPatched = true; + tag.defineSlot = patchedDefineSlot; + } + + if (!tag.display.__tsSlotHandoffPatched) { + var originalDisplay = tag.display.bind(tag); + var patchedDisplay = function (elementId) { + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if ( + !ts.gptSlotHandoffInternal && + handoff && + handoff.suppressPublisherDisplay + ) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(elementId); + }; + patchedDisplay.__tsSlotHandoffPatched = true; + tag.display = patchedDisplay; + } + + if (!pubads.refresh.__tsSlotHandoffPatched) { + var originalRefresh = pubads.refresh.bind(pubads); + var patchedRefresh = function (requestedSlots) { + if (ts.gptSlotHandoffInternal) { + originalRefresh(requestedSlots); + return; + } + var slots = + requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); + if (!slots) { + originalRefresh(requestedSlots); + return; + } + var suppressed = false; + var remainingSlots = slots.filter(function (slot) { + var handoff = + ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; + if (!handoff || !handoff.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + }); + if (!suppressed) { + originalRefresh(requestedSlots); + } else if (remainingSlots.length > 0) { + originalRefresh(remainingSlots); + } + }; + patchedRefresh.__tsSlotHandoffPatched = true; + pubads.refresh = patchedRefresh; + } + }); + } + + installSlotHandoff(); + ts.adInit = function () { var slots = ts.adSlots || []; var bids = ts.bids || {}; @@ -162,15 +283,26 @@ }) || null; var tsOwned = false; if (!s) { - // Use outer container div for TS's slot when publisher hasn't defined - // theirs yet — keeps both slots on separate divs so publisher's - // later defineSlot on the inner div doesn't conflict. - var containerEl = document.getElementById(actualDivId + "-container"); - var slotDivId = containerEl ? containerEl.id : actualDivId; - s = googletag.defineSlot(slot.gam_unit_path, slot.formats, slotDivId); + // Define TS's fallback on the publisher's actual div. The scoped + // handoff wrapper returns this slot if the publisher defines it later. + s = runHandoffInternal(function () { + return googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + actualDivId, + ); + }); if (!s) return; s.addService(googletag.pubads()); tsOwned = true; + ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; + ts.gptSlotHandoffs[actualDivId] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; } Object.entries(slot.targeting || {}).forEach(function (e) { @@ -187,11 +319,9 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); - // Map both the inner div and the GPT slot's element ID (the - // "-container" div when TS defined the slot there) into divToSlotId. - // This bootstrap fires no beacons and registers no slotRenderEnded - // listener; the map is consumed by the bundle's render bridge (index.ts) - // once it loads, which reports the GPT slot element ID. + // Map the resolved inner div to the slot ID. This bootstrap fires no + // beacons and registers no slotRenderEnded listener; the map is consumed + // by the bundle's render bridge (index.ts) once it loads. divToSlotId[actualDivId] = slot.id; var slotElementId = s.getSlotElementId(); if (slotElementId && slotElementId !== actualDivId) { @@ -217,7 +347,9 @@ // impression. Runs after enableServices(); on SPA navigation services are // already enabled, so this runs unconditionally for new slots. slotsToDisplay.forEach(function (divId) { - googletag.display(divId); + runHandoffInternal(function () { + googletag.display(divId); + }); }); // Reused publisher-owned slots always need a refresh to pick up the // server-side targeting. TS-defined slots are fetched by display() above @@ -236,7 +368,9 @@ // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. ts.adInitRefreshInProgress = true; try { - googletag.pubads().refresh(slotsNeedingRefresh); + runHandoffInternal(function () { + googletag.pubads().refresh(slotsNeedingRefresh); + }); } finally { ts.adInitRefreshInProgress = false; } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index f26088db2..16310f747 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -175,6 +175,20 @@ export interface GptDiagnosticsApi { hide(): void; } +/** + * Lifecycle state for a GPT slot TS created before its publisher declares it. + * + * Stored on `window.tsjs` so the head bootstrap and the full TSJS bundle share + * one handoff protocol. + */ +export interface GptSlotHandoff { + gamUnitPath: string; + formats: Array<[number, number]>; + publisherClaimed: boolean; + suppressPublisherDisplay: boolean; + suppressPublisherRefresh: boolean; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -235,6 +249,10 @@ export interface TsjsApi { * slots so they are not left blank. */ gptInitialLoadDisabled?: boolean; + /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ + gptSlotHandoffs?: Record; + /** True only while TS calls a GPT function that the handoff wrappers observe. */ + gptSlotHandoffInternal?: boolean; /** Guards SPA pushState hook installation. */ spaHookInstalled?: boolean; /** diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 30cfbbbdd..c31b35f7f 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; +import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -552,12 +552,146 @@ function installScheduleInitialAdInit(ts: TsjsApi): void { window.addEventListener('load', afterHydrationFrames, { once: true }); } }; + +interface HandoffPatchedFunction { + __tsSlotHandoffPatched?: boolean; +} + +function findGptSlotByElementId( + pubads: GoogleTagPubAdsService, + elementId: string +): GoogleTagSlot | undefined { + return pubads.getSlots?.().find((slot) => slot.getSlotElementId() === elementId); +} + +function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | undefined { + return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; +} + +function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { + const wasInternal = ts.gptSlotHandoffInternal; + ts.gptSlotHandoffInternal = true; + try { + return callback(); + } finally { + ts.gptSlotHandoffInternal = wasInternal; + } +} + +/** + * Reuse a TS-created inner-div slot when its publisher defines that div later. + * + * TS cannot wait an arbitrary amount of time for framework hydration: doing so + * would leave placements blank when no publisher slot is ever defined. Instead, + * TS creates its fallback on the publisher's actual div and aliases only a later + * `defineSlot()` for that exact div. The first duplicate publisher request is + * suppressed because TS has already issued the initial request with TS targeting. + */ +function installLatePublisherSlotHandoff(ts: TsjsApi): void { + const win = window as GptWindow; + const cmd = win.googletag?.cmd; + if (!cmd) return; + + cmd.push(() => { + const g = win.googletag; + const pubads = g?.pubads?.(); + if (!g?.defineSlot || !g.display || !pubads) return; + + const defineSlot = g.defineSlot; + if (!(defineSlot as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalDefineSlot = defineSlot.bind(g); + const patchedDefineSlot = ( + adUnitPath: string, + formats: Array, + elementId: string + ): GoogleTagSlot | null => { + const handoff = ts.gptSlotHandoffs?.[elementId]; + if (!ts.gptSlotHandoffInternal && handoff) { + const existingSlot = findGptSlotByElementId(pubads, elementId); + if (existingSlot) { + if (!handoff.publisherClaimed) { + handoff.publisherClaimed = true; + handoff.suppressPublisherDisplay = true; + handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; + ts.prevGptSlots = (ts.prevGptSlots ?? []).filter( + (ownedSlot) => ownedSlot !== existingSlot + ); + if ( + handoff.gamUnitPath !== adUnitPath || + JSON.stringify(handoff.formats) !== JSON.stringify(formats) + ) { + log.warn('GPT slot handoff: publisher definition differs from TS configuration', { + elementId, + tsGamUnitPath: handoff.gamUnitPath, + publisherGamUnitPath: adUnitPath, + }); + } + } + return existingSlot; + } + } + return originalDefineSlot(adUnitPath, formats, elementId); + }; + (patchedDefineSlot as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + g.defineSlot = patchedDefineSlot; + } + + const display = g.display; + if (!(display as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalDisplay = display.bind(g); + const patchedDisplay = (elementId: string): void => { + const handoff = ts.gptSlotHandoffs?.[elementId]; + if (!ts.gptSlotHandoffInternal && handoff?.suppressPublisherDisplay) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(elementId); + }; + (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + g.display = patchedDisplay; + } + + const refresh = pubads.refresh; + if (!(refresh as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalRefresh = refresh.bind(pubads); + const patchedRefresh = (requestedSlots?: GoogleTagSlot[]): void => { + if (ts.gptSlotHandoffInternal) { + originalRefresh(requestedSlots); + return; + } + + const slots = requestedSlots ?? pubads.getSlots?.(); + if (!slots) { + originalRefresh(requestedSlots); + return; + } + + let suppressed = false; + const remainingSlots = slots.filter((slot) => { + const handoff = handoffForSlot(ts, slot); + if (!handoff?.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + }); + if (!suppressed) { + originalRefresh(requestedSlots); + } else if (remainingSlots.length > 0) { + originalRefresh(remainingSlots); + } + }; + (patchedRefresh as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + pubads.refresh = patchedRefresh; + } + }); } export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); installInitialLoadDetector(ts); installScheduleInitialAdInit(ts); + + installLatePublisherSlotHandoff(ts); ts.adInit = function () { const slots = ts.adSlots ?? []; // Snapshot bids at adInit() call time — correct for targeting setup. @@ -635,16 +769,23 @@ export function installTsAdInit(): void { if (existingSlot) { gptSlot = existingSlot; } else { - // Use outer container div for TS's slot when publisher hasn't defined - // theirs yet — keeps both slots on separate divs so publisher's - // later defineSlot on the inner div doesn't conflict. - const containerEl = document.getElementById(`${actualDivId}-container`); - const slotDivId = containerEl?.id ?? actualDivId; - const defined = g.defineSlot?.(slot.gam_unit_path, slot.formats, slotDivId); + // Define TS's fallback on the publisher's actual div. A late publisher + // defineSlot() for this div is handed the same slot by the scoped GPT + // wrapper, preventing a competing container-slot request. + const defined = withGptSlotHandoffInternal(ts, () => + g.defineSlot?.(slot.gam_unit_path, slot.formats, actualDivId) + ); if (!defined) return; defined.addService(g.pubads!()); gptSlot = defined; tsOwned = true; + (ts.gptSlotHandoffs ??= {})[actualDivId] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; } const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; @@ -659,9 +800,8 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); - // Map both inner div and container div → slot ID so slotRenderEnded - // (which reports the GPT slot's div, i.e. slotDivId/container) can look up - // the slot, while adm injection (which targets the inner div) also works. + // Map the resolved inner div to the slot ID so slotRenderEnded and ADM + // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; if (slotDivId2 !== actualDivId) divToSlotId[slotDivId2] = slot.id; const slotTargetingKeys = Object.keys(slot.targeting ?? {}); @@ -728,7 +868,7 @@ export function installTsAdInit(): void { // called without a matching display call") and misses its impression. // Must run after enableServices(); on SPA navigation services are already // enabled, so this runs unconditionally for any newly-defined slots. - slotsToDisplay.forEach((divId) => g.display?.(divId)); + slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); // Slots needing an explicit ad request via refresh(). Reused // publisher-owned slots always need one to pick up the just-applied @@ -752,7 +892,7 @@ export function installTsAdInit(): void { // the same slots still go through the wrapper normally. ts.adInitRefreshInProgress = true; try { - g.pubads!().refresh(slotsNeedingRefresh); + withGptSlotHandoffInternal(ts, () => g.pubads!().refresh(slotsNeedingRefresh)); } finally { ts.adInitRefreshInProgress = false; } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index e295aa7b1..10c85036d 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -181,12 +181,13 @@ describe('installTsAdInit', () => { getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue([]), }; + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), // Publisher has not defined this slot, so TS defines (owns) it. getSlots: vi.fn().mockReturnValue([]), addEventListener: vi.fn(), - refresh: vi.fn(), + refresh: nativeRefresh, }; const defineSlotMock = vi.fn().mockReturnValue(mockSlot); const displayMock = vi.fn(); @@ -219,7 +220,171 @@ describe('installTsAdInit', () => { expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); // TS-owned slots are displayed, not refreshed (refresh() no-ops for a slot // that was never displayed). - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); + }); + + it('hands a late publisher definition the TS inner-div slot without a second request', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const slots = new Map(); + const requests: string[] = []; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: vi.fn((requestedSlots?: FakeSlot[]) => { + (requestedSlots ?? Array.from(slots.values())).forEach((slot) => + requests.push(slot.getSlotElementId()) + ); + }), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); + const destroySlots = vi.fn(); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + destroySlots, + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + const publisherDefineSlot = googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot; + const publisherDisplay = googletag.display as unknown as (elementId: string) => void; + const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); + publisherSlot.addService(pubads); + publisherDisplay('div-atf-sidebar'); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['div-atf-sidebar']); + expect((window as TestWindow).tsjs!.prevGptSlots).toEqual([]); + + (window as TestWindow).tsjs!.adSlots = []; + (window as TestWindow).tsjs!.adInit!(); + expect(destroySlots).not.toHaveBeenCalled(); + }); + + it('suppresses only the claimed slot from the first disabled-load publisher refresh', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const slots = new Map(); + const requests: string[] = []; + let initialLoadDisabled = false; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: vi.fn((requestedSlots?: FakeSlot[]) => { + (requestedSlots ?? Array.from(slots.values())).forEach((slot) => + requests.push(slot.getSlotElementId()) + ); + }), + disableInitialLoad: vi.fn(() => { + initialLoadDisabled = true; + }), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((elementId: string) => { + if (!initialLoadDisabled) requests.push(elementId); + }); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + pubads.disableInitialLoad(); + (window as TestWindow).tsjs!.adInit!(); + + const publisherDefineSlot = googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot; + const publisherDisplay = googletag.display as unknown as (elementId: string) => void; + const publisherRefresh = pubads.refresh as unknown as () => void; + const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); + publisherSlot.addService(pubads); + publisherDisplay('div-atf-sidebar'); + slots.set('div-unrelated', makeSlot('div-unrelated')); + publisherRefresh(); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(requests.filter((elementId) => elementId === 'div-atf-sidebar')).toHaveLength(1); + expect(requests).toContain('div-unrelated'); }); it('refreshes TS-defined slots when the publisher disabled GPT initial load', async () => { @@ -232,12 +397,13 @@ describe('installTsAdInit', () => { getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue([]), }; + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), // Publisher has not defined this slot, so TS defines (owns) it. getSlots: vi.fn().mockReturnValue([]), addEventListener: vi.fn(), - refresh: vi.fn(), + refresh: nativeRefresh, disableInitialLoad: vi.fn(), }; const getConfigMock = vi.fn().mockReturnValue(undefined); @@ -277,7 +443,7 @@ describe('installTsAdInit', () => { // The slot is still registered via display(), and additionally refreshed so // it actually requests an ad under disableInitialLoad(). expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); }); it('preserves legacy state in the edge bootstrap when getConfig does not report it', async () => { diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md new file mode 100644 index 000000000..4699e3c42 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -0,0 +1,219 @@ +# Prevent Duplicate GPT Slot Requests — Implementation Plan + +> **Status:** Implemented locally; production-like browser validation remains pending. +> +> **Spec:** `docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md` + +**Goal:** Ensure one GPT slot and one initial request per configured placement when +TS `adInit()` runs before a publisher later defines the placement's inner GPT div. + +**Architecture:** TS creates its fallback on the resolved inner div and records a +handoff claim. Narrow, idempotent wrappers around GPT's `defineSlot`, `display`, and +`pubads().refresh` alias a matching late publisher definition to that slot and +suppress only the duplicate initial publisher request. A successful handoff transfers +SPA-destruction ownership to the publisher. The head bootstrap and full TSJS bundle +share this runtime protocol through `window.tsjs`. + +**Primary files:** + +- `crates/trusted-server-js/lib/src/core/types.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` +- `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- `crates/trusted-server-core/src/integrations/gpt.rs` + +## Preconditions + +- [ ] Confirm with the issue owner that the intended late-owner behavior is slot + handoff (publisher receives the existing inner-div slot), not a hydration-delay + policy. +- [ ] Capture representative publisher call sequences for normal initial load and + `disableInitialLoad()` before changing wrappers. The expected sequence is + `defineSlot` → `addService` → `display`; initial-load-disabled pages additionally + call `refresh`. +- [ ] Establish an automated fake-GPT request counter: calling native `display` with + initial load enabled, or native `refresh` with initial load disabled, records a + request. Assertions must use this counter rather than only `getSlots()`. + +## Task 1: Add the shared handoff state and typed GPT wrapper surface + +**Files:** + +- Modify `crates/trusted-server-js/lib/src/core/types.ts` +- Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` + +- [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must + retain serializable lifecycle flags: TS-created, ownership-transferred, initial + request made, and one-shot publisher display/refresh suppression state. +- [ ] Add only the minimal optional/internal type surface needed for idempotence + markers on GPT functions and `pubads`. Do not weaken the public GPT types with + `any`. +- [ ] Add helper functions in `index.ts` to: + - find a live GPT slot by exact element ID; + - register and retrieve a claim; + - remove a transferred slot from `ts.prevGptSlots`; + - run an internal TS GPT call behind a short-lived guard; + - filter a requested refresh list (including no-argument/global refresh) by the + entries whose one-shot publisher refresh must be suppressed. +- [ ] Keep the registry on `window.tsjs`, not in module scope, so the bootstrap state + survives bundle loading. + +**Focused checks:** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index.test.ts +``` + +## Task 2: Install scoped idempotent handoff wrappers + +**File:** `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` + +- [ ] From the GPT command queue, install wrappers once GPT exposes the real methods. + Mark the wrapped functions/service so a later `installTsAdInit()` call or the + bootstrap-to-bundle handoff cannot stack wrappers. +- [ ] `defineSlot` wrapper: + - pass through TS-internal calls and IDs absent from the registry; + - for a late publisher call on a claimed inner div, find and return the existing + slot without calling native `defineSlot`; + - mark ownership transferred and remove that slot from `prevGptSlots` before + returning it; + - log, but do not create a second slot, if publisher arguments differ from the TS + configuration. +- [ ] `display` wrapper: consume the one permitted publisher post-handoff display + call without invoking native `display`; pass every other call through unchanged. +- [ ] `refresh` wrapper: when initial load was disabled, consume the one permitted + post-handoff refresh for each claimed slot. If called with no slot list, expand + `getSlots()`, filter only the claimed slots, and forward the remaining slots + explicitly. Preserve all unrelated refreshes. +- [ ] Ensure wrapper installation precedes the fallback definition path and does not + change existing publisher-owned-slot behavior. + +**Focused checks:** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt/ad_init.test.ts +``` + +## Task 3: Change fallback creation to the actual inner div + +**File:** `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` + +- [ ] Delete the `${actualDivId}-container` fallback selection. When no existing + publisher slot is found, call `defineSlot` with `actualDivId`. +- [ ] Register the handoff claim immediately after successful TS definition. +- [ ] Keep `display()` for TS-created slots; with initial load disabled, retain the + single TS `refresh()` that makes the required initial request. +- [ ] Simplify `divToSlotId` and `prevSlotTargetingKeys` to the actual inner div; + remove only mappings that existed exclusively for the container fallback. +- [ ] On SPA navigation, destroy only claims that remain TS-owned. A transferred + claim must participate in stale-targeting cleanup but never be passed to + `destroySlots()`. +- [ ] Retain exact match then prefix-based dynamic-ID lookup; do not interpolate + publisher-provided IDs into CSS selectors. + +## Task 4: Add request-level regression coverage for the full bundle + +**Files:** + +- Modify `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` if the + shared wrapper setup belongs there + +- [ ] Introduce a reusable fake GPT fixture that models slots by element ID and + records native `defineSlot`, `display`, `refresh`, and request events. Its + `getSlots()` result must update when a slot is defined so the test cannot pass by + asserting a stale static array. +- [ ] Add a failing regression test for the critical sequence: + 1. TS finds the inner div and runs `adInit()` before publisher setup; + 2. TS defines/displays the inner div and makes one request; + 3. publisher calls `defineSlot(innerDiv).addService(...); display(innerDiv)`; + 4. assert native `defineSlot` was called once, there is one slot, and there is one + request. +- [ ] Add the same sequence with `disableInitialLoad()`: TS display plus its refresh + makes one request; the publisher's first refresh cannot make a second request. +- [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert + the claimed slot is suppressed once and the unrelated slot is refreshed. +- [ ] Add an already publisher-owned test proving TS does not install a claim, applies + targeting, and refreshes that slot. +- [ ] Add a no-publisher test proving TS still creates, displays, and requests its + inner-div slot exactly once. +- [ ] Add a SPA handoff test: after late publisher claim, the next `adInit()` does not + destroy the transferred slot, clears old TS keys, and reapplies current-route + targeting. +- [ ] Retain or extend the dynamic prefix-ID test to prove a resolved runtime ID is + the handoff key. + +## Task 5: Mirror the runtime protocol in the head bootstrap + +**Files:** + +- Modify `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- Modify `crates/trusted-server-core/src/integrations/gpt.rs` + +- [ ] Port the same actual-inner-div fallback, registry names, lifecycle flags, and + idempotence markers to the plain-JavaScript bootstrap. +- [ ] Use the existing bootstrap `window.tsjs` properties exactly so `index.ts` can + adopt the initial claim after the bundle loads. +- [ ] Ensure its internal definition/display/refresh calls use the same guards as the + bundle; bootstrap must not transfer or suppress its own operations. +- [ ] Extend the `gpt.rs` head-insert tests to assert that the bootstrap contains the + inner-div handoff protocol and no longer contains the container fallback. +- [ ] Add an executable bootstrap behavior test if practical by evaluating the + injected script against the same fake GPT fixture. If the test setup cannot execute + the included asset without duplication, record that limitation and keep the Rust + source-contract assertion plus identical bundle lifecycle tests as the minimum + coverage. + +## Task 6: Validate, inspect, and ship + +- [ ] Run focused request-level tests: + + ```bash + cd crates/trusted-server-js/lib + npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index.test.ts + ``` + +- [ ] Run all TSJS tests and formatting: + + ```bash + cd crates/trusted-server-js/lib + npx vitest run + npm run format + ``` + +- [ ] Run the target-matched Rust tests that cover the embedded bootstrap, followed by + project formatting and linting: + + ```bash + cargo test-axum + cargo fmt --all -- --check + cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare + ``` + +- [ ] Before PR handoff, run the full required CI gates from `CLAUDE.md`, including + Fastly, Axum, Cloudflare, Spin, integration parity, JS build/tests/format, and docs + format. +- [ ] Review the diff specifically for bootstrap/bundle protocol drift and for any + use of container IDs in GPT slot creation. +- [ ] In a controlled production-like browser capture, verify one initial request for + each affected visible placement and independently verify an unrelated placement + remains requestable. +- [ ] Update issue #944 with the ownership-handoff decision, test evidence, and + browser-capture result. + +## Stop conditions + +Stop and return to design review instead of adding heuristics if any of these occur: + +- A publisher relies on a late `defineSlot` with materially different path or size + arguments and cannot accept the existing TS slot. +- The publisher's first initial-load-disabled refresh cannot be identified without + suppressing unrelated legitimate refreshes. +- A cross-bundle bootstrap handoff requires module-local identity that cannot be + represented safely through `window.tsjs`. +- Browser validation shows a second request despite native `defineSlot`/`display`/ + `refresh` suppression; capture the GPT event ordering before choosing another + strategy. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md new file mode 100644 index 000000000..770718199 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -0,0 +1,165 @@ +# Prevent Duplicate GPT Slot Requests — Design Specification + +## Problem + +When `tsjs.adInit()` executes before a publisher's framework later calls +`googletag.defineSlot()` for the same placement, TS currently defines and displays a +slot on the outer `-container` element. The publisher subsequently defines and +displays an inner-div slot. These are distinct GPT slots, so they make separate GAM +requests for one visible placement. + +The affected paths are deliberately duplicated today: + +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` is the full bundle + implementation used after the TSJS bundle loads. +- `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` is the head-injected + implementation that can make the initial request before the bundle loads. + +A fix must keep both implementations in sync. + +## Goals + +1. A configured placement has at most one initial GPT slot and ad request when TS + runs before a publisher defines its inner div. +2. Apply TS targeting and the `ts_initial=1` marker before that single initial + request. +3. Continue reusing a slot that the publisher has already defined. +4. Keep the TS-only fallback: if the publisher never defines the placement, TS still + displays it and makes exactly one initial request. +5. Preserve `disableInitialLoad()`, SPA targeting cleanup, and the rule that TS does + not destroy genuinely publisher-owned slots. +6. Keep dynamic div-ID prefix resolution intact. + +## Non-goals + +- Deduplicating by GAM ad-unit path. Multiple visible placements may validly share a + path. +- Changing publisher GAM configuration, line items, or refresh policy. +- Delaying the initial TS request while waiting an arbitrary amount of time for + framework hydration. A time-based grace period cannot distinguish a slow + publisher-owned slot from a placement that the publisher will never define. +- General interception of unrelated GPT slots. + +## Decision: one inner-div slot with late-definition handoff + +TS will define its fallback slot on the **actual inner div**, never on its outer +`-container` element. It will record a narrowly scoped handoff claim keyed by that +inner div ID. A `googletag.defineSlot` wrapper then recognizes a later publisher +request for that exact div and returns the existing TS slot rather than invoking +GPT's native `defineSlot` again. + +GPT requires a one-to-one slot-to-div relationship and documents that a slot should +be displayed only once. Sharing the initial inner-div slot therefore avoids both the +competing container slot and an invalid duplicate definition. + +### Lifecycle + +1. **Already publisher-owned** — `getSlots()` finds a slot for the resolved inner + div. TS applies targeting, records it as publisher-owned, and refreshes it as it + does today. +2. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, + enables services when needed, and displays it. When initial load is disabled, TS + performs its existing one explicit refresh. TS records this slot as TS-owned and + handoff-eligible. +3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded + inner-div claim, returns the existing slot, and transfers ownership: it removes + the slot from TS's future `destroySlots()` set. The publisher's setup continues + against that same slot. +4. **Publisher's first request call** — the wrapper suppresses the duplicate + publisher `display()` call. With `disableInitialLoad()`, it instead suppresses + only the publisher's first refresh for the transferred slot, because TS has + already issued the required initial refresh. For a no-argument/global refresh, + the wrapper must expand `getSlots()`, remove only the one-shot suppressed slots, + and forward the remaining slots explicitly so unrelated slots still refresh. +5. **Later refreshes and SPA navigation** — after the one-shot suppression is + consumed, publisher refreshes are untouched. On navigation, TS clears its + targeting from the shared slot and may reuse it for the next route; it must not + destroy a slot after ownership has transferred. + +The wrapper is not a global deduplicator. It only handles IDs present in TS's +handoff registry and must preserve native `defineSlot`, `display`, and `refresh` +behavior for every other placement. + +## Implementation shape + +### Shared runtime state + +Add a small, serializable `window.tsjs` registry that both initial implementations +can read after the bundle replaces the bootstrap implementation. It is keyed by the +resolved actual div ID and records at least: + +- whether TS created the slot and whether ownership has transferred; +- whether one publisher `display()` or initial-load-disabled `refresh()` remains to + suppress. + +Do not rely only on module-local state: the bootstrap can define the initial slot +before `index.ts` is loaded. Look up the live slot by element ID through +`pubads().getSlots()` when a wrapper needs it. + +Install idempotent markers on the wrapped GPT functions/services so the bootstrap and +bundle do not stack wrappers. Each wrapper must retain and call the original bound +function for non-claimed slots. Internal TS calls need a short-lived guard so the +wrappers do not mistake TS's own `defineSlot`, `display`, or `refresh` for a +publisher handoff. + +### Full bundle + +In `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: + +- Replace the container fallback with `actualDivId`. +- Add the typed handoff-registry state to `TsjsApi` in + `crates/trusted-server-js/lib/src/core/types.ts`. +- Install the idempotent `defineSlot`, `display`, and `pubads().refresh` handoff + wrappers from the GPT command queue before `adInit()` can create a fallback slot. +- When a late publisher definition is aliased to the existing slot, remove it from + `prevGptSlots` and mark it transferred before returning it. +- Keep targeting cleanup keyed by the real inner div. Remove the old dual + inner/container mappings because the slot element ID is now the inner div. + +### Head bootstrap + +Mirror the same ownership registry and wrappers in +`crates/trusted-server-core/src/integrations/gpt_bootstrap.js`. The bootstrap must +leave the registry and idempotence markers in `window.tsjs` so the full bundle adopts +rather than re-wraps or reclaims the initial slot. + +This duplication is intentional for now: the head bootstrap is needed to apply +server-side targeting before the normal bundle becomes available. The regression +suite must exercise both implementations' observable contract. + +## Compatibility rules and risks + +| Risk | Mitigation | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Publisher passes a different ad-unit path or sizes in its late `defineSlot` call | Return the existing claimed slot but log a diagnostic. Do not define a second slot. Treat the TS configuration and publisher configuration mismatch as an integration error to resolve separately. | +| Publisher invokes global `refresh()` after `disableInitialLoad()` | Filter the one-shot claimed slot from the expanded slot list and refresh all remaining slots. A no-argument refresh must not be silently dropped. | +| Publisher calls a legitimate refresh without an initial display | The one-shot suppression is consumed only immediately after a successful late handoff. Document and test the standard publisher sequence (`defineSlot` → `addService` → `display`, with `refresh` when initial load is disabled). Escalate unusual publisher lifecycle requirements rather than adding a time heuristic. | +| Publisher-owned slot is destroyed on SPA navigation | Transfer ownership synchronously in the `defineSlot` wrapper and remove the slot from `prevGptSlots`. | +| Bootstrap and bundle diverge | Give both paths the same black-box regression cases; retain a Rust source-contract assertion for bootstrap-specific sentinels. | +| A framework creates the inner element only after `adInit()` | TS still skips an absent element, as it does today; when the publisher owns that later-created slot it will not be duplicated. Supplying TS targeting to such a slot is a separate readiness problem, not part of this duplicate-request fix. | + +## Acceptance criteria + +- A late `defineSlot(innerDiv)` aliases the already-created inner-div TS slot; native + `defineSlot` is not called a second time for that placement. +- Request instrumentation records one initial request for the placement in normal and + initial-load-disabled modes. +- The late publisher `display()` (and its first initial-load-disabled refresh) cannot + create a second request, while unrelated slots retain their normal calls. +- Existing publisher slots are still reused and receive TS targeting. +- A slot that no publisher claims is displayed and requested once by TS. +- A transferred slot is absent from TS's SPA `destroySlots()` argument; targeting is + still cleared and reapplied correctly on the next route. +- Dynamic resolved div IDs work without constructing a CSS selector from the ID. +- Bootstrap and bundle paths pass the same ownership/request assertions. + +## Validation + +1. Add focused Vitest lifecycle tests with a fake GPT that records native + `defineSlot`, `display`, `refresh`, and synthetic request events. +2. Run the focused GPT test files, then the full TSJS Vitest suite and formatter. +3. Run the target-matched Rust test suite so the included bootstrap and its source + assertions compile and pass. +4. In a controlled browser capture, verify that one configured header and one + configured fixed placement each produce one initial slot request, while a distinct + in-content placement remains independently requestable. From b07f0dff4107feb70542695704dd7266d0569ada Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 14:14:54 -0500 Subject: [PATCH 08/16] Gate publisher GPT requests until targeting is ready --- .../src/integrations/gpt.rs | 7 +- .../src/integrations/gpt_bootstrap.js | 108 +++++++++++--- .../trusted-server-js/lib/src/core/types.ts | 11 ++ .../lib/src/integrations/gpt/index.ts | 134 +++++++++++++---- .../lib/test/integrations/gpt/ad_init.test.ts | 137 +++++++++++++++++- ...-24-prevent-duplicate-gpt-slot-requests.md | 50 ++++--- ...vent-duplicate-gpt-slot-requests-design.md | 60 +++++--- 7 files changed, 401 insertions(+), 106 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index fae701756..b93d4122d 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -474,7 +474,8 @@ impl IntegrationHeadInjector for GptIntegration { /// ## Scroll / refresh handoff contract (Phase 1) /// /// `tsjs.adInit` handles **initial render only**: it wires server-side bid - /// targeting into GPT slots and refreshes them. Win/billing beacons fire + /// targeting into GPT slots and replays only publisher requests held until + /// that targeting was available. Win/billing beacons fire /// only from the TS render bridge in the JS bundle, where a matching /// Prebid Universal Creative request proves the TS creative rendered. /// It does **not** trigger refresh auctions or handle GPT slot refresh events. @@ -1281,6 +1282,10 @@ mod tests { combined.contains("__tsSlotHandoffPatched"), "bootstrap should install idempotent GPT handoff wrappers" ); + assert!( + combined.contains("gptInitialRequestGate") && combined.contains("pendingDisplays"), + "bootstrap should hold configured publisher requests until initial targeting is applied" + ); assert!( combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), "bootstrap should define the TS fallback on the actual inner div" diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 7dfb541fa..b0e10fbc6 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -10,8 +10,8 @@ // - Both implementations must set `window.tsjs.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a // subsequent call becomes a no-op. -// - `refresh()` is called only for the slots defined in this pass, -// never the global slot list. +// - `refresh()` is called only for TS-defined slots in this pass and +// publisher requests the initial gate held, never the global slot list. // // Only installed if `window.tsjs.adInit` isn't already defined. (function () { @@ -119,6 +119,45 @@ ); } + function configuredSlotForElementId(elementId) { + return (ts.adSlots || []).find(function (slot) { + return ( + slot.div_id && + (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && + !elementId.endsWith("-container") + ); + }); + } + + function initialRequestGate() { + if (!ts.gptInitialRequestGate) { + ts.gptInitialRequestGate = { + pendingDisplays: {}, + pendingRefreshes: {}, + released: false, + }; + } + return ts.gptInitialRequestGate; + } + + function takeInitialPublisherRequests(pubads) { + var gate = initialRequestGate(); + if (gate.released) return { displayIds: [], refreshSlots: [] }; + + gate.released = true; + var displayIds = Object.keys(gate.pendingDisplays); + var refreshIds = Object.keys(gate.pendingRefreshes); + gate.pendingDisplays = {}; + gate.pendingRefreshes = {}; + var slots = pubads.getSlots ? pubads.getSlots() : []; + return { + displayIds: displayIds, + refreshSlots: slots.filter(function (slot) { + return refreshIds.includes(slot.getSlotElementId()); + }), + }; + } + function runHandoffInternal(callback) { var wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -189,6 +228,15 @@ handoff.suppressPublisherDisplay = false; return; } + var gate = initialRequestGate(); + if ( + !ts.gptSlotHandoffInternal && + !gate.released && + configuredSlotForElementId(elementId) + ) { + gate.pendingDisplays[elementId] = true; + return; + } originalDisplay(elementId); }; patchedDisplay.__tsSlotHandoffPatched = true; @@ -209,13 +257,22 @@ return; } var suppressed = false; + var gate = initialRequestGate(); var remainingSlots = slots.filter(function (slot) { var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; - if (!handoff || !handoff.suppressPublisherRefresh) return true; - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; + if (handoff && handoff.suppressPublisherRefresh) { + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + } + var elementId = slot.getSlotElementId(); + if (!gate.released && configuredSlotForElementId(elementId)) { + gate.pendingRefreshes[elementId] = true; + suppressed = true; + return false; + } + return true; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -246,13 +303,15 @@ // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. var newSlots = []; - // Publisher-owned slots TS reused — refreshed to pick up server-side - // targeting. The publisher already display()ed these. + // Publisher-owned slots can be refreshed on SPA navigation. On initial + // load their first request is held until the targeting below is applied. var slotsToRefresh = []; + var isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself. GPT requires display() to // register/render a freshly-defined slot; refresh() alone no-ops for a // slot that was never displayed, so these are display()ed instead. var slotsToDisplay = []; + var hasAppliedTargeting = false; slots.forEach(function (slot) { // Resolve actual div ID: exact match first, then safe prefix scan. // div_id in config may be a stable prefix (e.g. "ad-header-0-") when @@ -319,6 +378,7 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); + hasAppliedTargeting = true; // Map the resolved inner div to the slot ID. This bootstrap fires no // beacons and registers no slotRenderEnded listener; the map is consumed // by the bundle's render bridge (index.ts) once it loads. @@ -331,35 +391,37 @@ newSlots.push(s); var displayId = s.getSlotElementId() || actualDivId; slotsToDisplay.push(displayId); - } else { + } else if (!isInitialAdInit) { slotsToRefresh.push(s); } }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; - if (!ts.servicesEnabled) { + var heldPublisherRequests = isInitialAdInit + ? takeInitialPublisherRequests(googletag.pubads()) + : { displayIds: [], refreshSlots: [] }; + ts.gptInitialAdInitCompleted = true; + if (!ts.servicesEnabled && (hasAppliedTargeting || heldPublisherRequests.displayIds.length > 0 || heldPublisherRequests.refreshSlots.length > 0)) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); ts.servicesEnabled = true; } - // Register and render TS-defined slots. GPT requires display() for a - // freshly-defined slot; without it the slot no-ops and misses its - // impression. Runs after enableServices(); on SPA navigation services are - // already enabled, so this runs unconditionally for new slots. - slotsToDisplay.forEach(function (divId) { + // Register/render TS-defined slots and replay publisher displays held + // before server-side bids were available. The replay is the publisher's + // one initial request, not a later TS refresh. + heldPublisherRequests.displayIds.concat(slotsToDisplay).forEach(function (divId) { runHandoffInternal(function () { googletag.display(divId); }); }); - // Reused publisher-owned slots always need a refresh to pick up the - // server-side targeting. TS-defined slots are fetched by display() above - // unless the publisher disabled initial load, in which case display() only - // registers them and refresh() must request the ad — otherwise they render - // blank. Only add them in that case to avoid double-requesting. + // Replay held publisher refreshes after targeting. On SPA navigation TS + // refreshes reused publisher slots as before; TS-defined slots need a + // refresh only when effective GPT configuration disabled initial load. syncInitialLoadDisabled(window.googletag); - var slotsNeedingRefresh = ts.gptInitialLoadDisabled - ? slotsToRefresh.concat(newSlots) - : slotsToRefresh; + var slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( + slotsToRefresh, + ts.gptInitialLoadDisabled ? newSlots : [], + ); if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied // server-side targeting to GAM. If slim-Prebid has already wrapped diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 16310f747..b023eafba 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -189,6 +189,13 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } +/** Publisher requests held until initial TS targeting has been applied. */ +export interface GptInitialRequestGate { + pendingDisplays: Record; + pendingRefreshes: Record; + released: boolean; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -251,6 +258,10 @@ export interface TsjsApi { gptInitialLoadDisabled?: boolean; /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ gptSlotHandoffs?: Record; + /** Publisher initial requests held until TS has applied server-side targeting. */ + gptInitialRequestGate?: GptInitialRequestGate; + /** True after the first page-load `adInit()` has handled publisher slots. */ + gptInitialAdInitCompleted?: boolean; /** True only while TS calls a GPT function that the handoff wrappers observe. */ gptSlotHandoffInternal?: boolean; /** Guards SPA pushState hook installation. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index c31b35f7f..cffb6cfba 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,5 +1,11 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; +import type { + AuctionSlot, + AuctionBidData, + GptInitialRequestGate, + GptSlotHandoff, + TsjsApi, +} from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -568,6 +574,41 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } +function configuredSlotForElementId(ts: TsjsApi, elementId: string): AuctionSlot | undefined { + return ts.adSlots?.find( + (slot) => + !!slot.div_id && + (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && + !elementId.endsWith('-container') + ); +} + +function initialRequestGate(ts: TsjsApi): GptInitialRequestGate { + return (ts.gptInitialRequestGate ??= { + pendingDisplays: {}, + pendingRefreshes: {}, + released: false, + }); +} + +function takeInitialPublisherRequests( + ts: TsjsApi, + pubads: GoogleTagPubAdsService +): { displayIds: string[]; refreshSlots: GoogleTagSlot[] } { + const gate = initialRequestGate(ts); + if (gate.released) return { displayIds: [], refreshSlots: [] }; + + gate.released = true; + const displayIds = Object.keys(gate.pendingDisplays); + const refreshIds = new Set(Object.keys(gate.pendingRefreshes)); + gate.pendingDisplays = {}; + gate.pendingRefreshes = {}; + const refreshSlots = (pubads.getSlots?.() ?? []).filter((slot) => + refreshIds.has(slot.getSlotElementId()) + ); + return { displayIds, refreshSlots }; +} + function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -645,6 +686,15 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { handoff.suppressPublisherDisplay = false; return; } + const gate = initialRequestGate(ts); + if ( + !ts.gptSlotHandoffInternal && + !gate.released && + configuredSlotForElementId(ts, elementId) + ) { + gate.pendingDisplays[elementId] = true; + return; + } originalDisplay(elementId); }; (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; @@ -667,12 +717,21 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { } let suppressed = false; + const gate = initialRequestGate(ts); const remainingSlots = slots.filter((slot) => { const handoff = handoffForSlot(ts, slot); - if (!handoff?.suppressPublisherRefresh) return true; - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; + if (handoff?.suppressPublisherRefresh) { + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + } + const elementId = slot.getSlotElementId(); + if (!gate.released && configuredSlotForElementId(ts, elementId)) { + gate.pendingRefreshes[elementId] = true; + suppressed = true; + return false; + } + return true; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -719,14 +778,17 @@ export function installTsAdInit(): void { // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. const newSlots: GoogleTagSlot[] = []; - // Publisher-owned slots TS reused — refreshed to pick up server-side - // targeting. The publisher already display()ed these. + // Publisher-owned slots can be refreshed on SPA navigation. On initial + // load their first request is held by the head-installed gate and replayed + // only after the targeting below has been applied. const slotsToRefresh: GoogleTagSlot[] = []; + const isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself this call. GPT requires a // display() call to register/render a freshly-defined slot; refresh() // alone no-ops for a slot that was never displayed, so these are // display()ed instead of refreshed. const slotsToDisplay: string[] = []; + let hasAppliedTargeting = false; const divToSlotId: Record = {}; const prevSlotTargetingKeys = ts.prevSlotTargetingKeys ?? {}; const nextSlotTargetingKeys: Record = {}; @@ -800,6 +862,7 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); + hasAppliedTargeting = true; // Map the resolved inner div to the slot ID so slotRenderEnded and ADM // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; @@ -810,7 +873,7 @@ export function installTsAdInit(): void { if (tsOwned) { newSlots.push(gptSlot); slotsToDisplay.push(slotDivId2); - } else { + } else if (!isInitialAdInit) { slotsToRefresh.push(gptSlot); } @@ -827,11 +890,20 @@ export function installTsAdInit(): void { // Replace (not merge) so destroyed slots from previous navigation don't linger. ts.divToSlotId = divToSlotId; ts.prevSlotTargetingKeys = nextSlotTargetingKeys; - - // Whether this call produced any TS slot to render. A gated page-bids - // response (auction kill switch or consent denial) returns no slots, so - // the loops above leave these empty. - const hasRenderableWork = slotsToDisplay.length > 0 || slotsToRefresh.length > 0; + const heldPublisherRequests = isInitialAdInit + ? takeInitialPublisherRequests(ts, g.pubads!()) + : { displayIds: [], refreshSlots: [] }; + ts.gptInitialAdInitCompleted = true; + + // Whether this call produced a request to make. A gated page-bids response + // (auction kill switch or consent denial) returns no slots, so the loops + // above leave these empty. + const hasRenderableWork = + slotsToDisplay.length > 0 || + slotsToRefresh.length > 0 || + heldPublisherRequests.displayIds.length > 0 || + heldPublisherRequests.refreshSlots.length > 0 || + hasAppliedTargeting; // enableSingleRequest and enableServices must only be called once per page // load. Skip activating GPT services when TS has nothing to display or @@ -863,26 +935,24 @@ export function installTsAdInit(): void { }); } - // Register and render TS-defined slots. GPT requires display() for a - // freshly-defined slot — without it the slot no-ops ("defineSlot was - // called without a matching display call") and misses its impression. - // Must run after enableServices(); on SPA navigation services are already - // enabled, so this runs unconditionally for any newly-defined slots. - slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); - - // Slots needing an explicit ad request via refresh(). Reused - // publisher-owned slots always need one to pick up the just-applied - // server-side targeting. TS-defined slots are normally fetched by the - // display() above — but when the publisher disabled initial load through - // setConfig() or the legacy pubads() method, display() only registers the - // slot and the ad request must come from refresh(). Without this, a TS-owned - // first-impression slot renders blank on initial-load-disabled pages. Only - // add them in that case; otherwise display() + refresh() would - // double-request the impression. + // Register/render TS-defined slots and replay publisher displays held + // before the server-side bids were available. The gate is released only + // after targeting has been applied, so this remains the publisher's one + // initial request rather than a later TS refresh. + heldPublisherRequests.displayIds + .concat(slotsToDisplay) + .forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); + + // Slots needing an explicit ad request via refresh(). Publisher refreshes + // held on the initial page load are replayed after targeting. On SPA + // navigation TS refreshes reused publisher slots as before. TS-defined + // slots need a refresh only when effective GPT configuration disabled + // initial load. syncInitialLoadDisabled(g, ts); - const slotsNeedingRefresh = ts.gptInitialLoadDisabled - ? slotsToRefresh.concat(newSlots) - : slotsToRefresh; + const slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( + slotsToRefresh, + ts.gptInitialLoadDisabled ? newSlots : [] + ); if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 10c85036d..6aa1376a5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -113,7 +113,7 @@ describe('installTsAdInit', () => { document.getElementById("ad'prefix-real")?.remove(); }); - it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { + it('reads window.tsjs.bids synchronously without re-requesting an existing publisher slot', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -169,11 +169,130 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); expect(mockPubads.enableSingleRequest).toHaveBeenCalledOnce(); - expect(mockPubads.refresh).toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); fetchSpy.mockRestore(); }); + it('holds and replays a publisher display once after applying initial targeting', async () => { + const requests: string[] = []; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); + const nativeRefresh = vi.fn(); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: nativeRefresh, + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ], + bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + googletag.display('div-atf-sidebar'); + expect(nativeDisplay).not.toHaveBeenCalled(); + + (window as TestWindow).tsjs!.adInit!(); + + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['div-atf-sidebar']); + expect(nativeRefresh).not.toHaveBeenCalled(); + }); + + it('holds and replays a disabled-load publisher refresh once after targeting', async () => { + const requests: string[] = []; + let initialLoadDisabled = false; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const unrelatedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), + }; + const nativeDisplay = vi.fn((elementId: string) => { + if (!initialLoadDisabled) requests.push(elementId); + }); + const nativeRefresh = vi.fn((slots?: Array) => { + (slots ?? [mockSlot, unrelatedSlot]).forEach((slot) => + requests.push(slot.getSlotElementId()) + ); + }); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot, unrelatedSlot]), + addEventListener: vi.fn(), + refresh: nativeRefresh, + disableInitialLoad: vi.fn(() => { + initialLoadDisabled = true; + }), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + mockPubads.disableInitialLoad(); + googletag.display('div-atf-sidebar'); + mockPubads.refresh(); + expect(nativeDisplay).not.toHaveBeenCalled(); + expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); + + (window as TestWindow).tsjs!.adInit!(); + + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(nativeRefresh).toHaveBeenCalledTimes(2); + expect(nativeRefresh).toHaveBeenLastCalledWith([mockSlot]); + expect(requests).toEqual(['div-unrelated', 'div-atf-sidebar']); + }); + it('displays TS-defined slots and does not include them in refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -787,7 +906,11 @@ describe('installTsAdInit', () => { }, ], bids: {}, - }; + // This models a route update: existing publisher slots are refreshed on + // SPA navigation, while initial-load publisher slots are not re-requested. + gptInitialAdInitCompleted: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); installTsAdInit(); @@ -929,7 +1052,7 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(mockPubads.refresh).not.toHaveBeenCalled(); }); // Helper: full adInit setup for a single slot whose bid carries an iframe adm. @@ -1326,7 +1449,7 @@ describe('installTsAdInit', () => { delete (window as TestWindow).apstag; }); - it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { + it('does not re-request an existing publisher slot when tsjs.bids is empty', async () => { const emptyTestSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -1365,7 +1488,7 @@ describe('installTsAdInit', () => { installTsAdInit(); (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); }); it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { @@ -1408,7 +1531,7 @@ describe('installTsAdInit', () => { installTsAdInit(); expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(mockPubads.refresh).toHaveBeenCalledWith([dynamicSlot]); + expect(mockPubads.refresh).not.toHaveBeenCalled(); }); }); diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md index 4699e3c42..24879c8e4 100644 --- a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -1,6 +1,7 @@ # Prevent Duplicate GPT Slot Requests — Implementation Plan -> **Status:** Implemented locally; production-like browser validation remains pending. +> **Status:** Revised after production-like validation found a second request for +> publisher-owned slots when hydration-safe scheduling defers `adInit()`. > > **Spec:** `docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md` @@ -8,10 +9,11 @@ TS `adInit()` runs before a publisher later defines the placement's inner GPT div. **Architecture:** TS creates its fallback on the resolved inner div and records a -handoff claim. Narrow, idempotent wrappers around GPT's `defineSlot`, `display`, and -`pubads().refresh` alias a matching late publisher definition to that slot and -suppress only the duplicate initial publisher request. A successful handoff transfers -SPA-destruction ownership to the publisher. The head bootstrap and full TSJS bundle +handoff claim. Narrow, idempotent GPT wrappers also gate a configured publisher +slot's first `display`/`refresh` while the server auction result is unavailable. At +`adInit()`, TS applies targeting to that same publisher slot and replays the held +native request once; it does not issue a second TS refresh. Late-definition handoff +and SPA ownership transfer remain unchanged. The head bootstrap and full TSJS bundle share this runtime protocol through `window.tsjs`. **Primary files:** @@ -43,9 +45,9 @@ share this runtime protocol through `window.tsjs`. - Modify `crates/trusted-server-js/lib/src/core/types.ts` - Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` -- [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must - retain serializable lifecycle flags: TS-created, ownership-transferred, initial - request made, and one-shot publisher display/refresh suppression state. +- [ ] Add `TsjsApi` state for both the div-ID-keyed late-handoff registry and an + initial publisher-request gate. The gate records held display/refresh IDs and + a released marker so it applies only once per page load. - [ ] Add only the minimal optional/internal type surface needed for idempotence markers on GPT functions and `pubads`. Do not weaken the public GPT types with `any`. @@ -81,14 +83,14 @@ npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index returning it; - log, but do not create a second slot, if publisher arguments differ from the TS configuration. -- [ ] `display` wrapper: consume the one permitted publisher post-handoff display - call without invoking native `display`; pass every other call through unchanged. -- [ ] `refresh` wrapper: when initial load was disabled, consume the one permitted - post-handoff refresh for each claimed slot. If called with no slot list, expand - `getSlots()`, filter only the claimed slots, and forward the remaining slots - explicitly. Preserve all unrelated refreshes. -- [ ] Ensure wrapper installation precedes the fallback definition path and does not - change existing publisher-owned-slot behavior. +- [ ] `display` wrapper: consume the one permitted post-handoff display; before the + first `adInit()`, also hold a configured publisher slot's native display. +- [ ] `refresh` wrapper: consume one permitted post-handoff disabled-load refresh; + before the first `adInit()`, hold configured publisher refreshes and forward + all unrelated slots explicitly, including a no-argument/global refresh. +- [ ] At initial `adInit()`, apply targeting then replay held native calls; never + refresh an existing publisher-owned slot that has already requested. +- [ ] Ensure wrapper installation precedes publisher setup and fallback creation. **Focused checks:** @@ -136,8 +138,9 @@ npx vitest run test/integrations/gpt/ad_init.test.ts makes one request; the publisher's first refresh cannot make a second request. - [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert the claimed slot is suppressed once and the unrelated slot is refreshed. -- [ ] Add an already publisher-owned test proving TS does not install a claim, applies - targeting, and refreshes that slot. +- [ ] Add publisher-owned tests proving TS holds normal and disabled-load initial + requests, applies targeting, and replays exactly one native request. Also prove + an already-requested publisher slot is not refreshed again. - [ ] Add a no-publisher test proving TS still creates, displays, and requests its inner-div slot exactly once. - [ ] Add a SPA handoff test: after late publisher claim, the next `adInit()` does not @@ -153,8 +156,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts - Modify `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` - Modify `crates/trusted-server-core/src/integrations/gpt.rs` -- [ ] Port the same actual-inner-div fallback, registry names, lifecycle flags, and - idempotence markers to the plain-JavaScript bootstrap. +- [ ] Port the same initial-request gate, actual-inner-div fallback, registry names, + lifecycle flags, and idempotence markers to the plain-JavaScript bootstrap. - [ ] Use the existing bootstrap `window.tsjs` properties exactly so `index.ts` can adopt the initial claim after the bundle loads. - [ ] Ensure its internal definition/display/refresh calls use the same guards as the @@ -198,9 +201,10 @@ npx vitest run test/integrations/gpt/ad_init.test.ts format. - [ ] Review the diff specifically for bootstrap/bundle protocol drift and for any use of container IDs in GPT slot creation. -- [ ] In a controlled production-like browser capture, verify one initial request for - each affected visible placement and independently verify an unrelated placement - remains requestable. +- [ ] In a controlled production-like browser capture with the hydration-safe + deferred `adInit()` path, verify one targeted initial request for each affected + visible placement and independently verify an unrelated placement remains + requestable. - [ ] Update issue #944 with the ownership-handoff decision, test evidence, and browser-capture result. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index 770718199..c94e25b2c 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -8,6 +8,12 @@ slot on the outer `-container` element. The publisher subsequently defines and displays an inner-div slot. These are distinct GPT slots, so they make separate GAM requests for one visible placement. +A production deployment also exposed the inverse ordering: the hydration-safe +body bootstrap delays `adInit()` until after `window.load`, so publisher code can +already have defined **and requested** its inner-div slot. In that ordering, +reusing the slot and refreshing it applies targeting too late and creates a second +SRA request. + The affected paths are deliberately duplicated today: - `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` is the full bundle @@ -40,7 +46,7 @@ A fix must keep both implementations in sync. publisher-owned slot from a placement that the publisher will never define. - General interception of unrelated GPT slots. -## Decision: one inner-div slot with late-definition handoff +## Decision: inner-div fallback, late-definition handoff, and an initial request gate TS will define its fallback slot on the **actual inner div**, never on its outer `-container` element. It will record a narrowly scoped handoff claim keyed by that @@ -54,31 +60,36 @@ competing container slot and an invalid duplicate definition. ### Lifecycle -1. **Already publisher-owned** — `getSlots()` finds a slot for the resolved inner - div. TS applies targeting, records it as publisher-owned, and refreshes it as it - does today. -2. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, +1. **Publisher-owned before bids are available** — a scoped head-installed gate + holds the configured placement's first publisher `display()` or `refresh()`. + At `adInit()`, TS finds the publisher slot, applies targeting, and replays that + held native call exactly once. It never adds a second TS refresh. +2. **Already-requested publisher-owned slot** — if a configured publisher request + was not observed by the gate, TS applies targeting for later lifecycle work but + does not re-request the already-served initial impression. +3. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, enables services when needed, and displays it. When initial load is disabled, TS performs its existing one explicit refresh. TS records this slot as TS-owned and handoff-eligible. -3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded +4. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded inner-div claim, returns the existing slot, and transfers ownership: it removes the slot from TS's future `destroySlots()` set. The publisher's setup continues against that same slot. -4. **Publisher's first request call** — the wrapper suppresses the duplicate +5. **Publisher's first request call after a late handoff** — the wrapper suppresses the duplicate publisher `display()` call. With `disableInitialLoad()`, it instead suppresses only the publisher's first refresh for the transferred slot, because TS has already issued the required initial refresh. For a no-argument/global refresh, the wrapper must expand `getSlots()`, remove only the one-shot suppressed slots, and forward the remaining slots explicitly so unrelated slots still refresh. -5. **Later refreshes and SPA navigation** — after the one-shot suppression is +6. **Later refreshes and SPA navigation** — after the one-shot suppression is consumed, publisher refreshes are untouched. On navigation, TS clears its targeting from the shared slot and may reuse it for the next route; it must not destroy a slot after ownership has transferred. -The wrapper is not a global deduplicator. It only handles IDs present in TS's -handoff registry and must preserve native `defineSlot`, `display`, and `refresh` -behavior for every other placement. +The wrappers are not global deduplicators. The initial request gate only holds the +first `display`/`refresh` for a configured placement until initial TS targeting is +available; handoff suppression only handles IDs present in TS's handoff registry. +All unrelated GPT calls retain native behavior. ## Implementation shape @@ -89,8 +100,10 @@ can read after the bundle replaces the bootstrap implementation. It is keyed by resolved actual div ID and records at least: - whether TS created the slot and whether ownership has transferred; -- whether one publisher `display()` or initial-load-disabled `refresh()` remains to - suppress. +- whether one post-handoff publisher `display()` or initial-load-disabled `refresh()` + remains to suppress; +- configured publisher displays and refreshes held before initial targeting, plus a + released marker so the gate applies only once per page load. Do not rely only on module-local state: the bootstrap can define the initial slot before `index.ts` is loaded. Look up the live slot by element ID through @@ -109,8 +122,12 @@ In `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: - Replace the container fallback with `actualDivId`. - Add the typed handoff-registry state to `TsjsApi` in `crates/trusted-server-js/lib/src/core/types.ts`. -- Install the idempotent `defineSlot`, `display`, and `pubads().refresh` handoff - wrappers from the GPT command queue before `adInit()` can create a fallback slot. +- Install idempotent `defineSlot`, `display`, and `pubads().refresh` wrappers from + the GPT command queue before publisher setup. The latter two also hold the first + configured publisher request until `adInit()` has applied initial targeting. +- Replay held initial publisher displays/refreshes after targeting rather than + refreshing an existing publisher-owned slot. Retain reused-slot refreshes only for + later SPA navigations. - When a late publisher definition is aliased to the existing slot, remove it from `prevGptSlots` and mark it transferred before returning it. - Keep targeting cleanup keyed by the real inner div. Remove the old dual @@ -132,7 +149,7 @@ suite must exercise both implementations' observable contract. | Risk | Mitigation | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Publisher passes a different ad-unit path or sizes in its late `defineSlot` call | Return the existing claimed slot but log a diagnostic. Do not define a second slot. Treat the TS configuration and publisher configuration mismatch as an integration error to resolve separately. | -| Publisher invokes global `refresh()` after `disableInitialLoad()` | Filter the one-shot claimed slot from the expanded slot list and refresh all remaining slots. A no-argument refresh must not be silently dropped. | +| Publisher invokes global `refresh()` before bids after `disableInitialLoad()` | Filter only configured held slots from the expanded list, forward unrelated slots immediately, then replay the held slots once after targeting. A no-argument refresh must not be silently dropped. | | Publisher calls a legitimate refresh without an initial display | The one-shot suppression is consumed only immediately after a successful late handoff. Document and test the standard publisher sequence (`defineSlot` → `addService` → `display`, with `refresh` when initial load is disabled). Escalate unusual publisher lifecycle requirements rather than adding a time heuristic. | | Publisher-owned slot is destroyed on SPA navigation | Transfer ownership synchronously in the `defineSlot` wrapper and remove the slot from `prevGptSlots`. | | Bootstrap and bundle diverge | Give both paths the same black-box regression cases; retain a Rust source-contract assertion for bootstrap-specific sentinels. | @@ -146,7 +163,9 @@ suite must exercise both implementations' observable contract. initial-load-disabled modes. - The late publisher `display()` (and its first initial-load-disabled refresh) cannot create a second request, while unrelated slots retain their normal calls. -- Existing publisher slots are still reused and receive TS targeting. +- A configured publisher slot whose first request occurs before the deferred + `adInit()` is held, receives TS targeting, and makes exactly one replayed native + request. An already-requested publisher slot is never re-requested by TS. - A slot that no publisher claims is displayed and requested once by TS. - A transferred slot is absent from TS's SPA `destroySlots()` argument; targeting is still cleared and reapplied correctly on the next route. @@ -160,6 +179,7 @@ suite must exercise both implementations' observable contract. 2. Run the focused GPT test files, then the full TSJS Vitest suite and formatter. 3. Run the target-matched Rust test suite so the included bootstrap and its source assertions compile and pass. -4. In a controlled browser capture, verify that one configured header and one - configured fixed placement each produce one initial slot request, while a distinct - in-content placement remains independently requestable. +4. In a controlled browser capture with deferred `adInit()`, verify that one + configured header and one configured fixed placement each produce one initial + slot request with TS targeting, while a distinct in-content placement remains + independently requestable. From 49e3d9c8d5c2327ebc8d62f24dbc5564d49277e9 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 16:17:31 -0500 Subject: [PATCH 09/16] Revert "Gate publisher GPT requests until targeting is ready" This reverts commit b65e1aedd33440a281cf28443c0ffd6e46b9de02. --- .../src/integrations/gpt.rs | 7 +- .../src/integrations/gpt_bootstrap.js | 109 +++----------- .../trusted-server-js/lib/src/core/types.ts | 11 -- .../lib/src/integrations/gpt/index.ts | 135 +++++------------- .../lib/test/integrations/gpt/ad_init.test.ts | 134 +---------------- ...-24-prevent-duplicate-gpt-slot-requests.md | 50 +++---- ...vent-duplicate-gpt-slot-requests-design.md | 60 +++----- 7 files changed, 105 insertions(+), 401 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index b93d4122d..fae701756 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -474,8 +474,7 @@ impl IntegrationHeadInjector for GptIntegration { /// ## Scroll / refresh handoff contract (Phase 1) /// /// `tsjs.adInit` handles **initial render only**: it wires server-side bid - /// targeting into GPT slots and replays only publisher requests held until - /// that targeting was available. Win/billing beacons fire + /// targeting into GPT slots and refreshes them. Win/billing beacons fire /// only from the TS render bridge in the JS bundle, where a matching /// Prebid Universal Creative request proves the TS creative rendered. /// It does **not** trigger refresh auctions or handle GPT slot refresh events. @@ -1282,10 +1281,6 @@ mod tests { combined.contains("__tsSlotHandoffPatched"), "bootstrap should install idempotent GPT handoff wrappers" ); - assert!( - combined.contains("gptInitialRequestGate") && combined.contains("pendingDisplays"), - "bootstrap should hold configured publisher requests until initial targeting is applied" - ); assert!( combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), "bootstrap should define the TS fallback on the actual inner div" diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index b0e10fbc6..c4165f957 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -10,8 +10,8 @@ // - Both implementations must set `window.tsjs.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a // subsequent call becomes a no-op. -// - `refresh()` is called only for TS-defined slots in this pass and -// publisher requests the initial gate held, never the global slot list. +// - `refresh()` is called only for the slots defined in this pass, +// never the global slot list. // // Only installed if `window.tsjs.adInit` isn't already defined. (function () { @@ -119,45 +119,6 @@ ); } - function configuredSlotForElementId(elementId) { - return (ts.adSlots || []).find(function (slot) { - return ( - slot.div_id && - (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && - !elementId.endsWith("-container") - ); - }); - } - - function initialRequestGate() { - if (!ts.gptInitialRequestGate) { - ts.gptInitialRequestGate = { - pendingDisplays: {}, - pendingRefreshes: {}, - released: false, - }; - } - return ts.gptInitialRequestGate; - } - - function takeInitialPublisherRequests(pubads) { - var gate = initialRequestGate(); - if (gate.released) return { displayIds: [], refreshSlots: [] }; - - gate.released = true; - var displayIds = Object.keys(gate.pendingDisplays); - var refreshIds = Object.keys(gate.pendingRefreshes); - gate.pendingDisplays = {}; - gate.pendingRefreshes = {}; - var slots = pubads.getSlots ? pubads.getSlots() : []; - return { - displayIds: displayIds, - refreshSlots: slots.filter(function (slot) { - return refreshIds.includes(slot.getSlotElementId()); - }), - }; - } - function runHandoffInternal(callback) { var wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -228,15 +189,6 @@ handoff.suppressPublisherDisplay = false; return; } - var gate = initialRequestGate(); - if ( - !ts.gptSlotHandoffInternal && - !gate.released && - configuredSlotForElementId(elementId) - ) { - gate.pendingDisplays[elementId] = true; - return; - } originalDisplay(elementId); }; patchedDisplay.__tsSlotHandoffPatched = true; @@ -257,22 +209,13 @@ return; } var suppressed = false; - var gate = initialRequestGate(); var remainingSlots = slots.filter(function (slot) { var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; - if (handoff && handoff.suppressPublisherRefresh) { - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; - } - var elementId = slot.getSlotElementId(); - if (!gate.released && configuredSlotForElementId(elementId)) { - gate.pendingRefreshes[elementId] = true; - suppressed = true; - return false; - } - return true; + if (!handoff || !handoff.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -303,15 +246,13 @@ // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. var newSlots = []; - // Publisher-owned slots can be refreshed on SPA navigation. On initial - // load their first request is held until the targeting below is applied. + // Publisher-owned slots TS reused — refreshed to pick up server-side + // targeting. The publisher already display()ed these. var slotsToRefresh = []; - var isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself. GPT requires display() to // register/render a freshly-defined slot; refresh() alone no-ops for a // slot that was never displayed, so these are display()ed instead. var slotsToDisplay = []; - var hasAppliedTargeting = false; slots.forEach(function (slot) { // Resolve actual div ID: exact match first, then safe prefix scan. // div_id in config may be a stable prefix (e.g. "ad-header-0-") when @@ -378,7 +319,6 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); - hasAppliedTargeting = true; // Map the resolved inner div to the slot ID. This bootstrap fires no // beacons and registers no slotRenderEnded listener; the map is consumed // by the bundle's render bridge (index.ts) once it loads. @@ -391,37 +331,34 @@ newSlots.push(s); var displayId = s.getSlotElementId() || actualDivId; slotsToDisplay.push(displayId); - } else if (!isInitialAdInit) { + } else { slotsToRefresh.push(s); } }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; - var heldPublisherRequests = isInitialAdInit - ? takeInitialPublisherRequests(googletag.pubads()) - : { displayIds: [], refreshSlots: [] }; - ts.gptInitialAdInitCompleted = true; - if (!ts.servicesEnabled && (hasAppliedTargeting || heldPublisherRequests.displayIds.length > 0 || heldPublisherRequests.refreshSlots.length > 0)) { + if (!ts.servicesEnabled) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); ts.servicesEnabled = true; } - // Register/render TS-defined slots and replay publisher displays held - // before server-side bids were available. The replay is the publisher's - // one initial request, not a later TS refresh. - heldPublisherRequests.displayIds.concat(slotsToDisplay).forEach(function (divId) { + // Register and render TS-defined slots. GPT requires display() for a + // freshly-defined slot; without it the slot no-ops and misses its + // impression. Runs after enableServices(); on SPA navigation services are + // already enabled, so this runs unconditionally for new slots. + slotsToDisplay.forEach(function (divId) { runHandoffInternal(function () { googletag.display(divId); }); }); - // Replay held publisher refreshes after targeting. On SPA navigation TS - // refreshes reused publisher slots as before; TS-defined slots need a - // refresh only when effective GPT configuration disabled initial load. - syncInitialLoadDisabled(window.googletag); - var slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( - slotsToRefresh, - ts.gptInitialLoadDisabled ? newSlots : [], - ); + // Reused publisher-owned slots always need a refresh to pick up the + // server-side targeting. TS-defined slots are fetched by display() above + // unless the publisher disabled initial load, in which case display() only + // registers the slot and refresh() must request the ad — otherwise they render + // blank. Only add them in that case to avoid double-requesting. + var slotsNeedingRefresh = ts.gptInitialLoadDisabled + ? slotsToRefresh.concat(newSlots) + : slotsToRefresh; if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied // server-side targeting to GAM. If slim-Prebid has already wrapped diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index b023eafba..16310f747 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -189,13 +189,6 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } -/** Publisher requests held until initial TS targeting has been applied. */ -export interface GptInitialRequestGate { - pendingDisplays: Record; - pendingRefreshes: Record; - released: boolean; -} - export interface TsjsApi { version: string; que: Array<() => void>; @@ -258,10 +251,6 @@ export interface TsjsApi { gptInitialLoadDisabled?: boolean; /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ gptSlotHandoffs?: Record; - /** Publisher initial requests held until TS has applied server-side targeting. */ - gptInitialRequestGate?: GptInitialRequestGate; - /** True after the first page-load `adInit()` has handled publisher slots. */ - gptInitialAdInitCompleted?: boolean; /** True only while TS calls a GPT function that the handoff wrappers observe. */ gptSlotHandoffInternal?: boolean; /** Guards SPA pushState hook installation. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index cffb6cfba..b2e76ab61 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,11 +1,5 @@ import { log } from '../../core/log'; -import type { - AuctionSlot, - AuctionBidData, - GptInitialRequestGate, - GptSlotHandoff, - TsjsApi, -} from '../../core/types'; +import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -574,41 +568,6 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } -function configuredSlotForElementId(ts: TsjsApi, elementId: string): AuctionSlot | undefined { - return ts.adSlots?.find( - (slot) => - !!slot.div_id && - (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && - !elementId.endsWith('-container') - ); -} - -function initialRequestGate(ts: TsjsApi): GptInitialRequestGate { - return (ts.gptInitialRequestGate ??= { - pendingDisplays: {}, - pendingRefreshes: {}, - released: false, - }); -} - -function takeInitialPublisherRequests( - ts: TsjsApi, - pubads: GoogleTagPubAdsService -): { displayIds: string[]; refreshSlots: GoogleTagSlot[] } { - const gate = initialRequestGate(ts); - if (gate.released) return { displayIds: [], refreshSlots: [] }; - - gate.released = true; - const displayIds = Object.keys(gate.pendingDisplays); - const refreshIds = new Set(Object.keys(gate.pendingRefreshes)); - gate.pendingDisplays = {}; - gate.pendingRefreshes = {}; - const refreshSlots = (pubads.getSlots?.() ?? []).filter((slot) => - refreshIds.has(slot.getSlotElementId()) - ); - return { displayIds, refreshSlots }; -} - function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -686,15 +645,6 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { handoff.suppressPublisherDisplay = false; return; } - const gate = initialRequestGate(ts); - if ( - !ts.gptSlotHandoffInternal && - !gate.released && - configuredSlotForElementId(ts, elementId) - ) { - gate.pendingDisplays[elementId] = true; - return; - } originalDisplay(elementId); }; (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; @@ -717,21 +667,12 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { } let suppressed = false; - const gate = initialRequestGate(ts); const remainingSlots = slots.filter((slot) => { const handoff = handoffForSlot(ts, slot); - if (handoff?.suppressPublisherRefresh) { - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; - } - const elementId = slot.getSlotElementId(); - if (!gate.released && configuredSlotForElementId(ts, elementId)) { - gate.pendingRefreshes[elementId] = true; - suppressed = true; - return false; - } - return true; + if (!handoff?.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -778,17 +719,14 @@ export function installTsAdInit(): void { // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. const newSlots: GoogleTagSlot[] = []; - // Publisher-owned slots can be refreshed on SPA navigation. On initial - // load their first request is held by the head-installed gate and replayed - // only after the targeting below has been applied. + // Publisher-owned slots TS reused — refreshed to pick up server-side + // targeting. The publisher already display()ed these. const slotsToRefresh: GoogleTagSlot[] = []; - const isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself this call. GPT requires a // display() call to register/render a freshly-defined slot; refresh() // alone no-ops for a slot that was never displayed, so these are // display()ed instead of refreshed. const slotsToDisplay: string[] = []; - let hasAppliedTargeting = false; const divToSlotId: Record = {}; const prevSlotTargetingKeys = ts.prevSlotTargetingKeys ?? {}; const nextSlotTargetingKeys: Record = {}; @@ -862,7 +800,6 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); - hasAppliedTargeting = true; // Map the resolved inner div to the slot ID so slotRenderEnded and ADM // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; @@ -873,7 +810,7 @@ export function installTsAdInit(): void { if (tsOwned) { newSlots.push(gptSlot); slotsToDisplay.push(slotDivId2); - } else if (!isInitialAdInit) { + } else { slotsToRefresh.push(gptSlot); } @@ -890,20 +827,11 @@ export function installTsAdInit(): void { // Replace (not merge) so destroyed slots from previous navigation don't linger. ts.divToSlotId = divToSlotId; ts.prevSlotTargetingKeys = nextSlotTargetingKeys; - const heldPublisherRequests = isInitialAdInit - ? takeInitialPublisherRequests(ts, g.pubads!()) - : { displayIds: [], refreshSlots: [] }; - ts.gptInitialAdInitCompleted = true; - - // Whether this call produced a request to make. A gated page-bids response - // (auction kill switch or consent denial) returns no slots, so the loops - // above leave these empty. - const hasRenderableWork = - slotsToDisplay.length > 0 || - slotsToRefresh.length > 0 || - heldPublisherRequests.displayIds.length > 0 || - heldPublisherRequests.refreshSlots.length > 0 || - hasAppliedTargeting; + + // Whether this call produced any TS slot to render. A gated page-bids + // response (auction kill switch or consent denial) returns no slots, so + // the loops above leave these empty. + const hasRenderableWork = slotsToDisplay.length > 0 || slotsToRefresh.length > 0; // enableSingleRequest and enableServices must only be called once per page // load. Skip activating GPT services when TS has nothing to display or @@ -935,24 +863,25 @@ export function installTsAdInit(): void { }); } - // Register/render TS-defined slots and replay publisher displays held - // before the server-side bids were available. The gate is released only - // after targeting has been applied, so this remains the publisher's one - // initial request rather than a later TS refresh. - heldPublisherRequests.displayIds - .concat(slotsToDisplay) - .forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); - - // Slots needing an explicit ad request via refresh(). Publisher refreshes - // held on the initial page load are replayed after targeting. On SPA - // navigation TS refreshes reused publisher slots as before. TS-defined - // slots need a refresh only when effective GPT configuration disabled - // initial load. - syncInitialLoadDisabled(g, ts); - const slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( - slotsToRefresh, - ts.gptInitialLoadDisabled ? newSlots : [] - ); + // Register and render TS-defined slots. GPT requires display() for a + // freshly-defined slot — without it the slot no-ops ("defineSlot was + // called without a matching display call") and misses its impression. + // Must run after enableServices(); on SPA navigation services are already + // enabled, so this runs unconditionally for any newly-defined slots. + slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); + + // Slots needing an explicit ad request via refresh(). Reused + // publisher-owned slots always need one to pick up the just-applied + // server-side targeting. TS-defined slots are normally fetched by the + // display() above — but when the publisher called + // pubads().disableInitialLoad(), display() only registers the slot and the + // ad request must come from refresh(). Without this, a TS-owned + // first-impression slot renders blank on initial-load-disabled pages. Only + // add them in that case; otherwise display() + refresh() would + // double-request the impression. + const slotsNeedingRefresh = ts.gptInitialLoadDisabled + ? slotsToRefresh.concat(newSlots) + : slotsToRefresh; if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 6aa1376a5..75c1673c3 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -113,7 +113,7 @@ describe('installTsAdInit', () => { document.getElementById("ad'prefix-real")?.remove(); }); - it('reads window.tsjs.bids synchronously without re-requesting an existing publisher slot', async () => { + it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -169,130 +169,11 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); expect(mockPubads.enableSingleRequest).toHaveBeenCalledOnce(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalled(); fetchSpy.mockRestore(); }); - it('holds and replays a publisher display once after applying initial targeting', async () => { - const requests: string[] = []; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ], - bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - googletag.display('div-atf-sidebar'); - expect(nativeDisplay).not.toHaveBeenCalled(); - - (window as TestWindow).tsjs!.adInit!(); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(requests).toEqual(['div-atf-sidebar']); - expect(nativeRefresh).not.toHaveBeenCalled(); - }); - - it('holds and replays a disabled-load publisher refresh once after targeting', async () => { - const requests: string[] = []; - let initialLoadDisabled = false; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const unrelatedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), - }; - const nativeDisplay = vi.fn((elementId: string) => { - if (!initialLoadDisabled) requests.push(elementId); - }); - const nativeRefresh = vi.fn((slots?: Array) => { - (slots ?? [mockSlot, unrelatedSlot]).forEach((slot) => - requests.push(slot.getSlotElementId()) - ); - }); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot, unrelatedSlot]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - disableInitialLoad: vi.fn(() => { - initialLoadDisabled = true; - }), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - mockPubads.disableInitialLoad(); - googletag.display('div-atf-sidebar'); - mockPubads.refresh(); - expect(nativeDisplay).not.toHaveBeenCalled(); - expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); - - (window as TestWindow).tsjs!.adInit!(); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(nativeRefresh).toHaveBeenCalledTimes(2); - expect(nativeRefresh).toHaveBeenLastCalledWith([mockSlot]); - expect(requests).toEqual(['div-unrelated', 'div-atf-sidebar']); - }); - it('displays TS-defined slots and does not include them in refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -906,9 +787,6 @@ describe('installTsAdInit', () => { }, ], bids: {}, - // This models a route update: existing publisher slots are refreshed on - // SPA navigation, while initial-load publisher slots are not re-requested. - gptInitialAdInitCompleted: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -1052,7 +930,7 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); }); // Helper: full adInit setup for a single slot whose bid carries an iframe adm. @@ -1449,7 +1327,7 @@ describe('installTsAdInit', () => { delete (window as TestWindow).apstag; }); - it('does not re-request an existing publisher slot when tsjs.bids is empty', async () => { + it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { const emptyTestSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -1488,7 +1366,7 @@ describe('installTsAdInit', () => { installTsAdInit(); (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalled(); }); it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { @@ -1531,7 +1409,7 @@ describe('installTsAdInit', () => { installTsAdInit(); expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalledWith([dynamicSlot]); }); }); diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md index 24879c8e4..4699e3c42 100644 --- a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -1,7 +1,6 @@ # Prevent Duplicate GPT Slot Requests — Implementation Plan -> **Status:** Revised after production-like validation found a second request for -> publisher-owned slots when hydration-safe scheduling defers `adInit()`. +> **Status:** Implemented locally; production-like browser validation remains pending. > > **Spec:** `docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md` @@ -9,11 +8,10 @@ TS `adInit()` runs before a publisher later defines the placement's inner GPT div. **Architecture:** TS creates its fallback on the resolved inner div and records a -handoff claim. Narrow, idempotent GPT wrappers also gate a configured publisher -slot's first `display`/`refresh` while the server auction result is unavailable. At -`adInit()`, TS applies targeting to that same publisher slot and replays the held -native request once; it does not issue a second TS refresh. Late-definition handoff -and SPA ownership transfer remain unchanged. The head bootstrap and full TSJS bundle +handoff claim. Narrow, idempotent wrappers around GPT's `defineSlot`, `display`, and +`pubads().refresh` alias a matching late publisher definition to that slot and +suppress only the duplicate initial publisher request. A successful handoff transfers +SPA-destruction ownership to the publisher. The head bootstrap and full TSJS bundle share this runtime protocol through `window.tsjs`. **Primary files:** @@ -45,9 +43,9 @@ share this runtime protocol through `window.tsjs`. - Modify `crates/trusted-server-js/lib/src/core/types.ts` - Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` -- [ ] Add `TsjsApi` state for both the div-ID-keyed late-handoff registry and an - initial publisher-request gate. The gate records held display/refresh IDs and - a released marker so it applies only once per page load. +- [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must + retain serializable lifecycle flags: TS-created, ownership-transferred, initial + request made, and one-shot publisher display/refresh suppression state. - [ ] Add only the minimal optional/internal type surface needed for idempotence markers on GPT functions and `pubads`. Do not weaken the public GPT types with `any`. @@ -83,14 +81,14 @@ npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index returning it; - log, but do not create a second slot, if publisher arguments differ from the TS configuration. -- [ ] `display` wrapper: consume the one permitted post-handoff display; before the - first `adInit()`, also hold a configured publisher slot's native display. -- [ ] `refresh` wrapper: consume one permitted post-handoff disabled-load refresh; - before the first `adInit()`, hold configured publisher refreshes and forward - all unrelated slots explicitly, including a no-argument/global refresh. -- [ ] At initial `adInit()`, apply targeting then replay held native calls; never - refresh an existing publisher-owned slot that has already requested. -- [ ] Ensure wrapper installation precedes publisher setup and fallback creation. +- [ ] `display` wrapper: consume the one permitted publisher post-handoff display + call without invoking native `display`; pass every other call through unchanged. +- [ ] `refresh` wrapper: when initial load was disabled, consume the one permitted + post-handoff refresh for each claimed slot. If called with no slot list, expand + `getSlots()`, filter only the claimed slots, and forward the remaining slots + explicitly. Preserve all unrelated refreshes. +- [ ] Ensure wrapper installation precedes the fallback definition path and does not + change existing publisher-owned-slot behavior. **Focused checks:** @@ -138,9 +136,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts makes one request; the publisher's first refresh cannot make a second request. - [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert the claimed slot is suppressed once and the unrelated slot is refreshed. -- [ ] Add publisher-owned tests proving TS holds normal and disabled-load initial - requests, applies targeting, and replays exactly one native request. Also prove - an already-requested publisher slot is not refreshed again. +- [ ] Add an already publisher-owned test proving TS does not install a claim, applies + targeting, and refreshes that slot. - [ ] Add a no-publisher test proving TS still creates, displays, and requests its inner-div slot exactly once. - [ ] Add a SPA handoff test: after late publisher claim, the next `adInit()` does not @@ -156,8 +153,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts - Modify `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` - Modify `crates/trusted-server-core/src/integrations/gpt.rs` -- [ ] Port the same initial-request gate, actual-inner-div fallback, registry names, - lifecycle flags, and idempotence markers to the plain-JavaScript bootstrap. +- [ ] Port the same actual-inner-div fallback, registry names, lifecycle flags, and + idempotence markers to the plain-JavaScript bootstrap. - [ ] Use the existing bootstrap `window.tsjs` properties exactly so `index.ts` can adopt the initial claim after the bundle loads. - [ ] Ensure its internal definition/display/refresh calls use the same guards as the @@ -201,10 +198,9 @@ npx vitest run test/integrations/gpt/ad_init.test.ts format. - [ ] Review the diff specifically for bootstrap/bundle protocol drift and for any use of container IDs in GPT slot creation. -- [ ] In a controlled production-like browser capture with the hydration-safe - deferred `adInit()` path, verify one targeted initial request for each affected - visible placement and independently verify an unrelated placement remains - requestable. +- [ ] In a controlled production-like browser capture, verify one initial request for + each affected visible placement and independently verify an unrelated placement + remains requestable. - [ ] Update issue #944 with the ownership-handoff decision, test evidence, and browser-capture result. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index c94e25b2c..770718199 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -8,12 +8,6 @@ slot on the outer `-container` element. The publisher subsequently defines and displays an inner-div slot. These are distinct GPT slots, so they make separate GAM requests for one visible placement. -A production deployment also exposed the inverse ordering: the hydration-safe -body bootstrap delays `adInit()` until after `window.load`, so publisher code can -already have defined **and requested** its inner-div slot. In that ordering, -reusing the slot and refreshing it applies targeting too late and creates a second -SRA request. - The affected paths are deliberately duplicated today: - `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` is the full bundle @@ -46,7 +40,7 @@ A fix must keep both implementations in sync. publisher-owned slot from a placement that the publisher will never define. - General interception of unrelated GPT slots. -## Decision: inner-div fallback, late-definition handoff, and an initial request gate +## Decision: one inner-div slot with late-definition handoff TS will define its fallback slot on the **actual inner div**, never on its outer `-container` element. It will record a narrowly scoped handoff claim keyed by that @@ -60,36 +54,31 @@ competing container slot and an invalid duplicate definition. ### Lifecycle -1. **Publisher-owned before bids are available** — a scoped head-installed gate - holds the configured placement's first publisher `display()` or `refresh()`. - At `adInit()`, TS finds the publisher slot, applies targeting, and replays that - held native call exactly once. It never adds a second TS refresh. -2. **Already-requested publisher-owned slot** — if a configured publisher request - was not observed by the gate, TS applies targeting for later lifecycle work but - does not re-request the already-served initial impression. -3. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, +1. **Already publisher-owned** — `getSlots()` finds a slot for the resolved inner + div. TS applies targeting, records it as publisher-owned, and refreshes it as it + does today. +2. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, enables services when needed, and displays it. When initial load is disabled, TS performs its existing one explicit refresh. TS records this slot as TS-owned and handoff-eligible. -4. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded +3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded inner-div claim, returns the existing slot, and transfers ownership: it removes the slot from TS's future `destroySlots()` set. The publisher's setup continues against that same slot. -5. **Publisher's first request call after a late handoff** — the wrapper suppresses the duplicate +4. **Publisher's first request call** — the wrapper suppresses the duplicate publisher `display()` call. With `disableInitialLoad()`, it instead suppresses only the publisher's first refresh for the transferred slot, because TS has already issued the required initial refresh. For a no-argument/global refresh, the wrapper must expand `getSlots()`, remove only the one-shot suppressed slots, and forward the remaining slots explicitly so unrelated slots still refresh. -6. **Later refreshes and SPA navigation** — after the one-shot suppression is +5. **Later refreshes and SPA navigation** — after the one-shot suppression is consumed, publisher refreshes are untouched. On navigation, TS clears its targeting from the shared slot and may reuse it for the next route; it must not destroy a slot after ownership has transferred. -The wrappers are not global deduplicators. The initial request gate only holds the -first `display`/`refresh` for a configured placement until initial TS targeting is -available; handoff suppression only handles IDs present in TS's handoff registry. -All unrelated GPT calls retain native behavior. +The wrapper is not a global deduplicator. It only handles IDs present in TS's +handoff registry and must preserve native `defineSlot`, `display`, and `refresh` +behavior for every other placement. ## Implementation shape @@ -100,10 +89,8 @@ can read after the bundle replaces the bootstrap implementation. It is keyed by resolved actual div ID and records at least: - whether TS created the slot and whether ownership has transferred; -- whether one post-handoff publisher `display()` or initial-load-disabled `refresh()` - remains to suppress; -- configured publisher displays and refreshes held before initial targeting, plus a - released marker so the gate applies only once per page load. +- whether one publisher `display()` or initial-load-disabled `refresh()` remains to + suppress. Do not rely only on module-local state: the bootstrap can define the initial slot before `index.ts` is loaded. Look up the live slot by element ID through @@ -122,12 +109,8 @@ In `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: - Replace the container fallback with `actualDivId`. - Add the typed handoff-registry state to `TsjsApi` in `crates/trusted-server-js/lib/src/core/types.ts`. -- Install idempotent `defineSlot`, `display`, and `pubads().refresh` wrappers from - the GPT command queue before publisher setup. The latter two also hold the first - configured publisher request until `adInit()` has applied initial targeting. -- Replay held initial publisher displays/refreshes after targeting rather than - refreshing an existing publisher-owned slot. Retain reused-slot refreshes only for - later SPA navigations. +- Install the idempotent `defineSlot`, `display`, and `pubads().refresh` handoff + wrappers from the GPT command queue before `adInit()` can create a fallback slot. - When a late publisher definition is aliased to the existing slot, remove it from `prevGptSlots` and mark it transferred before returning it. - Keep targeting cleanup keyed by the real inner div. Remove the old dual @@ -149,7 +132,7 @@ suite must exercise both implementations' observable contract. | Risk | Mitigation | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Publisher passes a different ad-unit path or sizes in its late `defineSlot` call | Return the existing claimed slot but log a diagnostic. Do not define a second slot. Treat the TS configuration and publisher configuration mismatch as an integration error to resolve separately. | -| Publisher invokes global `refresh()` before bids after `disableInitialLoad()` | Filter only configured held slots from the expanded list, forward unrelated slots immediately, then replay the held slots once after targeting. A no-argument refresh must not be silently dropped. | +| Publisher invokes global `refresh()` after `disableInitialLoad()` | Filter the one-shot claimed slot from the expanded slot list and refresh all remaining slots. A no-argument refresh must not be silently dropped. | | Publisher calls a legitimate refresh without an initial display | The one-shot suppression is consumed only immediately after a successful late handoff. Document and test the standard publisher sequence (`defineSlot` → `addService` → `display`, with `refresh` when initial load is disabled). Escalate unusual publisher lifecycle requirements rather than adding a time heuristic. | | Publisher-owned slot is destroyed on SPA navigation | Transfer ownership synchronously in the `defineSlot` wrapper and remove the slot from `prevGptSlots`. | | Bootstrap and bundle diverge | Give both paths the same black-box regression cases; retain a Rust source-contract assertion for bootstrap-specific sentinels. | @@ -163,9 +146,7 @@ suite must exercise both implementations' observable contract. initial-load-disabled modes. - The late publisher `display()` (and its first initial-load-disabled refresh) cannot create a second request, while unrelated slots retain their normal calls. -- A configured publisher slot whose first request occurs before the deferred - `adInit()` is held, receives TS targeting, and makes exactly one replayed native - request. An already-requested publisher slot is never re-requested by TS. +- Existing publisher slots are still reused and receive TS targeting. - A slot that no publisher claims is displayed and requested once by TS. - A transferred slot is absent from TS's SPA `destroySlots()` argument; targeting is still cleared and reapplied correctly on the next route. @@ -179,7 +160,6 @@ suite must exercise both implementations' observable contract. 2. Run the focused GPT test files, then the full TSJS Vitest suite and formatter. 3. Run the target-matched Rust test suite so the included bootstrap and its source assertions compile and pass. -4. In a controlled browser capture with deferred `adInit()`, verify that one - configured header and one configured fixed placement each produce one initial - slot request with TS targeting, while a distinct in-content placement remains - independently requestable. +4. In a controlled browser capture, verify that one configured header and one + configured fixed placement each produce one initial slot request, while a distinct + in-content placement remains independently requestable. From 3637ebb0e03e0c1eda63c884bd6c149921476468 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 27 Jul 2026 13:59:39 -0500 Subject: [PATCH 10/16] Harden GPT slot handoff --- .../src/integrations/gpt_bootstrap.js | 59 +++- .../trusted-server-js/lib/src/core/types.ts | 4 + .../lib/src/integrations/gpt/index.ts | 79 ++++- .../lib/test/integrations/gpt/ad_init.test.ts | 300 ++++++++++++++++++ ...-24-prevent-duplicate-gpt-slot-requests.md | 25 +- ...vent-duplicate-gpt-slot-requests-design.md | 17 +- 6 files changed, 445 insertions(+), 39 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index c4165f957..bcbceb528 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -119,6 +119,33 @@ ); } + function matchingHandoff(pubads, adUnitPath, formats, elementId) { + var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if (exact) return exact; + + var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( + function (handoff, index, allHandoffs) { + return ( + allHandoffs.indexOf(handoff) === index && + !handoff.publisherClaimed && + elementId.startsWith(handoff.divIdPrefix) && + handoff.gamUnitPath === adUnitPath && + JSON.stringify(handoff.formats) === JSON.stringify(formats) && + findSlotByElementId(pubads, handoff.slotElementId) + ); + }, + ); + return candidates.length === 1 ? candidates[0] : null; + } + + function displayTargetElementId(target) { + if (typeof target === "string") return target; + if (target && typeof target.getSlotElementId === "function") { + return target.getSlotElementId(); + } + return target && target.id ? target.id : null; + } + function runHandoffInternal(callback) { var wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -142,11 +169,12 @@ if (!tag.defineSlot.__tsSlotHandoffPatched) { var originalDefineSlot = tag.defineSlot.bind(tag); var patchedDefineSlot = function (adUnitPath, formats, elementId) { - var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); if (!ts.gptSlotHandoffInternal && handoff) { - var existingSlot = findSlotByElementId(pubads, elementId); + var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); if (existingSlot) { if (!handoff.publisherClaimed) { + ts.gptSlotHandoffs[elementId] = handoff; handoff.publisherClaimed = true; handoff.suppressPublisherDisplay = true; handoff.suppressPublisherRefresh = @@ -179,8 +207,10 @@ if (!tag.display.__tsSlotHandoffPatched) { var originalDisplay = tag.display.bind(tag); - var patchedDisplay = function (elementId) { - var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + var patchedDisplay = function (target) { + var elementId = displayTargetElementId(target); + var handoff = + elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; if ( !ts.gptSlotHandoffInternal && handoff && @@ -189,7 +219,7 @@ handoff.suppressPublisherDisplay = false; return; } - originalDisplay(elementId); + originalDisplay(target); }; patchedDisplay.__tsSlotHandoffPatched = true; tag.display = patchedDisplay; @@ -197,15 +227,22 @@ if (!pubads.refresh.__tsSlotHandoffPatched) { var originalRefresh = pubads.refresh.bind(pubads); - var patchedRefresh = function (requestedSlots) { + var callRefresh = function (slots, options) { + if (options === undefined) { + originalRefresh(slots); + } else { + originalRefresh(slots, options); + } + }; + var patchedRefresh = function (requestedSlots, options) { if (ts.gptSlotHandoffInternal) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); return; } var slots = requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); if (!slots) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); return; } var suppressed = false; @@ -218,9 +255,9 @@ return false; }); if (!suppressed) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); } else if (remainingSlots.length > 0) { - originalRefresh(remainingSlots); + callRefresh(remainingSlots, options); } }; patchedRefresh.__tsSlotHandoffPatched = true; @@ -299,6 +336,8 @@ ts.gptSlotHandoffs[actualDivId] = { gamUnitPath: slot.gam_unit_path, formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: actualDivId, publisherClaimed: false, suppressPublisherDisplay: false, suppressPublisherRefresh: false, diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 16310f747..6cece1e8b 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -184,6 +184,10 @@ export interface GptDiagnosticsApi { export interface GptSlotHandoff { gamUnitPath: string; formats: Array<[number, number]>; + /** Stable configured prefix used to safely bridge framework-generated IDs. */ + divIdPrefix: string; + /** Element ID GPT received when TS created the fallback slot. */ + slotElementId: string; publisherClaimed: boolean; suppressPublisherDisplay: boolean; suppressPublisherRefresh: boolean; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index b2e76ab61..6fed843fa 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -99,12 +99,16 @@ function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable): void { } } +interface GoogleTagRefreshOptions { + changeCorrelator?: boolean; +} + interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; - refresh(slots?: GoogleTagSlot[]): void; + refresh(slots?: GoogleTagSlot[], options?: GoogleTagRefreshOptions): void; getSlots?(): GoogleTagSlot[]; disableInitialLoad?(): void; } @@ -117,6 +121,8 @@ interface GoogleTagEffectiveConfig { disableInitialLoad?: boolean; } +type GoogleTagDisplayTarget = string | Element | GoogleTagSlot; + interface GoogleTag { cmd: Array<() => void>; pubads(): GoogleTagPubAdsService; @@ -127,7 +133,7 @@ interface GoogleTag { ): GoogleTagSlot | null; destroySlots(slots?: GoogleTagSlot[]): boolean; enableServices(): void; - display(elementId: string): void; + display(target: GoogleTagDisplayTarget): void; setConfig?(config: GoogleTagConfig): void; getConfig?(keys: string | string[]): GoogleTagEffectiveConfig | undefined; _loaded_?: boolean; @@ -568,6 +574,38 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } +function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefined { + if (typeof target === 'string') return target; + if (target instanceof Element) return target.id || undefined; + return target.getSlotElementId(); +} + +function matchingHandoff( + ts: TsjsApi, + pubads: GoogleTagPubAdsService, + adUnitPath: string, + formats: Array, + elementId: string +): GptSlotHandoff | undefined { + const exact = ts.gptSlotHandoffs?.[elementId]; + if (exact) return exact; + + const candidates = new Set(Object.values(ts.gptSlotHandoffs ?? {})).values(); + const matching = Array.from(candidates).filter( + (handoff) => + !handoff.publisherClaimed && + elementId.startsWith(handoff.divIdPrefix) && + handoff.gamUnitPath === adUnitPath && + JSON.stringify(handoff.formats) === JSON.stringify(formats) && + findGptSlotByElementId(pubads, handoff.slotElementId) + ); + return matching.length === 1 ? matching[0] : undefined; +} + +function registerHandoffAlias(ts: TsjsApi, elementId: string, handoff: GptSlotHandoff): void { + (ts.gptSlotHandoffs ??= {})[elementId] = handoff; +} + function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -605,11 +643,12 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { formats: Array, elementId: string ): GoogleTagSlot | null => { - const handoff = ts.gptSlotHandoffs?.[elementId]; + const handoff = matchingHandoff(ts, pubads, adUnitPath, formats, elementId); if (!ts.gptSlotHandoffInternal && handoff) { - const existingSlot = findGptSlotByElementId(pubads, elementId); + const existingSlot = findGptSlotByElementId(pubads, handoff.slotElementId); if (existingSlot) { if (!handoff.publisherClaimed) { + registerHandoffAlias(ts, elementId, handoff); handoff.publisherClaimed = true; handoff.suppressPublisherDisplay = true; handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; @@ -639,13 +678,14 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { const display = g.display; if (!(display as HandoffPatchedFunction).__tsSlotHandoffPatched) { const originalDisplay = display.bind(g); - const patchedDisplay = (elementId: string): void => { - const handoff = ts.gptSlotHandoffs?.[elementId]; + const patchedDisplay = (target: GoogleTagDisplayTarget): void => { + const elementId = displayTargetElementId(target); + const handoff = elementId ? ts.gptSlotHandoffs?.[elementId] : undefined; if (!ts.gptSlotHandoffInternal && handoff?.suppressPublisherDisplay) { handoff.suppressPublisherDisplay = false; return; } - originalDisplay(elementId); + originalDisplay(target); }; (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; g.display = patchedDisplay; @@ -654,15 +694,28 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { const refresh = pubads.refresh; if (!(refresh as HandoffPatchedFunction).__tsSlotHandoffPatched) { const originalRefresh = refresh.bind(pubads); - const patchedRefresh = (requestedSlots?: GoogleTagSlot[]): void => { + const callRefresh = ( + slots: GoogleTagSlot[] | undefined, + options: GoogleTagRefreshOptions | undefined + ): void => { + if (options === undefined) { + originalRefresh(slots); + } else { + originalRefresh(slots, options); + } + }; + const patchedRefresh = ( + requestedSlots?: GoogleTagSlot[], + options?: GoogleTagRefreshOptions + ): void => { if (ts.gptSlotHandoffInternal) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); return; } const slots = requestedSlots ?? pubads.getSlots?.(); if (!slots) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); return; } @@ -675,9 +728,9 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { return false; }); if (!suppressed) { - originalRefresh(requestedSlots); + callRefresh(requestedSlots, options); } else if (remainingSlots.length > 0) { - originalRefresh(remainingSlots); + callRefresh(remainingSlots, options); } }; (patchedRefresh as HandoffPatchedFunction).__tsSlotHandoffPatched = true; @@ -782,6 +835,8 @@ export function installTsAdInit(): void { (ts.gptSlotHandoffs ??= {})[actualDivId] = { gamUnitPath: slot.gam_unit_path, formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: actualDivId, publisherClaimed: false, suppressPublisherDisplay: false, suppressPublisherRefresh: false, diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 75c1673c3..5438b6692 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -1,5 +1,7 @@ +import { readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { resolve } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; @@ -110,6 +112,7 @@ describe('installTsAdInit', () => { afterEach(() => { document.getElementById('div-atf-sidebar')?.remove(); + document.getElementById('ad-header-0-_r_1_')?.remove(); document.getElementById("ad'prefix-real")?.remove(); }); @@ -303,6 +306,303 @@ describe('installTsAdInit', () => { expect(destroySlots).not.toHaveBeenCalled(); }); + it.each(['slot', 'element'] as const)( + 'hands a hydrated publisher ID off when it displays by %s', + async (displayMode) => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const ssrDiv = document.getElementById('div-atf-sidebar')!; + ssrDiv.id = 'ad-header-0-_R_0_'; + const hydratedId = 'ad-header-0-_r_1_'; + const slots = new Map(); + const requests: string[] = []; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((target: string | Element | FakeSlot) => { + if (typeof target === 'string') { + requests.push(target); + } else if ('getSlotElementId' in target) { + requests.push(target.getSlotElementId()); + } else { + requests.push(target.id); + } + }); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'header_ad', + gam_unit_path: '/123/header', + div_id: 'ad-header-0-', + formats: [[970, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + ssrDiv.id = hydratedId; + + const publisherSlot = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot + )('/123/header', [[970, 250]], hydratedId); + publisherSlot.addService(pubads); + const publisherDisplay = googletag.display as unknown as ( + target: string | Element | FakeSlot + ) => void; + publisherDisplay(displayMode === 'slot' ? publisherSlot : ssrDiv); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['ad-header-0-_R_0_']); + expect((window as TestWindow).tsjs!.gptSlotHandoffs[hydratedId]).toBe( + (window as TestWindow).tsjs!.gptSlotHandoffs['ad-header-0-_R_0_'] + ); + } + ); + + it('does not transfer an ambiguous hydrated publisher definition', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const firstSlot = makeSlot('ad-header-0-_R_0_'); + const secondSlot = makeSlot('ad-header-0-_R_1_'); + const nativeDefineSlot = vi.fn((_adUnitPath: string, _formats: number[][], elementId: string) => + makeSlot(elementId) + ); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => [firstSlot, secondSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + const firstHandoff = { + gamUnitPath: '/123/header', + formats: [[970, 250]], + divIdPrefix: 'ad-header-0-', + slotElementId: 'ad-header-0-_R_0_', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + const secondHandoff = { ...firstHandoff, slotElementId: 'ad-header-0-_R_1_' }; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'ad-header-0-_R_0_': firstHandoff, + 'ad-header-0-_R_1_': secondHandoff, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const defined = ( + (window as TestWindow).googletag as { + defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot; + } + ).defineSlot('/123/header', [[970, 250]], 'ad-header-0-_r_1_'); + + expect(nativeDefineSlot).toHaveBeenCalledOnce(); + expect(defined).not.toBe(firstSlot); + expect(defined).not.toBe(secondSlot); + expect(firstHandoff.publisherClaimed).toBe(false); + expect(secondHandoff.publisherClaimed).toBe(false); + }); + + it('runs the embedded bootstrap handoff for a hydrated publisher ID', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + }; + const ssrDiv = document.getElementById('div-atf-sidebar')!; + ssrDiv.id = 'ad-header-0-_R_0_'; + const slots = new Map(); + const requests: string[] = []; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + refresh: vi.fn(), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((target: string | FakeSlot) => { + requests.push(typeof target === 'string' ? target : target.getSlotElementId()); + }); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'header_ad', + gam_unit_path: '/123/header', + div_id: 'ad-header-0-', + formats: [[970, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const bootstrap = readFileSync( + resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' + ); + window.eval(bootstrap); + (window as TestWindow).tsjs!.adInit!(); + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ssrDiv.id = 'ad-header-0-_r_1_'; + + const googletag = (window as TestWindow).googletag as { + defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot; + display(target: FakeSlot): void; + }; + const publisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); + googletag.display(publisherSlot); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['ad-header-0-_R_0_']); + }); + + it('preserves refresh options while filtering a claimed disabled-load slot', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const slots = new Map(); + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const nativeRefresh = vi.fn(); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: nativeRefresh, + disableInitialLoad: vi.fn(), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + pubads.disableInitialLoad(); + (window as TestWindow).tsjs!.adInit!(); + + const publisherSlot = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot + )('/123/atf', [[300, 250]], 'div-atf-sidebar'); + const unrelatedSlot = makeSlot('div-unrelated'); + const refreshOptions = { changeCorrelator: false }; + ( + pubads.refresh as unknown as ( + requestedSlots: FakeSlot[], + options: { changeCorrelator: boolean } + ) => void + )([publisherSlot, unrelatedSlot], refreshOptions); + + expect(nativeRefresh).toHaveBeenLastCalledWith([unrelatedSlot], refreshOptions); + }); + it('suppresses only the claimed slot from the first disabled-load publisher refresh', async () => { type FakeSlot = { addService(service: unknown): FakeSlot; diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md index 4699e3c42..dc2c7426c 100644 --- a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -44,8 +44,9 @@ share this runtime protocol through `window.tsjs`. - Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` - [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must - retain serializable lifecycle flags: TS-created, ownership-transferred, initial - request made, and one-shot publisher display/refresh suppression state. + retain serializable lifecycle flags, the configured stable div-ID prefix, the + original GPT slot element ID, ownership transfer state, and one-shot publisher + display/refresh suppression state. - [ ] Add only the minimal optional/internal type surface needed for idempotence markers on GPT functions and `pubads`. Do not weaken the public GPT types with `any`. @@ -76,7 +77,9 @@ npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index - [ ] `defineSlot` wrapper: - pass through TS-internal calls and IDs absent from the registry; - for a late publisher call on a claimed inner div, find and return the existing - slot without calling native `defineSlot`; + slot without calling native `defineSlot`; for hydration-generated ID changes, + permit this only for one live, unclaimed fallback with identical path/formats and + the configured div-ID prefix; - mark ownership transferred and remove that slot from `prevGptSlots` before returning it; - log, but do not create a second slot, if publisher arguments differ from the TS @@ -131,11 +134,14 @@ npx vitest run test/integrations/gpt/ad_init.test.ts 2. TS defines/displays the inner div and makes one request; 3. publisher calls `defineSlot(innerDiv).addService(...); display(innerDiv)`; 4. assert native `defineSlot` was called once, there is one slot, and there is one - request. + request. Repeat with the SSR-generated ID changed to the publisher's hydrated + ID, and assert an ambiguous prefix does not transfer ownership. - [ ] Add the same sequence with `disableInitialLoad()`: TS display plus its refresh makes one request; the publisher's first refresh cannot make a second request. -- [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert - the claimed slot is suppressed once and the unrelated slot is refreshed. +- [ ] Add no-argument and explicit-slot publisher refresh tests containing an + unrelated slot. Assert the claimed slot is suppressed once, the unrelated slot + is refreshed, and `changeCorrelator` options are preserved. Cover string, + element, and slot-object `display()` calls. - [ ] Add an already publisher-owned test proving TS does not install a claim, applies targeting, and refreshes that slot. - [ ] Add a no-publisher test proving TS still creates, displays, and requests its @@ -161,11 +167,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts bundle; bootstrap must not transfer or suppress its own operations. - [ ] Extend the `gpt.rs` head-insert tests to assert that the bootstrap contains the inner-div handoff protocol and no longer contains the container fallback. -- [ ] Add an executable bootstrap behavior test if practical by evaluating the - injected script against the same fake GPT fixture. If the test setup cannot execute - the included asset without duplication, record that limitation and keep the Rust - source-contract assertion plus identical bundle lifecycle tests as the minimum - coverage. +- [ ] Add an executable bootstrap behavior test by evaluating the included asset + against the fake GPT fixture, including a bootstrap-to-bundle adoption check. ## Task 6: Validate, inspect, and ship diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index 770718199..422eb8ef8 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -46,7 +46,11 @@ TS will define its fallback slot on the **actual inner div**, never on its outer `-container` element. It will record a narrowly scoped handoff claim keyed by that inner div ID. A `googletag.defineSlot` wrapper then recognizes a later publisher request for that exact div and returns the existing TS slot rather than invoking -GPT's native `defineSlot` again. +GPT's native `defineSlot` again. Framework hydration can change generated suffixes +between the first TS request and the publisher definition; in that case, TS may +alias the new ID only when exactly one unclaimed fallback has the configured div-ID +prefix, identical GAM path, identical formats, and a live GPT slot. Ambiguous or +mismatched definitions remain native GPT calls. GPT requires a one-to-one slot-to-div relationship and documents that a slot should be displayed only once. Sharing the initial inner-div slot therefore avoids both the @@ -62,9 +66,9 @@ competing container slot and an invalid duplicate definition. performs its existing one explicit refresh. TS records this slot as TS-owned and handoff-eligible. 3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded - inner-div claim, returns the existing slot, and transfers ownership: it removes - the slot from TS's future `destroySlots()` set. The publisher's setup continues - against that same slot. + inner-div claim (or the uniquely matching hydrated-ID claim), returns the existing + slot, and transfers ownership: it removes the slot from TS's future + `destroySlots()` set. The publisher's setup continues against that same slot. 4. **Publisher's first request call** — the wrapper suppresses the duplicate publisher `display()` call. With `disableInitialLoad()`, it instead suppresses only the publisher's first refresh for the transferred slot, because TS has @@ -77,8 +81,9 @@ competing container slot and an invalid duplicate definition. destroy a slot after ownership has transferred. The wrapper is not a global deduplicator. It only handles IDs present in TS's -handoff registry and must preserve native `defineSlot`, `display`, and `refresh` -behavior for every other placement. +handoff registry, or one uniquely safe hydrated-ID match, and must preserve native +`defineSlot`, all supported `display()` argument forms, and both `refresh()` +arguments for every other placement. ## Implementation shape From d6eab4d7ca7e0f7da8b9f4fb056766f3e6a2eea3 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 30 Jul 2026 12:41:37 -0500 Subject: [PATCH 11/16] Preserve native GPT slot behavior --- .../src/integrations/gpt_bootstrap.js | 20 +- .../lib/src/integrations/gpt/index.ts | 40 +++- .../lib/test/integrations/gpt/ad_init.test.ts | 222 +++++++++++++++++- 3 files changed, 259 insertions(+), 23 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index bcbceb528..f142b8d4f 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -121,7 +121,7 @@ function matchingHandoff(pubads, adUnitPath, formats, elementId) { var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; - if (exact) return exact; + if (exact) return exact.publisherClaimed ? null : exact; var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( function (handoff, index, allHandoffs) { @@ -169,13 +169,15 @@ if (!tag.defineSlot.__tsSlotHandoffPatched) { var originalDefineSlot = tag.defineSlot.bind(tag); var patchedDefineSlot = function (adUnitPath, formats, elementId) { - var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); - if (!ts.gptSlotHandoffInternal && handoff) { - var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); - if (existingSlot) { - if (!handoff.publisherClaimed) { + if (!ts.gptSlotHandoffInternal && typeof elementId === "string") { + var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); + if (handoff) { + var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); + if (existingSlot) { ts.gptSlotHandoffs[elementId] = handoff; handoff.publisherClaimed = true; + // The supported publisher lifecycle is defineSlot → addService → display. + // Intentionally wait for that display instead of applying a time heuristic. handoff.suppressPublisherDisplay = true; handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; @@ -195,11 +197,13 @@ elementId, ); } + return existingSlot; } - return existingSlot; } } - return originalDefineSlot(adUnitPath, formats, elementId); + return elementId === undefined + ? originalDefineSlot(adUnitPath, formats) + : originalDefineSlot(adUnitPath, formats, elementId); }; patchedDefineSlot.__tsSlotHandoffPatched = true; tag.defineSlot = patchedDefineSlot; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 6fed843fa..005d46cbe 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -129,7 +129,7 @@ interface GoogleTag { defineSlot( adUnitPath: string, size: Array, - elementId: string + elementId?: string ): GoogleTagSlot | null; destroySlots(slots?: GoogleTagSlot[]): boolean; enableServices(): void; @@ -576,8 +576,10 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefined { if (typeof target === 'string') return target; - if (target instanceof Element) return target.id || undefined; - return target.getSlotElementId(); + if (typeof (target as GoogleTagSlot).getSlotElementId === 'function') { + return (target as GoogleTagSlot).getSlotElementId(); + } + return (target as Element).id || undefined; } function matchingHandoff( @@ -588,7 +590,7 @@ function matchingHandoff( elementId: string ): GptSlotHandoff | undefined { const exact = ts.gptSlotHandoffs?.[elementId]; - if (exact) return exact; + if (exact) return exact.publisherClaimed ? undefined : exact; const candidates = new Set(Object.values(ts.gptSlotHandoffs ?? {})).values(); const matching = Array.from(candidates).filter( @@ -641,15 +643,17 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { const patchedDefineSlot = ( adUnitPath: string, formats: Array, - elementId: string + elementId?: string ): GoogleTagSlot | null => { - const handoff = matchingHandoff(ts, pubads, adUnitPath, formats, elementId); - if (!ts.gptSlotHandoffInternal && handoff) { - const existingSlot = findGptSlotByElementId(pubads, handoff.slotElementId); - if (existingSlot) { - if (!handoff.publisherClaimed) { + if (!ts.gptSlotHandoffInternal && typeof elementId === 'string') { + const handoff = matchingHandoff(ts, pubads, adUnitPath, formats, elementId); + if (handoff) { + const existingSlot = findGptSlotByElementId(pubads, handoff.slotElementId); + if (existingSlot) { registerHandoffAlias(ts, elementId, handoff); handoff.publisherClaimed = true; + // The supported publisher lifecycle is defineSlot → addService → display. + // Intentionally wait for that display instead of applying a time heuristic. handoff.suppressPublisherDisplay = true; handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; ts.prevGptSlots = (ts.prevGptSlots ?? []).filter( @@ -665,11 +669,13 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { publisherGamUnitPath: adUnitPath, }); } + return existingSlot; } - return existingSlot; } } - return originalDefineSlot(adUnitPath, formats, elementId); + return elementId === undefined + ? originalDefineSlot(adUnitPath, formats) + : originalDefineSlot(adUnitPath, formats, elementId); }; (patchedDefineSlot as HandoffPatchedFunction).__tsSlotHandoffPatched = true; g.defineSlot = patchedDefineSlot; @@ -765,7 +771,17 @@ export function installTsAdInit(): void { if ((ts.navGeneration ?? 0) !== generation) return; // Destroy previously defined TS slots before redefining for the new page. if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { + const destroyedSlotElementIds = new Set( + (ts.prevGptSlots as GoogleTagSlot[]).map((slot) => slot.getSlotElementId()) + ); g.destroySlots?.(ts.prevGptSlots as GoogleTagSlot[]); + if (ts.gptSlotHandoffs) { + for (const [elementId, handoff] of Object.entries(ts.gptSlotHandoffs)) { + if (destroyedSlotElementIds.has(handoff.slotElementId)) { + delete ts.gptSlotHandoffs[elementId]; + } + } + } ts.prevGptSlots = []; } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 5438b6692..5bff9b191 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -253,6 +253,7 @@ describe('installTsAdInit', () => { }; const nativeDefineSlot = vi.fn( (_adUnitPath: string, _formats: number[][], elementId: string) => { + if (slots.has(elementId)) return null; const slot = makeSlot(elementId); slots.set(elementId, slot); return slot; @@ -301,6 +302,10 @@ describe('installTsAdInit', () => { expect(requests).toEqual(['div-atf-sidebar']); expect((window as TestWindow).tsjs!.prevGptSlots).toEqual([]); + const duplicatePublisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); + expect(duplicatePublisherSlot).toBeNull(); + expect(nativeDefineSlot).toHaveBeenCalledTimes(2); + (window as TestWindow).tsjs!.adSlots = []; (window as TestWindow).tsjs!.adInit!(); expect(destroySlots).not.toHaveBeenCalled(); @@ -458,6 +463,162 @@ describe('installTsAdInit', () => { expect(secondHandoff.publisherClaimed).toBe(false); }); + it('delegates a div-less publisher definition with an unclaimed bundle handoff', async () => { + const fallbackSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-ts-fallback'), + }; + const nativeDefineSlot = vi.fn().mockReturnValue(null); + const pubads = { + getSlots: vi.fn().mockReturnValue([fallbackSlot]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + const handoff = { + gamUnitPath: '/123/fallback', + formats: [[300, 250]], + divIdPrefix: 'div-ts-', + slotElementId: 'div-ts-fallback', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { 'div-ts-fallback': handoff }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + expect(() => + ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId?: string + ) => unknown + )('/123/unrelated', [[728, 90]]) + ).not.toThrow(); + expect(nativeDefineSlot).toHaveBeenCalledWith('/123/unrelated', [[728, 90]]); + expect(handoff.publisherClaimed).toBe(false); + }); + + it('prunes destroyed TS-owned handoffs and their aliases on SPA navigation', async () => { + const slots = new Map< + string, + { + addService(service: unknown): unknown; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + setTargeting(key: string, value: string | string[]): unknown; + } + >(); + const makeSlot = (elementId: string) => ({ + addService: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + setTargeting: vi.fn().mockReturnThis(), + }); + const destroySlots = vi.fn(); + const pubads = { + addEventListener: vi.fn(), + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn((_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + }), + destroySlots, + display: vi.fn(), + enableServices: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + const handoff = (window as TestWindow).tsjs!.gptSlotHandoffs['div-atf-sidebar']; + (window as TestWindow).tsjs!.gptSlotHandoffs['div-atf-sidebar-hydrated'] = handoff; + (window as TestWindow).tsjs!.gptSlotHandoffs.unrelated = { + ...handoff, + slotElementId: 'div-unrelated', + }; + const ownedSlot = slots.get('div-atf-sidebar')!; + + (window as TestWindow).tsjs!.adSlots = []; + (window as TestWindow).tsjs!.adInit!(); + + expect(destroySlots).toHaveBeenCalledWith([ownedSlot]); + expect((window as TestWindow).tsjs!.gptSlotHandoffs).toEqual({ + unrelated: expect.objectContaining({ slotElementId: 'div-unrelated' }), + }); + }); + + it('suppresses a cross-realm element display without throwing', async () => { + const nativeDisplay = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn(), + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + }; + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const crossRealmElement = iframe.contentDocument!.createElement('div'); + crossRealmElement.id = 'div-cross-realm'; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'div-cross-realm': { + gamUnitPath: '/123/cross-realm', + formats: [[300, 250]], + divIdPrefix: 'div-cross-realm', + slotElementId: 'div-cross-realm', + publisherClaimed: true, + suppressPublisherDisplay: true, + suppressPublisherRefresh: false, + }, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + expect(() => + (googletag.display as unknown as (target: Element) => void)(crossRealmElement) + ).not.toThrow(); + expect(nativeDisplay).not.toHaveBeenCalled(); + iframe.remove(); + }); + it('runs the embedded bootstrap handoff for a hydrated publisher ID', async () => { type FakeSlot = { addService(service: unknown): FakeSlot; @@ -465,6 +626,7 @@ describe('installTsAdInit', () => { getSlotElementId(): string; }; const ssrDiv = document.getElementById('div-atf-sidebar')!; + const hydratedId = 'ad-header-0-_r_1_'; ssrDiv.id = 'ad-header-0-_R_0_'; const slots = new Map(); const requests: string[] = []; @@ -480,6 +642,7 @@ describe('installTsAdInit', () => { }; const nativeDefineSlot = vi.fn( (_adUnitPath: string, _formats: number[][], elementId: string) => { + if (slots.has(elementId) || elementId === hydratedId) return null; const slot = makeSlot(elementId); slots.set(elementId, slot); return slot; @@ -516,17 +679,70 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); installTsAdInit(); - ssrDiv.id = 'ad-header-0-_r_1_'; + ssrDiv.id = hydratedId; const googletag = (window as TestWindow).googletag as { - defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot; + defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot | null; display(target: FakeSlot): void; }; const publisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); - googletag.display(publisherSlot); + expect(publisherSlot).not.toBeNull(); + googletag.display(publisherSlot!); expect(nativeDefineSlot).toHaveBeenCalledTimes(1); expect(requests).toEqual(['ad-header-0-_R_0_']); + + const duplicatePublisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); + expect(duplicatePublisherSlot).toBeNull(); + expect(nativeDefineSlot).toHaveBeenCalledTimes(2); + }); + + it('delegates a div-less publisher definition with an unclaimed bootstrap handoff', () => { + const fallbackSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-ts-fallback'), + }; + const nativeDefineSlot = vi.fn().mockReturnValue(null); + const pubads = { + getSlots: vi.fn().mockReturnValue([fallbackSlot]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + const handoff = { + gamUnitPath: '/123/fallback', + formats: [[300, 250]], + divIdPrefix: 'div-ts-', + slotElementId: 'div-ts-fallback', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { 'div-ts-fallback': handoff }, + }; + + const bootstrap = readFileSync( + resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' + ); + window.eval(bootstrap); + + expect(() => + ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId?: string + ) => unknown + )('/123/unrelated', [[728, 90]]) + ).not.toThrow(); + expect(nativeDefineSlot).toHaveBeenCalledWith('/123/unrelated', [[728, 90]]); + expect(handoff.publisherClaimed).toBe(false); }); it('preserves refresh options while filtering a claimed disabled-load slot', async () => { From 01d7f809d9568cf88b8563f4b2a2a683ac0ce5e5 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 31 Jul 2026 12:15:13 -0500 Subject: [PATCH 12/16] Fix GPT slot handoff matching --- .../src/integrations/gpt_bootstrap.js | 36 ++- .../lib/src/integrations/gpt/index.ts | 24 +- .../lib/test/integrations/gpt/ad_init.test.ts | 269 ++++++++++++++++++ 3 files changed, 317 insertions(+), 12 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index f142b8d4f..a544c0f77 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -119,6 +119,22 @@ ); } + function normalizedGptFormats(formats) { + return formats.length === 2 && + formats.every(function (format) { + return typeof format === "number"; + }) + ? [formats] + : formats; + } + + function handoffFormatsMatch(handoff, formats) { + return ( + JSON.stringify(handoff.formats) === + JSON.stringify(normalizedGptFormats(formats)) + ); + } + function matchingHandoff(pubads, adUnitPath, formats, elementId) { var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; if (exact) return exact.publisherClaimed ? null : exact; @@ -128,9 +144,10 @@ return ( allHandoffs.indexOf(handoff) === index && !handoff.publisherClaimed && + !document.getElementById(handoff.slotElementId) && elementId.startsWith(handoff.divIdPrefix) && handoff.gamUnitPath === adUnitPath && - JSON.stringify(handoff.formats) === JSON.stringify(formats) && + handoffFormatsMatch(handoff, formats) && findSlotByElementId(pubads, handoff.slotElementId) ); }, @@ -159,7 +176,8 @@ // TS cannot wait an arbitrary amount of time for a framework to define a // slot: publishers that never define one would render blank. Instead, TS // defines its fallback on the actual inner div and aliases only a later - // publisher defineSlot() for that exact div to the same GPT slot. + // publisher defineSlot() for that exact div, or a hydration-renamed replacement + // after the original div is gone, to the same GPT slot. function installSlotHandoff() { window.googletag.cmd.push(function () { var tag = window.googletag; @@ -170,9 +188,17 @@ var originalDefineSlot = tag.defineSlot.bind(tag); var patchedDefineSlot = function (adUnitPath, formats, elementId) { if (!ts.gptSlotHandoffInternal && typeof elementId === "string") { - var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); + var handoff = matchingHandoff( + pubads, + adUnitPath, + formats, + elementId, + ); if (handoff) { - var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); + var existingSlot = findSlotByElementId( + pubads, + handoff.slotElementId, + ); if (existingSlot) { ts.gptSlotHandoffs[elementId] = handoff; handoff.publisherClaimed = true; @@ -188,7 +214,7 @@ ); if ( handoff.gamUnitPath !== adUnitPath || - JSON.stringify(handoff.formats) !== JSON.stringify(formats) + !handoffFormatsMatch(handoff, formats) ) { ts.log && ts.log.warn && diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 005d46cbe..23b97b99b 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -558,6 +558,7 @@ function installScheduleInitialAdInit(ts: TsjsApi): void { window.addEventListener('load', afterHydrationFrames, { once: true }); } }; +} interface HandoffPatchedFunction { __tsSlotHandoffPatched?: boolean; @@ -582,6 +583,16 @@ function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefi return (target as Element).id || undefined; } +function normalizedGptFormats(formats: Array): Array { + return formats.length === 2 && formats.every((format) => typeof format === 'number') + ? [formats as number[]] + : formats; +} + +function handoffFormatsMatch(handoff: GptSlotHandoff, formats: Array): boolean { + return JSON.stringify(handoff.formats) === JSON.stringify(normalizedGptFormats(formats)); +} + function matchingHandoff( ts: TsjsApi, pubads: GoogleTagPubAdsService, @@ -596,9 +607,10 @@ function matchingHandoff( const matching = Array.from(candidates).filter( (handoff) => !handoff.publisherClaimed && + !document.getElementById(handoff.slotElementId) && elementId.startsWith(handoff.divIdPrefix) && handoff.gamUnitPath === adUnitPath && - JSON.stringify(handoff.formats) === JSON.stringify(formats) && + handoffFormatsMatch(handoff, formats) && findGptSlotByElementId(pubads, handoff.slotElementId) ); return matching.length === 1 ? matching[0] : undefined; @@ -624,8 +636,9 @@ function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { * TS cannot wait an arbitrary amount of time for framework hydration: doing so * would leave placements blank when no publisher slot is ever defined. Instead, * TS creates its fallback on the publisher's actual div and aliases only a later - * `defineSlot()` for that exact div. The first duplicate publisher request is - * suppressed because TS has already issued the initial request with TS targeting. + * `defineSlot()` for that exact div, or for a hydration-renamed replacement after + * the original div is gone. The first duplicate publisher request is suppressed + * because TS has already issued the initial request with TS targeting. */ function installLatePublisherSlotHandoff(ts: TsjsApi): void { const win = window as GptWindow; @@ -659,10 +672,7 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { ts.prevGptSlots = (ts.prevGptSlots ?? []).filter( (ownedSlot) => ownedSlot !== existingSlot ); - if ( - handoff.gamUnitPath !== adUnitPath || - JSON.stringify(handoff.formats) !== JSON.stringify(formats) - ) { + if (handoff.gamUnitPath !== adUnitPath || !handoffFormatsMatch(handoff, formats)) { log.warn('GPT slot handoff: publisher definition differs from TS configuration', { elementId, tsGamUnitPath: handoff.gamUnitPath, diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 5bff9b191..5d418e497 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -88,6 +88,22 @@ async function runGptBootstrap(googletag: object): Promise { runBootstrap(window, googletag); } +type HandoffImplementation = 'bootstrap' | 'bundle'; + +async function installHandoff(implementation: HandoffImplementation): Promise { + if (implementation === 'bootstrap') { + const bootstrap = readFileSync( + resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' + ); + window.eval(bootstrap); + return; + } + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); +} + describe('installTsAdInit', () => { beforeEach(() => { vi.resetModules(); @@ -112,6 +128,8 @@ describe('installTsAdInit', () => { afterEach(() => { document.getElementById('div-atf-sidebar')?.remove(); + document.getElementById('div-atf-sidebar-2')?.remove(); + document.getElementById('div-size-hydrated')?.remove(); document.getElementById('ad-header-0-_r_1_')?.remove(); document.getElementById("ad'prefix-real")?.remove(); }); @@ -745,6 +763,257 @@ describe('installTsAdInit', () => { expect(handoff.publisherClaimed).toBe(false); }); + it.each(['bootstrap', 'bundle'] as const)( + 'does not hand a sibling slot to a TS fallback through the %s prefix path', + async (implementation) => { + const fallbackSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + }; + const siblingSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar-2'), + }; + const nativeDefineSlot = vi.fn().mockReturnValue(siblingSlot); + const nativeDisplay = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([fallbackSlot]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + }; + const handoff = { + gamUnitPath: '/123/mpu', + formats: [[300, 250]], + divIdPrefix: 'div-atf-sidebar', + slotElementId: 'div-atf-sidebar', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + const siblingElement = document.createElement('div'); + siblingElement.id = 'div-atf-sidebar-2'; + document.body.appendChild(siblingElement); + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { 'div-atf-sidebar': handoff }, + }; + + await installHandoff(implementation); + + const publisherSlot = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => typeof siblingSlot + )('/123/mpu', [[300, 250]], siblingElement.id); + (googletag.display as unknown as (target: string) => void)(siblingElement.id); + + expect(publisherSlot).toBe(siblingSlot); + expect(nativeDefineSlot).toHaveBeenCalledOnce(); + expect(nativeDisplay).toHaveBeenCalledWith(siblingElement.id); + expect(handoff.publisherClaimed).toBe(false); + expect(handoff.suppressPublisherDisplay).toBe(false); + } + ); + + it.each(['bootstrap', 'bundle'] as const)( + 'hands a publisher shorthand size to the TS fallback through the %s prefix path', + async (implementation) => { + const fallbackSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-size-original'), + }; + const nativeDefineSlot = vi.fn(); + const nativeDisplay = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([fallbackSlot]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + }; + const handoff = { + gamUnitPath: '/123/size', + formats: [[300, 250]], + divIdPrefix: 'div-size-', + slotElementId: 'div-size-original', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + const hydratedElement = document.createElement('div'); + hydratedElement.id = 'div-size-hydrated'; + document.body.appendChild(hydratedElement); + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { 'div-size-original': handoff }, + }; + + await installHandoff(implementation); + + const publisherSlot = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[], + elementId: string + ) => typeof fallbackSlot + )('/123/size', [300, 250], hydratedElement.id); + (googletag.display as unknown as (target: string) => void)(hydratedElement.id); + + expect(publisherSlot).toBe(fallbackSlot); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + expect(nativeDisplay).not.toHaveBeenCalled(); + expect(handoff.publisherClaimed).toBe(true); + expect(handoff.suppressPublisherDisplay).toBe(false); + } + ); + + it('filters only the claimed slot from the first bootstrap global refresh', () => { + const claimedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-claimed'), + }; + const unrelatedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), + }; + const nativeRefresh = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([claimedSlot, unrelatedSlot]), + refresh: nativeRefresh, + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn(), + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'div-claimed': { + gamUnitPath: '/123/claimed', + formats: [[300, 250]], + divIdPrefix: 'div-claimed', + slotElementId: 'div-claimed', + publisherClaimed: true, + suppressPublisherDisplay: false, + suppressPublisherRefresh: true, + }, + }, + }; + + return installHandoff('bootstrap').then(() => { + (pubads.refresh as () => void)(); + + expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); + expect((window as TestWindow).tsjs!.gptSlotHandoffs['div-claimed']).toEqual( + expect.objectContaining({ suppressPublisherRefresh: false }) + ); + }); + }); + + it('preserves refresh options while filtering a claimed bootstrap slot', () => { + const claimedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-claimed'), + }; + const unrelatedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), + }; + const nativeRefresh = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([claimedSlot, unrelatedSlot]), + refresh: nativeRefresh, + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn(), + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'div-claimed': { + gamUnitPath: '/123/claimed', + formats: [[300, 250]], + divIdPrefix: 'div-claimed', + slotElementId: 'div-claimed', + publisherClaimed: true, + suppressPublisherDisplay: false, + suppressPublisherRefresh: true, + }, + }, + }; + const refreshOptions = { changeCorrelator: false }; + + return installHandoff('bootstrap').then(() => { + (pubads.refresh as (slots: (typeof claimedSlot)[], options: typeof refreshOptions) => void)( + [claimedSlot, unrelatedSlot], + refreshOptions + ); + + expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot], refreshOptions); + }); + }); + + it('does not transfer an ambiguous hydrated publisher definition through bootstrap', () => { + const firstSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-prefix-original-a'), + }; + const secondSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-prefix-original-b'), + }; + const nativeDefineSlot = vi.fn().mockReturnValue(null); + const pubads = { + getSlots: vi.fn().mockReturnValue([firstSlot, secondSlot]), + refresh: vi.fn(), + }; + const firstHandoff = { + gamUnitPath: '/123/prefix', + formats: [[300, 250]], + divIdPrefix: 'div-prefix-', + slotElementId: 'div-prefix-original-a', + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + const secondHandoff = { + ...firstHandoff, + slotElementId: 'div-prefix-original-b', + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(pubads), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + 'div-prefix-original-a': firstHandoff, + 'div-prefix-original-b': secondHandoff, + }, + }; + + return installHandoff('bootstrap').then(() => { + const defined = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => null + )('/123/prefix', [[300, 250]], 'div-prefix-hydrated'); + + expect(defined).toBeNull(); + expect(nativeDefineSlot).toHaveBeenCalledOnce(); + expect(firstHandoff.publisherClaimed).toBe(false); + expect(secondHandoff.publisherClaimed).toBe(false); + }); + }); + it('preserves refresh options while filtering a claimed disabled-load slot', async () => { type FakeSlot = { addService(service: unknown): FakeSlot; From 41ea002327cfcb7196999e8804f5a4762c68e1de Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 4 Aug 2026 12:14:33 -0500 Subject: [PATCH 13/16] Invalidate stale GPT handoffs on SPA navigation --- .../lib/src/integrations/gpt/index.ts | 5 + .../test/integrations/gpt/spa_hook.test.ts | 95 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 23b97b99b..6b8df5c96 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1167,6 +1167,11 @@ export function installSpaAuctionHook(): void { if (path === currentPath) return; currentPath = path; ts.navGeneration = (ts.navGeneration ?? 0) + 1; + // A route change invalidates hydration aliases before the new route's + // publisher can define a same-prefix slot while page-bids is in flight. + for (const [elementId, handoff] of Object.entries(ts.gptSlotHandoffs ?? {})) { + if (!handoff.publisherClaimed) delete ts.gptSlotHandoffs![elementId]; + } inflight?.abort(); const controller = new AbortController(); inflight = controller; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 19739e3dd..c3e5eae89 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { TsjsApi } from '../../../src/core/types'; @@ -83,6 +86,98 @@ describe('installSpaAuctionHook', () => { await flushAsync(); }); + it.each(['bootstrap', 'bundle'] as const)( + 'invalidates unclaimed GPT handoffs before an SPA fetch (%s)', + async (implementation) => { + let resolveFetch: ((response: unknown) => void) | undefined; + fetchStub.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + const routeADiv = document.createElement('div'); + routeADiv.id = 'div-atf-sidebar'; + document.body.appendChild(routeADiv); + const routeASlot = { + getSlotElementId: vi.fn().mockReturnValue(routeADiv.id), + }; + const routeBSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar-2'), + }; + const nativeDefineSlot = vi.fn().mockReturnValue(routeBSlot); + const nativeDisplay = vi.fn(); + const pubads = { + getSlots: vi.fn().mockReturnValue([routeASlot]), + refresh: vi.fn(), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + }; + const staleHandoff = { + gamUnitPath: '/123/atf', + formats: [[300, 250]], + divIdPrefix: 'div-atf-sidebar', + slotElementId: routeADiv.id, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + const claimedHandoff = { + ...staleHandoff, + slotElementId: 'div-claimed', + publisherClaimed: true, + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + gptSlotHandoffs: { + [staleHandoff.slotElementId]: staleHandoff, + 'div-atf-sidebar-hydrated': staleHandoff, + [claimedHandoff.slotElementId]: claimedHandoff, + }, + }; + + if (implementation === 'bootstrap') { + const bootstrap = readFileSync( + resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' + ); + window.eval(bootstrap); + } + await importGptModule(); + + routeADiv.remove(); + const routeBDiv = document.createElement('div'); + routeBDiv.id = 'div-atf-sidebar-2'; + document.body.appendChild(routeBDiv); + history.pushState({}, '', '/route-b'); + + const publisherSlot = ( + googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => typeof routeBSlot + )('/123/atf', [[300, 250]], routeBDiv.id); + googletag.display(routeBDiv.id); + + expect(publisherSlot).toBe(routeBSlot); + expect(nativeDefineSlot).toHaveBeenCalledWith('/123/atf', [[300, 250]], routeBDiv.id); + expect(nativeDisplay).toHaveBeenCalledWith(routeBDiv.id); + expect((window as TestWindow).tsjs!.gptSlotHandoffs).toEqual({ + [claimedHandoff.slotElementId]: claimedHandoff, + }); + expect(resolveFetch).toBeDefined(); + + resolveFetch!({ ok: true, json: async () => ({ slots: [], bids: {} }) }); + await flushAsync(); + } + ); + it('fetches page-bids on pushState and applies slots/bids via adInit', async () => { // The route's ad container already exists, so bids apply immediately. document.body.innerHTML = '
'; From 44f0d10172c96ed2ba23d163a29b17f5f8c645aa Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 6 Aug 2026 13:52:34 -0500 Subject: [PATCH 14/16] Restore GPT initial-load synchronization --- .../src/integrations/gpt_bootstrap.js | 1 + .../lib/src/integrations/gpt/index.ts | 1 + .../lib/test/integrations/gpt/ad_init.test.ts | 40 ++++++++++--------- .../integrations/gpt/gpt_bootstrap.test.ts | 22 +++++----- 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index a544c0f77..97846a13b 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -425,6 +425,7 @@ // unless the publisher disabled initial load, in which case display() only // registers the slot and refresh() must request the ad — otherwise they render // blank. Only add them in that case to avoid double-requesting. + syncInitialLoadDisabled(window.googletag); var slotsNeedingRefresh = ts.gptInitialLoadDisabled ? slotsToRefresh.concat(newSlots) : slotsToRefresh; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 6b8df5c96..5dd948c3b 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -960,6 +960,7 @@ export function installTsAdInit(): void { // first-impression slot renders blank on initial-load-disabled pages. Only // add them in that case; otherwise display() + refresh() would // double-request the impression. + syncInitialLoadDisabled(g, ts); const slotsNeedingRefresh = ts.gptInitialLoadDisabled ? slotsToRefresh.concat(newSlots) : slotsToRefresh; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 5d418e497..82ab4f4f8 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -1238,10 +1238,11 @@ describe('installTsAdInit', () => { getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), }; const disableInitialLoadMock = vi.fn(); + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), getSlots: vi.fn().mockReturnValue([]), - refresh: vi.fn(), + refresh: nativeRefresh, disableInitialLoad: disableInitialLoadMock, }; const displayMock = vi.fn(); @@ -1277,7 +1278,7 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); }); it('tracks setConfig state and re-enabling in the edge bootstrap', async () => { @@ -1295,10 +1296,11 @@ describe('installTsAdInit', () => { effectiveConfig = { disableInitialLoad: config.disableInitialLoad === true }; } }); + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), getSlots: vi.fn().mockReturnValue([]), - refresh: vi.fn(), + refresh: nativeRefresh, }; const displayMock = vi.fn(); const googletag = { @@ -1342,15 +1344,15 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - mockPubads.refresh.mockClear(); + nativeRefresh.mockClear(); googletag.setConfig({ disableInitialLoad: false }); expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); googletag.setConfig({ disableInitialLoad: true }); googletag.setConfig({ disableInitialLoad: null }); @@ -1358,7 +1360,7 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); }); it('tracks the effective initial-load state from setConfig', async () => { @@ -1383,12 +1385,13 @@ describe('installTsAdInit', () => { const disableInitialLoadMock = vi.fn(() => { effectiveConfig = { disableInitialLoad: true }; }); + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), // Publisher has not defined this slot, so TS defines (owns) it. getSlots: vi.fn().mockReturnValue([]), addEventListener: vi.fn(), - refresh: vi.fn(), + refresh: nativeRefresh, disableInitialLoad: disableInitialLoadMock, }; const displayMock = vi.fn(); @@ -1430,7 +1433,7 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); // Fall back to the explicit setConfig value when getConfig is unavailable. gpt.setConfig({ disableInitialLoad: true }); @@ -1449,9 +1452,9 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - mockPubads.refresh.mockClear(); + nativeRefresh.mockClear(); gpt.setConfig({ disableInitialLoad: false }); expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); gpt.setConfig({ disableInitialLoad: null }); @@ -1459,7 +1462,7 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); // GPT exposes one effective setting across the modern and legacy APIs. // A legacy call made after setConfig(false) disables initial load. @@ -1468,16 +1471,16 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); // A later modern call can re-enable initial load after the legacy API. - mockPubads.refresh.mockClear(); + nativeRefresh.mockClear(); gpt.setConfig({ disableInitialLoad: false }); expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); // Resetting the setting to its default has the same effective result. mockPubads.disableInitialLoad(); @@ -1486,7 +1489,7 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); }); it('reads initial-load configuration effective before detector installation', async () => { @@ -1496,11 +1499,12 @@ describe('installTsAdInit', () => { getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue([]), }; + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), getSlots: vi.fn().mockReturnValue([]), addEventListener: vi.fn(), - refresh: vi.fn(), + refresh: nativeRefresh, }; const displayMock = vi.fn(); const getConfigMock = vi.fn().mockReturnValue({ disableInitialLoad: true }); @@ -1534,7 +1538,7 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); }); it('sets adInitRefreshInProgress only for the duration of the internal refresh', async () => { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index f5c43ed7f..d3e1d7099 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -163,19 +163,21 @@ describe('gpt_bootstrap.js fallback', () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar-container'), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), }; const mockPubads = { enableSingleRequest: vi.fn(), getSlots: vi.fn().mockReturnValue([]), refresh: vi.fn(), }; + const defineSlot = vi.fn().mockReturnValue(mockSlot); + const display = vi.fn(); (window as TestWindow).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), + defineSlot, pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), - display: vi.fn(), + display, }; document.body.innerHTML = '
'; @@ -193,24 +195,20 @@ describe('gpt_bootstrap.js fallback', () => { ts.adInit!(); - const googletag = (window as TestWindow).googletag!; - expect(googletag.defineSlot).toHaveBeenCalledWith( - '/123/atf', - [[300, 250]], - 'div-atf-sidebar-container' - ); + expect(defineSlot).toHaveBeenCalledWith('/123/atf', [[300, 250]], 'div-atf-sidebar'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(googletag.display).toHaveBeenCalledWith('div-atf-sidebar-container'); + expect(display).toHaveBeenCalledWith('div-atf-sidebar'); expect(ts.servicesEnabled).toBe(true); }); it('fallback adInit cancels queued work when the generation advances before the queue drains', () => { const commandQueue: Array<() => void> = []; + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), getSlots: vi.fn().mockReturnValue([]), - refresh: vi.fn(), + refresh: nativeRefresh, }; const defineSlot = vi.fn(); (window as TestWindow).googletag = { @@ -241,7 +239,7 @@ describe('gpt_bootstrap.js fallback', () => { ts.navGeneration = 1; commandQueue.splice(0).forEach((fn) => fn()); expect(defineSlot).not.toHaveBeenCalled(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); expect(mockPubads.enableSingleRequest).not.toHaveBeenCalled(); }); }); From ac5a53cbe8fe4466e4bfc3705375d642850c56df Mon Sep 17 00:00:00 2001 From: Christian Pavilonis Date: Thu, 6 Aug 2026 14:24:37 -0500 Subject: [PATCH 15/16] Select active GPT responsive slots (#978) --- .../src/integrations/gpt.rs | 2 +- .../src/integrations/gpt_bootstrap.js | 119 ++++- .../lib/src/integrations/gpt/index.ts | 149 +++++- .../lib/test/integrations/gpt/ad_init.test.ts | 442 +++++++++++++++++- .../test/integrations/gpt/spa_hook.test.ts | 77 ++- ...vent-duplicate-gpt-slot-requests-design.md | 6 +- 6 files changed, 740 insertions(+), 55 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index fae701756..3b3b40b78 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -1253,7 +1253,7 @@ mod tests { "bootstrap should scan ID-bearing elements instead of interpolating div_id into CSS" ); assert!( - combined.contains(".startsWith(slot.div_id)"), + combined.contains("candidate.id.startsWith(divId)"), "bootstrap should match metacharacter-containing div_id prefixes with startsWith" ); assert!( diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 97846a13b..25a9da904 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -163,6 +163,85 @@ return target && target.id ? target.id : null; } + function isElementVisible(element) { + if (typeof element.checkVisibility === "function") { + return element.checkVisibility({ + checkVisibilityCSS: true, + visibilityProperty: true, + }); + } + + for (var current = element; current; current = current.parentElement) { + var style = window.getComputedStyle(current); + if ( + style.display === "none" || + style.visibility === "hidden" || + style.visibility === "collapse" + ) { + return false; + } + } + return true; + } + + function slotElementHasLayout(element) { + if (!isElementVisible(element)) return false; + var elementRect = element.getBoundingClientRect(); + if (elementRect.width > 0 && elementRect.height > 0) return true; + + var container = document.getElementById(element.id + "-container"); + if (!container || !isElementVisible(container)) return false; + var containerRect = container.getBoundingClientRect(); + return containerRect.width > 0; + } + + function resolveSlotElementByDivId(divId) { + if (!divId) { + return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; + } + var exact = document.getElementById(divId); + if (exact) { + return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; + } + + var idElements = document.querySelectorAll("[id]"); + var prefixMatches = []; + for (var i = 0; i < idElements.length; i++) { + var candidate = idElements[i]; + if ( + candidate.id.startsWith(divId) && + !candidate.id.endsWith("-container") + ) { + prefixMatches.push(candidate); + } + } + // A unique prefix match may be a lazy slot that has not been sized yet, + // but it must still be visible through its ancestor containers. + if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0])) { + return { + element: prefixMatches[0], + prefixMatchCount: 1, + activeMatchCount: 1, + }; + } + + var visibleMatches = prefixMatches.filter(isElementVisible); + if (visibleMatches.length === 1) { + return { + element: visibleMatches[0], + prefixMatchCount: prefixMatches.length, + activeMatchCount: 1, + }; + } + + var activeMatches = visibleMatches.filter(slotElementHasLayout); + return { + element: activeMatches.length === 1 ? activeMatches[0] : null, + prefixMatchCount: prefixMatches.length, + activeMatchCount: activeMatches.length, + }; + } + function runHandoffInternal(callback) { var wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -307,6 +386,7 @@ // the queued callback so a navigation committed in the gap cancels the // stale mutation — mirrors the bundle's adInit. var generation = ts.navGeneration || 0; + var warnedResolutionFailures = Object.create(null); googletag.cmd.push(function () { if ((ts.navGeneration || 0) !== generation) return; @@ -321,25 +401,28 @@ // slot that was never displayed, so these are display()ed instead. var slotsToDisplay = []; slots.forEach(function (slot) { - // Resolve actual div ID: exact match first, then safe prefix scan. - // div_id in config may be a stable prefix (e.g. "ad-header-0-") when - // the suffix is dynamically generated by the framework at render time. - var el = document.getElementById(slot.div_id); + // Resolve actual div ID: exact match first, then the visibility and + // geometry tiers for prefix matches. Responsive publishers may emit + // several mutually exclusive siblings for one stable prefix, so + // document order is not sufficient. + var resolution = resolveSlotElementByDivId(slot.div_id); + var el = resolution.element; if (!el) { - var idElements = document.querySelectorAll("[id]"); - for (var i = 0; i < idElements.length; i++) { - var candidate = idElements[i]; - if ( - slot.div_id && - candidate.id.startsWith(slot.div_id) && - !candidate.id.endsWith("-container") - ) { - el = candidate; - break; + if ( + resolution.prefixMatchCount > 1 && + !warnedResolutionFailures[slot.div_id] + ) { + warnedResolutionFailures[slot.div_id] = true; + if (ts.log && typeof ts.log.warn === "function") { + ts.log.warn("GPT slot prefix did not resolve to one active element", { + divId: slot.div_id, + prefixMatchCount: resolution.prefixMatchCount, + activeMatchCount: resolution.activeMatchCount, + }); } } + return; } - if (!el) return; var actualDivId = el.id; var b = bids[slot.id] || {}; @@ -420,15 +503,17 @@ googletag.display(divId); }); }); + syncInitialLoadDisabled(window.googletag); + // Reused publisher-owned slots always need a refresh to pick up the // server-side targeting. TS-defined slots are fetched by display() above // unless the publisher disabled initial load, in which case display() only - // registers the slot and refresh() must request the ad — otherwise they render + // registers them and refresh() must request the ad — otherwise they render // blank. Only add them in that case to avoid double-requesting. - syncInitialLoadDisabled(window.googletag); var slotsNeedingRefresh = ts.gptInitialLoadDisabled ? slotsToRefresh.concat(newSlots) : slotsToRefresh; + if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied // server-side targeting to GAM. If slim-Prebid has already wrapped diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 5dd948c3b..741cc70f3 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -52,29 +52,103 @@ interface SlotRenderEndedEvent { slot: GoogleTagSlot; } -function findSlotElementByDivId(divId: string): HTMLElement | null { +interface SlotElementResolution { + element: HTMLElement | null; + prefixMatchCount: number; + activeMatchCount: number; +} + +function isElementVisible(element: HTMLElement): boolean { + const elementWithVisibilityCheck = element as HTMLElement & { + checkVisibility?: (options?: { + checkVisibilityCSS?: boolean; + visibilityProperty?: boolean; + }) => boolean; + }; + if (typeof elementWithVisibilityCheck.checkVisibility === 'function') { + return elementWithVisibilityCheck.checkVisibility({ + checkVisibilityCSS: true, + visibilityProperty: true, + }); + } + + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + const style = window.getComputedStyle(current); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.visibility === 'collapse' + ) { + return false; + } + } + return true; +} + +function slotElementHasLayout(element: HTMLElement): boolean { + if (!isElementVisible(element)) return false; + const elementRect = element.getBoundingClientRect(); + if (elementRect.width > 0 && elementRect.height > 0) return true; + + const container = document.getElementById(`${element.id}-container`); + if (!container || !isElementVisible(container)) return false; + const containerRect = container.getBoundingClientRect(); + return containerRect.width > 0; +} + +function resolveSlotElementByDivId(divId: string): SlotElementResolution { + if (!divId) { + return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; + } const exact = document.getElementById(divId); - if (exact) return exact; + if (exact) { + return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; + } - return ( - Array.from(document.querySelectorAll('[id]')).find( - (el) => el.id.startsWith(divId) && !el.id.endsWith('-container') - ) ?? null + const prefixMatches = Array.from(document.querySelectorAll('[id]')).filter( + (element) => element.id.startsWith(divId) && !element.id.endsWith('-container') ); + // A unique prefix match may be a lazy slot that has not been sized yet, but + // it must still be visible through its ancestor containers. + if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0]!)) { + return { + element: prefixMatches[0]!, + prefixMatchCount: 1, + activeMatchCount: 1, + }; + } + + const visibleMatches = prefixMatches.filter(isElementVisible); + if (visibleMatches.length === 1) { + return { + element: visibleMatches[0]!, + prefixMatchCount: prefixMatches.length, + activeMatchCount: 1, + }; + } + + const activeMatches = visibleMatches.filter(slotElementHasLayout); + return { + element: activeMatches.length === 1 ? activeMatches[0]! : null, + prefixMatchCount: prefixMatches.length, + activeMatchCount: activeMatches.length, + }; +} + +function findSlotElementByDivId(divId: string): HTMLElement | null { + return resolveSlotElementByDivId(divId).element; } -function candidateSlotRoots(divId: string): HTMLElement[] { +function candidateSlotRoots(elementId: string): HTMLElement[] { const roots: HTMLElement[] = []; - const slotEl = findSlotElementByDivId(divId); + const slotEl = document.getElementById(elementId); if (slotEl) { roots.push(slotEl); - const container = document.getElementById(`${slotEl.id}-container`); - if (container) roots.push(container); } - const configuredContainer = document.getElementById(`${divId}-container`); - if (configuredContainer && !roots.includes(configuredContainer)) { - roots.push(configuredContainer); + const container = document.getElementById(`${elementId}-container`); + if (container && !roots.includes(container)) { + roots.push(container); } return roots; @@ -83,12 +157,12 @@ function candidateSlotRoots(divId: string): HTMLElement[] { function slotIdForMessageSource(source: MessageEventSource | null): string | undefined { if (!source) return undefined; - const slots = window.tsjs?.adSlots ?? []; - return slots.find((slot) => - candidateSlotRoots(slot.div_id).some((root) => + const divToSlotId = window.tsjs?.divToSlotId ?? {}; + return Object.entries(divToSlotId).find(([elementId]) => + candidateSlotRoots(elementId).some((root) => Array.from(root.querySelectorAll('iframe')).some((iframe) => iframe.contentWindow === source) ) - )?.id; + )?.[1]; } function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable): void { @@ -776,6 +850,7 @@ export function installTsAdInit(): void { const generation = ts.navGeneration ?? 0; const g = (window as GptWindow).googletag; if (!g) return; + const warnedResolutionFailures = new Set(); g.cmd?.push(() => { if ((ts.navGeneration ?? 0) !== generation) return; @@ -832,11 +907,23 @@ export function installTsAdInit(): void { } slots.forEach((slot) => { - // Resolve actual div ID: exact match first, then prefix query. - // div_id in config may be a stable prefix (e.g. "ad-header-0-") when - // the suffix is dynamically generated by the framework at render time. - const el = findSlotElementByDivId(slot.div_id); - if (!el) return; + // Resolve actual div ID: exact match first, then the visibility and + // geometry tiers for prefix matches. div_id in config may be a stable + // prefix (e.g. "ad-header-0-") when the suffix is dynamically + // generated by the framework at render time. + const resolution = resolveSlotElementByDivId(slot.div_id); + const el = resolution.element; + if (!el) { + if (resolution.prefixMatchCount > 1 && !warnedResolutionFailures.has(slot.div_id)) { + warnedResolutionFailures.add(slot.div_id); + log.warn('GPT slot prefix did not resolve to one active element', { + divId: slot.div_id, + prefixMatchCount: resolution.prefixMatchCount, + activeMatchCount: resolution.activeMatchCount, + }); + } + return; + } const actualDivId = el.id; const bid = bids[slot.id] ?? {}; @@ -951,6 +1038,7 @@ export function installTsAdInit(): void { // enabled, so this runs unconditionally for any newly-defined slots. slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); + syncInitialLoadDisabled(g, ts); // Slots needing an explicit ad request via refresh(). Reused // publisher-owned slots always need one to pick up the just-applied // server-side targeting. TS-defined slots are normally fetched by the @@ -960,7 +1048,6 @@ export function installTsAdInit(): void { // first-impression slot renders blank on initial-load-disabled pages. Only // add them in that case; otherwise display() + refresh() would // double-request the impression. - syncInitialLoadDisabled(g, ts); const slotsNeedingRefresh = ts.gptInitialLoadDisabled ? slotsToRefresh.concat(newSlots) : slotsToRefresh; @@ -1088,16 +1175,30 @@ function waitForSlotElements(slots: AuctionSlot[], signal: AbortSignal): Promise return new Promise((resolve) => { let settled = false; + let animationFrame: number | undefined; const finish = (): void => { if (settled) return; settled = true; + if (animationFrame !== undefined) cancelAnimationFrame(animationFrame); observer.disconnect(); clearTimeout(timer); signal.removeEventListener('abort', finish); resolve(); }; const observer = new MutationObserver(() => { - if (allPresent()) finish(); + if (document.visibilityState === 'hidden' || typeof requestAnimationFrame === 'undefined') { + if (animationFrame !== undefined) { + cancelAnimationFrame(animationFrame); + animationFrame = undefined; + } + if (allPresent()) finish(); + return; + } + if (animationFrame !== undefined) return; + animationFrame = requestAnimationFrame(() => { + animationFrame = undefined; + if (allPresent()) finish(); + }); }); observer.observe(document.documentElement, { childList: true, subtree: true }); const timer = setTimeout(finish, SPA_SLOT_WAIT_MS); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 82ab4f4f8..51ff0e540 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -66,7 +66,7 @@ type TestWindow = Omit & { tsjs?: Partial; }; -async function runGptBootstrap(googletag: object): Promise { +async function runGptBootstrapWithGoogleTag(googletag: object): Promise { const bootstrapUrl = new URL( '../../../../../trusted-server-core/src/integrations/gpt_bootstrap.js', import.meta.url @@ -104,6 +104,57 @@ async function installHandoff(implementation: HandoffImplementation): Promise + ({ width: containerWidth, height: containerHeight }) as DOMRect; + + const element = document.createElement('div'); + element.id = id; + element.style.display = elementHidden ? 'none' : 'block'; + element.getBoundingClientRect = () => ({ width: elementWidth, height: elementHeight }) as DOMRect; + if (checkVisibility !== undefined) { + (element as HTMLElement & { checkVisibility?: () => boolean }).checkVisibility = vi + .fn() + .mockReturnValue(checkVisibility); + } + container.appendChild(element); + document.body.appendChild(container); + return element; +} + +function runGptBootstrap(): void { + const bootstrap = readFileSync( + resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' + ); + window.eval(bootstrap); +} + describe('installTsAdInit', () => { beforeEach(() => { vi.resetModules(); @@ -132,6 +183,7 @@ describe('installTsAdInit', () => { document.getElementById('div-size-hydrated')?.remove(); document.getElementById('ad-header-0-_r_1_')?.remove(); document.getElementById("ad'prefix-real")?.remove(); + document.querySelectorAll('[data-responsive-slot-test]').forEach((element) => element.remove()); }); it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { @@ -689,11 +741,7 @@ describe('installTsAdInit', () => { bids: {}, }; - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); + runGptBootstrap(); (window as TestWindow).tsjs!.adInit!(); const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); installTsAdInit(); @@ -1269,7 +1317,7 @@ describe('installTsAdInit', () => { bids: {}, }; - await runGptBootstrap(googletag); + await runGptBootstrapWithGoogleTag(googletag); mockPubads.disableInitialLoad(); expect(disableInitialLoadMock).toHaveBeenCalledOnce(); @@ -1326,7 +1374,7 @@ describe('installTsAdInit', () => { bids: {}, }; - await runGptBootstrap(googletag); + await runGptBootstrapWithGoogleTag(googletag); // Older GPT runtimes may expose setConfig without getConfig. In that case, // the wrapper tracks explicit initial-load updates directly. @@ -2158,6 +2206,324 @@ describe('installTsAdInit', () => { expect(mockPubads.refresh).toHaveBeenCalled(); }); + it.each([ + { implementation: 'runtime', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 }, + { implementation: 'runtime', activeIndexes: [], selectedIndex: null }, + { + implementation: 'runtime', + candidateIndexes: [2], + activeIndexes: [], + selectedIndex: null, + }, + { + implementation: 'runtime', + activeIndexes: [], + elementLayoutIndexes: [1], + visibleContainerIndexes: [1], + selectedIndex: 1, + }, + { implementation: 'runtime', activeIndexes: [0, 2], selectedIndex: null }, + { + implementation: 'runtime', + activeIndexes: [2, 3], + hiddenElementIndexes: [2], + selectedIndex: 3, + }, + { + implementation: 'runtime', + activeIndexes: [], + hiddenElementIndexes: [0, 1, 3], + visibleContainerIndexes: [2], + selectedIndex: 2, + }, + { + implementation: 'runtime', + activeIndexes: [], + hiddenElementIndexes: [0, 1, 3], + visibleContainerIndexes: [1, 2], + containerWidthIndexes: [2], + selectedIndex: 2, + }, + { implementation: 'runtime', activeIndexes: [2], divId: '', selectedIndex: null }, + { implementation: 'bootstrap', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 }, + { implementation: 'bootstrap', activeIndexes: [], selectedIndex: null }, + { + implementation: 'bootstrap', + candidateIndexes: [2], + activeIndexes: [], + selectedIndex: null, + }, + { + implementation: 'bootstrap', + activeIndexes: [], + elementLayoutIndexes: [1], + visibleContainerIndexes: [1], + selectedIndex: 1, + }, + { implementation: 'bootstrap', activeIndexes: [0, 2], selectedIndex: null }, + { + implementation: 'bootstrap', + activeIndexes: [2, 3], + hiddenElementIndexes: [2], + selectedIndex: 3, + }, + { + implementation: 'bootstrap', + activeIndexes: [], + hiddenElementIndexes: [0, 1, 3], + visibleContainerIndexes: [2], + selectedIndex: 2, + }, + { + implementation: 'bootstrap', + activeIndexes: [], + hiddenElementIndexes: [0, 1, 3], + visibleContainerIndexes: [1, 2], + containerWidthIndexes: [2], + selectedIndex: 2, + }, + { implementation: 'bootstrap', activeIndexes: [2], divId: '', selectedIndex: null }, + ] as const)( + '$implementation resolves responsive matches $activeIndexes to $selectedIndex', + async (testCase) => { + const { implementation, activeIndexes, selectedIndex } = testCase; + const hiddenElementIndexes = + 'hiddenElementIndexes' in testCase ? testCase.hiddenElementIndexes : []; + const elementLayoutIndexes = + 'elementLayoutIndexes' in testCase ? testCase.elementLayoutIndexes : []; + const visibleContainerIndexes = + 'visibleContainerIndexes' in testCase ? testCase.visibleContainerIndexes : activeIndexes; + const containerWidthIndexes = + 'containerWidthIndexes' in testCase ? testCase.containerWidthIndexes : activeIndexes; + const containerHeightIndexes = + 'containerHeightIndexes' in testCase ? testCase.containerHeightIndexes : activeIndexes; + const elementWidthIndexes = + 'elementWidthIndexes' in testCase ? testCase.elementWidthIndexes : elementLayoutIndexes; + const elementHeightIndexes = + 'elementHeightIndexes' in testCase ? testCase.elementHeightIndexes : elementLayoutIndexes; + const candidateIndexes = + 'candidateIndexes' in testCase ? testCase.candidateIndexes : [0, 1, 2, 3]; + const divId = 'divId' in testCase ? testCase.divId : 'ad-responsive-'; + const publisherOwned = 'publisherOwned' in testCase && testCase.publisherOwned; + const elements = ['a', 'b', 'c', 'd'].map((suffix, index) => + appendResponsiveSlotElement( + (candidateIndexes as readonly number[]).includes(index) + ? `ad-responsive-${suffix}` + : `unrelated-responsive-${suffix}`, + { + containerVisible: (visibleContainerIndexes as readonly number[]).includes(index), + containerWidth: (containerWidthIndexes as readonly number[]).includes(index) ? 320 : 0, + containerHeight: (containerHeightIndexes as readonly number[]).includes(index) + ? 100 + : 0, + elementHidden: (hiddenElementIndexes as readonly number[]).includes(index), + elementWidth: (elementWidthIndexes as readonly number[]).includes(index) ? 300 : 0, + elementHeight: (elementHeightIndexes as readonly number[]).includes(index) ? 250 : 0, + } + ) + ); + const selectedElement = selectedIndex === null ? undefined : elements[selectedIndex]; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(selectedElement?.id ?? elements[0]!.id), + getTargeting: vi.fn().mockReturnValue([]), + }; + const nativeRefresh = vi.fn(); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue(publisherOwned ? [mockSlot] : []), + addEventListener: vi.fn(), + refresh: nativeRefresh, + }; + const defineSlot = vi.fn().mockReturnValue(mockSlot); + const nativeDisplay = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'responsive_slot', + gam_unit_path: '/123/responsive', + div_id: divId, + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + if (implementation === 'runtime') { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + } else { + runGptBootstrap(); + } + (window as TestWindow).tsjs!.adInit!(); + + if (selectedElement) { + if (publisherOwned) { + expect(defineSlot).not.toHaveBeenCalled(); + expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); + } else { + expect(defineSlot).toHaveBeenCalledWith( + '/123/responsive', + [[300, 250]], + selectedElement.id + ); + expect(nativeDisplay).toHaveBeenCalledWith(selectedElement.id); + } + expect((window as TestWindow).tsjs!.divToSlotId).toEqual({ + [selectedElement.id]: 'responsive_slot', + }); + } else { + expect(defineSlot).not.toHaveBeenCalled(); + expect((window as TestWindow).tsjs!.divToSlotId).toEqual({}); + } + } + ); + + it.each(['runtime', 'bootstrap'] as const)( + '$implementation reports an ambiguous prefix once during adInit', + async (implementation) => { + const elements = ['a', 'b', 'c', 'd'].map((suffix) => + appendResponsiveSlotElement(`ad-warning-${suffix}`, { containerVisible: true }) + ); + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elements[0]!.id), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: vi.fn(), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + const bootstrapWarn = vi.fn(); + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'warning_slot', + gam_unit_path: '/123/warning', + div_id: 'ad-warning-', + formats: [[300, 250]], + targeting: {}, + }, + { + id: 'warning_slot_duplicate', + gam_unit_path: '/123/warning', + div_id: 'ad-warning-', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + ...(implementation === 'bootstrap' ? { log: { warn: bootstrapWarn } } : {}), + }; + + const runtimeWarn = vi.spyOn(console, 'warn'); + if (implementation === 'runtime') { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + } else { + runGptBootstrap(); + } + (window as TestWindow).tsjs!.adInit!(); + + if (implementation === 'runtime') { + const warningCall = runtimeWarn.mock.calls.find((call) => + call.includes('GPT slot prefix did not resolve to one active element') + ); + expect(runtimeWarn).toHaveBeenCalledTimes(1); + expect(warningCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + divId: 'ad-warning-', + prefixMatchCount: 4, + activeMatchCount: 0, + }), + ]) + ); + } else { + expect(bootstrapWarn).toHaveBeenCalledTimes(1); + expect(bootstrapWarn).toHaveBeenCalledWith( + 'GPT slot prefix did not resolve to one active element', + { + divId: 'ad-warning-', + prefixMatchCount: 4, + activeMatchCount: 0, + } + ); + } + runtimeWarn.mockRestore(); + } + ); + + it.each(['runtime', 'bootstrap'] as const)( + '$implementation trusts checkVisibility when resolving a visible slot', + async (implementation) => { + const element = appendResponsiveSlotElement('ad-native-slot', { + checkVisibility: true, + }); + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(element.id), + getTargeting: vi.fn().mockReturnValue([]), + }; + const defineSlot = vi.fn().mockReturnValue(mockSlot); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'native_visibility_slot', + gam_unit_path: '/123/native-visibility', + div_id: 'ad-native-', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + if (implementation === 'runtime') { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + } else { + runGptBootstrap(); + } + (window as TestWindow).tsjs!.adInit!(); + + expect(defineSlot).toHaveBeenCalledWith('/123/native-visibility', [[300, 250]], element.id); + } + ); + it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { const dynamicDiv = document.createElement('div'); dynamicDiv.id = "ad'prefix-real"; @@ -2283,6 +2649,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ], + divToSlotId: { 'div-header': 'homepage_header' }, }; }); @@ -2401,6 +2768,61 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('uses the adInit-resolved div when a responsive prefix becomes ambiguous', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + fetchStub.mockResolvedValue({ + ok: true, + text: () => Promise.resolve('
Responsive Creative
'), + } as Response); + + const resolvedSlot = document.createElement('div'); + resolvedSlot.id = 'div-responsive-a'; + const iframe = document.createElement('iframe'); + resolvedSlot.appendChild(iframe); + document.body.appendChild(resolvedSlot); + const laterSibling = document.createElement('div'); + laterSibling.id = 'div-responsive-b'; + document.body.appendChild(laterSibling); + + (window as TestWindow).tsjs!.adSlots = [ + { + id: 'homepage_header', + formats: [[728, 90]] as [number, number][], + gam_unit_path: '/a/b/c', + div_id: 'div-responsive-', + targeting: {}, + }, + ]; + (window as TestWindow).tsjs!.divToSlotId = { + 'div-responsive-a': 'homepage_header', + }; + + const bridgeListener = await captureBridgeListener(); + const portMessages: string[] = []; + const fakePort = { postMessage: (s: string) => portMessages.push(s) }; + const stopSpy = vi.fn(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [fakePort], + source: iframe.contentWindow, + stopImmediatePropagation: stopSpy, + }) as unknown as MessageEvent + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchStub).toHaveBeenCalledWith( + 'https://openads.example.com/cache?uuid=test-cache-uuid', + { mode: 'cors' } + ); + expect(portMessages).toHaveLength(1); + expect(stopSpy).toHaveBeenCalled(); + expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); + expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); + beaconSpy.mockRestore(); + }); + it('declines to render when the PBS Cache response carries no adm', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); // A returnCreative=false JSON entry with no `adm` (VAST-only, or malformed). @@ -2630,6 +3052,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ], + divToSlotId: { 'div-a': 'slot_a', 'div-b': 'slot_b' }, }; const bridgeListener = await captureBridgeListener(); @@ -2693,6 +3116,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ], + divToSlotId: { 'div-header': 'homepage_header' }, }; let bridgeListener: ((e: MessageEvent) => unknown) | undefined; @@ -2774,6 +3198,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ], + divToSlotId: { 'div-header': 'homepage_header' }, }; const bridgeListener = await captureBridgeListener(); @@ -2842,6 +3267,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ], + divToSlotId: { 'div-header': 'homepage_header', 'div-in-content': 'homepage_in_content' }, }; const bridgeListener = await captureBridgeListener(); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index c3e5eae89..a35965aea 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -22,6 +22,12 @@ async function flushAsync(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } +/** Allow a MutationObserver-scheduled slot check to run. */ +async function flushAnimationFrame(): Promise { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await Promise.resolve(); +} + describe('installSpaAuctionHook', () => { let fetchStub: ReturnType; // popstate listeners registered by each module import. In production the hook @@ -274,13 +280,80 @@ describe('installSpaAuctionHook', () => { // Container commits — the hook should now apply bids exactly once. document.body.innerHTML = '
'; - await flushAsync(); + await flushAnimationFrame(); expect(ts.adSlots).toEqual([{ id: 'late', div_id: 'div-late' }]); expect(ts.bids).toEqual({ late: { hb_pb: '2.00' } }); expect(adInit).toHaveBeenCalledTimes(1); }); + it('checks for route containers directly in a hidden document', async () => { + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + vi.stubGlobal('requestAnimationFrame', undefined); + fetchStub.mockResolvedValue({ + ok: true, + json: async () => ({ + slots: [{ id: 'hidden', div_id: 'div-hidden' }], + bids: { hidden: { hb_pb: '3.00' } }, + }), + }); + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + history.pushState({}, '', '/hidden-route'); + await flushAsync(); + expect(adInit).not.toHaveBeenCalled(); + + document.body.innerHTML = '
'; + await flushAsync(); + + expect(ts.adSlots).toEqual([{ id: 'hidden', div_id: 'div-hidden' }]); + expect(ts.bids).toEqual({ hidden: { hb_pb: '3.00' } }); + expect(adInit).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending visible-tab frame when the document becomes hidden', async () => { + let visibility: DocumentVisibilityState = 'visible'; + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility); + const requestAnimationFrameMock = vi.fn().mockReturnValue(17); + const cancelAnimationFrameMock = vi.fn(); + vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock); + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock); + fetchStub.mockResolvedValue({ + ok: true, + json: async () => ({ + slots: [{ id: 'hidden-late', div_id: 'div-hidden-late' }], + bids: { 'hidden-late': { hb_pb: '3.50' } }, + }), + }); + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + history.pushState({}, '', '/hidden-late-route'); + await flushAsync(); + + // A mutation while visible schedules a frame that never runs. + document.body.appendChild(document.createElement('span')); + await flushAsync(); + expect(requestAnimationFrameMock).toHaveBeenCalledTimes(1); + + // The next mutation happens after the document is hidden. It must cancel + // the stale frame and perform the presence check immediately. + visibility = 'hidden'; + document.body.innerHTML = '
'; + await flushAsync(); + + expect(cancelAnimationFrameMock).toHaveBeenCalledWith(17); + expect(adInit).toHaveBeenCalledTimes(1); + expect(ts.adSlots).toEqual([{ id: 'hidden-late', div_id: 'div-hidden-late' }]); + }); + it('waits for every configured route ad container before applying bids', async () => { document.body.innerHTML = '
'; fetchStub.mockResolvedValue({ @@ -311,7 +384,7 @@ describe('installSpaAuctionHook', () => { const second = document.createElement('div'); second.id = 'div-second'; document.body.appendChild(second); - await flushAsync(); + await flushAnimationFrame(); expect(ts.adSlots).toEqual([ { id: 'first', div_id: 'div-first' }, diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index 422eb8ef8..68e1cf75e 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -81,9 +81,9 @@ competing container slot and an invalid duplicate definition. destroy a slot after ownership has transferred. The wrapper is not a global deduplicator. It only handles IDs present in TS's -handoff registry, or one uniquely safe hydrated-ID match, and must preserve native -`defineSlot`, all supported `display()` argument forms, and both `refresh()` -arguments for every other placement. +handoff registry or one uniquely safe hydrated-ID match and must preserve native +`defineSlot`, all supported `display()` argument forms, and `refresh()` options for +every other placement. ## Implementation shape From 8bfb44275a562e04c9cff2c6eca2bc0f519e8df4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:51:42 -0700 Subject: [PATCH 16/16] Stop the SPA slot wait stalling on hidden prefix placements The tiered resolver returns no element for a prefix match that is hidden, so a breakpoint-hidden placement made waitForSlotElements wait out the full 2s timeout on every navigation to its route before bids applied to any slot. Count a slot as present for the wait when its prefix matches any element; adInit still applies the strict tiers. Also log the single-hidden-prefix-match case at debug level in both implementations, document the exact-vs-prefix visibility asymmetry as intentional, and restore Prettier formatting in gpt_bootstrap.js. --- .../src/integrations/gpt_bootstrap.js | 43 ++++++++++++++----- .../lib/src/integrations/gpt/index.ts | 42 ++++++++++++++---- .../test/integrations/gpt/spa_hook.test.ts | 36 ++++++++++++++++ 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 25a9da904..86b51ffa7 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -199,6 +199,11 @@ if (!divId) { return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; } + // Exact-id matches intentionally skip the visibility tiers below: a + // configured literal id is unambiguous, so a hidden match is still the + // right element. Prefix matches go through the tiers because a prefix can + // match several candidates and only visibility/layout disambiguates them — + // so a hidden exact-id match resolves while a hidden prefix match does not. var exact = document.getElementById(divId); if (exact) { return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; @@ -408,17 +413,33 @@ var resolution = resolveSlotElementByDivId(slot.div_id); var el = resolution.element; if (!el) { - if ( - resolution.prefixMatchCount > 1 && - !warnedResolutionFailures[slot.div_id] - ) { - warnedResolutionFailures[slot.div_id] = true; - if (ts.log && typeof ts.log.warn === "function") { - ts.log.warn("GPT slot prefix did not resolve to one active element", { - divId: slot.div_id, - prefixMatchCount: resolution.prefixMatchCount, - activeMatchCount: resolution.activeMatchCount, - }); + if (!warnedResolutionFailures[slot.div_id]) { + if (resolution.prefixMatchCount > 1) { + warnedResolutionFailures[slot.div_id] = true; + if (ts.log && typeof ts.log.warn === "function") { + ts.log.warn( + "GPT slot prefix did not resolve to one active element", + { + divId: slot.div_id, + prefixMatchCount: resolution.prefixMatchCount, + activeMatchCount: resolution.activeMatchCount, + }, + ); + } + } else if ( + resolution.prefixMatchCount === 1 && + resolution.activeMatchCount === 0 + ) { + // The common breakpoint-hidden config: the prefix matched one + // element but it is hidden, so the slot is skipped. Logged so a + // blank placement is diagnosable without stepping the resolver. + warnedResolutionFailures[slot.div_id] = true; + if (ts.log && typeof ts.log.debug === "function") { + ts.log.debug( + "GPT slot prefix matched only a hidden element; skipping slot", + { divId: slot.div_id }, + ); + } } } return; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index a0becdb7f..0ddff13d5 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -100,6 +100,12 @@ function resolveSlotElementByDivId(divId: string): SlotElementResolution { if (!divId) { return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; } + // Exact-id matches intentionally skip the visibility tiers below: a + // configured literal id is unambiguous, so a hidden match is still the + // right element (adInit defines the slot; GPT simply renders nothing while + // it is hidden). Prefix matches go through the tiers because a prefix can + // match several candidates and only visibility/layout disambiguates them — + // so a hidden exact-id match resolves while a hidden prefix match does not. const exact = document.getElementById(divId); if (exact) { return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; @@ -914,13 +920,23 @@ export function installTsAdInit(): void { const resolution = resolveSlotElementByDivId(slot.div_id); const el = resolution.element; if (!el) { - if (resolution.prefixMatchCount > 1 && !warnedResolutionFailures.has(slot.div_id)) { - warnedResolutionFailures.add(slot.div_id); - log.warn('GPT slot prefix did not resolve to one active element', { - divId: slot.div_id, - prefixMatchCount: resolution.prefixMatchCount, - activeMatchCount: resolution.activeMatchCount, - }); + if (!warnedResolutionFailures.has(slot.div_id)) { + if (resolution.prefixMatchCount > 1) { + warnedResolutionFailures.add(slot.div_id); + log.warn('GPT slot prefix did not resolve to one active element', { + divId: slot.div_id, + prefixMatchCount: resolution.prefixMatchCount, + activeMatchCount: resolution.activeMatchCount, + }); + } else if (resolution.prefixMatchCount === 1 && resolution.activeMatchCount === 0) { + // The common breakpoint-hidden config: the prefix matched one + // element but it is hidden, so the slot is skipped. Logged so a + // blank placement is diagnosable without stepping the resolver. + warnedResolutionFailures.add(slot.div_id); + log.debug('GPT slot prefix matched only a hidden element; skipping slot', { + divId: slot.div_id, + }); + } } return; } @@ -1168,7 +1184,17 @@ function waitForSlotElements(slots: AuctionSlot[], signal: AbortSignal): Promise // A newer navigation may have aborted this signal before we were called; skip // installing an observer/timer that the stale run would only tear down. if (signal.aborted) return Promise.resolve(); - const allPresent = (): boolean => slots.every((slot) => !!findSlotElementByDivId(slot.div_id)); + // Presence and eligibility are different questions here. The tiered + // resolver returns no element for a prefix match that is hidden (e.g. a + // breakpoint-hidden mobile-only placement), but such a slot has rendered and + // will never "appear" — waiting on it would stall every slot on the route + // for the full timeout. Count it as present; adInit still applies the strict + // tiers when it runs and skips ineligible slots. + const allPresent = (): boolean => + slots.every((slot) => { + const resolution = resolveSlotElementByDivId(slot.div_id); + return resolution.element !== null || resolution.prefixMatchCount > 0; + }); if (slots.length === 0 || allPresent() || typeof MutationObserver === 'undefined') { return Promise.resolve(); } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index a35965aea..f16468642 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -287,6 +287,42 @@ describe('installSpaAuctionHook', () => { expect(adInit).toHaveBeenCalledTimes(1); }); + it('applies bids immediately when a prefix-configured placement exists but is hidden', async () => { + // A breakpoint-hidden placement (mobile-only config while on desktop) has + // rendered its div but the tiered resolver returns no element for it. The + // slot wait must count it as present — otherwise every navigation to the + // route stalls for the full SPA_SLOT_WAIT_MS before applying bids to the + // visible slots, and adInit skips the hidden slot anyway. + document.body.innerHTML = + '
' + ''; + fetchStub.mockResolvedValue({ + ok: true, + json: async () => ({ + slots: [ + { id: 'visible', div_id: 'div-visible' }, + { id: 'hidden', div_id: 'ad-hidden-' }, + ], + bids: { visible: { hb_pb: '2.00' } }, + }), + }); + const { installSpaAuctionHook } = await importGptModule(); + installSpaAuctionHook(); + const ts = (window as TestWindow).tsjs!; + const adInit = vi.fn(); + ts.adInit = adInit; + + history.pushState({}, '', '/mixed-route'); + await flushAsync(); + + // Bids apply without waiting out the slot timeout. + expect(ts.adSlots).toEqual([ + { id: 'visible', div_id: 'div-visible' }, + { id: 'hidden', div_id: 'ad-hidden-' }, + ]); + expect(ts.bids).toEqual({ visible: { hb_pb: '2.00' } }); + expect(adInit).toHaveBeenCalledTimes(1); + }); + it('checks for route containers directly in a hidden document', async () => { vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); vi.stubGlobal('requestAnimationFrame', undefined);