diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index f142a9317..3e8f021fe 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!( @@ -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..86b51ffa7 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -110,6 +110,278 @@ 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 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; + + var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( + function (handoff, index, allHandoffs) { + return ( + allHandoffs.indexOf(handoff) === index && + !handoff.publisherClaimed && + !document.getElementById(handoff.slotElementId) && + elementId.startsWith(handoff.divIdPrefix) && + handoff.gamUnitPath === adUnitPath && + handoffFormatsMatch(handoff, 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 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 }; + } + // 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 }; + } + + 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; + 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, 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; + 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) { + 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; + ts.prevGptSlots = (ts.prevGptSlots || []).filter( + function (ownedSlot) { + return ownedSlot !== existingSlot; + }, + ); + if ( + handoff.gamUnitPath !== adUnitPath || + !handoffFormatsMatch(handoff, formats) + ) { + ts.log && + ts.log.warn && + ts.log.warn( + "GPT slot handoff: publisher definition differs from TS configuration", + elementId, + ); + } + return existingSlot; + } + } + } + return elementId === undefined + ? originalDefineSlot(adUnitPath, formats) + : originalDefineSlot(adUnitPath, formats, elementId); + }; + patchedDefineSlot.__tsSlotHandoffPatched = true; + tag.defineSlot = patchedDefineSlot; + } + + if (!tag.display.__tsSlotHandoffPatched) { + var originalDisplay = tag.display.bind(tag); + var patchedDisplay = function (target) { + var elementId = displayTargetElementId(target); + var handoff = + elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if ( + !ts.gptSlotHandoffInternal && + handoff && + handoff.suppressPublisherDisplay + ) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(target); + }; + patchedDisplay.__tsSlotHandoffPatched = true; + tag.display = patchedDisplay; + } + + if (!pubads.refresh.__tsSlotHandoffPatched) { + var originalRefresh = pubads.refresh.bind(pubads); + var callRefresh = function (slots, options) { + if (options === undefined) { + originalRefresh(slots); + } else { + originalRefresh(slots, options); + } + }; + var patchedRefresh = function (requestedSlots, options) { + if (ts.gptSlotHandoffInternal) { + callRefresh(requestedSlots, options); + return; + } + var slots = + requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); + if (!slots) { + callRefresh(requestedSlots, options); + 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) { + callRefresh(requestedSlots, options); + } else if (remainingSlots.length > 0) { + callRefresh(remainingSlots, options); + } + }; + patchedRefresh.__tsSlotHandoffPatched = true; + pubads.refresh = patchedRefresh; + } + }); + } + + installSlotHandoff(); + ts.adInit = function () { var slots = ts.adSlots || []; var bids = ts.bids || {}; @@ -119,6 +391,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; @@ -133,25 +406,44 @@ // 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") + 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 ) { - el = candidate; - break; + // 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; } - if (!el) return; var actualDivId = el.id; var b = bids[slot.id] || {}; @@ -162,15 +454,28 @@ }) || 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, + divIdPrefix: slot.div_id, + slotElementId: actualDivId, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; } Object.entries(slot.targeting || {}).forEach(function (e) { @@ -187,11 +492,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,17 +520,21 @@ // 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); + }); }); + 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 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 @@ -236,7 +543,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..6cece1e8b 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -175,6 +175,24 @@ 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]>; + /** 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; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -235,6 +253,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 066bec12b..0ddff13d5 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'; @@ -52,29 +52,109 @@ 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 }; + } + // 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 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 +163,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 { @@ -99,12 +179,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,17 +201,19 @@ interface GoogleTagEffectiveConfig { disableInitialLoad?: boolean; } +type GoogleTagDisplayTarget = string | Element | GoogleTagSlot; + interface GoogleTag { cmd: Array<() => void>; pubads(): GoogleTagPubAdsService; defineSlot( adUnitPath: string, size: Array, - elementId: string + elementId?: string ): 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; @@ -554,10 +640,207 @@ function installScheduleInitialAdInit(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 displayTargetElementId(target: GoogleTagDisplayTarget): string | undefined { + if (typeof target === 'string') return target; + if (typeof (target as GoogleTagSlot).getSlotElementId === 'function') { + return (target as GoogleTagSlot).getSlotElementId(); + } + 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, + adUnitPath: string, + formats: Array, + elementId: string +): GptSlotHandoff | undefined { + const exact = ts.gptSlotHandoffs?.[elementId]; + if (exact) return exact.publisherClaimed ? undefined : exact; + + const candidates = new Set(Object.values(ts.gptSlotHandoffs ?? {})).values(); + const matching = Array.from(candidates).filter( + (handoff) => + !handoff.publisherClaimed && + !document.getElementById(handoff.slotElementId) && + elementId.startsWith(handoff.divIdPrefix) && + handoff.gamUnitPath === adUnitPath && + handoffFormatsMatch(handoff, 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; + 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, 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; + 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 => { + 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( + (ownedSlot) => ownedSlot !== existingSlot + ); + if (handoff.gamUnitPath !== adUnitPath || !handoffFormatsMatch(handoff, formats)) { + log.warn('GPT slot handoff: publisher definition differs from TS configuration', { + elementId, + tsGamUnitPath: handoff.gamUnitPath, + publisherGamUnitPath: adUnitPath, + }); + } + return existingSlot; + } + } + } + return elementId === undefined + ? originalDefineSlot(adUnitPath, formats) + : 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 = (target: GoogleTagDisplayTarget): void => { + const elementId = displayTargetElementId(target); + const handoff = elementId ? ts.gptSlotHandoffs?.[elementId] : undefined; + if (!ts.gptSlotHandoffInternal && handoff?.suppressPublisherDisplay) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(target); + }; + (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + g.display = patchedDisplay; + } + + const refresh = pubads.refresh; + if (!(refresh as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalRefresh = refresh.bind(pubads); + 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) { + callRefresh(requestedSlots, options); + return; + } + + const slots = requestedSlots ?? pubads.getSlots?.(); + if (!slots) { + callRefresh(requestedSlots, options); + 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) { + callRefresh(requestedSlots, options); + } else if (remainingSlots.length > 0) { + callRefresh(remainingSlots, options); + } + }; + (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. @@ -573,12 +856,23 @@ 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; // 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 = []; } @@ -619,11 +913,33 @@ 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 (!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; + } const actualDivId = el.id; const bid = bids[slot.id] ?? {}; @@ -635,16 +951,25 @@ 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, + divIdPrefix: slot.div_id, + slotElementId: actualDivId, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; } const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; @@ -659,9 +984,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,18 +1052,18 @@ 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))); + 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 - // 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 + // 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. - syncInitialLoadDisabled(g, ts); const slotsNeedingRefresh = ts.gptInitialLoadDisabled ? slotsToRefresh.concat(newSlots) : slotsToRefresh; @@ -752,7 +1076,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; } @@ -860,23 +1184,47 @@ 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(); } 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); @@ -947,6 +1295,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/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index e295aa7b1..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 @@ -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'; @@ -64,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 @@ -86,6 +88,73 @@ 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(); +} + +interface ResponsiveSlotElementOptions { + containerVisible?: boolean; + containerWidth?: number; + containerHeight?: number; + elementHidden?: boolean; + elementWidth?: number; + elementHeight?: number; + checkVisibility?: boolean; +} + +function appendResponsiveSlotElement( + id: string, + { + containerVisible = false, + containerWidth = 0, + containerHeight = 0, + elementHidden = false, + elementWidth = 0, + elementHeight = 0, + checkVisibility, + }: ResponsiveSlotElementOptions = {} +): HTMLDivElement { + const container = document.createElement('div'); + container.id = `${id}-container`; + container.dataset.responsiveSlotTest = 'true'; + container.style.display = containerVisible ? 'block' : 'none'; + container.getBoundingClientRect = () => + ({ 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(); @@ -110,7 +179,11 @@ 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(); + document.querySelectorAll('[data-responsive-slot-test]').forEach((element) => element.remove()); }); it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { @@ -181,12 +254,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 +293,931 @@ 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) => { + if (slots.has(elementId)) return null; + 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([]); + + 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(); + }); + + 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('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; + setTargeting(key: string, value: string | string[]): FakeSlot; + 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[] = []; + 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) => { + if (slots.has(elementId) || elementId === hydratedId) return null; + 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: {}, + }; + + runGptBootstrap(); + (window as TestWindow).tsjs!.adInit!(); + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ssrDiv.id = hydratedId; + + const googletag = (window as TestWindow).googletag as { + defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot | null; + display(target: FakeSlot): void; + }; + const publisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); + 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.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; + 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; + 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 +1230,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 +1276,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 () => { @@ -287,10 +1286,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(); @@ -317,7 +1317,7 @@ describe('installTsAdInit', () => { bids: {}, }; - await runGptBootstrap(googletag); + await runGptBootstrapWithGoogleTag(googletag); mockPubads.disableInitialLoad(); expect(disableInitialLoadMock).toHaveBeenCalledOnce(); @@ -326,7 +1326,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 () => { @@ -344,10 +1344,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 = { @@ -373,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. @@ -391,15 +1392,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 }); @@ -407,7 +1408,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 () => { @@ -432,12 +1433,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(); @@ -479,7 +1481,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 }); @@ -498,9 +1500,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 }); @@ -508,7 +1510,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. @@ -517,16 +1519,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(); @@ -535,7 +1537,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 () => { @@ -545,11 +1547,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 }); @@ -583,7 +1586,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 () => { @@ -621,7 +1624,8 @@ describe('installTsAdInit', () => { }, ], bids: {}, - }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); installTsAdInit(); @@ -1202,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"; @@ -1327,6 +2649,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ], + divToSlotId: { 'div-header': 'homepage_header' }, }; }); @@ -1445,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). @@ -1674,6 +3052,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ], + divToSlotId: { 'div-a': 'slot_a', 'div-b': 'slot_b' }, }; const bridgeListener = await captureBridgeListener(); @@ -1737,6 +3116,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ], + divToSlotId: { 'div-header': 'homepage_header' }, }; let bridgeListener: ((e: MessageEvent) => unknown) | undefined; @@ -1818,6 +3198,7 @@ describe('installTsRenderBridge', () => { targeting: {}, }, ], + divToSlotId: { 'div-header': 'homepage_header' }, }; const bridgeListener = await captureBridgeListener(); @@ -1886,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/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(); }); }); 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..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 @@ -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'; @@ -19,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 @@ -83,6 +92,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 = '
'; @@ -179,13 +280,116 @@ 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('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); + 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({ @@ -216,7 +420,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/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..dc2c7426c --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -0,0 +1,222 @@ +# 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, 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`. +- [ ] 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`; 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 + 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. 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 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 + 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 by evaluating the included asset + against the fake GPT fixture, including a bootstrap-to-bundle adoption check. + +## 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..68e1cf75e --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -0,0 +1,170 @@ +# 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. 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 +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 (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 + 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 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 + +### 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.