Skip to content
Open
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
217 changes: 189 additions & 28 deletions libraries/percentInView/percentInView.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { getWinDimensions, inIframe } from '../../src/utils.js';
import { getBoundingClientRect } from '../boundingClientRect/boundingClientRect.js';
import { defer, PbPromise, delay } from '../../src/utils/promise.js';
import { startAuction } from '../../src/prebid.js';
import { PbPromise, urgentDelay } from '../../src/utils/promise.js';
import { requestBids, startAuction } from '../../src/prebid.js';
import { getAdUnitElement } from '../../src/utils/adUnits.js';
import { getGlobal } from '../../src/prebidGlobal.js';
import { config } from '../../src/config.js';

/**
* Whether viewability may be measured with an intersection observer. Doing so means waiting for the
* observer to deliver, and so yielding the main thread; otherwise measurements are taken from the
* element's bounding rect instead.
*
* Follows `auctionOptions.viewabilityMeasurement` where it is set, and whether the main thread may be
* yielded at all otherwise.
*/
function observerAllowed() {
const measurement = config.getConfig('auctionOptions')?.viewabilityMeasurement;
return measurement == null ? (getGlobal().yield ?? true) : measurement === 'observer';
}

/**
* return the offset between the given window's viewport and the top window's.
Expand Down Expand Up @@ -71,28 +86,33 @@ function getIntersectionOfRects(rects) {
return bbox;
}

const percentInViewStatic = (element, { w, h } = {}) => {
const elementBoundingBox = getBoundingBox(element, { w, h });

/**
* Percentage of the given bounding box that lies within the top window's viewport,
* and within each of `clipRects`.
*
* `elementBoundingBox` is taken relative to `win`'s viewport, and is modified in place.
* `clipRects` are in top window coordinates.
*/
function percentInViewOfBox(elementBoundingBox, win, clipRects = []) {
// when in an iframe, the bounding box is relative to the iframe's viewport
// since we are intersecting it with the top window's viewport, attempt to
// compensate for the offset between them

const offset = getViewportOffset(element?.ownerDocument?.defaultView);
const offset = getViewportOffset(win);
elementBoundingBox.left += offset.x;
elementBoundingBox.right += offset.x;
elementBoundingBox.top += offset.y;
elementBoundingBox.bottom += offset.y;

const dims = getWinDimensions();

// Obtain the intersection of the element and the viewport
// Obtain the intersection of the element, the viewport, and everything that clips the element
const elementInViewBoundingBox = getIntersectionOfRects([{
left: 0,
top: 0,
right: dims.document.documentElement.clientWidth,
bottom: dims.document.documentElement.clientHeight
}, elementBoundingBox]);
}, elementBoundingBox, ...clipRects]);

let elementInViewArea, elementTotalArea;

Expand All @@ -107,6 +127,85 @@ const percentInViewStatic = (element, { w, h } = {}) => {
// No overlap between element and the viewport; therefore, the element
// lies completely out of view
return 0;
}

/**
* Whether an ancestor with the given style contains an element positioned as `position`, and can
* therefore clip it. Out of flow elements are laid out against something further up the tree, and
* are not clipped by the ancestors they skip over.
*/
function contains(style, position) {
if (position !== 'absolute' && position !== 'fixed') {
return true;
}
return (position === 'absolute' && style.position !== 'static') ||
style.transform !== 'none' ||
style.perspective !== 'none' ||
style.filter !== 'none' ||
/transform|perspective|filter/.test(style.willChange) ||
/paint|layout|strict|content/.test(style.contain);
}

/**
* Rectangles that clip the given element, in top window coordinates: the boxes of its
* scrolling or overflow-hidden ancestors, and of every frame that contains it.
*
* Returns null if the element or one of its ancestors is styled so that nothing renders.
*
* Clipping is approximated: the boxes include the ancestors' borders rather than stopping at their
* padding edge.
*/
function getClipRects(element) {
const rects = [];
let el = element;
let win = element?.ownerDocument?.defaultView;
try {
while (el != null && win != null) {
const { x, y } = getViewportOffset(win);
let node = el;
// how the subtree being walked is positioned, which decides which ancestors can clip it
let position;
while (node != null) {
const style = getComputedStyle(node);
// an ancestor that is transparent or hidden hides the element regardless of positioning
if (style.visibility === 'hidden' || style.opacity === '0') {
return null;
}
if (node === el) {
position = style.position;
} else if (contains(style, position)) {
if (style.overflow !== 'visible') {
const rect = getBoundingClientRect(node);
rects.push({ left: rect.left + x, top: rect.top + y, right: rect.right + x, bottom: rect.bottom + y });
}
// from here up it is this ancestor that carries the element, so its own positioning
// decides which of the remaining ancestors can clip it
position = style.position;
}
node = node.parentElement;
}
// nothing can render outside the frame that contains it
const frame = win.frameElement;
if (frame == null) break;
win = frame.ownerDocument?.defaultView;
const offset = getViewportOffset(win);
const rect = getBoundingClientRect(frame);
rects.push({ left: rect.left + offset.x, top: rect.top + offset.y, right: rect.right + offset.x, bottom: rect.bottom + offset.y });
el = frame;
}
} catch (e) {
// some ancestors are cross-frame and cannot be inspected; clip against those we could reach
}
return rects;
}

