Skip to content

Commit 80fe392

Browse files
committed
Resolve server-side ad template review issues
1 parent def951a commit 80fe392

10 files changed

Lines changed: 374 additions & 103 deletions

File tree

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ TRUSTED_SERVER__REQUEST_SIGNING__ENABLED=false
3737

3838
# Prebid
3939
TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false
40-
# TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.com/openrtb2/auction
40+
# TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.example.com/openrtb2/auction
4141
# TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000
4242
# TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,rubicon,appnexus
4343
# TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}'

crates/js/lib/src/core/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ export interface TsjsApi {
9898
servicesEnabled?: boolean;
9999
/** Maps actualDivId → slotId for slotRenderEnded billing lookup. */
100100
divToSlotId?: Record<string, string>;
101+
/** Slot-level GPT targeting keys TS applied on the previous route. */
102+
prevSlotTargetingKeys?: Record<string, string[]>;
101103
/** Guards SPA pushState hook installation. */
102104
spaHookInstalled?: boolean;
103105
}

crates/js/lib/src/integrations/gpt/index.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ import { installGptGuard } from './script_guard';
2525
*/
2626

2727
const TS_INITIAL_TARGETING_KEY = 'ts_initial' as const;
28+
const TS_BID_TARGETING_KEYS = [
29+
'hb_pb',
30+
'hb_bidder',
31+
'hb_adid',
32+
'hb_cache_host',
33+
'hb_cache_path',
34+
] as const;
35+
const TS_BASE_TARGETING_KEYS = [...TS_BID_TARGETING_KEYS, TS_INITIAL_TARGETING_KEY] as const;
2836

2937
// ------------------------------------------------------------------
3038
// googletag type stubs (minimal surface needed by the shim)
@@ -34,6 +42,7 @@ interface GoogleTagSlot {
3442
getAdUnitPath(): string;
3543
getSlotElementId(): string;
3644
setTargeting(key: string, value: string | string[]): GoogleTagSlot;
45+
clearTargeting?(key?: string): GoogleTagSlot;
3746
addService(service: GoogleTagPubAdsService): GoogleTagSlot;
3847
getTargeting?(key: string): string[];
3948
}
@@ -82,6 +91,14 @@ function messageSourceBelongsToConfiguredSlot(source: MessageEventSource | null)
8291
);
8392
}
8493

94+
function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable<string>): void {
95+
if (typeof slot.clearTargeting !== 'function') return;
96+
97+
for (const key of new Set(keys)) {
98+
slot.clearTargeting(key);
99+
}
100+
}
101+
85102
interface GoogleTagPubAdsService {
86103
setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService;
87104
getTargeting(key: string): string[];
@@ -333,6 +350,8 @@ export function installTsAdInit(): void {
333350
// All slots to refresh (TS-defined + publisher-owned reused).
334351
const slotsToRefresh: GoogleTagSlot[] = [];
335352
const divToSlotId: Record<string, string> = {};
353+
const prevSlotTargetingKeys = ts.prevSlotTargetingKeys ?? {};
354+
const nextSlotTargetingKeys: Record<string, string[]> = {};
336355

337356
slots.forEach((slot) => {
338357
// Resolve actual div ID: exact match first, then prefix query.
@@ -363,19 +382,26 @@ export function installTsAdInit(): void {
363382
tsOwned = true;
364383
}
365384

385+
const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId;
386+
clearTargetingKeys(gptSlot, [
387+
...TS_BASE_TARGETING_KEYS,
388+
...(prevSlotTargetingKeys[actualDivId] ?? []),
389+
...(prevSlotTargetingKeys[slotDivId2] ?? []),
390+
]);
391+
366392
Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v));
367-
(['hb_pb', 'hb_bidder', 'hb_adid', 'hb_cache_host', 'hb_cache_path'] as const).forEach(
368-
(key) => {
369-
if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!));
370-
}
371-
);
393+
TS_BID_TARGETING_KEYS.forEach((key) => {
394+
if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!));
395+
});
372396
gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1');
373397
// Map both inner div and container div → slot ID so slotRenderEnded
374398
// (which reports the GPT slot's div, i.e. slotDivId/container) can look up
375399
// the slot, while adm injection (which targets the inner div) also works.
376400
divToSlotId[actualDivId] = slot.id;
377-
const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId;
378401
if (slotDivId2 !== actualDivId) divToSlotId[slotDivId2] = slot.id;
402+
const slotTargetingKeys = Object.keys(slot.targeting ?? {});
403+
nextSlotTargetingKeys[actualDivId] = slotTargetingKeys;
404+
if (slotDivId2 !== actualDivId) nextSlotTargetingKeys[slotDivId2] = slotTargetingKeys;
379405
if (tsOwned) newSlots.push(gptSlot);
380406
slotsToRefresh.push(gptSlot);
381407

