Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/trusted-server-core/src/integrations/gpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1253,7 +1253,7 @@ mod tests {
"bootstrap should scan ID-bearing elements instead of interpolating div_id into CSS"
);
assert!(
combined.contains(".startsWith(slot.div_id)"),
combined.contains("candidate.id.startsWith(divId)"),
Comment thread
ChristianPavilonis marked this conversation as resolved.
"bootstrap should match metacharacter-containing div_id prefixes with startsWith"
);
assert!(
Expand Down
119 changes: 102 additions & 17 deletions crates/trusted-server-core/src/integrations/gpt_bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,85 @@
return target && target.id ? target.id : null;
}

function isElementVisible(element) {
if (typeof element.checkVisibility === "function") {
return element.checkVisibility({
checkVisibilityCSS: true,
visibilityProperty: true,
});
}

for (var current = element; current; current = current.parentElement) {
var style = window.getComputedStyle(current);
if (
style.display === "none" ||
style.visibility === "hidden" ||
style.visibility === "collapse"
) {
return false;
}
}
return true;
}

function slotElementHasLayout(element) {
if (!isElementVisible(element)) return false;
var elementRect = element.getBoundingClientRect();
if (elementRect.width > 0 && elementRect.height > 0) return true;

var container = document.getElementById(element.id + "-container");
if (!container || !isElementVisible(container)) return false;
var containerRect = container.getBoundingClientRect();
return containerRect.width > 0;
}

function resolveSlotElementByDivId(divId) {
if (!divId) {
return { element: null, prefixMatchCount: 0, activeMatchCount: 0 };
}
var exact = document.getElementById(divId);
if (exact) {
return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 };
}

var idElements = document.querySelectorAll("[id]");
var prefixMatches = [];
for (var i = 0; i < idElements.length; i++) {
var candidate = idElements[i];
if (
candidate.id.startsWith(divId) &&
!candidate.id.endsWith("-container")
) {
prefixMatches.push(candidate);
}
}
// A unique prefix match may be a lazy slot that has not been sized yet,
// but it must still be visible through its ancestor containers.
if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0])) {
return {
element: prefixMatches[0],
prefixMatchCount: 1,
activeMatchCount: 1,
};
}

var visibleMatches = prefixMatches.filter(isElementVisible);
if (visibleMatches.length === 1) {
return {
element: visibleMatches[0],
prefixMatchCount: prefixMatches.length,
activeMatchCount: 1,
};
}

var activeMatches = visibleMatches.filter(slotElementHasLayout);
return {
element: activeMatches.length === 1 ? activeMatches[0] : null,
prefixMatchCount: prefixMatches.length,
activeMatchCount: activeMatches.length,
};
}