const percentInViewStatic = (element, { w, h } = {}) => {
const clipRects = getClipRects(element);
return clipRects == null ? 0 : percentInViewOfBox(
getBoundingBox(element, { w, h }),
element?.ownerDocument?.defaultView,
clipRects
);
};

export const dep = {
Expand All @@ -122,13 +221,20 @@ export const dep = {
*/
export function intersections(mkObserver) {
const intersections = new WeakMap();
let next = defer();
// resolvers waiting on the first entry for an element, keyed by that element.
// only elements that someone is actually waiting on appear here; they are dropped
// as soon as they are woken, so a page that is merely scrolling allocates nothing.
const waiting = new WeakMap();

function observerCallback(entries) {
entries.forEach(entry => {
if ((intersections.get(entry.target)?.time ?? -1) < entry.time) {
intersections.set(entry.target, entry);
next.resolve();
next = defer();
const resolvers = waiting.get(entry.target);
if (resolvers != null) {
waiting.delete(entry.target);
resolvers.forEach(resolve => resolve(entry));
}
}
});
}
Expand All @@ -140,26 +246,32 @@ export function intersections(mkObserver) {
// IntersectionObserver not supported
}

async function waitFor(element) {
const intersection = getIntersection(element);
if (intersection != null) {
return intersection;
} else {
return next.promise.then(() => waitFor(element));
}
function waitFor(element) {
return new PbPromise(resolve => {
const resolvers = waiting.get(element);
if (resolvers == null) {
waiting.set(element, [resolve]);
} else {
resolvers.push(resolve);
}
});
}
/**
* Observe the given element; returns a promise to the first available intersection observed for it.
*/
async function observe(element) {
element = dep.getElement(element);
if (element != null && obs != null && !intersections.has(element)) {
if (element == null || obs == null) {
return PbPromise.resolve(getIntersection(element));
}
if (!intersections.has(element)) {
obs.observe(element);
intersections.set(element, null);
return waitFor(element);
} else {
return PbPromise.resolve(getIntersection(element));
}
// a null entry marks an element that is being observed but has not been reported on yet; wait
// for it, so that observing the same element again does not resolve the second caller with
// nothing to measure
return getIntersection(element) ?? waitFor(element);
}

/**
Expand All @@ -182,31 +294,80 @@ export const viewportIntersections = intersections((callback) => new Intersectio

export function mkIntersectionHook(intersections = viewportIntersections) {
return function (next, request) {
if (!observerAllowed()) {
// measurements will not consult the observer, so there is nothing to wait for
next.call(this, request);
return;
}
PbPromise.race([
PbPromise.allSettled((request.adUnits ?? []).map(adUnit =>
intersections.observe(getAdUnitElement(adUnit))
)),
// according to MDN, with threshold 0 "the callback will be run as soon as the target element intersects or touches the boundary of the root, even if no pixels are yet visible"
// https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
// However, browsers appear to run it even when the element is outside the DOM
// just to be sure, cap the amount of time we wait for intersections
delay(20)
// just to be sure, cap the amount of time we wait for intersections.
//
// the cap only holds as an upper bound with high priority scheduling: an ordinary timer is
// queued behind all other ready work, so on a busy page it comes due well past its deadline -
// which is when bounding the wait matters most.
//
// the first observation for an element arrives as a queued task, rather than with the
// rendering lifecycle that carries later updates, so it needs well under a millisecond of free
// main thread; 20ms leaves margin for a moderately busy one. Much less would be
// counterproductive: a user-blocking task outruns the observer, so the wait would end before
// any entry had arrived.
urgentDelay(20)
]).then(() => next.call(this, request));
};
}

/**
* Begin observing ad unit elements as soon as bids are requested, rather than when the auction
* starts, so that entries are already available by the time `mkIntersectionHook` needs them and it
* does not have to wait. The hooks that run in between are asynchronous (consent, price floors,
* currency, user ids), which gives the observer the main thread time it needs to deliver.
*/
export function mkPrewarmHook(intersections = viewportIntersections) {
return function (next, request) {
if (observerAllowed()) {
(request?.adUnits ?? []).forEach(adUnit => {
// deliberately not waited on; this only needs to get the observation started
intersections.observe(getAdUnitElement(adUnit)).catch(() => {
Comment thread
dgirardi marked this conversation as resolved.
// the element cannot be observed, so percentInView measures the DOM directly instead
});
});
}
next.call(this, request);
};
}

startAuction.before(mkIntersectionHook());
// ahead of the asynchronous requestBids hooks (all of which are priority 50 or lower), so that their
// main thread time is available to the observer
requestBids.before(mkPrewarmHook(), 100);

export function percentInView(element, { w, h } = {}) {
if (!observerAllowed()) {
return percentInViewStatic(element, { w, h });
}
const intersection = viewportIntersections.getIntersection(element);
if (intersection == null) {
viewportIntersections.observe(element);
return percentInViewStatic(element, { w, h });
} else {
const adjusted = applySize(intersection.boundingClientRect, { w, h });
if (adjusted.width !== intersection.boundingClientRect.width || adjusted.height !== intersection.boundingClientRect.height) {
// use w/h override
return percentInViewStatic(element, { w, h });
const bbox = intersection.boundingClientRect;
const adjusted = applySize(bbox, { w, h });
if (adjusted.width !== bbox.width || adjusted.height !== bbox.height) {
// the element has collapsed, so the observer's ratio describes a rect of no area;
// recompute from the w/h override, reusing the position the observer already
// reported to avoid forcing a layout for a rect we have on hand
return percentInViewOfBox(adjusted, element?.ownerDocument?.defaultView);
}
if (bbox.width === 0 || bbox.height === 0) {
// an element with no area renders nothing, but intersection observers report a ratio
// of 1 for a zero-area target that touches the viewport, which would read as fully in view
return 0;
}
return intersection.isIntersecting ? intersection.intersectionRatio * 100 : 0;
}
Expand Down
27 changes: 27 additions & 0 deletions src/auction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,33 @@
*/
legacyRender?: boolean;

/**
* How viewability is measured when it is included in bid requests. The two options trade processing
* work against how much the auction can be held up by the rest of the page.
*
* `'observer'` takes the measurement from an intersection observer. It is much the cheaper of the
* two: reading it is a property access on a figure the browser has already worked out, so it costs
* no layout at all, and it correctly accounts for everything that clips the ad, including the
* bounds of a cross origin iframe. The cost is that the auction cannot start until the observer has
* reported, which requires yielding the main thread. On a page that keeps the main thread busy with
* long tasks, that yield is only taken once the longest of them has finished, and the auction is
* held up for that whole time.
*
* `'boundingBox'` computes the measurement from the ad element's bounding rect. Nothing is waited
* for, so the auction never queues behind the rest of the page. In exchange every measurement
* forces a layout, which is orders of magnitude dearer than reading an observer entry and on a page
* with complex CSS can run into milliseconds; and inside a cross origin iframe it can only measure
* against the frame's own viewport, so an ad scrolled well off the page can still read as fully in
* view.
*
* So: `'observer'` to do less work, `'boundingBox'` to keep the auction off the critical path of
* whatever else the page is doing.
*
* Defaults to `'observer'`, or to `'boundingBox'` when main thread yielding is turned off with
* `pbjs.yield = false`.
*/
viewabilityMeasurement?: 'observer' | 'boundingBox';

/**
* When true, reject bids without a response `mediaType` when the ad unit has an explicit mediaTypes list.
* Default is false to preserve legacy behavior for responses that omit mediaType.
Expand Down Expand Up @@ -282,7 +309,7 @@
_callback = null;
}
} catch (e) {
logError('Error executing bidsBackHandler', null, e);

Check warning on line 312 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

312 line is not covered with tests
} finally {
// Calling timed out bidders
if (timedOutRequests.length) {
Expand Down Expand Up @@ -371,8 +398,8 @@
done(origin) {
outstandingRequests[origin]--;
if (queuedCalls[0]) {
if (runIfOriginHasCapacity(queuedCalls[0])) {
queuedCalls.shift();

Check warning on line 402 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

401-402 lines are not covered with tests
}
}
}
Expand All @@ -382,8 +409,8 @@
};

if (!runIfOriginHasCapacity(call)) {
logWarn('queueing auction due to limited endpoint capacity');
queuedCalls.push(call);

Check warning on line 413 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

412-413 lines are not covered with tests
}

function runIfOriginHasCapacity(call) {
Expand All @@ -398,7 +425,7 @@

// if the bidder has alwaysHasCapacity flag set and forceMaxRequestsPerOrigin is false, don't check capacity
if (bidRequest.alwaysHasCapacity && !config.getConfig('forceMaxRequestsPerOrigin')) {
return false;

Check warning on line 428 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

428 line is not covered with tests
}

// if we have no previous info on this source just let them through
Expand All @@ -407,10 +434,10 @@
// some bidders might use more than the MAX_REQUESTS_PER_ORIGIN in a single auction. In those cases
// set their request count to MAX_REQUESTS_PER_ORIGIN so the auction isn't permanently queued waiting
// for capacity for that bidder
requests = Math.min(bidRequest.bids.length, maxRequests);

Check warning on line 437 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

437 line is not covered with tests
}
if (outstandingRequests[sourceInfo[source].origin] + requests > maxRequests) {
hasCapacity = false;

Check warning on line 440 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

440 line is not covered with tests
}
}
// return only used for terminating this .some() iteration early if it is determined we don't have capacity
Expand Down Expand Up @@ -460,9 +487,9 @@
addWinningBid,
setBidTargeting,
getWinningBids: () => _winningBids,
getAuctionStart: () => _auctionStart,

Check warning on line 490 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

490 line is not covered with tests
getAuctionEnd: () => _auctionEnd,
getTimeout: () => _timeout,

Check warning on line 492 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

492 line is not covered with tests
getAuctionId: () => _auctionId,
getAuctionStatus: () => _auctionStatus,
getAdUnits: () => _adUnits,
Expand All @@ -472,7 +499,7 @@
getNoBids: () => _noBids,
getNonBids: () => _nonBids,
getFPD: () => ortb2Fragments,
getMetrics: () => metrics,

Check warning on line 502 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

502 line is not covered with tests
end: done.promise,
requestsDone: requestsDone.promise,
getProperties
Expand All @@ -496,7 +523,7 @@
ortb2: auctionManager.index.getOrtb2(bid),
adUnit: auctionManager.index.getAdUnit(bid),
}))) {
reject(REJECTION_REASON.BIDDER_DISALLOWED);

Check warning on line 526 in src/auction.ts

View workflow job for this annotation

GitHub Actions / Coverage

526 line is not covered with tests
} else {
this.dispatch.call(null, adUnitCode, bid);
}
Expand Down
8 changes: 7 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ function attachProperties(config, useDefaultValues = true) {
const validateauctionOptions = (() => {
const boolKeys = ['suppressStaleRender', 'suppressExpiredRender', 'legacyRender', 'rejectUnknownMediaTypes', 'rejectInvalidMediaTypes'];
const arrKeys = ['secondaryBidders'];
const allKeys = [].concat(boolKeys).concat(arrKeys);
const enumKeys = { viewabilityMeasurement: ['observer', 'boundingBox'] };
const allKeys = [].concat(boolKeys).concat(arrKeys).concat(Object.keys(enumKeys));

return function validateauctionOptions(val) {
if (!isPlainObject(val)) {
Expand All @@ -92,6 +93,11 @@ function attachProperties(config, useDefaultValues = true) {
logWarn(`Auction Options ${k} must be of type boolean`);
return false;
}
} else if (enumKeys.hasOwnProperty(k)) {
if (!enumKeys[k].includes(val[k])) {
logWarn(`Auction Options ${k} must be one of: ${enumKeys[k].join(', ')}`);
return false;
}
}
}
return true;
Expand Down
26 changes: 26 additions & 0 deletions src/utils/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,32 @@ export function delay(delayMs = 0): Promise<void> {
});
}

/**
* Like `delay`, but asks for high priority scheduling, so that the continuation is dispatched ahead
* of ordinary timers and network callbacks once the main thread frees up. Use this where a timeout
* is meant to act as an upper bound: plain timers queue behind work that is already pending, which
* on a busy page can put them arbitrarily far past their deadline.
*
* This is not a real time guarantee. Nothing in JS preempts a running task, so a single long task
* still pushes the continuation past `delayMs`; what this bounds is the wait by the longest blocking
* task rather than by the whole backlog of ready work.
*/
export function urgentDelay(delayMs = 0): Promise<void> {
const scheduler = (window as any).scheduler;
if (typeof scheduler?.postTask === 'function') {
try {
return PbPromise.resolve(
scheduler.postTask(() => {}, { priority: 'user-blocking', delay: delayMs })
// a task can only be aborted through a signal, which is not used here; resolve regardless so
// that callers racing against this never stall on a rejection
).catch(() => {}) as Promise<void>;
} catch (e) {
// options rejected by this implementation of postTask; fall back to a timer
}
}
return delay(delayMs);
}

export interface Defer<T> {
promise: Promise<T>;
resolve: Parameters<ConstructorParameters<typeof Promise<T>>[0]>[0],
Expand Down
Loading
Loading