From a1e91584d22cc11aa5c6aa5a44b1a141e8e2c7a1 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 11:09:10 -0700 Subject: [PATCH 01/10] Percent in view library: avoid allocating a deferred per intersection entry The intersection cache woke waiters through a single shared deferred, which the observer callback had to resolve and replace for every entry it stored. Since the observer is a page-lifetime singleton registered with 101 thresholds, that allocation ran on every threshold crossing of every observed element for as long as the page lived, whether or not anything was waiting. Replace the shared deferred with a per-element map of pending resolvers, so a promise is created only when a caller actually waits on an element and is dropped as soon as that element's first entry arrives. Waking is now targeted rather than a broadcast that every waiter re-checked itself against. Measured over a 6000px scroll with 30 observed elements: promise allocations drop from 1294 to 0 once the initial observations have settled. Behavior is unchanged - `waiting` is a WeakMap so it adds no retention, and check-then-subscribe stays synchronous so there is no lost-wakeup window. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 31 +++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index fc6ed7d27d7..cd2f0b7b06e 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -1,6 +1,6 @@ import { getWinDimensions, inIframe } from '../../src/utils.js'; import { getBoundingClientRect } from '../boundingClientRect/boundingClientRect.js'; -import { defer, PbPromise, delay } from '../../src/utils/promise.js'; +import { PbPromise, delay } from '../../src/utils/promise.js'; import { startAuction } from '../../src/prebid.js'; import { getAdUnitElement } from '../../src/utils/adUnits.js'; @@ -122,13 +122,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)); + } } }); } @@ -140,13 +147,15 @@ 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. From 3860e517b8f8960ff0390df2fbfbf5754cc793d3 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 11:37:25 -0700 Subject: [PATCH 02/10] Percent in view library: report zero-area elements as not in view Intersection observers define the ratio of a zero-area target as 1 whenever it is intersecting, so an element that had collapsed to no width or no height read back as 100% in view. Callers that pass no size override - taboola, for example - rounded that straight onto the bid request as full viewability for a slot that renders nothing. Return 0 for an element with no area instead, which is what the bounding-client- rect path already reports for the same element. The check sits after the size override test so a caller supplying w/h still gets the substituted size. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 10 +++++++-- test/spec/libraries/percentInView_spec.js | 27 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index cd2f0b7b06e..3f716c702b3 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -212,11 +212,17 @@ export function percentInView(element, { w, h } = {}) { 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) { + const bbox = intersection.boundingClientRect; + const adjusted = applySize(bbox, { w, h }); + if (adjusted.width !== bbox.width || adjusted.height !== bbox.height) { // use w/h override return percentInViewStatic(element, { w, h }); } + 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; } } diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js index 322a786fe9d..b14b7f4a414 100644 --- a/test/spec/libraries/percentInView_spec.js +++ b/test/spec/libraries/percentInView_spec.js @@ -275,5 +275,32 @@ describe('percentInView', () => { }; expect(percentInView({}, { w: 100, h: 200 })).to.not.eql(100); }); + + it('uses the intersection ratio when the element has an area', () => { + intersection = { + boundingClientRect: { + width: 300, + height: 250, + }, + isIntersecting: true, + intersectionRatio: 0.5 + }; + expect(percentInView({})).to.eql(50); + }); + + Object.entries({ + 'height': { width: 300, height: 0 }, + 'width': { width: 0, height: 250 }, + }).forEach(([dimension, boundingClientRect]) => { + it(`returns 0 for an element with no ${dimension} and no size override`, () => { + // intersection observers report a ratio of 1 for zero-area targets + intersection = { + boundingClientRect, + isIntersecting: true, + intersectionRatio: 1 + }; + expect(percentInView({})).to.eql(0); + }); + }); }); }); From 7c96019a560a7ca653168418a83ef3647bbff81c Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 11:47:24 -0700 Subject: [PATCH 03/10] Percent in view library: reuse the observed rect for size overrides When a size override applies, the element has collapsed to no area and the percentage has to be recomputed from the substituted w/h. That recomputation measured the element again with getBoundingClientRect, which flushes pending layout - on a page with a dirty, expensive layout that call alone can cost tens of milliseconds. The observer has already reported where the element is, so the rect is available for free. Split the geometry out of percentInViewStatic into percentInViewOfBox and feed it the rect from the intersection entry. The arithmetic and its inputs are unchanged, so the resulting percentage is the same; only the layout flush goes away. The entry's rootBounds is deliberately not used as the viewport here: for an observer inside an iframe, boundingClientRect is expressed in the iframe's own viewport while rootBounds describes the top level one, so intersecting the two would report a below-the-fold frame as fully in view. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 24 ++++++++++++++++------- test/spec/libraries/percentInView_spec.js | 17 ++++++++-------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index 3f716c702b3..56e1f440f1d 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -71,14 +71,17 @@ 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. + * + * `elementBoundingBox` is taken relative to `win`'s viewport, and is modified in place. + */ +function percentInViewOfBox(elementBoundingBox, win) { // 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; @@ -107,7 +110,12 @@ const percentInViewStatic = (element, { w, h } = {}) => { // No overlap between element and the viewport; therefore, the element // lies completely out of view return 0; -}; +} + +const percentInViewStatic = (element, { w, h } = {}) => percentInViewOfBox( + getBoundingBox(element, { w, h }), + element?.ownerDocument?.defaultView +); export const dep = { // for stubbing in tests, see test/mocks/percentInView.js @@ -215,8 +223,10 @@ export function percentInView(element, { w, h } = {}) { const bbox = intersection.boundingClientRect; const adjusted = applySize(bbox, { w, h }); if (adjusted.width !== bbox.width || adjusted.height !== bbox.height) { - // use w/h override - return percentInViewStatic(element, { w, h }); + // 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 diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js index b14b7f4a414..26bf8f8e843 100644 --- a/test/spec/libraries/percentInView_spec.js +++ b/test/spec/libraries/percentInView_spec.js @@ -258,22 +258,23 @@ describe('percentInView', () => { sandbox.stub(bbox, 'getBoundingClientRect'); }); - it('does not use intersection if w/h are relevant', () => { - bbox.getBoundingClientRect.returns({ - width: 0, - height: 0, - left: -50, - top: -100, - }); + it('does not use intersection ratio if w/h are relevant', () => { + const element = {}; intersection = { boundingClientRect: { width: 0, height: 0, + left: -50, + top: -100, }, isIntersecting: true, intersectionRatio: 1 }; - expect(percentInView({}, { w: 100, h: 200 })).to.not.eql(100); + // a quarter of the overridden 100x200 size lies within the viewport + expect(percentInView(element, { w: 100, h: 200 })).to.eql(25); + // the observer already reported where the element is; measuring it again would + // force a layout + sinon.assert.neverCalledWith(bbox.getBoundingClientRect, element); }); it('uses the intersection ratio when the element has an area', () => { From 55f71dcbb591d7921aa44129456392d848bcae25 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 13:15:03 -0700 Subject: [PATCH 04/10] Percent in view library: measure viewability against ancestor clipping The bounding rect path only intersected the element's own box with the viewport, so it reported an element as fully in view whenever its layout position happened to fall inside the viewport - even when an overflow-hidden ancestor, a scroll container, or the bounds of a containing frame meant that nothing was actually painted. It also ignored styling that renders nothing at all, reporting a transparent or hidden slot as fully viewable. Collect the boxes of the element's scrolling and overflow-hidden ancestors, and of every frame containing it, and intersect those along with the viewport; report 0 when the element or an ancestor is hidden or fully transparent. getIntersectionOfRects already took a list of rectangles, so the intersection itself is unchanged. Costs 222 bytes gzipped, and roughly a microsecond per ancestor walked. Clipping is approximated, as documented on getClipRects: the boxes include ancestor borders rather than stopping at the padding edge, an ancestor clipping one axis is treated as clipping both, and an out-of-flow element is treated as clipped by ancestors outside its containing block chain. Walking out of the frame containing the tests exposed that the harness patch faking the frame's box has always thrown, since a Window has no getBoundingClientRect - which went unnoticed because getViewportOffset catches and zeroes the offset, the same result the patch intended. Move it next to the other percentInView mocks, where it works and where a test that needs the frame's real geometry can turn it off. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 67 +++++++++++++++-- test/mocks/percentInView.js | 38 ++++++++++ test/spec/libraries/percentInView_spec.js | 90 ++++++++++++++++++++++- test/test_deps.js | 8 -- 4 files changed, 186 insertions(+), 17 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index 56e1f440f1d..feeb53b923d 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -72,11 +72,13 @@ function getIntersectionOfRects(rects) { } /** - * Percentage of the given bounding box that lies within the top window's viewport. + * 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) { +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 @@ -89,13 +91,13 @@ function percentInViewOfBox(elementBoundingBox, win) { 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; @@ -112,10 +114,59 @@ function percentInViewOfBox(elementBoundingBox, win) { return 0; } -const percentInViewStatic = (element, { w, h } = {}) => percentInViewOfBox( - getBoundingBox(element, { w, h }), - element?.ownerDocument?.defaultView -); +/** + * 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, an ancestor that clips only one axis is treated as clipping both, + * and an out-of-flow element is treated as clipped by ancestors that are not in its + * containing block chain (a fixed position element is clipped by no ancestor at all). + */ +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; + while (node != null) { + const style = getComputedStyle(node); + if (style.visibility === 'hidden' || style.opacity === '0') { + return null; + } + if (node !== el && 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 }); + } + 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 = { // for stubbing in tests, see test/mocks/percentInView.js diff --git a/test/mocks/percentInView.js b/test/mocks/percentInView.js index 741f68109ba..29c2ec6e9ba 100644 --- a/test/mocks/percentInView.js +++ b/test/mocks/percentInView.js @@ -22,3 +22,41 @@ export function enable() { export function disable() { enabled = false; } + +let frameRectEnabled = true; +const frameElement = window.frameElement; + +if (frameElement != null) { + // karma runs tests inside an iframe (context.html) that is offset from the top window, while the + // debug page runs them in the top window. Report the top window's viewport as the containing + // frame's box, so that viewability measurements do not depend on which of the two is in use. + const original = frameElement.getBoundingClientRect; + frameElement.getBoundingClientRect = function () { + if (!frameRectEnabled) { + return original.call(this); + } + const doc = window.top.document.documentElement; + return { + left: 0, + top: 0, + x: 0, + y: 0, + right: doc.clientWidth, + bottom: doc.clientHeight, + width: doc.clientWidth, + height: doc.clientHeight + }; + }; +} + +export function enableFrameRect() { + frameRectEnabled = true; +} + +/** + * Report the real box of the frame containing the tests, for tests that need the frame's actual + * position or size. Remember to `enableFrameRect()` afterwards. + */ +export function disableFrameRect() { + frameRectEnabled = false; +} diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js index 26bf8f8e843..d6ee19c954e 100644 --- a/test/spec/libraries/percentInView_spec.js +++ b/test/spec/libraries/percentInView_spec.js @@ -6,7 +6,7 @@ import { viewportIntersections, } from '../../../libraries/percentInView/percentInView.js'; import * as bbox from 'libraries/boundingClientRect/boundingClientRect'; -import { enable, disable } from 'test/mocks/percentInView.js'; +import { enable, disable, enableFrameRect, disableFrameRect } from 'test/mocks/percentInView.js'; import { defer } from 'src/utils/promise.js'; @@ -304,4 +304,92 @@ describe('percentInView', () => { }); }); }); + + describe('frame rect mock', () => { + // only applies when karma runs the tests in an iframe, which excludes the debug page + if (window.frameElement == null) return; + + afterEach(() => { + enableFrameRect(); + }); + + it('reports the top window viewport while enabled', () => { + const { left, top, width, height } = window.frameElement.getBoundingClientRect(); + const doc = window.top.document.documentElement; + expect({ left, top, width, height }).to.eql({ + left: 0, top: 0, width: doc.clientWidth, height: doc.clientHeight + }); + }); + + it('reports the real frame rect once disabled', () => { + disableFrameRect(); + // the frame element belongs to the containing document, so its rect comes from that realm + const { DOMRect } = window.frameElement.ownerDocument.defaultView; + expect(window.frameElement.getBoundingClientRect()).to.be.instanceOf(DOMRect); + }); + }); + + describe('percentInView, with no intersection available', () => { + let container; + + beforeEach(() => { + // no intersection entry, so the measurement runs off the DOM + sandbox.stub(viewportIntersections, 'getIntersection').returns(undefined); + sandbox.stub(viewportIntersections, 'observe'); + bbox.clearCache(); + container = document.createElement('div'); + container.style.cssText = 'position:absolute;left:0;top:0'; + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + bbox.clearCache(); + }); + + function measure(html) { + container.innerHTML = html; + bbox.clearCache(); + return percentInView(container.querySelector('#target')); + } + + const TARGET = '
'; + + it('is clipped by an overflow-hidden ancestor', () => { + const unclipped = measure(TARGET); + expect(unclipped).to.be.greaterThan(0); + const clipped = measure(`
${TARGET}
`); + expect(clipped).to.be.greaterThan(0); + expect(clipped).to.be.lessThan(unclipped); + }); + + it('returns 0 for an element held entirely outside an overflow-hidden ancestor', () => { + expect(measure( + `
+
${TARGET}
+
` + )).to.eql(0); + }); + + it('returns 0 for an element scrolled out of a scrolling ancestor', () => { + expect(measure( + `
+
${TARGET} +
` + )).to.eql(0); + }); + + Object.entries({ + 'visibility:hidden': 'visibility:hidden', + 'opacity:0': 'opacity:0', + }).forEach(([label, css]) => { + it(`returns 0 for an element with ${label}`, () => { + expect(measure(`
`)).to.eql(0); + }); + + it(`returns 0 for an element under an ancestor with ${label}`, () => { + expect(measure(`
${TARGET}
`)).to.eql(0); + }); + }); + }); }); diff --git a/test/test_deps.js b/test/test_deps.js index 4abbd63d258..5c8e7127e39 100644 --- a/test/test_deps.js +++ b/test/test_deps.js @@ -47,14 +47,6 @@ sinon.createFakeServerWithClock = fakeServerWithClock.create.bind(fakeServerWith localStorage.clear(); -if (window.frameElement != null) { - // sometimes (e.g. chrome headless) the tests run in an iframe that is offset from the top window - // other times (e.g. browser debug page) they run in the top window - // this can cause inconsistencies with the percentInView libraries; if we are in a frame, - // fake the same dimensions as the top window - window.frameElement.getBoundingClientRect = () => window.top.getBoundingClientRect(); -} - require('test/helpers/global_hooks.js'); require('test/helpers/consentData.js'); require('test/helpers/prebidGlobal.js'); From e8d32cf77fd4986a1f4e067406a827b9e5019c06 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 13:16:47 -0700 Subject: [PATCH 05/10] Percent in view library: make the pre-auction intersection wait bind Auctions wait for intersection entries for their ad unit elements, capped by racing against a timer. The cap does not hold: intersections are delivered as part of the rendering lifecycle and the timer callback queues behind the same pending work, so on a busy page both sides of the race are held up together and the wait runs well past its deadline. Two changes. `urgentDelay` asks for the continuation through scheduler.postTask at user-blocking priority, so it is dispatched ahead of ordinary timers and network callbacks once the main thread frees, falling back to a timer where postTask is missing or rejects the options. This is not a real time bound - nothing preempts a running task - but it bounds the wait by the longest single blocking task rather than by the whole backlog of ready work. Second, start observing when bids are requested rather than when the auction starts. Observation previously began in the same hook that waited on it, so the asynchronous work that runs beforehand - consent, price floors, currency, user ids - gave the observer no main thread time it could use. Observing from a requestBids hook, at a priority ahead of those, means entries are normally cached by the time the auction hook runs and it resolves without waiting. The requestBids wrapper resolves the global ad unit array before any hook runs, and passes the unfiltered set, so this sees every element the auction hook will ask about and possibly a few more, which is harmless. `delay` is left alone; it is used widely and its scheduling should not change. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 32 ++++++++++++++-- src/utils/promise.ts | 26 +++++++++++++ test/spec/libraries/percentInView_spec.js | 43 ++++++++++++++++++++++ test/spec/unit/utils/promise_spec.js | 45 ++++++++++++++++++++++- 4 files changed, 141 insertions(+), 5 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index feeb53b923d..9a3a2f67233 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -1,7 +1,7 @@ import { getWinDimensions, inIframe } from '../../src/utils.js'; import { getBoundingClientRect } from '../boundingClientRect/boundingClientRect.js'; -import { 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'; /** @@ -257,13 +257,37 @@ export function mkIntersectionHook(intersections = viewportIntersections) { // 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. + // this needs high priority scheduling to be an upper bound: intersections are delivered as + // part of the rendering lifecycle, and an ordinary timer queues behind the same ready work, + // so on a busy page both sides of this race are held up together + 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) { + (request?.adUnits ?? []).forEach(adUnit => { + // deliberately not waited on; this only needs to get the observation started + intersections.observe(getAdUnitElement(adUnit)).catch(() => { + // 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 } = {}) { const intersection = viewportIntersections.getIntersection(element); diff --git a/src/utils/promise.ts b/src/utils/promise.ts index a83f5bf23c1..fa00fe2d4fb 100644 --- a/src/utils/promise.ts +++ b/src/utils/promise.ts @@ -23,6 +23,32 @@ export function delay(delayMs = 0): Promise { }); } +/** + * 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 { + 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; + } catch (e) { + // options rejected by this implementation of postTask; fall back to a timer + } + } + return delay(delayMs); +} + export interface Defer { promise: Promise; resolve: Parameters>[0]>[0], diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js index d6ee19c954e..ca3b8894f62 100644 --- a/test/spec/libraries/percentInView_spec.js +++ b/test/spec/libraries/percentInView_spec.js @@ -2,6 +2,7 @@ import { getViewportOffset, intersections, mkIntersectionHook, + mkPrewarmHook, percentInView, viewportIntersections, } from '../../../libraries/percentInView/percentInView.js'; @@ -305,6 +306,48 @@ describe('percentInView', () => { }); }); + describe('prewarm hook', () => { + let intersections, hook, next, request; + beforeEach(() => { + next = sinon.stub(); + intersections = { observe: sinon.stub().resolves() }; + hook = mkPrewarmHook(intersections); + request = {}; + }); + + it('observes the element of every ad unit', () => { + request.adUnits = [{ element: 'el1' }, { code: 'el2' }]; + sandbox.stub(document, 'getElementById').returns('el2'); + hook(next, request); + sinon.assert.calledWith(intersections.observe, 'el1'); + sinon.assert.calledWith(intersections.observe, 'el2'); + }); + + it('does not wait for the observations to resolve', () => { + let observed; + intersections.observe.returns(new Promise((resolve) => { observed = resolve; })); + request.adUnits = [{ element: 'el1' }]; + hook(next, request); + sinon.assert.calledWith(next, request); + observed(); + }); + + it('continues when an element cannot be observed', async () => { + intersections.observe.rejects(new Error()); + request.adUnits = [{ element: 'el1' }]; + hook(next, request); + sinon.assert.calledWith(next, request); + // give the rejection a chance to surface as an unhandled rejection + await delay(); + }); + + it('does not choke on a request with no ad units', () => { + hook(next, request); + sinon.assert.notCalled(intersections.observe); + sinon.assert.calledWith(next, request); + }); + }); + describe('frame rect mock', () => { // only applies when karma runs the tests in an iframe, which excludes the debug page if (window.frameElement == null) return; diff --git a/test/spec/unit/utils/promise_spec.js b/test/spec/unit/utils/promise_spec.js index 2b14394b67e..bf960bd3647 100644 --- a/test/spec/unit/utils/promise_spec.js +++ b/test/spec/unit/utils/promise_spec.js @@ -1,4 +1,4 @@ -import { defer } from '../../../../src/utils/promise.js'; +import { defer, urgentDelay } from '../../../../src/utils/promise.js'; describe('defer', () => { Object.entries({ @@ -21,3 +21,46 @@ describe('defer', () => { }); }); }); + +describe('urgentDelay', () => { + let sandbox, scheduler; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + scheduler = window.scheduler; + }); + + afterEach(() => { + sandbox.restore(); + if (scheduler == null) { + delete window.scheduler; + } else { + window.scheduler = scheduler; + } + }); + + it('schedules through postTask, at user-blocking priority, when it is available', async () => { + const postTask = sinon.stub().resolves(); + window.scheduler = { postTask }; + await urgentDelay(20); + sinon.assert.calledOnce(postTask); + expect(postTask.firstCall.args[1]).to.eql({ priority: 'user-blocking', delay: 20 }); + }); + + it('resolves through a timer when postTask is unavailable', async () => { + delete window.scheduler; + await urgentDelay(1); + }); + + it('falls back to a timer when postTask throws', async () => { + const postTask = sinon.stub().throws(new Error()); + window.scheduler = { postTask }; + await urgentDelay(1); + sinon.assert.called(postTask); + }); + + it('resolves, rather than rejecting, when the scheduled task is aborted', async () => { + window.scheduler = { postTask: sinon.stub().rejects(new Error()) }; + await urgentDelay(1); + }); +}); From a1603b8bcc7837041fc7689510cbdf1075b498d0 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 13:55:11 -0700 Subject: [PATCH 06/10] Percent in view library: only clip against ancestors that contain the element Out of flow elements are laid out against something further up the tree and are not clipped by the ancestors they skip over: an untransformed overflow-hidden ancestor does not clip a fixed position descendant, and a statically positioned one does not clip an absolutely positioned descendant. Clipping every ancestor regardless reported such an element as 0% in view while it was fully on screen - the largest error possible, and one that lands on anchored and sticky ad units, which are typically fixed inside a wrapper that hides its overflow. Track how the subtree being walked is positioned, and only collect a clip box from an ancestor that contains it: for an absolutely positioned element, one that is itself positioned, and for either an absolute or a fixed one, any ancestor establishing a containing block through a transform, perspective, filter, will-change or contain. Once an ancestor is found to carry the element, its own positioning decides which of the remaining ancestors can clip it. Hidden and transparent ancestors are still checked whether they contain the element or not, since an ancestor that is not painted hides a fixed descendant even though it does not clip it. The properties this needs are read from the style already being fetched for the overflow check, and only for elements that are out of flow, so an in flow element costs nothing extra and an absolutely positioned one about 3us per call. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 39 +++++++++++++++++++---- test/spec/libraries/percentInView_spec.js | 28 ++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index 9a3a2f67233..0771268d4c8 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -114,16 +114,31 @@ function percentInViewOfBox(elementBoundingBox, win, clipRects = []) { 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, an ancestor that clips only one axis is treated as clipping both, - * and an out-of-flow element is treated as clipped by ancestors that are not in its - * containing block chain (a fixed position element is clipped by no ancestor at all). + * Clipping is approximated: the boxes include the ancestors' borders rather than stopping at their + * padding edge. */ function getClipRects(element) { const rects = []; @@ -133,14 +148,24 @@ function getClipRects(element) { 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 && 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 }); + 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; } diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js index ca3b8894f62..6d7cb619a09 100644 --- a/test/spec/libraries/percentInView_spec.js +++ b/test/spec/libraries/percentInView_spec.js @@ -422,6 +422,34 @@ describe('percentInView', () => { )).to.eql(0); }); + // identical markup except for what makes the clipper contain the target, so the only thing + // the comparison can be measuring is whether the clip was applied + const ABS_TARGET = '
'; + const escaping = () => measure(`
${ABS_TARGET}
`); + + it('is not clipped by an ancestor that does not contain it', () => { + const contained = measure( + `
${ABS_TARGET}
` + ); + expect(contained).to.be.lessThan(escaping()); + }); + + it('is clipped by an ancestor that establishes a containing block', () => { + const contained = measure( + `
${ABS_TARGET}
` + ); + expect(contained).to.be.lessThan(escaping()); + }); + + it('is not clipped by an ancestor when it is fixed to the viewport', () => { + // the target sits well clear of the clipper, so any clipping at all would report 0 + expect(measure( + `
+
+
` + )).to.be.greaterThan(0); + }); + Object.entries({ 'visibility:hidden': 'visibility:hidden', 'opacity:0': 'opacity:0', From bb68e6a23670a412438451dc398760e604075a19 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 13:55:30 -0700 Subject: [PATCH 07/10] Percent in view library: document why the intersection wait is capped at 20ms The cap read as a safety net that was not expected to fire, which is what let it go unnoticed that it did not bound anything. Record what the value is chosen against: 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, and a much smaller cap would be counterproductive because a user-blocking task outruns the observer. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index 0771268d4c8..9ddaba708a0 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -283,9 +283,16 @@ export function mkIntersectionHook(intersections = viewportIntersections) { // 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. - // this needs high priority scheduling to be an upper bound: intersections are delivered as - // part of the rendering lifecycle, and an ordinary timer queues behind the same ready work, - // so on a busy page both sides of this race are held up together + // + // 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)); }; From 6ee4800d434af70872e357b01a6a4bdcccf86e28 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 14:21:48 -0700 Subject: [PATCH 08/10] Percent in view library: do not use the observer when yielding is disabled Reading viewability from an intersection observer means waiting for the observer to report, and so yielding the main thread. Setting `pbjs.yield = false` asks Prebid not to do that, so honour it here too: skip the wait before the auction, skip observing ad unit elements ahead of it, and measure from the element's bounding rect instead. Skipping the two hooks is not enough on its own to keep to the bounding rect path, because percentInView starts an observation itself whenever it has no entry for an element - so later calls would find one and go back to reading the observer. It now goes straight to the bounding rect, without observing. The check is made where the measurement is taken rather than when the hooks are attached, because attachment happens as this module loads, while `pbjs.yield` is read lazily wherever else it is used and so may still be set from the command queue. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 30 +++++++++++++--- test/spec/libraries/percentInView_spec.js | 44 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index 9ddaba708a0..a3bcd685988 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -3,6 +3,16 @@ import { getBoundingClientRect } from '../boundingClientRect/boundingClientRect. 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'; + +/** + * Whether viewability may be measured with an intersection observer. Doing so means waiting for the + * observer to deliver, and so yielding the main thread; where that is not allowed, measurements are + * taken from the element's bounding rect instead. + */ +function observerAllowed() { + return getGlobal().yield ?? true; +} /** * return the offset between the given window's viewport and the top window's. @@ -275,6 +285,11 @@ 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)) @@ -306,12 +321,14 @@ export function mkIntersectionHook(intersections = viewportIntersections) { */ export function mkPrewarmHook(intersections = viewportIntersections) { return function (next, request) { - (request?.adUnits ?? []).forEach(adUnit => { - // deliberately not waited on; this only needs to get the observation started - intersections.observe(getAdUnitElement(adUnit)).catch(() => { - // the element cannot be observed, so percentInView measures the DOM directly instead + if (observerAllowed()) { + (request?.adUnits ?? []).forEach(adUnit => { + // deliberately not waited on; this only needs to get the observation started + intersections.observe(getAdUnitElement(adUnit)).catch(() => { + // the element cannot be observed, so percentInView measures the DOM directly instead + }); }); - }); + } next.call(this, request); }; } @@ -322,6 +339,9 @@ startAuction.before(mkIntersectionHook()); 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); diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js index 6d7cb619a09..25ac3eeec86 100644 --- a/test/spec/libraries/percentInView_spec.js +++ b/test/spec/libraries/percentInView_spec.js @@ -10,6 +10,7 @@ import * as bbox from 'libraries/boundingClientRect/boundingClientRect'; import { enable, disable, enableFrameRect, disableFrameRect } from 'test/mocks/percentInView.js'; import { defer } from 'src/utils/promise.js'; +import { getGlobal } from 'src/prebidGlobal.js'; describe('percentInView', () => { before(() => { @@ -306,6 +307,49 @@ describe('percentInView', () => { }); }); + describe('when yielding is disabled', () => { + beforeEach(() => { + getGlobal().yield = false; + }); + afterEach(() => { + delete getGlobal().yield; + }); + + it('the intersection hook does not wait', () => { + const next = sinon.stub(); + const request = { adUnits: [{ element: 'el1' }] }; + const intersections = { observe: sinon.stub().returns(new Promise(() => {})) }; + mkIntersectionHook(intersections)(next, request); + // synchronously, with no observation started + sinon.assert.calledWith(next, request); + sinon.assert.notCalled(intersections.observe); + }); + + it('the prewarm hook does not observe', () => { + const next = sinon.stub(); + const request = { adUnits: [{ element: 'el1' }] }; + const intersections = { observe: sinon.stub().resolves() }; + mkPrewarmHook(intersections)(next, request); + sinon.assert.notCalled(intersections.observe); + sinon.assert.calledWith(next, request); + }); + + it('percentInView measures the DOM instead of consulting the observer', () => { + const getIntersection = sandbox.stub(viewportIntersections, 'getIntersection'); + const observe = sandbox.stub(viewportIntersections, 'observe'); + const el = document.createElement('div'); + el.style.cssText = 'position:absolute;left:0;top:0;width:50px;height:50px'; + document.body.appendChild(el); + try { + expect(percentInView(el)).to.be.a('number'); + sinon.assert.notCalled(getIntersection); + sinon.assert.notCalled(observe); + } finally { + el.remove(); + } + }); + }); + describe('prewarm hook', () => { let intersections, hook, next, request; beforeEach(() => { From 67f19c793ba8844e0d3cb5a70efe812c3f9cb535 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 14:23:42 -0700 Subject: [PATCH 09/10] Core: add auctionOptions.viewabilityMeasurement Whether viewability comes from an intersection observer or from the ad element's bounding rect is a trade between processing work and how much the auction can be held up by the rest of the page: reading an observer entry costs no layout at all, but the auction cannot start until the observer has reported, which on a page busy with long tasks means waiting out the longest of them. The bounding rect is immediate but forces a layout for every measurement, and inside a cross origin iframe can only measure against the frame's own viewport. That choice was only reachable through `pbjs.yield`, which turns off main thread yielding everywhere and is too broad an instrument for it. Give it a setting of its own, defaulting to the observer, or to the bounding rect where yielding is turned off. The auctionOptions validator only knew how to check booleans and arrays, so it would have rejected the whole object; it now also takes keys limited to a set of values, and warns which ones are accepted. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 11 ++++-- src/auction.ts | 27 +++++++++++++++ src/config.ts | 8 ++++- test/spec/config_spec.js | 19 ++++++++++ test/spec/libraries/percentInView_spec.js | 42 +++++++++++++++++++++++ 5 files changed, 103 insertions(+), 4 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index a3bcd685988..9a5762d9426 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -4,14 +4,19 @@ 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; where that is not allowed, measurements are - * taken from the element's bounding rect instead. + * 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() { - return getGlobal().yield ?? true; + const measurement = config.getConfig('auctionOptions')?.viewabilityMeasurement; + return measurement == null ? (getGlobal().yield ?? true) : measurement === 'observer'; } /** diff --git a/src/auction.ts b/src/auction.ts index 9eb60ba23aa..075c8464f86 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -150,6 +150,33 @@ export interface AuctionOptionsConfig { */ 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. diff --git a/src/config.ts b/src/config.ts index c866d05df9c..6fe22bcf09e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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)) { @@ -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; diff --git a/test/spec/config_spec.js b/test/spec/config_spec.js index 370bdec72fd..e1b106bb8b5 100644 --- a/test/spec/config_spec.js +++ b/test/spec/config_spec.js @@ -353,6 +353,25 @@ describe('config API', function () { expect(getConfig('auctionOptions')).to.eql(auctionOptionsConfig); }); + it('sets auctionOptions viewabilityMeasurement', function () { + const auctionOptionsConfig = { + 'viewabilityMeasurement': 'boundingBox' + }; + setConfig({ auctionOptions: auctionOptionsConfig }); + expect(getConfig('auctionOptions')).to.eql(auctionOptionsConfig); + }); + + it('should log warning for invalid auctionOptions viewabilityMeasurement', function () { + setConfig({ + auctionOptions: { + 'viewabilityMeasurement': 'nope', + } + }); + expect(logWarnSpy.calledOnce).to.equal(true); + const warning = 'Auction Options viewabilityMeasurement must be one of: observer, boundingBox'; + assert.ok(logWarnSpy.calledWith(warning), 'expected warning was logged'); + }); + it('should log warning for the wrong value passed to auctionOptions', function () { setConfig({ auctionOptions: '' }); expect(logWarnSpy.calledOnce).to.equal(true); diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js index 25ac3eeec86..a09b0adb54d 100644 --- a/test/spec/libraries/percentInView_spec.js +++ b/test/spec/libraries/percentInView_spec.js @@ -11,6 +11,7 @@ import { enable, disable, enableFrameRect, disableFrameRect } from 'test/mocks/p import { defer } from 'src/utils/promise.js'; import { getGlobal } from 'src/prebidGlobal.js'; +import { config } from 'src/config.js'; describe('percentInView', () => { before(() => { @@ -307,6 +308,47 @@ describe('percentInView', () => { }); }); + describe('viewabilityMeasurement', () => { + afterEach(() => { + config.resetConfig(); + delete getGlobal().yield; + }); + + function usesObserver() { + const getIntersection = sandbox.stub(viewportIntersections, 'getIntersection'); + sandbox.stub(viewportIntersections, 'observe'); + sandbox.stub(bbox, 'getBoundingClientRect').returns({ width: 1, height: 1, left: 0, top: 0, right: 1, bottom: 1 }); + percentInView(document.createElement('div')); + return getIntersection.called; + } + + it('uses the observer when neither it nor pbjs.yield is set', () => { + expect(usesObserver()).to.be.true; + }); + + it('is taken from pbjs.yield when unset', () => { + getGlobal().yield = false; + expect(usesObserver()).to.be.false; + }); + + it('uses the observer when pbjs.yield is explicitly true', () => { + getGlobal().yield = true; + expect(usesObserver()).to.be.true; + }); + + it('overrides pbjs.yield when set to observer', () => { + getGlobal().yield = false; + config.setConfig({ auctionOptions: { viewabilityMeasurement: 'observer' } }); + expect(usesObserver()).to.be.true; + }); + + it('overrides pbjs.yield when set to boundingBox', () => { + getGlobal().yield = true; + config.setConfig({ auctionOptions: { viewabilityMeasurement: 'boundingBox' } }); + expect(usesObserver()).to.be.false; + }); + }); + describe('when yielding is disabled', () => { beforeEach(() => { getGlobal().yield = false; From 02e8cf9284d6cde278226057d7dfb8d527e4d0de Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 30 Jul 2026 16:07:49 -0700 Subject: [PATCH 10/10] Percent in view library: wait for elements that are observed but not yet reported Observing an element records it with a null entry until the observer first reports on it. Observing it a second time in that window found it already recorded and resolved straight away with that null, rather than waiting - so once ad unit elements were observed as bids are requested, the hook that waits for them before the auction had nothing left to wait on. Where nothing yields the main thread in between, no entry has arrived by then, and every measurement falls back to the bounding rect even though the observer was asked for. Distinguish an element that has never been observed from one that is observed and pending, and wait in the second case. An element that already has an entry still resolves immediately. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/percentInView/percentInView.js | 12 ++++++--- test/spec/libraries/percentInView_spec.js | 32 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index 9a5762d9426..fc12d12f5ba 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -261,13 +261,17 @@ export function intersections(mkObserver) { */ 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); } /** diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js index a09b0adb54d..b3f35e1d3f4 100644 --- a/test/spec/libraries/percentInView_spec.js +++ b/test/spec/libraries/percentInView_spec.js @@ -146,6 +146,14 @@ describe('percentInView', () => { expect(result).to.eql(entry); }); }); + it('observe should wait when the element is already observed but has no entry yet', async () => { + obs.observe(el); + const pm = obs.observe(el); + const entry = { target: el, time: 1 }; + callback([entry]); + expect(await pm).to.eql(entry); + }); + it('should ignore stale entries', async () => { const entry = { target: el, @@ -434,6 +442,30 @@ describe('percentInView', () => { }); }); + describe('prewarm followed by the auction hook', () => { + it('still waits for the observer when prewarm has already observed the element', async () => { + let callback; + const nakedObs = { observe: sinon.stub() }; + const obs = intersections((cb) => { callback = cb; return nakedObs; }); + const el = document.createElement('div'); + const request = { adUnits: [{ element: el }] }; + + // requestBids: prewarm starts the observation + mkPrewarmHook(obs)(sinon.stub(), request); + // startAuction runs before anything has yielded, so no entry has arrived yet + const next = sinon.stub(); + mkIntersectionHook(obs)(next, request); + + await delay(0); + expect(obs.getIntersection(el)).to.eql(null); + sinon.assert.notCalled(next); + + callback([{ target: el, time: 1, isIntersecting: true, intersectionRatio: 1 }]); + await delay(0); + sinon.assert.called(next); + }); + }); + describe('frame rect mock', () => { // only applies when karma runs the tests in an iframe, which excludes the debug page if (window.frameElement == null) return;