function runHandoffInternal(callback) {
var wasInternal = ts.gptSlotHandoffInternal;
ts.gptSlotHandoffInternal = true;
Expand Down Expand Up @@ -307,6 +386,7 @@
// the queued callback so a navigation committed in the gap cancels the
// stale mutation — mirrors the bundle's adInit.
var generation = ts.navGeneration || 0;
var warnedResolutionFailures = Object.create(null);

googletag.cmd.push(function () {
if ((ts.navGeneration || 0) !== generation) return;
Expand All @@ -321,25 +401,28 @@
// slot that was never displayed, so these are display()ed instead.
var slotsToDisplay = [];
slots.forEach(function (slot) {
// Resolve actual div ID: exact match first, then safe prefix scan.
// div_id in config may be a stable prefix (e.g. "ad-header-0-") when
// the suffix is dynamically generated by the framework at render time.
var el = document.getElementById(slot.div_id);
// Resolve actual div ID: exact match first, then the visibility and
// geometry tiers for prefix matches. Responsive publishers may emit
// several mutually exclusive siblings for one stable prefix, so
// document order is not sufficient.
var resolution = resolveSlotElementByDivId(slot.div_id);
var el = resolution.element;
if (!el) {
var idElements = document.querySelectorAll("[id]");
for (var i = 0; i < idElements.length; i++) {
var candidate = idElements[i];
if (
slot.div_id &&
candidate.id.startsWith(slot.div_id) &&
!candidate.id.endsWith("-container")
) {
el = candidate;
break;
if (
resolution.prefixMatchCount > 1 &&
!warnedResolutionFailures[slot.div_id]
) {
warnedResolutionFailures[slot.div_id] = true;
if (ts.log && typeof ts.log.warn === "function") {
ts.log.warn("GPT slot prefix did not resolve to one active element", {
divId: slot.div_id,
prefixMatchCount: resolution.prefixMatchCount,
activeMatchCount: resolution.activeMatchCount,
});
}
}
return;
}
if (!el) return;
var actualDivId = el.id;
var b = bids[slot.id] || {};

Expand Down Expand Up @@ -420,15 +503,17 @@
googletag.display(divId);
});
});
syncInitialLoadDisabled(window.googletag);

// Reused publisher-owned slots always need a refresh to pick up the
// server-side targeting. TS-defined slots are fetched by display() above
// unless the publisher disabled initial load, in which case display() only
// registers the slot and refresh() must request the ad — otherwise they render
// registers them and refresh() must request the ad — otherwise they render
// blank. Only add them in that case to avoid double-requesting.
syncInitialLoadDisabled(window.googletag);
var slotsNeedingRefresh = ts.gptInitialLoadDisabled
? slotsToRefresh.concat(newSlots)
: slotsToRefresh;

if (slotsNeedingRefresh.length > 0) {
// One-shot bypass: this internal refresh delivers the just-applied
// server-side targeting to GAM. If slim-Prebid has already wrapped
Expand Down
149 changes: 125 additions & 24 deletions crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,29 +52,103 @@ interface SlotRenderEndedEvent {
slot: GoogleTagSlot;
}

function findSlotElementByDivId(divId: string): HTMLElement | null {
interface SlotElementResolution {
element: HTMLElement | null;
prefixMatchCount: number;
activeMatchCount: number;
}

function isElementVisible(element: HTMLElement): boolean {
const elementWithVisibilityCheck = element as HTMLElement & {
checkVisibility?: (options?: {
checkVisibilityCSS?: boolean;
visibilityProperty?: boolean;
}) => boolean;
};
if (typeof elementWithVisibilityCheck.checkVisibility === 'function') {
return elementWithVisibilityCheck.checkVisibility({
checkVisibilityCSS: true,
visibilityProperty: true,
});
}

for (let current: HTMLElement | null = element; current; current = current.parentElement) {
Comment thread
ChristianPavilonis marked this conversation as resolved.
const style = window.getComputedStyle(current);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.visibility === 'collapse'
) {
return false;
}
}
return true;
}

function slotElementHasLayout(element: HTMLElement): boolean {
if (!isElementVisible(element)) return false;
const elementRect = element.getBoundingClientRect();
if (elementRect.width > 0 && elementRect.height > 0) return true;

const container = document.getElementById(`${element.id}-container`);
if (!container || !isElementVisible(container)) return false;
const containerRect = container.getBoundingClientRect();
return containerRect.width > 0;
}

function resolveSlotElementByDivId(divId: string): SlotElementResolution {
if (!divId) {
return { element: null, prefixMatchCount: 0, activeMatchCount: 0 };
}
const exact = document.getElementById(divId);
if (exact) return exact;
if (exact) {
return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 };
}

return (
Array.from(document.querySelectorAll<HTMLElement>('[id]')).find(
(el) => el.id.startsWith(divId) && !el.id.endsWith('-container')
) ?? null
const prefixMatches = Array.from(document.querySelectorAll<HTMLElement>('[id]')).filter(
(element) => element.id.startsWith(divId) && !element.id.endsWith('-container')
);
// A unique prefix match may be a lazy slot that has not been sized yet, but
// it must still be visible through its ancestor containers.
if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0]!)) {
return {
element: prefixMatches[0]!,
prefixMatchCount: 1,
activeMatchCount: 1,
};
}

const visibleMatches = prefixMatches.filter(isElementVisible);
if (visibleMatches.length === 1) {
return {
element: visibleMatches[0]!,
prefixMatchCount: prefixMatches.length,
activeMatchCount: 1,
};
}

const activeMatches = visibleMatches.filter(slotElementHasLayout);
return {
element: activeMatches.length === 1 ? activeMatches[0]! : null,
prefixMatchCount: prefixMatches.length,
activeMatchCount: activeMatches.length,
};
}

function findSlotElementByDivId(divId: string): HTMLElement | null {
return resolveSlotElementByDivId(divId).element;
}

function candidateSlotRoots(divId: string): HTMLElement[] {
function candidateSlotRoots(elementId: string): HTMLElement[] {
const roots: HTMLElement[] = [];
const slotEl = findSlotElementByDivId(divId);
const slotEl = document.getElementById(elementId);
if (slotEl) {
roots.push(slotEl);
const container = document.getElementById(`${slotEl.id}-container`);
if (container) roots.push(container);
}

const configuredContainer = document.getElementById(`${divId}-container`);
if (configuredContainer && !roots.includes(configuredContainer)) {
roots.push(configuredContainer);
const container = document.getElementById(`${elementId}-container`);
if (container && !roots.includes(container)) {
roots.push(container);
}

return roots;
Expand All @@ -83,12 +157,12 @@ function candidateSlotRoots(divId: string): HTMLElement[] {
function slotIdForMessageSource(source: MessageEventSource | null): string | undefined {
if (!source) return undefined;

const slots = window.tsjs?.adSlots ?? [];
return slots.find((slot) =>
candidateSlotRoots(slot.div_id).some((root) =>
const divToSlotId = window.tsjs?.divToSlotId ?? {};
return Object.entries(divToSlotId).find(([elementId]) =>
candidateSlotRoots(elementId).some((root) =>
Array.from(root.querySelectorAll('iframe')).some((iframe) => iframe.contentWindow === source)
)
)?.id;
)?.[1];
}

function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable<string>): void {
Expand Down Expand Up @@ -776,6 +850,7 @@ export function installTsAdInit(): void {
const generation = ts.navGeneration ?? 0;
const g = (window as GptWindow).googletag;
if (!g) return;
const warnedResolutionFailures = new Set<string>();

g.cmd?.push(() => {
if ((ts.navGeneration ?? 0) !== generation) return;
Expand Down Expand Up @@ -832,11 +907,23 @@ export function installTsAdInit(): void {
}

slots.forEach((slot) => {
// Resolve actual div ID: exact match first, then prefix query.
// div_id in config may be a stable prefix (e.g. "ad-header-0-") when
// the suffix is dynamically generated by the framework at render time.
const el = findSlotElementByDivId(slot.div_id);
if (!el) return;
// Resolve actual div ID: exact match first, then the visibility and
// geometry tiers for prefix matches. div_id in config may be a stable
// prefix (e.g. "ad-header-0-") when the suffix is dynamically
// generated by the framework at render time.
const resolution = resolveSlotElementByDivId(slot.div_id);
const el = resolution.element;
if (!el) {
if (resolution.prefixMatchCount > 1 && !warnedResolutionFailures.has(slot.div_id)) {
Comment thread
ChristianPavilonis marked this conversation as resolved.
warnedResolutionFailures.add(slot.div_id);
log.warn('GPT slot prefix did not resolve to one active element', {
divId: slot.div_id,
prefixMatchCount: resolution.prefixMatchCount,
activeMatchCount: resolution.activeMatchCount,
});
}
return;
}
const actualDivId = el.id;
const bid = bids[slot.id] ?? {};

Expand Down Expand Up @@ -951,6 +1038,7 @@ export function installTsAdInit(): void {
// enabled, so this runs unconditionally for any newly-defined slots.
slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId)));

syncInitialLoadDisabled(g, ts);
// Slots needing an explicit ad request via refresh(). Reused
// publisher-owned slots always need one to pick up the just-applied
// server-side targeting. TS-defined slots are normally fetched by the
Expand All @@ -960,7 +1048,6 @@ export function installTsAdInit(): void {
// first-impression slot renders blank on initial-load-disabled pages. Only
// add them in that case; otherwise display() + refresh() would
// double-request the impression.
syncInitialLoadDisabled(g, ts);
const slotsNeedingRefresh = ts.gptInitialLoadDisabled
? slotsToRefresh.concat(newSlots)
: slotsToRefresh;
Expand Down Expand Up @@ -1088,16 +1175,30 @@ function waitForSlotElements(slots: AuctionSlot[], signal: AbortSignal): Promise

return new Promise<void>((resolve) => {
let settled = false;
let animationFrame: number | undefined;
const finish = (): void => {
if (settled) return;
settled = true;
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
observer.disconnect();
clearTimeout(timer);
signal.removeEventListener('abort', finish);
resolve();
};
const observer = new MutationObserver(() => {
if (allPresent()) finish();
if (document.visibilityState === 'hidden' || typeof requestAnimationFrame === 'undefined') {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame);
animationFrame = undefined;
}
if (allPresent()) finish();
return;
}
if (animationFrame !== undefined) return;
animationFrame = requestAnimationFrame(() => {
Comment thread
ChristianPavilonis marked this conversation as resolved.
animationFrame = undefined;
if (allPresent()) finish();
});
});
observer.observe(document.documentElement, { childList: true, subtree: true });
const timer = setTimeout(finish, SPA_SLOT_WAIT_MS);
Expand Down
Loading