Core: allow viewability to be measured without yielding the main thread - #15421
Core: allow viewability to be measured without yielding the main thread#15421dgirardi wants to merge 11 commits into
Conversation
… 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
…abled 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
This PR introduces changes that may not work on all browsers. According to Babel, the following polyfills may be needed, and they are not automatically included:
The best way to address this is to provide good test coverage, as normal PR checks run unit tests on older browsers. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 67f19c793b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Barecheck - Code coverage reportTotal: 96.66%Your code coverage diff: 0.00% ▴ Uncovered files and lines
|
…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) <noreply@anthropic.com>
Type of change
Bugfix
Feature
New bidder adapter
Updated bidder adapter
Code style update (formatting, local variables)
Refactoring (no functional changes, no api changes)
Build related changes
CI related changes
Does this change affect user-facing APIs or examples documented on http://prebid.org?
Other
Description of change
Prebid 11 (#14433) added a
startAuctionhook that waits forIntersectionObserverentries forevery ad unit element before the auction may begin, so that
percentInView()can reportobserver-derived viewability. That wait is a pre-auction delay that did not exist in Prebid 10, and it
is a plausible contributor to the slowdown reported in #15403.
The headline of this PR is the last commit:
auctionOptions.viewabilityMeasurement, which lets apublisher choose not to wait at all. Everything before it exists to make that a real choice rather
than a trade of latency for wrong numbers — the bounding-rect path it falls back to was substantially
less accurate than the observer, so opting out used to mean accepting bad viewability.
The new setting
'observer'(default) — reading the measurement costs no layout at all and correctly accounts foreverything that clips the ad, including the bounds of a cross-origin iframe. But the auction cannot
start until the observer reports, which requires yielding the main thread; on a page busy with long
tasks, that yield is only taken once the longest of them finishes.
'boundingBox'— nothing is waited for, so the auction never queues behind the rest of the page.In exchange each measurement forces a layout, and inside a cross-origin iframe it can only measure
against the frame's own viewport.
It also follows
pbjs.yield: with no explicit setting,pbjs.yield = falseselects'boundingBox',since that flag already asks Prebid not to yield the main thread. An explicit
viewabilityMeasurementwins overpbjs.yieldin both directions.Making the wait bind, and usually unnecessary
The wait was capped by racing against
delay(20), which does not bound it: intersection delivery andan ordinary timer both queue behind the same ready work, so on a busy page both sides of the race are
held up together.
urgentDelay(new, insrc/utils/promise.ts) schedules throughscheduler.postTaskatuser-blockingpriority, falling back to a timer where that is unavailable or rejects the options.Measured against a single 100 ms blocking task, the worst-case wait drops from ~201 ms to ~101 ms —
bounded by the longest blocking task rather than the whole backlog.
delay()is unchanged.requestBidshook rather than from thestartAuctionhook that waits on them. Observation previously began in the same hook that awaitedit, so the asynchronous work running beforehand (consent, price floors, currency) gave the observer
no main-thread time it could use. First entries arrive 0.1–0.3 ms after
observe(), so wherethose modules are present the auction hook now resolves without waiting.
Making
'boundingBox'accurate enough to chooseThe bounding-rect path only intersected the element's own box with the viewport. Measured against the
observer as ground truth:
overflow:hiddenancestoroverflow:hiddenancestorvisibility:hidden/opacity:0(element or ancestor)position:fixedorabsoluteescaping a clipperCosts 222 bytes gzipped, and 0.06–0.71 ms per auction for 5–30 ad units at DOM depth 10–30
(~0.85 µs per ancestor for the style read, 3.2 µs per clipping ancestor for its rect). Clipping is
approximated in one respect, documented on
getClipRects: clip boxes include ancestor borders ratherthan stopping at the padding edge.
Out-of-flow elements are excluded from clipping by ancestors that do not contain them — an
untransformed
overflow:hiddenancestor does not clip aposition:fixeddescendant. Without that,anchored and sticky ad units reported 0% while fully on screen.
Other fixes in the observer path
as 1 when it is intersecting, so a collapsed slot read as fully viewable; callers that pass no size
override (taboola, for one) rounded that onto the bid request. Now 0, matching the bounding-rect
path.
registered with 101 thresholds, so this ran on every threshold crossing of every observed element
for as long as the page lived, whether or not anything was waiting. Over a 6000 px scroll with 30
observed elements, promise allocations drop from 1294 to 0.
Other information
Addresses #15403.
Labels:
feature,minorDocs PR: required for
auctionOptions.viewabilityMeasurement— to be linked here.Lint:
npx eslintclean on all changed files.Tests:
npx gulp test— 8/8 chunks, 23,911 tests, 0 failures. Added coverage for the new settingand its resolution against
pbjs.yield, the clipping and containing-block behaviour,urgentDelay'sscheduling and fallbacks, and the pre-warm hook.
Scope: this is deliberately one PR because the earlier commits are what make the new setting
usable, but it does span a bugfix set and a feature. Happy to split it into (1) the accuracy and
allocation fixes to
percentInView, (2)urgentDelayplus pre-warming, and (3) the config option, ifthat reviews better.
Known limitations, unchanged by this PR: in a cross-origin iframe the bounding-rect path cannot
determine the frame's position in the page, so an off-screen ad can still read as fully in view — no
JS in a cross-origin child can obtain that geometry. Neither path detects an ad occluded by other page
content; that needs
IntersectionObserverv2trackVisibility, which mandatesdelay >= 100and isChromium-only.