@@ -391,6 +417,7 @@ export function installTsAdInit(): void {
391417
ts.prevGptSlots = newSlots as unknown[];
392418
// Replace (not merge) so destroyed slots from previous navigation don't linger.
393419
ts.divToSlotId = divToSlotId;
420+
ts.prevSlotTargetingKeys = nextSlotTargetingKeys;
394421

395422
// enableSingleRequest and enableServices must only be called once per page load.
396423
if (!ts.servicesEnabled) {

crates/js/lib/src/integrations/prebid/index.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ import { DEFAULT_PREBID_USER_ID_MODULES, PREBID_USER_ID_MODULE_REGISTRY } from '
3939
const ADAPTER_CODE = 'trustedServer';
4040
const BIDDER_PARAMS_KEY = 'bidderParams';
4141
const ZONE_KEY = 'zone';
42+
const TS_REFRESH_TARGETING_KEYS = [
43+
'ts_initial',
44+
'hb_pb',
45+
'hb_bidder',
46+
'hb_adid',
47+
'hb_cache_host',
48+
'hb_cache_path',
49+
] as const;
4250

4351
/** Configuration options for the Prebid integration. */
4452
export interface PrebidNpmConfig {
@@ -242,6 +250,7 @@ type PrebidUserIdEid = {
242250
type RefreshGptSlot = {
243251
getSlotElementId?: () => string;
244252
getTargeting?: (key: string) => string[];
253+
clearTargeting?: (key?: string) => RefreshGptSlot;
245254
getSizes?: () => unknown[];
246255
};
247256

@@ -333,6 +342,14 @@ function firstTargetingValue(values: string[] | undefined): string | undefined {
333342
return values?.find((value) => value.length > 0);
334343
}
335344

345+
function clearRefreshTargeting(slot: RefreshGptSlot): void {
346+
if (typeof slot.clearTargeting !== 'function') return;
347+
348+
for (const key of TS_REFRESH_TARGETING_KEYS) {
349+
slot.clearTargeting(key);
350+
}
351+
}
352+
336353
function collectAuctionEids(): AuctionEid[] | undefined {
337354
if (typeof pbjs.getUserIdsAsEids !== 'function') {
338355
return undefined;
@@ -569,8 +586,9 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
569586
* Wraps `googletag.pubads().refresh()` so that when the publisher's GPT
570587
* refresh policy fires (sticky anchor, viewability dwell, infinite scroll),
571588
* Prebid runs a fresh client-side auction for the refreshing slots before
572-
* the GAM call. TS-owned first-impression slots (`ts_initial=1`) are excluded
573-
* — they are managed server-side and should not re-auction client-side.
589+
* the GAM call. TS-owned first-impression slots (`ts_initial=1`) are included
590+
* on later publisher refreshes, but stale TS server-side targeting is cleared
591+
* before fresh Prebid targeting is applied.
574592
*
575593
* Must be called after `installPrebidNpm()` and after GPT is loaded.
576594
* Idempotent: safe to call multiple times — wraps only once via a sentinel.
@@ -598,24 +616,20 @@ export function installRefreshHandler(timeoutMs = 1500): void {
598616
const originalRefresh = pubads.refresh.bind(pubads);
599617
pubads.refresh = function (slots?: unknown[], opts?: unknown) {
600618
// For bare refresh() calls (no slots arg), get all registered slots from GPT
601-
// so we can filter out TS first-impression slots and auction the rest.
619+
// so we can auction the same concrete slot list and avoid stale targeting.
602620
const targetSlots = (
603621
slots ??
604622
(pubads as { getSlots?: () => unknown[] }).getSlots?.() ??
605623
[]
606624
).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null);
607625

608-
// Filter out TS first-impression slots — they don't need client-side refresh auctions.
609-
const nonTsSlots = targetSlots.filter(
610-
(slot) => !slot.getTargeting?.('ts_initial')?.includes('1')
611-
);
612-
613-
if (!nonTsSlots.length) {
614-
// All slots are TS-owned — pass through unchanged.
626+
if (!targetSlots.length) {
615627
return originalRefresh(slots, opts);
616628
}
617629

618-
const adUnits = nonTsSlots.map((slot) => {
630+
targetSlots.forEach(clearRefreshTargeting);
631+
632+
const adUnits = targetSlots.map((slot) => {
619633
const injectedSlot = findInjectedSlotForRefresh(slot);
620634
const zone =
621635
injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY));
@@ -638,8 +652,7 @@ export function installRefreshHandler(timeoutMs = 1500): void {
638652
adUnits,
639653
bidsBackHandler: () => {
640654
pbjs.setTargetingForGPTAsync?.();
641-
// Refresh only the non-TS slots (pass explicit list so TS slots are not re-refreshed).
642-
originalRefresh(nonTsSlots, opts);
655+
originalRefresh(targetSlots, opts);
643656
},
644657
timeout: timeoutMs,
645658
});

crates/js/lib/test/integrations/gpt/index.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,103 @@ describe('GPT – installSlimPrebidLoader', () => {
216216
});
217217
});
218218

219+
describe('GPT – installTsAdInit', () => {
220+
beforeEach(() => {
221+
document.body.innerHTML = '';
222+
delete (window as any).tsjs;
223+
delete (window as any).googletag;
224+
});
225+
226+
afterEach(() => {
227+
document.body.innerHTML = '';
228+
delete (window as any).tsjs;
229+
delete (window as any).googletag;
230+
});
231+
232+
it('clears stale TS-managed targeting before applying a new route to a reused GPT slot', async () => {
233+
const { installTsAdInit } = await import('../../../src/integrations/gpt/index');
234+
const slotTargeting = new Map<string, string[]>([
235+
['hb_pb', ['1.20']],
236+
['hb_bidder', ['kargo']],
237+
['hb_adid', ['old-ad']],
238+
['hb_cache_host', ['cache.example.com']],
239+
['hb_cache_path', ['/cache']],
240+
['ts_initial', ['1']],
241+
['pos', ['old-pos']],
242+
]);
243+
const gptSlot: any = {
244+
getSlotElementId: vi.fn(() => 'div-ad-homepage-header'),
245+
getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []),
246+
setTargeting: vi.fn((key: string, value: string | string[]) => {
247+
slotTargeting.set(key, Array.isArray(value) ? value : [value]);
248+
return gptSlot;
249+
}),
250+
clearTargeting: vi.fn((key?: string) => {
251+
if (key) {
252+
slotTargeting.delete(key);
253+
} else {
254+
slotTargeting.clear();
255+
}
256+
return gptSlot;
257+
}),
258+
};
259+
const pubads = {
260+
getSlots: vi.fn(() => [gptSlot]),
261+
enableSingleRequest: vi.fn(),
262+
addEventListener: vi.fn(),
263+
refresh: vi.fn(),
264+
};
265+
const cmd: Array<() => void> = [];
266+
cmd.push = (...callbacks: Array<() => void>) => {
267+
callbacks.forEach((callback) => callback());
268+
return cmd.length;
269+
};
270+
271+
document.body.innerHTML = '<div id="div-ad-homepage-header"></div>';
272+
(window as any).googletag = {
273+
cmd,
274+
pubads: () => pubads,
275+
defineSlot: vi.fn(),
276+
destroySlots: vi.fn(),
277+
enableServices: vi.fn(),
278+
};
279+
(window as any).tsjs = {
280+
prevSlotTargetingKeys: {
281+
'div-ad-homepage-header': ['pos'],
282+
},
283+
adSlots: [
284+
{
285+
id: 'homepage_header_ad',
286+
gam_unit_path: '/123/homepage',
287+
div_id: 'div-ad-homepage-header',
288+
formats: [[728, 90]],
289+
targeting: { zone: 'homepage' },
290+
},
291+
],
292+
bids: {},
293+
};
294+
295+
installTsAdInit();
296+
(window as any).tsjs.adInit();
297+
298+
expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_pb');
299+
expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_bidder');
300+
expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_adid');
301+
expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_host');
302+
expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path');
303+
expect(gptSlot.clearTargeting).toHaveBeenCalledWith('ts_initial');
304+
expect(gptSlot.clearTargeting).toHaveBeenCalledWith('pos');
305+
expect(slotTargeting.get('hb_pb')).toBeUndefined();
306+
expect(slotTargeting.get('hb_bidder')).toBeUndefined();
307+
expect(slotTargeting.get('hb_adid')).toBeUndefined();
308+
expect(slotTargeting.get('hb_cache_host')).toBeUndefined();
309+
expect(slotTargeting.get('hb_cache_path')).toBeUndefined();
310+
expect(slotTargeting.get('pos')).toBeUndefined();
311+
expect(slotTargeting.get('zone')).toEqual(['homepage']);
312+
expect(slotTargeting.get('ts_initial')).toEqual(['1']);
313+
});
314+
});
315+
219316
describe('GPT shim – runtime gating', () => {
220317
type GatedWindow = Window & {
221318
__tsjs_gpt_enabled?: boolean;

crates/js/lib/test/integrations/prebid/index.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,7 @@ describe('prebid/installPrebidNpm with server-injected config', () => {
769769
describe('prebid/installRefreshHandler', () => {
770770
beforeEach(() => {
771771
vi.clearAllMocks();
772+
mockRequestBids.mockReset();
772773
mockPbjs.requestBids = mockRequestBids;
773774
mockPbjs.adUnits = [];
774775
(window as any).tsjs = undefined;
@@ -833,6 +834,85 @@ describe('prebid/installRefreshHandler', () => {
833834
})
834835
);
835836
});
837+
838+
it('auctions refreshed TS initial slots and clears stale TS targeting before refresh', () => {
839+
const originalRefresh = vi.fn();
840+
const clearTargeting = vi.fn();
841+
const gptSlot = {
842+
getSlotElementId: vi.fn(() => 'div-ad-homepage-header'),
843+
getTargeting: vi.fn((key: string) => {
844+
if (key === 'ts_initial') return ['1'];
845+
if (key === 'zone') return ['homepage'];
846+
return [];
847+
}),
848+
getSizes: vi.fn(() => [
849+
{ getWidth: () => 970, getHeight: () => 250 },
850+
{ getWidth: () => 728, getHeight: () => 90 },
851+
]),
852+
clearTargeting,
853+
};
854+
const pubads = {
855+
refresh: originalRefresh,
856+
getSlots: vi.fn(() => [gptSlot]),
857+
};
858+
const setTargetingForGPTAsync = vi.fn();
859+
(mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync;
860+
(window as any).googletag = {
861+
cmd: { push: (fn: () => void) => fn() },
862+
pubads: () => pubads,
863+
};
864+
(window as any).tsjs = {
865+
adSlots: [
866+
{
867+
id: 'homepage_header_ad',
868+
gam_unit_path: '/123/homepage',
869+
div_id: 'div-ad-homepage-header',
870+
formats: [
871+
[970, 250],
872+
[728, 90],
873+
],
874+
targeting: { zone: 'homepage' },
875+
},
876+
],
877+
};
878+
879+
installRefreshHandler(750);
880+
pubads.refresh([gptSlot]);
881+
882+
expect(mockRequestBids).toHaveBeenCalledWith(
883+
expect.objectContaining({
884+
timeout: 750,
885+
adUnits: [
886+
expect.objectContaining({
887+
code: 'div-ad-homepage-header',
888+
mediaTypes: {
889+
banner: {
890+
name: 'homepage',
891+
sizes: [
892+
[970, 250],
893+
[728, 90],
894+
],
895+
},
896+
},
897+
bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }],
898+
}),
899+
],
900+
})
901+
);
902+
expect(clearTargeting).toHaveBeenCalledWith('ts_initial');
903+
expect(clearTargeting).toHaveBeenCalledWith('hb_pb');
904+
expect(clearTargeting).toHaveBeenCalledWith('hb_bidder');
905+
expect(clearTargeting).toHaveBeenCalledWith('hb_adid');
906+
expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host');
907+
expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path');
908+
expect(originalRefresh).not.toHaveBeenCalled();
909+
910+
const bidsBackHandler = mockRequestBids.mock.calls[0][0].bidsBackHandler;
911+
bidsBackHandler();
912+
913+
expect(setTargetingForGPTAsync).toHaveBeenCalled();
914+
expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined);
915+
});
836916
});
837917

838918
describe('prebid/client-side bidders', () => {

0 commit comments

Comments
 (0)