From 4ef2de6db5a8ac926fdde1b93ec0b127483adae4 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 14:07:32 -0500 Subject: [PATCH 1/5] Preserve Prebid ad units across GPT refreshes --- .../lib/src/integrations/prebid/index.ts | 239 +++++++- .../test/integrations/prebid/index.test.ts | 509 ++++++++++++++++++ 2 files changed, 731 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 342e4038d..29ac42399 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -238,6 +238,17 @@ type TrustedServerAdUnit = { mediaTypes?: { banner?: TrustedServerBanner }; bids?: TrustedServerBid[]; }; +type ClientSideBidSnapshot = { bidder: string; params: Record }; +type PublisherAdUnitSnapshot = { + bidderParams: Record>; + clientSideBids: ClientSideBidSnapshot[]; + zone?: string; +}; +type PublisherDeliveryContext = { remainingCodes: Set }; + +let publisherAdUnitSnapshots = new Map(); +let syntheticRefreshAdUnits = new WeakSet(); +const activePublisherDeliveryContexts: PublisherDeliveryContext[] = []; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -373,6 +384,17 @@ function firstTargetingValue(values: string[] | undefined): string | undefined { * code in order and return the first matching ad unit, so container-backed slots * still recover the publisher's configured params and bidders. */ +function findRefreshSnapshot( + candidateCodes: Array +): PublisherAdUnitSnapshot | undefined { + for (const code of candidateCodes) { + if (!code) continue; + const snapshot = publisherAdUnitSnapshots.get(code); + if (snapshot) return snapshot; + } + return undefined; +} + function findRefreshAdUnit( candidateCodes: Array ): TrustedServerAdUnit | undefined { @@ -385,6 +407,89 @@ function findRefreshAdUnit( return undefined; } +function copyParamValue(value: unknown, seen = new WeakMap()): unknown { + if (Array.isArray(value)) { + const existing = seen.get(value); + if (existing) return existing; + const copy: unknown[] = []; + seen.set(value, copy); + value.forEach((entry) => copy.push(copyParamValue(entry, seen))); + return copy; + } + + if (value && typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return value; + + const existing = seen.get(value); + if (existing) return existing; + const copy = Object.create(prototype) as Record; + seen.set(value, copy); + for (const [key, entry] of Object.entries(value)) { + Object.defineProperty(copy, key, { + value: copyParamValue(entry, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + return copy; + } + + return value; +} + +function copyParams(params: Record | undefined): Record { + return copyParamValue(params ?? {}) as Record; +} + +function foldedBidderParams( + bid: TrustedServerBid | undefined +): Record> { + const folded = (bid?.params?.[BIDDER_PARAMS_KEY] ?? {}) as Record< + string, + Record + >; + return Object.fromEntries( + Object.entries(folded).map(([bidder, params]) => [bidder, copyParams(params)]) + ); +} + +function capturePublisherAdUnitSnapshot( + unit: TrustedServerAdUnit, + clientSideBidders: Set +): PublisherAdUnitSnapshot | undefined { + if (typeof unit.code !== 'string' || unit.code.length === 0) return undefined; + + const rawBidderParams: Record> = {}; + const clientSideBids: ClientSideBidSnapshot[] = []; + let existingTsBid: TrustedServerBid | undefined; + + const bids = Array.isArray(unit.bids) ? unit.bids : []; + for (const bid of bids) { + if (!bid?.bidder) continue; + if (bid.bidder === ADAPTER_CODE) { + existingTsBid ??= bid; + continue; + } + if (clientSideBidders.has(bid.bidder)) { + clientSideBids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + continue; + } + rawBidderParams[bid.bidder] = copyParams(bid.params); + } + + const bidderParams = + Object.keys(rawBidderParams).length > 0 ? rawBidderParams : foldedBidderParams(existingTsBid); + const zone = unit.mediaTypes?.banner?.name; + + return { + bidderParams, + clientSideBids, + ...(zone ? { zone } : {}), + }; +} + /** * Collect the configured client-side bidder entries for a refreshing slot. * @@ -399,6 +504,14 @@ function findRefreshAdUnit( function clientSideBidsForRefresh( candidateCodes: Array ): Array<{ bidder: string; params: Record }> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return snapshot.clientSideBids.map((bid) => ({ + bidder: bid.bidder, + params: copyParams(bid.params), + })); + } + const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); if (clientSideBidders.size === 0) return []; @@ -408,7 +521,7 @@ function clientSideBidsForRefresh( const bids: Array<{ bidder: string; params: Record }> = []; for (const bid of match.bids) { if (bid?.bidder && clientSideBidders.has(bid.bidder)) { - bids.push({ bidder: bid.bidder, params: bid.params ?? {} }); + bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); } } return bids; @@ -430,6 +543,13 @@ function clientSideBidsForRefresh( function serverSideBidderParamsForRefresh( candidateCodes: Array ): Record> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return Object.fromEntries( + Object.entries(snapshot.bidderParams).map(([bidder, params]) => [bidder, copyParams(params)]) + ); + } + const match = findRefreshAdUnit(candidateCodes); if (!match?.bids) return {}; @@ -466,6 +586,50 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } +function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + const index = activePublisherDeliveryContexts.lastIndexOf(context); + if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); +} + +function consumeBarePublisherDeliveryContext(): boolean { + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + if (context.remainingCodes.size === 0) continue; + context.remainingCodes.clear(); + return true; + } + return false; +} + +function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { + if (targetSlots.length === 0) return false; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + const coveredCodes: string[] = []; + let allCovered = true; + + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + const coveredCode = candidates.find( + (code): code is string => !!code && context.remainingCodes.has(code) + ); + if (!coveredCode) { + allCovered = false; + break; + } + coveredCodes.push(coveredCode); + } + + if (!allCovered) continue; + coveredCodes.forEach((code) => context.remainingCodes.delete(code)); + return true; + } + + return false; +} + function collectAuctionEids(): AuctionEid[] | undefined { if (typeof pbjs.getUserIdsAsEids !== 'function') { return undefined; @@ -502,6 +666,10 @@ function collectAuctionEids(): AuctionEid[] | undefined { * 2. `config` argument — explicit overrides from the publisher's JS */ export function installPrebidNpm(config?: Partial): typeof pbjs { + publisherAdUnitSnapshots = new Map(); + syntheticRefreshAdUnits = new WeakSet(); + activePublisherDeliveryContexts.length = 0; + const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { endpoint: config?.endpoint, @@ -574,9 +742,20 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const opts = requestObj || {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; + const isSyntheticRefresh = + adUnits.length > 0 && adUnits.every((unit) => syntheticRefreshAdUnits.has(unit)); + const publisherAdUnitCodes = new Set(); // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { + if (!syntheticRefreshAdUnits.has(unit)) { + const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders); + if (snapshot && unit.code) { + publisherAdUnitSnapshots.set(unit.code, snapshot); + publisherAdUnitCodes.add(unit.code); + } + } + if (!Array.isArray(unit.bids)) { unit.bids = []; } @@ -660,8 +839,22 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const originalBidsBack = opts.bidsBackHandler; opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); - if (typeof originalBidsBack === 'function') { - originalBidsBack.apply(this, args); + if (typeof originalBidsBack !== 'function') return; + if (isSyntheticRefresh || publisherAdUnitCodes.size === 0) { + originalBidsBack.apply(this, args as Parameters); + return; + } + + const context: PublisherDeliveryContext = { + remainingCodes: new Set(publisherAdUnitCodes), + }; + // Delivery attribution is intentionally synchronous and ends as soon as + // the publisher's original callback returns. + activePublisherDeliveryContexts.push(context); + try { + originalBidsBack.apply(this, args as Parameters); + } finally { + removePublisherDeliveryContext(context); } }; @@ -745,6 +938,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { const originalRefresh = pubads.refresh.bind(pubads); pubads.refresh = function (slots?: unknown[], opts?: unknown) { + // For bare refresh() calls (no slots arg), get all registered slots from GPT + // so we can auction the same concrete slot list and avoid stale targeting. + const targetSlots = ( + slots ?? + (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? + [] + ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + // One-shot bypass for adInit()'s internal refresh: that refresh delivers // freshly applied server-side targeting to GAM and must not be turned // into a client-side auction (which would clear the TS targeting). @@ -754,13 +955,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - // For bare refresh() calls (no slots arg), get all registered slots from GPT - // so we can auction the same concrete slot list and avoid stale targeting. - const targetSlots = ( - slots ?? - (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? - [] - ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + const isExplicitSlotList = slots !== undefined; + const hasOnlyValidExplicitSlots = !isExplicitSlotList || targetSlots.length === slots.length; + const isPublisherDeliveryRefresh = isExplicitSlotList + ? hasOnlyValidExplicitSlots && consumeExplicitPublisherDeliveryContext(targetSlots) + : consumeBarePublisherDeliveryContext(); + if (isPublisherDeliveryRefresh) { + return originalRefresh(slots, opts); + } if (!targetSlots.length) { return originalRefresh(slots, opts); @@ -770,8 +972,16 @@ export function installRefreshHandler(timeoutMs = 1500): void { const adUnits = targetSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); + const code = refreshSlotElementId(slot) ?? 'refresh-slot'; + // A TS-owned slot may be defined on `${div_id}-container`, so the GPT + // element id used as the synthetic refresh code can differ from the + // inner `div_id` the publisher keyed their ad unit by. Recover from both. + const candidateCodes = [code, injectedSlot?.div_id]; + const snapshot = findRefreshSnapshot(candidateCodes); const zone = - injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)); + injectedSlot?.targeting?.[ZONE_KEY] ?? + firstTargetingValue(slot.getTargeting?.(ZONE_KEY)) ?? + snapshot?.zone; const banner: TrustedServerBanner = { sizes: bannerSizesFromInjectedSlot(injectedSlot) ?? @@ -779,12 +989,6 @@ export function installRefreshHandler(timeoutMs = 1500): void { DEFAULT_REFRESH_SIZES, ...(zone ? { name: zone } : {}), }; - - const code = refreshSlotElementId(slot) ?? 'refresh-slot'; - // A TS-owned slot may be defined on `${div_id}-container`, so the GPT - // element id used as the synthetic refresh code can differ from the - // inner `div_id` the publisher keyed their ad unit by. Recover from both. - const candidateCodes = [code, injectedSlot?.div_id]; const tsParams: Record = zone ? { [ZONE_KEY]: zone } : {}; // Carry the publisher's inline server-side (PBS) bidder params captured // on the initial ad unit so refresh/scroll auctions don't drop them. @@ -807,6 +1011,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { // unrelated GPT slots whose targeting this wrapper only cleared for // `targetSlots` — leaving their next request dependent on stale state. const refreshAdUnitCodes = adUnits.map((unit) => unit.code); + adUnits.forEach((unit) => syntheticRefreshAdUnits.add(unit)); pbjs.requestBids({ adUnits, bidsBackHandler: () => { diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 726f40b49..250bc0f0c 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -668,6 +668,15 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); + it('normalizes a truthy non-array bids value without throwing', () => { + const pbjs = installPrebidNpm(); + const adUnits = [{ code: 'example-malformed-slot', bids: { malformed: true } }] as any[]; + + expect(() => pbjs.requestBids({ adUnits } as any)).not.toThrow(); + + expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + }); + it('includes zone from mediaTypes.banner.name in trustedServer params', () => { const pbjs = installPrebidNpm(); @@ -1403,6 +1412,506 @@ describe('prebid/installRefreshHandler', () => { }); }); +describe('prebid publisher snapshots and delivery refreshes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRequestBids.mockReset(); + mockPbjs.requestBids = mockRequestBids; + mockPbjs.adUnits = []; + mockGetUserIdsAsEids.mockReset(); + mockGetUserIdsAsEids.mockReturnValue([]); + mockGetBidAdapter.mockReturnValue({}); + delete (mockPbjs as any).setTargetingForGPTAsync; + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + afterEach(() => { + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + function installGpt(slots: any[]) { + const originalRefresh = vi.fn(); + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => slots), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + installRefreshHandler(640); + return { originalRefresh, pubads }; + } + + function refreshAdUnitFromLastRequest(): any { + const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; + return lastCall?.[0]?.adUnits?.[0]; + } + + it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const runtimeInstance = 'example-runtime-instance'; + const code = `example-slot-${runtimeInstance}`; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [{ getWidth: () => 320, getHeight: () => 100 }], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const firstParams = { placement: 'first' }; + const effectiveParams = { placement: 'effective' }; + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { bidder: 'exampleServer', params: firstParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleServer', params: effectiveParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }, + ], + } as any); + effectiveParams.placement = 'changed-after-auction'; + + pubads.refresh([slot]); + + expect(mockPbjs.adUnits).toEqual([]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(refreshAdUnitFromLastRequest()).toEqual({ + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { exampleServer: { placement: 'effective' } }, + zone: 'example-zone', + }, + }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }); + }); + + it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const code = 'example-nested-params-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const serverParams = { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }; + const browserParams = { + groups: [{ values: ['original-value'] }], + }; + + pbjs.requestBids({ + adUnits: [ + { + code, + bids: [ + { bidder: 'exampleServer', params: serverParams }, + { bidder: 'exampleBrowser', params: browserParams }, + ], + }, + ], + } as any); + serverParams.placement.rules[0].label = 'changed-rule'; + serverParams.placement.sizes.push(999); + browserParams.groups[0].values[0] = 'changed-value'; + + pubads.refresh([slot]); + + const expectedBids = [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + exampleServer: { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }, + }, + }, + }, + { + bidder: 'exampleBrowser', + params: { groups: [{ values: ['original-value'] }] }, + }, + ]; + const firstRefreshBids = refreshAdUnitFromLastRequest().bids; + expect(firstRefreshBids).toEqual(expectedBids); + + firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = + 'changed-refresh-rule'; + firstRefreshBids[0].params.bidderParams.exampleServer.placement.sizes.push(777); + firstRefreshBids[1].params.groups[0].values[0] = 'changed-refresh-value'; + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); + }); + + it('keeps snapshots across repeated synthetic refreshes and overwrites newer publisher config', () => { + const code = 'example-dynamic-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-one', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + ], + } as any); + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-two', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'two' } }, + zone: 'example-zone-two', + }); + }); + + it('does not cross-contaminate dynamic-code snapshots and retains the global fallback', () => { + const slotOne = { + getSlotElementId: () => 'example-code-one', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-code-two', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const globalSlot = { + getSlotElementId: () => 'example-global-code', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slotOne, slotTwo, globalSlot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code: 'example-code-one', + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + { + code: 'example-code-two', + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + mockPbjs.adUnits = [ + { + code: 'example-global-code', + bids: [{ bidder: 'exampleFallback', params: { placement: 'global' } }], + }, + ]; + + pubads.refresh([slotOne]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'one' }, + }); + pubads.refresh([slotTwo]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'two' }, + }); + pubads.refresh([globalSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleFallback: { placement: 'global' }, + }); + }); + + it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { + const slotOne = { + getSlotElementId: () => 'example-covered-one', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-covered-two-container', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + (window as any).tsjs = { + adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], + }; + const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-covered-one', bids: [{ bidder: 'exampleServer', params: {} }] }, + { code: 'example-covered-two', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pubads.refresh([slotOne]); + pubads.refresh([slotTwo]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slotOne.clearTargeting).not.toHaveBeenCalled(); + expect(slotTwo.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slotOne], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); + }); + + it('bypasses a bare delivery refresh even when GPT includes a GAM-only extra slot', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh(), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); + expect(gamOnlySlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + }); + + it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const unrelatedSlot = { + getSlotElementId: () => 'example-unrelated', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + pubads.refresh([unrelatedSlot]); + pubads.refresh([coveredSlot, unrelatedSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-unrelated', + ]); + expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-covered', + 'example-unrelated', + ]); + expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); + }); + + it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + const slot = { + getSlotElementId: () => 'example-deferred-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + let deferredRefresh: Promise | undefined; + + pbjs.requestBids({ + adUnits: [ + { code: 'example-deferred-refresh', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + deferredRefresh = Promise.resolve().then(() => pubads.refresh([slot])); + }, + } as any); + await deferredRefresh; + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh([innerSlot]), + } as any); + pubads.refresh([outerSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [outerSlot], undefined); + }); + + it('cleans delivery context after a publisher callback throws', () => { + const slot = { + getSlotElementId: () => 'example-throwing-callback', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + expect(() => + pbjs.requestBids({ + adUnits: [ + { + code: 'example-throwing-callback', + bids: [{ bidder: 'exampleServer', params: {} }], + }, + ], + bidsBackHandler: () => { + throw new Error('example callback failure'); + }, + } as any) + ).toThrow('example callback failure'); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + }); + + it('completes an internal synthetic refresh once without recursion', () => { + const slot = { + getSlotElementId: () => 'example-independent-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); +}); + describe('prebid/client-side bidders', () => { beforeEach(() => { vi.clearAllMocks(); From a94559839747f432f8aee3d1c636820351fc6722 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 16:21:19 -0500 Subject: [PATCH 2/5] Handle deferred Prebid delivery refreshes --- .../lib/src/integrations/prebid/index.ts | 101 ++++++++-- .../test/integrations/prebid/index.test.ts | 188 +++++++++++++++++- 2 files changed, 259 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 29ac42399..0432d8695 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -48,6 +48,7 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; +const PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS = 1000; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -244,7 +245,12 @@ type PublisherAdUnitSnapshot = { clientSideBids: ClientSideBidSnapshot[]; zone?: string; }; -type PublisherDeliveryContext = { remainingCodes: Set }; +type PublisherDeliveryContext = { + remainingCodes: Set; + retainForTargetedRefresh: boolean; + cleanupTimer?: ReturnType; +}; +type SetTargetingForGptAsync = (...args: unknown[]) => unknown; let publisherAdUnitSnapshots = new Map(); let syntheticRefreshAdUnits = new WeakSet(); @@ -587,15 +593,32 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + if (context.cleanupTimer !== undefined) { + clearTimeout(context.cleanupTimer); + context.cleanupTimer = undefined; + } const index = activePublisherDeliveryContexts.lastIndexOf(context); if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); } +function targetingCoversPublisherDeliveryContext( + adUnitCodes: unknown, + context: PublisherDeliveryContext +): boolean { + if (adUnitCodes === undefined) return context.remainingCodes.size > 0; + const codes = typeof adUnitCodes === 'string' ? [adUnitCodes] : adUnitCodes; + return ( + Array.isArray(codes) && + codes.some((code) => typeof code === 'string' && context.remainingCodes.has(code)) + ); +} + function consumeBarePublisherDeliveryContext(): boolean { for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { const context = activePublisherDeliveryContexts[index]; if (context.remainingCodes.size === 0) continue; context.remainingCodes.clear(); + removePublisherDeliveryContext(context); return true; } return false; @@ -604,30 +627,35 @@ function consumeBarePublisherDeliveryContext(): boolean { function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { if (targetSlots.length === 0) return false; - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - const coveredCodes: string[] = []; - let allCovered = true; - - for (const slot of targetSlots) { - const injectedSlot = findInjectedSlotForRefresh(slot); - const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + // Publishers may include GAM-only slots in the same explicit refresh that + // delivers a completed Prebid auction. Attribute the call to delivery when + // any slot is covered, while consuming only the covered codes so an + // unrelated-only refresh still follows the synthetic auction path. + const matches = new Map>(); + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; const coveredCode = candidates.find( (code): code is string => !!code && context.remainingCodes.has(code) ); - if (!coveredCode) { - allCovered = false; - break; - } - coveredCodes.push(coveredCode); + if (!coveredCode) continue; + + const contextMatches = matches.get(context) ?? new Set(); + contextMatches.add(coveredCode); + matches.set(context, contextMatches); + break; } + } - if (!allCovered) continue; + if (matches.size === 0) return false; + for (const [context, coveredCodes] of matches) { coveredCodes.forEach((code) => context.remainingCodes.delete(code)); - return true; + if (context.remainingCodes.size === 0) removePublisherDeliveryContext(context); } - - return false; + return true; } function collectAuctionEids(): AuctionEid[] | undefined { @@ -668,7 +696,7 @@ function collectAuctionEids(): AuctionEid[] | undefined { export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); syntheticRefreshAdUnits = new WeakSet(); - activePublisherDeliveryContexts.length = 0; + [...activePublisherDeliveryContexts].forEach(removePublisherDeliveryContext); const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { @@ -847,14 +875,43 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const context: PublisherDeliveryContext = { remainingCodes: new Set(publisherAdUnitCodes), + retainForTargetedRefresh: false, + }; + const targetingPbjs = pbjs as unknown as { + setTargetingForGPTAsync?: SetTargetingForGptAsync; }; - // Delivery attribution is intentionally synchronous and ends as soon as - // the publisher's original callback returns. + const originalSetTargeting = targetingPbjs.setTargetingForGPTAsync; + let targetingWrapper: SetTargetingForGptAsync | undefined; + if (typeof originalSetTargeting === 'function') { + targetingWrapper = (...targetingArgs: unknown[]) => { + const result = originalSetTargeting.apply(targetingPbjs, targetingArgs); + if (targetingCoversPublisherDeliveryContext(targetingArgs[0], context)) { + context.retainForTargetedRefresh = true; + } + return result; + }; + targetingPbjs.setTargetingForGPTAsync = targetingWrapper; + } + activePublisherDeliveryContexts.push(context); try { originalBidsBack.apply(this, args as Parameters); } finally { - removePublisherDeliveryContext(context); + if (targetingWrapper && targetingPbjs.setTargetingForGPTAsync === targetingWrapper) { + targetingPbjs.setTargetingForGPTAsync = originalSetTargeting; + } + if (context.retainForTargetedRefresh && context.remainingCodes.size > 0) { + // Some publisher wrappers set targeting in bidsBackHandler, return, + // and schedule the matching GPT refresh shortly afterward. Retain + // this one-shot context only after that targeting signal, with a + // bounded expiry so a later independent refresh remains independent. + context.cleanupTimer = setTimeout( + () => removePublisherDeliveryContext(context), + PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS + ); + } else { + removePublisherDeliveryContext(context); + } } }; diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 250bc0f0c..4c88a02c2 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1745,7 +1745,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); }); - it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + it('keeps explicit unrelated lists synthetic and bypasses mixed delivery lists', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1772,15 +1772,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ 'example-unrelated', ]); - expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ - 'example-covered', - 'example-unrelated', - ]); - expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); expect(originalRefresh).toHaveBeenCalledTimes(2); @@ -1788,7 +1784,183 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); }); - it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + it('bypasses an explicit delivery refresh with four covered slots and a GAM-only extra', () => { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-covered-${index}`, + getTargeting: () => [], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: coveredSlots.map((_, index) => ({ + code: `example-covered-${index}`, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('bypasses a targeted delivery refresh shortly after the publisher callback returns', () => { + vi.useFakeTimers(); + try { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-targeted-${index}`, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-targeted-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + let refreshAfterCallback: (() => void) | undefined; + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + const pendingRefresh = refreshAfterCallback; + refreshAfterCallback = undefined; + if (pendingRefresh) setTimeout(pendingRefresh, 750); + }); + const pbjs = installPrebidNpm(); + const coveredCodes = coveredSlots.map((slot) => slot.getSlotElementId()); + + pbjs.requestBids({ + adUnits: coveredCodes.map((code, index) => ({ + code, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync([gamOnlySlot.getSlotElementId(), ...coveredCodes]); + refreshAfterCallback = () => pubads.refresh(refreshSlots); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(setTargetingForGPTAsync).toHaveBeenCalledWith([ + gamOnlySlot.getSlotElementId(), + ...coveredCodes, + ]); + expect((mockPbjs as any).setTargetingForGPTAsync).toBe(setTargetingForGPTAsync); + + vi.advanceTimersByTime(750); + + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + + vi.runOnlyPendingTimers(); + pubads.refresh([coveredSlots[0]]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(coveredSlots[0].clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('expires a targeted delivery context before a later event-loop task', () => { + vi.useFakeTimers(); + try { + const slot = { + getSlotElementId: () => 'example-expiring-delivery', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + (mockPbjs as any).setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-expiring-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => (pbjs as any).setTargetingForGPTAsync(['example-expiring-delivery']), + } as any); + vi.runOnlyPendingTimers(); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('bypasses a mixed explicit delivery list spanning nested contexts', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('treats a microtask refresh without a targeting signal as an independent auction', async () => { const slot = { getSlotElementId: () => 'example-deferred-refresh', getTargeting: () => [], From 461cff9b8ee0208b50854b09a49812ee2d98fd77 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 17 Jul 2026 11:54:55 -0500 Subject: [PATCH 3/5] Address Prebid refresh review feedback --- .../lib/src/integrations/prebid/index.ts | 359 ++++++------ .../test/integrations/prebid/index.test.ts | 543 ++++++++++++++---- 2 files changed, 604 insertions(+), 298 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 0432d8695..007a7d40d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -48,7 +48,8 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; -const PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS = 1000; +const MAX_PUBLISHER_AD_UNIT_SNAPSHOTS = 256; +const MAX_PENDING_PUBLISHER_BIDS = 2048; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -245,16 +246,14 @@ type PublisherAdUnitSnapshot = { clientSideBids: ClientSideBidSnapshot[]; zone?: string; }; -type PublisherDeliveryContext = { - remainingCodes: Set; - retainForTargetedRefresh: boolean; - cleanupTimer?: ReturnType; +type PendingPublisherBid = { + adUnitCode: string; }; -type SetTargetingForGptAsync = (...args: unknown[]) => unknown; +type RemoveAdUnit = (adUnitCode?: string | string[]) => unknown; let publisherAdUnitSnapshots = new Map(); +let pendingPublisherBids = new Map(); let syntheticRefreshAdUnits = new WeakSet(); -const activePublisherDeliveryContexts: PublisherDeliveryContext[] = []; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -381,26 +380,41 @@ function firstTargetingValue(values: string[] | undefined): string | undefined { return values?.find((value) => value.length > 0); } -/** - * Find the publisher's original `pbjs.adUnits` entry for a refreshing slot. - * - * A TS-owned GPT slot may be defined on `${div_id}-container`, so the GPT - * element id used as the synthetic refresh ad unit code can differ from the - * inner `div_id` the publisher keyed their Prebid ad unit by. Try each candidate - * code in order and return the first matching ad unit, so container-backed slots - * still recover the publisher's configured params and bidders. - */ +/** Store a snapshot and evict the least-recently used entry when capacity is exceeded. */ +function storePublisherAdUnitSnapshot(code: string, snapshot: PublisherAdUnitSnapshot): void { + publisherAdUnitSnapshots.delete(code); + publisherAdUnitSnapshots.set(code, snapshot); + + if (publisherAdUnitSnapshots.size > MAX_PUBLISHER_AD_UNIT_SNAPSHOTS) { + const oldestCode = publisherAdUnitSnapshots.keys().next().value; + if (oldestCode !== undefined) publisherAdUnitSnapshots.delete(oldestCode); + } +} + +/** Find and touch a request-scoped publisher snapshot by candidate code. */ function findRefreshSnapshot( candidateCodes: Array ): PublisherAdUnitSnapshot | undefined { for (const code of candidateCodes) { if (!code) continue; const snapshot = publisherAdUnitSnapshots.get(code); - if (snapshot) return snapshot; + if (!snapshot) continue; + publisherAdUnitSnapshots.delete(code); + publisherAdUnitSnapshots.set(code, snapshot); + return snapshot; } return undefined; } +/** + * Find the publisher's live `pbjs.adUnits` entry for a refreshing slot. + * + * A TS-owned GPT slot may be defined on `${div_id}-container`, so the GPT + * element id used as the synthetic refresh ad unit code can differ from the + * inner `div_id` the publisher keyed their Prebid ad unit by. Try each candidate + * code in order and return the first matching ad unit, so container-backed slots + * still recover the publisher's configured params and bidders. + */ function findRefreshAdUnit( candidateCodes: Array ): TrustedServerAdUnit | undefined { @@ -413,6 +427,7 @@ function findRefreshAdUnit( return undefined; } +/** Deep-copy plain publisher params while preserving cycles and non-plain values. */ function copyParamValue(value: unknown, seen = new WeakMap()): unknown { if (Array.isArray(value)) { const existing = seen.get(value); @@ -449,6 +464,7 @@ function copyParams(params: Record | undefined): Record; } +/** Copy bidder params previously folded into a `trustedServer` bid. */ function foldedBidderParams( bid: TrustedServerBid | undefined ): Record> { @@ -461,6 +477,7 @@ function foldedBidderParams( ); } +/** Capture immutable request-scoped bidder and zone data before the shim mutates an ad unit. */ function capturePublisherAdUnitSnapshot( unit: TrustedServerAdUnit, clientSideBidders: Set @@ -503,34 +520,34 @@ function capturePublisherAdUnitSnapshot( * `requestBids` shim preserves a client-side bidder only when its bid entry is * already present on the ad unit, so without re-attaching them here publishers * that split demand between server-side and native Prebid adapters would lose - * all client-side demand on refresh/scroll impressions. Bids are sourced from - * the matching `pbjs.adUnits` entry (by candidate ad unit code) so the - * publisher's configured params are preserved. + * all client-side demand on refresh/scroll impressions. A live exact + * `pbjs.adUnits` match is authoritative; request-scoped snapshots are used only + * when no live unit exists. */ function clientSideBidsForRefresh( candidateCodes: Array ): Array<{ bidder: string; params: Record }> { - const snapshot = findRefreshSnapshot(candidateCodes); - if (snapshot) { - return snapshot.clientSideBids.map((bid) => ({ - bidder: bid.bidder, - params: copyParams(bid.params), - })); - } - const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); - if (clientSideBidders.size === 0) return []; - const match = findRefreshAdUnit(candidateCodes); - if (!match?.bids) return []; + if (match) { + if (clientSideBidders.size === 0 || !Array.isArray(match.bids)) return []; - const bids: Array<{ bidder: string; params: Record }> = []; - for (const bid of match.bids) { - if (bid?.bidder && clientSideBidders.has(bid.bidder)) { - bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + const bids: Array<{ bidder: string; params: Record }> = []; + for (const bid of match.bids) { + if (bid?.bidder && clientSideBidders.has(bid.bidder)) { + bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + } } + return bids; } - return bids; + + const snapshot = findRefreshSnapshot(candidateCodes); + return ( + snapshot?.clientSideBids.map((bid) => ({ + bidder: bid.bidder, + params: copyParams(bid.params), + })) ?? [] + ); } /** @@ -539,49 +556,49 @@ function clientSideBidsForRefresh( * The synthetic refresh ad unit carries only the `trustedServer` bid, so the * `requestBids` shim has no original server-side bidder entries to collect into * `bidderParams` — without this, refresh/scroll `/auction` requests send `{}` - * and lose demand the publisher configured only on the initial ad unit. Source - * the params from the matching `pbjs.adUnits` entry by candidate code, covering - * both states the initial auction can leave that entry in: - * - raw server-side bidder entries (`{ bidder, params }`) not yet folded, and - * - params already folded into that unit's `trustedServer` bid `bidderParams` - * by a prior `requestBids` call. + * and lose demand the publisher configured only on the initial ad unit. A live + * exact `pbjs.adUnits` match is authoritative and covers both raw bidder entries + * and params already folded into a `trustedServer` bid. A request-scoped + * snapshot is used only when no live unit exists. */ function serverSideBidderParamsForRefresh( candidateCodes: Array ): Record> { - const snapshot = findRefreshSnapshot(candidateCodes); - if (snapshot) { - return Object.fromEntries( - Object.entries(snapshot.bidderParams).map(([bidder, params]) => [bidder, copyParams(params)]) - ); - } - const match = findRefreshAdUnit(candidateCodes); - if (!match?.bids) return {}; + if (match) { + if (!Array.isArray(match.bids)) return {}; - const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); - const params: Record> = {}; + const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); + const params: Record> = {}; - for (const bid of match.bids) { - if (!bid?.bidder) continue; - if (bid.bidder === ADAPTER_CODE) { - // Params captured and folded onto the trustedServer bid by an earlier - // requestBids call. - const folded = (bid.params?.[BIDDER_PARAMS_KEY] ?? {}) as Record< - string, - Record - >; - for (const [bidder, bidderParams] of Object.entries(folded)) { - params[bidder] = bidderParams; + for (const bid of match.bids) { + if (!bid?.bidder) continue; + if (bid.bidder === ADAPTER_CODE) { + Object.assign(params, foldedBidderParams(bid)); + continue; } - continue; + if (clientSideBidders.has(bid.bidder)) continue; + params[bid.bidder] = copyParams(bid.params); } - if (clientSideBidders.has(bid.bidder)) continue; - // Raw server-side bidder entry not yet folded by the shim. - params[bid.bidder] = bid.params ?? {}; + + return params; } - return params; + const snapshot = findRefreshSnapshot(candidateCodes); + return snapshot + ? Object.fromEntries( + Object.entries(snapshot.bidderParams).map(([bidder, params]) => [ + bidder, + copyParams(params), + ]) + ) + : {}; +} + +/** Return a live publisher zone, falling back to a request-scoped snapshot. */ +function publisherZoneForRefresh(candidateCodes: Array): string | undefined { + const match = findRefreshAdUnit(candidateCodes); + return match ? match.mediaTypes?.banner?.name : findRefreshSnapshot(candidateCodes)?.zone; } function clearRefreshTargeting(slot: RefreshGptSlot): void { @@ -592,70 +609,85 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } -function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { - if (context.cleanupTimer !== undefined) { - clearTimeout(context.cleanupTimer); - context.cleanupTimer = undefined; +/** Store an auction-local bid ID for one-shot GPT delivery correlation. */ +function storePendingPublisherBid(adId: string, pendingBid: PendingPublisherBid): void { + pendingPublisherBids.delete(adId); + pendingPublisherBids.set(adId, pendingBid); + + if (pendingPublisherBids.size > MAX_PENDING_PUBLISHER_BIDS) { + const oldestAdId = pendingPublisherBids.keys().next().value; + if (oldestAdId !== undefined) pendingPublisherBids.delete(oldestAdId); } - const index = activePublisherDeliveryContexts.lastIndexOf(context); - if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); } -function targetingCoversPublisherDeliveryContext( - adUnitCodes: unknown, - context: PublisherDeliveryContext -): boolean { - if (adUnitCodes === undefined) return context.remainingCodes.size > 0; - const codes = typeof adUnitCodes === 'string' ? [adUnitCodes] : adUnitCodes; - return ( - Array.isArray(codes) && - codes.some((code) => typeof code === 'string' && context.remainingCodes.has(code)) - ); +/** Remove every pending auction bid for an ad-unit code. */ +function removePendingPublisherBidsForCode(adUnitCode: string): void { + for (const [adId, pendingBid] of pendingPublisherBids) { + if (pendingBid.adUnitCode === adUnitCode) pendingPublisherBids.delete(adId); + } } -function consumeBarePublisherDeliveryContext(): boolean { - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - if (context.remainingCodes.size === 0) continue; - context.remainingCodes.clear(); - removePublisherDeliveryContext(context); - return true; +/** Register bid IDs from the current `bidsBackHandler` callback only. */ +function registerPendingPublisherBids(bidResponses: unknown): void { + if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) return; + + for (const [responseCode, responseGroup] of Object.entries(bidResponses)) { + if (!responseGroup || typeof responseGroup !== 'object') continue; + const bids = (responseGroup as { bids?: unknown }).bids; + if (!Array.isArray(bids)) continue; + + for (const bid of bids) { + if (!bid || typeof bid !== 'object') continue; + const response = bid as { adId?: unknown; adUnitCode?: unknown }; + const adId = typeof response.adId === 'string' ? response.adId : undefined; + const adUnitCode = + typeof response.adUnitCode === 'string' ? response.adUnitCode : responseCode; + if (!adId || !adUnitCode) continue; + + storePendingPublisherBid(adId, { adUnitCode }); + } } - return false; } -function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { - if (targetSlots.length === 0) return false; +/** + * Partition slots by whether their current `hb_adid` belongs to a pending + * publisher auction, consuming every older pending bid for each matched code. + */ +function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set { + const deliverySlots = new Set(); + const deliveredCodes = new Set(); - // Publishers may include GAM-only slots in the same explicit refresh that - // delivers a completed Prebid auction. Attribute the call to delivery when - // any slot is covered, while consuming only the covered codes so an - // unrelated-only refresh still follows the synthetic auction path. - const matches = new Map>(); for (const slot of targetSlots) { - const injectedSlot = findInjectedSlotForRefresh(slot); - const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + const adIds = slot.getTargeting?.('hb_adid'); + if (!Array.isArray(adIds)) continue; - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - const coveredCode = candidates.find( - (code): code is string => !!code && context.remainingCodes.has(code) - ); - if (!coveredCode) continue; + const pendingBid = adIds + .filter((adId): adId is string => typeof adId === 'string' && adId.length > 0) + .map((adId) => pendingPublisherBids.get(adId)) + .find((bid): bid is PendingPublisherBid => bid !== undefined); + if (!pendingBid) continue; - const contextMatches = matches.get(context) ?? new Set(); - contextMatches.add(coveredCode); - matches.set(context, contextMatches); - break; - } + deliverySlots.add(slot); + deliveredCodes.add(pendingBid.adUnitCode); } - if (matches.size === 0) return false; - for (const [context, coveredCodes] of matches) { - coveredCodes.forEach((code) => context.remainingCodes.delete(code)); - if (context.remainingCodes.size === 0) removePublisherDeliveryContext(context); + deliveredCodes.forEach(removePendingPublisherBidsForCode); + return deliverySlots; +} + +/** Evict publisher state after Prebid removes one or more ad units. */ +function removePublisherState(adUnitCode?: string | string[]): void { + if (!adUnitCode) { + publisherAdUnitSnapshots.clear(); + pendingPublisherBids.clear(); + return; + } + + const adUnitCodes = Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]; + for (const code of adUnitCodes) { + publisherAdUnitSnapshots.delete(code); + removePendingPublisherBidsForCode(code); } - return true; } function collectAuctionEids(): AuctionEid[] | undefined { @@ -695,8 +727,18 @@ function collectAuctionEids(): AuctionEid[] | undefined { */ export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); + pendingPublisherBids = new Map(); syntheticRefreshAdUnits = new WeakSet(); - [...activePublisherDeliveryContexts].forEach(removePublisherDeliveryContext); + + const prebidWithRemoveAdUnit = pbjs as unknown as { removeAdUnit?: RemoveAdUnit }; + const originalRemoveAdUnit = prebidWithRemoveAdUnit.removeAdUnit; + if (typeof originalRemoveAdUnit === 'function') { + prebidWithRemoveAdUnit.removeAdUnit = function (adUnitCode?: string | string[]) { + const result = originalRemoveAdUnit.call(this, adUnitCode); + removePublisherState(adUnitCode); + return result; + }; + } const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { @@ -772,15 +814,19 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; const isSyntheticRefresh = adUnits.length > 0 && adUnits.every((unit) => syntheticRefreshAdUnits.has(unit)); - const publisherAdUnitCodes = new Set(); + const publisherAdUnitCodes = new Set( + adUnits + .filter((unit) => !syntheticRefreshAdUnits.has(unit)) + .map((unit) => unit.code) + .filter((code): code is string => typeof code === 'string' && code.length > 0) + ); // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { if (!syntheticRefreshAdUnits.has(unit)) { const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders); if (snapshot && unit.code) { - publisherAdUnitSnapshots.set(unit.code, snapshot); - publisherAdUnitCodes.add(unit.code); + storePublisherAdUnitSnapshot(unit.code, snapshot); } } @@ -868,51 +914,11 @@ export function installPrebidNpm(config?: Partial): typeof pbjs opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); if (typeof originalBidsBack !== 'function') return; - if (isSyntheticRefresh || publisherAdUnitCodes.size === 0) { - originalBidsBack.apply(this, args as Parameters); - return; - } - - const context: PublisherDeliveryContext = { - remainingCodes: new Set(publisherAdUnitCodes), - retainForTargetedRefresh: false, - }; - const targetingPbjs = pbjs as unknown as { - setTargetingForGPTAsync?: SetTargetingForGptAsync; - }; - const originalSetTargeting = targetingPbjs.setTargetingForGPTAsync; - let targetingWrapper: SetTargetingForGptAsync | undefined; - if (typeof originalSetTargeting === 'function') { - targetingWrapper = (...targetingArgs: unknown[]) => { - const result = originalSetTargeting.apply(targetingPbjs, targetingArgs); - if (targetingCoversPublisherDeliveryContext(targetingArgs[0], context)) { - context.retainForTargetedRefresh = true; - } - return result; - }; - targetingPbjs.setTargetingForGPTAsync = targetingWrapper; - } - - activePublisherDeliveryContexts.push(context); - try { - originalBidsBack.apply(this, args as Parameters); - } finally { - if (targetingWrapper && targetingPbjs.setTargetingForGPTAsync === targetingWrapper) { - targetingPbjs.setTargetingForGPTAsync = originalSetTargeting; - } - if (context.retainForTargetedRefresh && context.remainingCodes.size > 0) { - // Some publisher wrappers set targeting in bidsBackHandler, return, - // and schedule the matching GPT refresh shortly afterward. Retain - // this one-shot context only after that targeting signal, with a - // bounded expiry so a later independent refresh remains independent. - context.cleanupTimer = setTimeout( - () => removePublisherDeliveryContext(context), - PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS - ); - } else { - removePublisherDeliveryContext(context); - } + if (!isSyntheticRefresh) { + publisherAdUnitCodes.forEach(removePendingPublisherBidsForCode); + registerPendingPublisherBids(args[0]); } + originalBidsBack.apply(this, args as Parameters); }; return originalRequestBids(opts); @@ -1012,33 +1018,30 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - const isExplicitSlotList = slots !== undefined; - const hasOnlyValidExplicitSlots = !isExplicitSlotList || targetSlots.length === slots.length; - const isPublisherDeliveryRefresh = isExplicitSlotList - ? hasOnlyValidExplicitSlots && consumeExplicitPublisherDeliveryContext(targetSlots) - : consumeBarePublisherDeliveryContext(); - if (isPublisherDeliveryRefresh) { + if (!targetSlots.length) { return originalRefresh(slots, opts); } - if (!targetSlots.length) { - return originalRefresh(slots, opts); + const deliverySlots = publisherDeliverySlots(targetSlots); + const independentSlots = targetSlots.filter((slot) => !deliverySlots.has(slot)); + if (deliverySlots.size > 0) { + originalRefresh([...deliverySlots], opts); } + if (independentSlots.length === 0) return; - targetSlots.forEach(clearRefreshTargeting); + independentSlots.forEach(clearRefreshTargeting); - const adUnits = targetSlots.map((slot) => { + const adUnits = independentSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); const code = refreshSlotElementId(slot) ?? 'refresh-slot'; // A TS-owned slot may be defined on `${div_id}-container`, so the GPT // element id used as the synthetic refresh code can differ from the // inner `div_id` the publisher keyed their ad unit by. Recover from both. const candidateCodes = [code, injectedSlot?.div_id]; - const snapshot = findRefreshSnapshot(candidateCodes); const zone = injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)) ?? - snapshot?.zone; + publisherZoneForRefresh(candidateCodes); const banner: TrustedServerBanner = { sizes: bannerSizesFromInjectedSlot(injectedSlot) ?? @@ -1073,7 +1076,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { adUnits, bidsBackHandler: () => { pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); - originalRefresh(targetSlots, opts); + originalRefresh(independentSlots, opts); }, timeout: timeoutMs, }); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 4c88a02c2..92d2b07d9 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -8,6 +8,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockRemoveAdUnit, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -21,14 +22,34 @@ const { () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); const mockGetConfig = vi.fn(); - const mockPbjs = { + let mockPbjs: { + setConfig: typeof mockSetConfig; + processQueue: typeof mockProcessQueue; + requestBids: typeof mockRequestBids; + registerBidAdapter: typeof mockRegisterBidAdapter; + getUserIdsAsEids: typeof mockGetUserIdsAsEids; + getConfig: typeof mockGetConfig; + removeAdUnit: ReturnType; + adUnits: any[]; + [key: string]: any; + }; + const mockRemoveAdUnit = vi.fn((adUnitCode?: string | string[]) => { + if (!adUnitCode) { + mockPbjs.adUnits = []; + return; + } + const codes = new Set(Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]); + mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !codes.has(unit.code)); + }); + mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, requestBids: mockRequestBids, registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, - adUnits: [] as any[], + removeAdUnit: mockRemoveAdUnit, + adUnits: [], }; const mockAdapterManager = { getBidAdapter: mockGetBidAdapter, @@ -40,6 +61,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockRemoveAdUnit, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -1413,10 +1435,18 @@ describe('prebid/installRefreshHandler', () => { }); describe('prebid publisher snapshots and delivery refreshes', () => { + let deliveryAdIds = new WeakMap(); + let installedGptSlots: any[] = []; + let auctionSequence = 0; + beforeEach(() => { vi.clearAllMocks(); + deliveryAdIds = new WeakMap(); + installedGptSlots = []; + auctionSequence = 0; mockRequestBids.mockReset(); mockPbjs.requestBids = mockRequestBids; + mockPbjs.removeAdUnit = mockRemoveAdUnit; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); @@ -1434,6 +1464,17 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); function installGpt(slots: any[]) { + installedGptSlots = slots; + for (const slot of slots) { + if (!slot || typeof slot !== 'object') continue; + const originalGetTargeting = slot.getTargeting?.bind(slot); + slot.getTargeting = (key: string) => { + const deliveryAdId = deliveryAdIds.get(slot); + if (key === 'hb_adid' && deliveryAdId) return [deliveryAdId]; + return originalGetTargeting?.(key) ?? []; + }; + } + const originalRefresh = vi.fn(); const pubads = { refresh: originalRefresh, @@ -1452,6 +1493,31 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return lastCall?.[0]?.adUnits?.[0]; } + function completePublisherAuction( + opts?: { adUnits?: Array<{ code?: string }>; bidsBackHandler?: (...args: any[]) => void }, + options: { auctionId?: string; applyTargeting?: boolean } = {} + ): void { + const auctionId = options.auctionId ?? `example-auction-${auctionSequence++}`; + const bidResponses: Record = {}; + + for (const unit of opts?.adUnits ?? []) { + if (!unit.code) continue; + const adId = `${auctionId}-${unit.code}`; + bidResponses[unit.code] = { + bids: [{ adId, adUnitCode: unit.code, auctionId }], + }; + if (options.applyTargeting !== false) { + const slot = installedGptSlots.find((candidate) => { + const elementId = candidate?.getSlotElementId?.(); + return elementId === unit.code || elementId === `${unit.code}-container`; + }); + if (slot) deliveryAdIds.set(slot, adId); + } + } + + opts?.bidsBackHandler?.(bidResponses, false, auctionId); + } + it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; const runtimeInstance = 'example-runtime-instance'; @@ -1677,6 +1743,143 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); }); + it('prefers a rich live unit when a fresh same-code request overwrites the snapshot with empty bids', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const code = 'example-live-rich-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const liveUnit = { + code, + bids: [ + { bidder: 'exampleServer', params: { placement: 'live-server' } }, + { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, + ], + }; + mockPbjs.adUnits = [liveUnit]; + const pbjs = installPrebidNpm(); + + pbjs.requestBids(); + pbjs.requestBids({ adUnits: [{ code, bids: [] }] } as any); + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual([ + { + bidder: 'trustedServer', + params: { bidderParams: { exampleServer: { placement: 'live-server' } } }, + }, + { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, + ]); + }); + + it('does not resurrect an older snapshot when the live unit is intentionally empty', () => { + const code = 'example-live-empty-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: { placement: 'snapshot' } }] }], + } as any); + mockPbjs.adUnits = [{ code, bids: [] }]; + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual([ + { bidder: 'trustedServer', params: { bidderParams: {} } }, + ]); + }); + + it('evicts snapshots with the matching removeAdUnit lifecycle', () => { + const codes = ['example-remove-one', 'example-remove-two', 'example-remove-all']; + const slots = codes.map((code) => ({ + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + })); + const { pubads } = installGpt(slots); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: codes.map((code) => ({ + code, + bids: [{ bidder: 'exampleServer', params: { placement: code } }], + })), + } as any); + (pbjs as any).removeAdUnit(codes[0]); + (pbjs as any).removeAdUnit([codes[1]]); + + pubads.refresh([slots[0]]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([slots[1]]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([slots[2]]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: codes[2] }, + }); + + (pbjs as any).removeAdUnit(); + pubads.refresh([slots[2]]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + }); + + it('bounds snapshots with LRU eviction while retaining a recently refreshed entry', () => { + const capacity = 256; + const oldestCode = 'example-lru-0'; + const activeCode = `example-lru-${capacity - 1}`; + const oldestSlot = { + getSlotElementId: () => oldestCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const activeSlot = { + getSlotElementId: () => activeCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([oldestSlot, activeSlot]); + const pbjs = installPrebidNpm(); + + for (let index = 0; index < capacity; index += 1) { + pbjs.requestBids({ + adUnits: [ + { + code: `example-lru-${index}`, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + }, + ], + } as any); + } + + pubads.refresh([activeSlot]); + pbjs.requestBids({ + adUnits: [ + { + code: `example-lru-${capacity}`, + bids: [{ bidder: 'exampleServer', params: { placement: capacity } }], + }, + ], + } as any); + + pubads.refresh([oldestSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([activeSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: capacity - 1 }, + }); + }); + it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { const slotOne = { getSlotElementId: () => 'example-covered-one', @@ -1692,9 +1895,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], }; const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1716,7 +1917,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); }); - it('bypasses a bare delivery refresh even when GPT includes a GAM-only extra slot', () => { + it('partitions a bare delivery refresh from an unmatched GPT slot', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1728,9 +1929,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1738,14 +1937,15 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => pubads.refresh(), } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); - expect(gamOnlySlot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [coveredSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); }); - it('keeps explicit unrelated lists synthetic and bypasses mixed delivery lists', () => { + it('keeps explicit unrelated lists synthetic and partitions mixed delivery lists', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1759,9 +1959,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1772,19 +1970,23 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(mockRequestBids).toHaveBeenCalledTimes(3); expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ 'example-unrelated', ]); + expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-unrelated', + ]); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledTimes(3); expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(3, [unrelatedSlot], undefined); }); - it('bypasses an explicit delivery refresh with four covered slots and a GAM-only extra', () => { + it('partitions four delivered slots from an unmatched explicit slot', () => { const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ getSlotElementId: () => `example-covered-${index}`, getTargeting: () => [], @@ -1797,9 +1999,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const refreshSlots = [...coveredSlots, gamOnlySlot]; const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1810,111 +2010,125 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => pubads.refresh(refreshSlots), } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + coveredSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, coveredSlots, undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); }); - it('bypasses a targeted delivery refresh shortly after the publisher callback returns', () => { + it('correlates a targeted delivery refresh after more than one second without a timer race', () => { vi.useFakeTimers(); try { - const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ - getSlotElementId: () => `example-targeted-${index}`, + const code = 'example-delayed-delivery'; + const auctionId = 'example-delayed-auction'; + const slot = { + getSlotElementId: () => code, getTargeting: () => [], getSizes: () => [[300, 250]], clearTargeting: vi.fn(), - })); - const gamOnlySlot = { - getSlotElementId: () => 'example-targeted-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), }; - const refreshSlots = [...coveredSlots, gamOnlySlot]; - const { originalRefresh, pubads } = installGpt(refreshSlots); - const setTargetingForGPTAsync = vi.fn(); - (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; - let refreshAfterCallback: (() => void) | undefined; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - const pendingRefresh = refreshAfterCallback; - refreshAfterCallback = undefined; - if (pendingRefresh) setTimeout(pendingRefresh, 750); - }); + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { auctionId, applyTargeting: false }) + ); const pbjs = installPrebidNpm(); - const coveredCodes = coveredSlots.map((slot) => slot.getSlotElementId()); pbjs.requestBids({ - adUnits: coveredCodes.map((code, index) => ({ - code, - bids: [{ bidder: 'exampleServer', params: { placement: index } }], - })), + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => { - (pbjs as any).setTargetingForGPTAsync([gamOnlySlot.getSlotElementId(), ...coveredCodes]); - refreshAfterCallback = () => pubads.refresh(refreshSlots); + setTimeout(() => { + deliveryAdIds.set(slot, `${auctionId}-${code}`); + pubads.refresh([slot]); + }, 1500); }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith([ - gamOnlySlot.getSlotElementId(), - ...coveredCodes, - ]); - expect((mockPbjs as any).setTargetingForGPTAsync).toBe(setTargetingForGPTAsync); - - vi.advanceTimersByTime(750); + vi.advanceTimersByTime(1500); - refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); - - vi.runOnlyPendingTimers(); - pubads.refresh([coveredSlots[0]]); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + pubads.refresh([slot]); expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(coveredSlots[0].clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); expect(originalRefresh).toHaveBeenCalledTimes(2); } finally { vi.runOnlyPendingTimers(); vi.useRealTimers(); - delete (mockPbjs as any).setTargetingForGPTAsync; } }); - it('expires a targeted delivery context before a later event-loop task', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-expiring-delivery', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - (mockPbjs as any).setTargetingForGPTAsync = vi.fn(); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - const pbjs = installPrebidNpm(); + it('correlates null and no-argument targeting with a custom GPT slot match', () => { + const code = 'example-custom-matched-code'; + const slot = { + getSlotElementId: () => 'example-different-gpt-slot', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + let auctionId = 'example-null-auction'; + const setTargetingForGPTAsync = vi.fn(() => { + deliveryAdIds.set(slot, `${auctionId}-${code}`); + }); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { auctionId, applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [ - { code: 'example-expiring-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => (pbjs as any).setTargetingForGPTAsync(['example-expiring-delivery']), - } as any); - vi.runOnlyPendingTimers(); - pubads.refresh([slot]); + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync(null, () => () => true); + pubads.refresh([slot]); + }, + } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - delete (mockPbjs as any).setTargetingForGPTAsync; - } + auctionId = 'example-no-argument-auction'; + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync(); + pubads.refresh([slot]); + }, + } as any); + + expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(1, null, expect.any(Function)); + expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(2); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); + delete (mockPbjs as any).setTargetingForGPTAsync; + }); + + it('uses the synthetic path when callback bid responses are missing or malformed', () => { + const slot = { + getSlotElementId: () => 'example-no-bid-delivery', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: (...args: any[]) => void }) => { + opts?.bidsBackHandler?.({ 'example-no-bid-delivery': { bids: [null, {}] } }, false, 'bad'); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-no-bid-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh([slot]), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); it('bypasses a mixed explicit delivery list spanning nested contexts', () => { @@ -1935,9 +2149,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1954,10 +2166,13 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot, outerSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); }); it('treats a microtask refresh without a targeting signal as an independent auction', async () => { @@ -1968,9 +2183,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); const pbjs = installPrebidNpm(); let deferredRefresh: Promise | undefined; @@ -1990,6 +2205,98 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); + it('correlates targeting and refresh deferred together to a microtask', async () => { + const code = 'example-targeted-microtask'; + const auctionId = 'example-targeted-microtask-auction'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { auctionId, applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); + let deferredRefresh: Promise | undefined; + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + deferredRefresh = Promise.resolve().then(() => { + deliveryAdIds.set(slot, `${auctionId}-${code}`); + pubads.refresh([slot]); + }); + }, + } as any); + await deferredRefresh; + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('consumes all overlapping pending bids for the same ad-unit code', () => { + const code = 'example-overlapping-code'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => {}, + } as any); + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => {}, + } as any); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + + deliveryAdIds.set(slot, `example-auction-0-${code}`); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); + }); + + it('filters invalid explicit entries without duplicating or leaking a valid delivery', () => { + const code = 'example-valid-delivery'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([slot, undefined, null] as any), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + }); + it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { const outerSlot = { getSlotElementId: () => 'example-outer-delivery', @@ -2002,9 +2309,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -2037,9 +2342,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); const pbjs = installPrebidNpm(); expect(() => @@ -2071,9 +2376,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); installPrebidNpm(); pubads.refresh([slot]); From e45b6b5a16e7274ac778fc9e6a0fa0f7f3c83d75 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 12:40:05 -0500 Subject: [PATCH 4/5] Resolve Prebid refresh review feedback --- .../lib/src/integrations/prebid/index.ts | 213 +++++++++++--- .../lib/test/integrations/gpt/ad_init.test.ts | 1 + .../test/integrations/prebid/index.test.ts | 267 ++++++++++++++++-- 3 files changed, 417 insertions(+), 64 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 007a7d40d..9e0632e26 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -50,6 +50,7 @@ const TS_REFRESH_TARGETING_KEYS = [ ] as const; const MAX_PUBLISHER_AD_UNIT_SNAPSHOTS = 256; const MAX_PENDING_PUBLISHER_BIDS = 2048; +const PENDING_PUBLISHER_DELIVERY_TTL_MS = 5000; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -248,11 +249,23 @@ type PublisherAdUnitSnapshot = { }; type PendingPublisherBid = { adUnitCode: string; + expiresAt: number; + registrationId: number; +}; +type PendingPublisherCode = { + expiresAt: number; + registrationId: number; }; type RemoveAdUnit = (adUnitCode?: string | string[]) => unknown; +type PrebidWithRemoveAdUnit = { + removeAdUnit?: RemoveAdUnit; + __tsRemoveAdUnitWrapped?: boolean; +}; let publisherAdUnitSnapshots = new Map(); let pendingPublisherBids = new Map(); +let pendingPublisherCodes = new Map(); +let pendingPublisherRegistrationId = 0; let syntheticRefreshAdUnits = new WeakSet(); type TrustedServerBidRequest = { adUnitCode?: string; @@ -609,7 +622,45 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } -/** Store an auction-local bid ID for one-shot GPT delivery correlation. */ +/** Remove pending delivery state for an ad unit, optionally from one registration only. */ +function removePendingPublisherBidsForCode(adUnitCode: string, registrationId?: number): void { + const pendingCode = pendingPublisherCodes.get(adUnitCode); + if (registrationId !== undefined && pendingCode?.registrationId !== registrationId) return; + + pendingPublisherCodes.delete(adUnitCode); + for (const [adId, pendingBid] of pendingPublisherBids) { + if ( + pendingBid.adUnitCode === adUnitCode && + (registrationId === undefined || pendingBid.registrationId === registrationId) + ) { + pendingPublisherBids.delete(adId); + } + } +} + +/** Discard delivery state that outlived the publisher auction which created it. */ +function prunePendingPublisherBids(now = Date.now()): void { + for (const [adUnitCode, pendingCode] of pendingPublisherCodes) { + if (pendingCode.expiresAt <= now) removePendingPublisherBidsForCode(adUnitCode); + } + + for (const [adId, pendingBid] of pendingPublisherBids) { + if (pendingBid.expiresAt <= now) pendingPublisherBids.delete(adId); + } +} + +/** Store a short-lived pending publisher ad-unit code for delivery correlation. */ +function storePendingPublisherCode(adUnitCode: string, pendingCode: PendingPublisherCode): void { + pendingPublisherCodes.delete(adUnitCode); + pendingPublisherCodes.set(adUnitCode, pendingCode); + + if (pendingPublisherCodes.size > MAX_PENDING_PUBLISHER_BIDS) { + const oldestCode = pendingPublisherCodes.keys().next().value; + if (oldestCode !== undefined) removePendingPublisherBidsForCode(oldestCode); + } +} + +/** Store an auction-local bid ID for precise one-shot GPT delivery correlation. */ function storePendingPublisherBid(adId: string, pendingBid: PendingPublisherBid): void { pendingPublisherBids.delete(adId); pendingPublisherBids.set(adId, pendingBid); @@ -620,16 +671,23 @@ function storePendingPublisherBid(adId: string, pendingBid: PendingPublisherBid) } } -/** Remove every pending auction bid for an ad-unit code. */ -function removePendingPublisherBidsForCode(adUnitCode: string): void { - for (const [adId, pendingBid] of pendingPublisherBids) { - if (pendingBid.adUnitCode === adUnitCode) pendingPublisherBids.delete(adId); +/** Register every requested publisher code and any bid IDs returned for that auction. */ +function registerPendingPublisherBids( + publisherAdUnitCodes: Set, + bidResponses: unknown +): number { + prunePendingPublisherBids(); + const registrationId = ++pendingPublisherRegistrationId; + const expiresAt = Date.now() + PENDING_PUBLISHER_DELIVERY_TTL_MS; + + for (const adUnitCode of publisherAdUnitCodes) { + removePendingPublisherBidsForCode(adUnitCode); + storePendingPublisherCode(adUnitCode, { expiresAt, registrationId }); } -} -/** Register bid IDs from the current `bidsBackHandler` callback only. */ -function registerPendingPublisherBids(bidResponses: unknown): void { - if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) return; + if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) { + return registrationId; + } for (const [responseCode, responseGroup] of Object.entries(bidResponses)) { if (!responseGroup || typeof responseGroup !== 'object') continue; @@ -642,36 +700,53 @@ function registerPendingPublisherBids(bidResponses: unknown): void { const adId = typeof response.adId === 'string' ? response.adId : undefined; const adUnitCode = typeof response.adUnitCode === 'string' ? response.adUnitCode : responseCode; - if (!adId || !adUnitCode) continue; + if (!adId || !adUnitCode || !publisherAdUnitCodes.has(adUnitCode)) continue; - storePendingPublisherBid(adId, { adUnitCode }); + storePendingPublisherBid(adId, { adUnitCode, expiresAt, registrationId }); } } + + return registrationId; } /** - * Partition slots by whether their current `hb_adid` belongs to a pending - * publisher auction, consuming every older pending bid for each matched code. + * Partition slots by whether they belong to a pending publisher auction. + * + * A current `hb_adid` is the precise signal. When publishers intentionally + * omit that targeting, a short-lived requested-code match preserves delivery + * for no-bid and custom-targeting auctions. A non-empty unmatched ID remains + * independent so stale targeting cannot suppress a fresh auction. Every match + * is consumed once. */ function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set { + prunePendingPublisherBids(); const deliverySlots = new Set(); const deliveredCodes = new Set(); for (const slot of targetSlots) { const adIds = slot.getTargeting?.('hb_adid'); - if (!Array.isArray(adIds)) continue; - - const pendingBid = adIds - .filter((adId): adId is string => typeof adId === 'string' && adId.length > 0) - .map((adId) => pendingPublisherBids.get(adId)) - .find((bid): bid is PendingPublisherBid => bid !== undefined); - if (!pendingBid) continue; + const pendingBid = Array.isArray(adIds) + ? adIds + .filter((adId): adId is string => typeof adId === 'string' && adId.length > 0) + .map((adId) => pendingPublisherBids.get(adId)) + .find((bid): bid is PendingPublisherBid => bid !== undefined) + : undefined; + const hasAdId = + Array.isArray(adIds) && adIds.some((adId) => typeof adId === 'string' && adId.length > 0); + const injectedSlot = findInjectedSlotForRefresh(slot); + const pendingCode = hasAdId + ? undefined + : [refreshSlotElementId(slot), injectedSlot?.div_id] + .filter((code): code is string => typeof code === 'string' && code.length > 0) + .find((code) => pendingPublisherCodes.has(code)); + const adUnitCode = pendingBid?.adUnitCode ?? pendingCode; + if (!adUnitCode) continue; deliverySlots.add(slot); - deliveredCodes.add(pendingBid.adUnitCode); + deliveredCodes.add(adUnitCode); } - deliveredCodes.forEach(removePendingPublisherBidsForCode); + deliveredCodes.forEach((adUnitCode) => removePendingPublisherBidsForCode(adUnitCode)); return deliverySlots; } @@ -680,6 +755,7 @@ function removePublisherState(adUnitCode?: string | string[]): void { if (!adUnitCode) { publisherAdUnitSnapshots.clear(); pendingPublisherBids.clear(); + pendingPublisherCodes.clear(); return; } @@ -728,16 +804,21 @@ function collectAuctionEids(): AuctionEid[] | undefined { export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); pendingPublisherBids = new Map(); + pendingPublisherCodes = new Map(); + pendingPublisherRegistrationId = 0; syntheticRefreshAdUnits = new WeakSet(); - const prebidWithRemoveAdUnit = pbjs as unknown as { removeAdUnit?: RemoveAdUnit }; - const originalRemoveAdUnit = prebidWithRemoveAdUnit.removeAdUnit; - if (typeof originalRemoveAdUnit === 'function') { - prebidWithRemoveAdUnit.removeAdUnit = function (adUnitCode?: string | string[]) { - const result = originalRemoveAdUnit.call(this, adUnitCode); - removePublisherState(adUnitCode); - return result; - }; + const prebidWithRemoveAdUnit = pbjs as unknown as PrebidWithRemoveAdUnit; + if (!prebidWithRemoveAdUnit.__tsRemoveAdUnitWrapped) { + const originalRemoveAdUnit = prebidWithRemoveAdUnit.removeAdUnit; + if (typeof originalRemoveAdUnit === 'function') { + prebidWithRemoveAdUnit.removeAdUnit = function (adUnitCode?: string | string[]) { + const result = originalRemoveAdUnit.call(this, adUnitCode); + removePublisherState(adUnitCode); + return result; + }; + prebidWithRemoveAdUnit.__tsRemoveAdUnitWrapped = true; + } } const injected = getInjectedConfig(); @@ -809,7 +890,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs log.debug('[tsjs-prebid] requestBids called'); recordUserIdModuleDiagnostics(); - const opts = requestObj || {}; + const opts = { ...(requestObj ?? {}) }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; const isSyntheticRefresh = @@ -913,12 +994,21 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const originalBidsBack = opts.bidsBackHandler; opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); + const registrationId = isSyntheticRefresh + ? undefined + : registerPendingPublisherBids(publisherAdUnitCodes, args[0]); if (typeof originalBidsBack !== 'function') return; - if (!isSyntheticRefresh) { - publisherAdUnitCodes.forEach(removePendingPublisherBidsForCode); - registerPendingPublisherBids(args[0]); + + try { + originalBidsBack.apply(this, args as Parameters); + } catch (error) { + if (registrationId !== undefined) { + publisherAdUnitCodes.forEach((code) => + removePendingPublisherBidsForCode(code, registrationId) + ); + } + throw error; } - originalBidsBack.apply(this, args as Parameters); }; return originalRequestBids(opts); @@ -1018,16 +1108,15 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - if (!targetSlots.length) { + if (!targetSlots.length || (slots !== undefined && targetSlots.length !== slots.length)) { return originalRefresh(slots, opts); } const deliverySlots = publisherDeliverySlots(targetSlots); const independentSlots = targetSlots.filter((slot) => !deliverySlots.has(slot)); - if (deliverySlots.size > 0) { - originalRefresh([...deliverySlots], opts); + if (independentSlots.length === 0) { + return originalRefresh(slots, opts); } - if (independentSlots.length === 0) return; independentSlots.forEach(clearRefreshTargeting); @@ -1072,14 +1161,44 @@ export function installRefreshHandler(timeoutMs = 1500): void { // `targetSlots` — leaving their next request dependent on stale state. const refreshAdUnitCodes = adUnits.map((unit) => unit.code); adUnits.forEach((unit) => syntheticRefreshAdUnits.add(unit)); - pbjs.requestBids({ - adUnits, - bidsBackHandler: () => { - pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); - originalRefresh(independentSlots, opts); - }, - timeout: timeoutMs, - }); + + // Preserve GPT Single Request Architecture: when a publisher refresh + // includes both already-targeted delivery slots and independent slots, + // delay the whole original list until the independent auction completes. + // A one-shot fallback prevents a failed Prebid callback from dropping any + // slots, and a late callback cannot issue a second GAM request. + let completed = false; + let fallbackTimer: ReturnType | undefined; + function completeRefresh(): void { + if (completed) return; + completed = true; + if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); + originalRefresh(slots, opts); + } + + try { + pbjs.requestBids({ + adUnits, + bidsBackHandler: () => { + if (completed) return; + try { + pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); + } catch (error) { + log.error('[tsjs-prebid] refresh targeting failed', error); + } finally { + completeRefresh(); + } + }, + timeout: timeoutMs, + }); + // Prebid schedules its own timeout during requestBids(). Schedule this + // fallback afterward so its normal timeout callback gets first chance + // to apply targeting before the one-shot GPT completion path runs. + if (!completed) fallbackTimer = setTimeout(completeRefresh, timeoutMs); + } catch (error) { + log.error('[tsjs-prebid] refresh auction failed', error); + completeRefresh(); + } }; log.info('[tsjs-prebid] GPT refresh handler installed'); 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 ac55b60de..733c43642 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 @@ -133,6 +133,7 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.enableSingleRequest).toHaveBeenCalledOnce(); expect(mockPubads.refresh).toHaveBeenCalled(); fetchSpy.mockRestore(); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 92d2b07d9..ab253f758 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1447,6 +1447,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockReset(); mockPbjs.requestBids = mockRequestBids; mockPbjs.removeAdUnit = mockRemoveAdUnit; + delete (mockPbjs as any).__tsRemoveAdUnitWrapped; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); @@ -1917,6 +1918,64 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); }); + it('registers delivery state for a publisher auction without a bidsBackHandler', () => { + const code = 'example-handlerless-delivery'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + } as any); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('preserves one mixed refresh request and its original options', () => { + const deliverySlot = { + getSlotElementId: () => 'example-sra-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const independentSlot = { + getSlotElementId: () => 'example-sra-independent', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshOptions = { changeCorrelator: true }; + const { originalRefresh, pubads } = installGpt([deliverySlot, independentSlot]); + let syntheticBidsBackHandler: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + if (mockRequestBids.mock.calls.length === 1) { + completePublisherAuction(opts); + } else { + syntheticBidsBackHandler = opts.bidsBackHandler; + } + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-sra-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([deliverySlot, independentSlot], refreshOptions), + } as any); + + expect(originalRefresh).not.toHaveBeenCalled(); + expect(independentSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + + syntheticBidsBackHandler?.(); + + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([deliverySlot, independentSlot], refreshOptions); + }); + it('partitions a bare delivery refresh from an unmatched GPT slot', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', @@ -1940,9 +1999,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [coveredSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); }); it('keeps explicit unrelated lists synthetic and partitions mixed delivery lists', () => { @@ -1980,10 +2038,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledTimes(3); + expect(originalRefresh).toHaveBeenCalledTimes(2); expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(3, [unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); }); it('partitions four delivered slots from an unmatched explicit slot', () => { @@ -2013,9 +2070,39 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(mockRequestBids).toHaveBeenCalledTimes(2); coveredSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, coveredSlots, undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('expires an unconsumed publisher delivery before a later refresh', () => { + vi.useFakeTimers(); + try { + const code = 'example-expired-delivery'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + } as any); + vi.advanceTimersByTime(5001); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } }); it('correlates a targeted delivery refresh after more than one second without a timer race', () => { @@ -2106,7 +2193,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { delete (mockPbjs as any).setTargetingForGPTAsync; }); - it('uses the synthetic path when callback bid responses are missing or malformed', () => { + it('correlates requested no-bid slots without manufacturing unrelated bid state', () => { const slot = { getSlotElementId: () => 'example-no-bid-delivery', getTargeting: () => [], @@ -2126,6 +2213,30 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => pubads.refresh([slot]), } as any); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('does not use code fallback when a slot has an unmatched hb_adid', () => { + const code = 'example-stale-targeting'; + const slot = { + getSlotElementId: () => code, + getTargeting: (key: string) => (key === 'hb_adid' ? ['example-stale-ad-id'] : []), + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([slot]), + } as any); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); @@ -2170,12 +2281,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot, outerSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); }); - it('treats a microtask refresh without a targeting signal as an independent auction', async () => { + it('correlates a microtask refresh by its requested code without targeting', async () => { const slot = { getSlotElementId: () => 'example-deferred-refresh', getTargeting: () => [], @@ -2199,8 +2309,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } as any); await deferredRefresh; - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalledTimes(1); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); @@ -2290,11 +2400,134 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(mockRequestBids).toHaveBeenCalledTimes(1); expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + expect(originalRefresh).toHaveBeenCalledWith([slot, undefined, null], undefined); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + }); + + it('does not mutate reused publisher request options', () => { + const code = 'example-reused-request'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + const request = { + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + }; + pbjs.requestBids(request as any); + pbjs.requestBids(request as any); pubads.refresh([slot]); + + expect(request).not.toHaveProperty('bidsBackHandler'); expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('falls back to one GPT refresh when a synthetic auction throws', () => { + const slot = { + getSlotElementId: () => 'example-throwing-refresh', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation(() => { + throw new Error('example synthetic failure'); + }); + installPrebidNpm(); + + pubads.refresh([slot]); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('falls back once when a synthetic auction never calls back', () => { + vi.useFakeTimers(); + try { + const slot = { + getSlotElementId: () => 'example-missing-refresh-callback', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation(() => undefined); + installPrebidNpm(); + + pubads.refresh([slot]); + expect(originalRefresh).not.toHaveBeenCalled(); + vi.advanceTimersByTime(640); + + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); + + it('ignores a synthetic callback that arrives after the fallback refresh', () => { + vi.useFakeTimers(); + try { + const slot = { + getSlotElementId: () => 'example-late-refresh-callback', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + let syntheticBidsBackHandler: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + syntheticBidsBackHandler = opts.bidsBackHandler; + }); + installPrebidNpm(); + + pubads.refresh([slot]); + vi.advanceTimersByTime(640); + syntheticBidsBackHandler?.(); + + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(setTargetingForGPTAsync).not.toHaveBeenCalled(); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); + + it('completes a synthetic refresh when targeting throws', () => { + const slot = { + getSlotElementId: () => 'example-throwing-targeting', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + (mockPbjs as any).setTargetingForGPTAsync = vi.fn(() => { + throw new Error('example targeting failure'); + }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('does not stack the removeAdUnit lifecycle wrapper across installation', () => { + const pbjs = installPrebidNpm(); + installPrebidNpm(); + + (pbjs as any).removeAdUnit('example-reinstalled-slot'); + + expect(mockRemoveAdUnit).toHaveBeenCalledTimes(1); }); it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { From 41367bdd3deec2e86fb824d0da6122a0d4e3b34a Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 28 Jul 2026 14:37:59 -0500 Subject: [PATCH 5/5] Preserve Prebid targeting during fallback refreshes --- .../lib/src/integrations/prebid/index.ts | 42 ++++--- .../test/integrations/prebid/index.test.ts | 117 +++++++++++++++++- 2 files changed, 136 insertions(+), 23 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 9e0632e26..f5267cac6 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -641,7 +641,7 @@ function removePendingPublisherBidsForCode(adUnitCode: string, registrationId?: /** Discard delivery state that outlived the publisher auction which created it. */ function prunePendingPublisherBids(now = Date.now()): void { for (const [adUnitCode, pendingCode] of pendingPublisherCodes) { - if (pendingCode.expiresAt <= now) removePendingPublisherBidsForCode(adUnitCode); + if (pendingCode.expiresAt <= now) pendingPublisherCodes.delete(adUnitCode); } for (const [adId, pendingBid] of pendingPublisherBids) { @@ -714,9 +714,11 @@ function registerPendingPublisherBids( * * A current `hb_adid` is the precise signal. When publishers intentionally * omit that targeting, a short-lived requested-code match preserves delivery - * for no-bid and custom-targeting auctions. A non-empty unmatched ID remains - * independent so stale targeting cannot suppress a fresh auction. Every match - * is consumed once. + * for no-bid and custom-targeting auctions. Without an ID, that fallback cannot + * distinguish a delayed delivery from the first independent refresh, so it may + * conservatively suppress one auction before its one-shot state is consumed. + * A non-empty unmatched ID remains independent so stale targeting cannot + * suppress a fresh auction. Every match is consumed once. */ function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set { prunePendingPublisherBids(); @@ -1169,35 +1171,35 @@ export function installRefreshHandler(timeoutMs = 1500): void { // slots, and a late callback cannot issue a second GAM request. let completed = false; let fallbackTimer: ReturnType | undefined; - function completeRefresh(): void { + function completeRefresh(applyTargeting: boolean): void { if (completed) return; completed = true; if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); + if (applyTargeting) { + try { + pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); + } catch (error) { + log.error('[tsjs-prebid] refresh targeting failed', error); + } + } originalRefresh(slots, opts); } try { pbjs.requestBids({ adUnits, - bidsBackHandler: () => { - if (completed) return; - try { - pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); - } catch (error) { - log.error('[tsjs-prebid] refresh targeting failed', error); - } finally { - completeRefresh(); - } - }, + bidsBackHandler: () => completeRefresh(true), timeout: timeoutMs, }); - // Prebid schedules its own timeout during requestBids(). Schedule this - // fallback afterward so its normal timeout callback gets first chance - // to apply targeting before the one-shot GPT completion path runs. - if (!completed) fallbackTimer = setTimeout(completeRefresh, timeoutMs); + // A one-shot watchdog completes the GAM request even if Prebid never + // invokes its callback. Apply any bids available at that point before + // refreshing because Prebid's own timeout completion may run later. + if (!completed) { + fallbackTimer = setTimeout(() => completeRefresh(true), timeoutMs); + } } catch (error) { log.error('[tsjs-prebid] refresh auction failed', error); - completeRefresh(); + completeRefresh(false); } }; diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index ab253f758..1c7c32bb2 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -2105,6 +2105,35 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } }); + it('expires an unconsumed targeted delivery before a later refresh', () => { + vi.useFakeTimers(); + try { + const code = 'example-expired-targeted-delivery'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + } as any); + vi.advanceTimersByTime(5001); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); + it('correlates a targeted delivery refresh after more than one second without a timer race', () => { vi.useFakeTimers(); try { @@ -2218,6 +2247,37 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); + it('bounds code-only delivery correlation to one suppressed independent refresh', () => { + const code = 'example-code-only-delivery'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); + + // Model an initial impression rendered with display() after an auction + // that did not apply hb_adid targeting. Its code-only state is unconsumed. + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + } as any); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + }); + it('does not use code fallback when a slot has an unmatched hb_adid', () => { const code = 'example-stale-targeting'; const slot = { @@ -2242,6 +2302,44 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); + it('uses an independent auction when a pending hb_adid exceeds the capacity bound', () => { + const capacity = 2048; + const code = 'example-capacity-delivery'; + const oldestAdId = 'example-capacity-ad-0'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => { + if (mockRequestBids.mock.calls.length === 1) { + opts.bidsBackHandler?.({ + [code]: { + bids: Array.from({ length: capacity + 1 }, (_, index) => ({ + adId: `example-capacity-ad-${index}`, + adUnitCode: code, + })), + }, + }); + return; + } + completePublisherAuction(opts); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + } as any); + deliveryAdIds.set(slot, oldestAdId); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + it('bypasses a mixed explicit delivery list spanning nested contexts', () => { const outerSlot = { getSlotElementId: () => 'example-outer-delivery', @@ -2437,6 +2535,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; mockRequestBids.mockImplementation(() => { throw new Error('example synthetic failure'); }); @@ -2445,11 +2545,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([slot]); expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(setTargetingForGPTAsync).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalledTimes(1); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); - it('falls back once when a synthetic auction never calls back', () => { + it('applies targeting before falling back when a synthetic auction never calls back', () => { vi.useFakeTimers(); try { const slot = { @@ -2458,6 +2559,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; mockRequestBids.mockImplementation(() => undefined); installPrebidNpm(); @@ -2465,6 +2568,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).not.toHaveBeenCalled(); vi.advanceTimersByTime(640); + expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-missing-refresh-callback']); + expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( + originalRefresh.mock.invocationCallOrder[0] + ); expect(originalRefresh).toHaveBeenCalledTimes(1); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); } finally { @@ -2473,7 +2580,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } }); - it('ignores a synthetic callback that arrives after the fallback refresh', () => { + it('applies fallback targeting once and ignores a late synthetic callback', () => { vi.useFakeTimers(); try { const slot = { @@ -2494,8 +2601,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { vi.advanceTimersByTime(640); syntheticBidsBackHandler?.(); + expect(setTargetingForGPTAsync).toHaveBeenCalledTimes(1); + expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-late-refresh-callback']); + expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( + originalRefresh.mock.invocationCallOrder[0] + ); expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).not.toHaveBeenCalled(); } finally { vi.runOnlyPendingTimers(); vi.useRealTimers();