diff --git a/src/inabox/inabox-resources.js b/src/inabox/inabox-resources.js index 683ec5d1a410..5b2d0ab71911 100644 --- a/src/inabox/inabox-resources.js +++ b/src/inabox/inabox-resources.js @@ -157,6 +157,11 @@ export class InaboxResources { return this.firstPassDone_.promise; } + /** @override */ + isIntersectionExperimentOn() { + return false; + } + /** * @private */ diff --git a/src/ini-load.js b/src/ini-load.js index e6d9ba3697ec..8826a1f10ddf 100644 --- a/src/ini-load.js +++ b/src/ini-load.js @@ -76,8 +76,6 @@ export function getMeasuredResources(ampdoc, hostWin, filterFn) { // First, wait for the `ready-scan` signal. Waiting for each element // individually is too expensive and `ready-scan` will cover most of // the initially parsed elements. - // TODO(jridgewell): this path should be switched to use a future - // "layer has been measured" signal. return ampdoc .signals() .whenSignal(READY_SCAN_SIGNAL) diff --git a/src/layout-rect.js b/src/layout-rect.js index 0ea0f4f68719..522fd7f929f1 100644 --- a/src/layout-rect.js +++ b/src/layout-rect.js @@ -109,11 +109,11 @@ export function layoutRectFromDomRect(rect) { /** * Returns true if the specified two rects overlap by a single pixel. - * @param {!LayoutRectDef} r1 - * @param {!LayoutRectDef} r2 + * @param {!LayoutRectDef|!ClientRect} r1 + * @param {!LayoutRectDef|!ClientRect} r2 * @return {boolean} */ -export function layoutRectsOverlap(r1, r2) { +export function rectsOverlap(r1, r2) { return ( r1.top <= r2.bottom && r2.top <= r1.bottom && @@ -188,7 +188,7 @@ export function layoutPositionRelativeToScrolledViewport( right: viewport.getWidth(), }) ); - if (layoutRectsOverlap(layoutBox, scrollLayoutBox)) { + if (rectsOverlap(layoutBox, scrollLayoutBox)) { return RelativePositions.INSIDE; } else { return layoutRectsRelativePos(layoutBox, scrollLayoutBox); diff --git a/src/service/mutator-impl.js b/src/service/mutator-impl.js index 683abac0acfc..3e9374a213f2 100644 --- a/src/service/mutator-impl.js +++ b/src/service/mutator-impl.js @@ -21,7 +21,7 @@ import {Services} from '../services'; import {areMarginsChanged} from '../layout-rect'; import {closest} from '../dom'; import {computedStyle} from '../style'; -import {dev} from '../log'; +import {dev, devAssert} from '../log'; import {isExperimentOn} from '../experiments'; import {registerServiceBuilderForDoc} from '../service'; @@ -58,6 +58,9 @@ export class MutatorImpl { this.activeHistory_.onFocus(element => { this.checkPendingChangeSize_(element); }); + + /** @private @const {boolean} */ + this.intersect_ = this.resources_.isIntersectionExperimentOn(); } /** @override */ @@ -132,17 +135,27 @@ export class MutatorImpl { /** @override */ collapseElement(element) { - const box = this.viewport_.getLayoutRect(element); - const resource = Resource.forElement(element); - if (box.width != 0 && box.height != 0) { - if (isExperimentOn(this.win, 'dirty-collapse-element')) { - this.dirtyElement(element); - } else { - this.resources_.setRelayoutTop(box.top); + // With IntersectionObserver, no need to relayout or remeasure + // due to a single element collapse (similar to "relayout top"). + if (!this.intersect_) { + const box = this.viewport_.getLayoutRect(element); + if (box.width != 0 && box.height != 0) { + if (isExperimentOn(this.win, 'dirty-collapse-element')) { + this.dirtyElement(element); + } else { + this.resources_.setRelayoutTop(box.top); + } } } + + const resource = Resource.forElement(element); resource.completeCollapse(); - this.resources_.schedulePass(FOUR_FRAME_DELAY_); + + // Unlike completeExpand(), there's no requestMeasure() call here that + // requires another pass (with IntersectionObserver). + if (!this.intersect_) { + this.resources_.schedulePass(FOUR_FRAME_DELAY_); + } } /** @override */ @@ -199,11 +212,28 @@ export class MutatorImpl { if (measurer) { measurer(); } - relayoutTop = calcRelayoutTop(); + // With IntersectionObserver, "relayout top" is no longer needed since + // relative positional changes won't affect correctness. + if (!this.intersect_) { + relayoutTop = calcRelayoutTop(); + } }, mutate: () => { mutator(); + // TODO(willchou): IntersectionObserver won't catch size changes, + // which means layout boxes may be stale. However, always requesting + // measure after any mutation is overkill and probably expensive. + // Instead, survey measureMutateElement() callers to determine which + // should explicitly call requestMeasure() to fix this. + + // With IntersectionObserver, no need to remeasure and set relayout + // on element size changes since enter/exit viewport will be detected. + if (this.intersect_) { + this.resources_.maybeHeightChanged(); + return; + } + if (element.classList.contains('i-amphtml-element')) { const r = Resource.forElement(element); r.requestMeasure(); @@ -245,6 +275,7 @@ export class MutatorImpl { * @param {!Element} element */ dirtyElement(element) { + devAssert(!this.intersect_); let relayoutAll = false; const isAmpElement = element.classList.contains('i-amphtml-element'); if (isAmpElement) { @@ -402,7 +433,10 @@ export class MutatorImpl { callback: opt_callback, } ); - this.resources_.schedulePassVsync(); + // With IntersectionObserver, remeasuring after size changes are no longer needed. + if (!this.intersect_) { + this.resources_.schedulePassVsync(); + } } } diff --git a/src/service/position-observer/position-observer-worker.js b/src/service/position-observer/position-observer-worker.js index 052fbe54e71a..d14773ae321f 100644 --- a/src/service/position-observer/position-observer-worker.js +++ b/src/service/position-observer/position-observer-worker.js @@ -19,8 +19,8 @@ import {devAssert} from '../../log'; import { layoutRectEquals, layoutRectLtwh, - layoutRectsOverlap, layoutRectsRelativePos, + rectsOverlap, } from '../../layout-rect'; /** @enum {number} */ @@ -103,7 +103,7 @@ export class PositionObserverWorker { position.viewportRect ); - if (layoutRectsOverlap(positionRect, position.viewportRect)) { + if (rectsOverlap(positionRect, position.viewportRect)) { // Update position this.prevPosition_ = position; // Only call handler if entry element overlap with viewport. diff --git a/src/service/resource.js b/src/service/resource.js index 0900c9dbc99a..2481d61a16cc 100644 --- a/src/service/resource.js +++ b/src/service/resource.js @@ -23,8 +23,8 @@ import {isBlockedByConsent} from '../error'; import { layoutRectLtwh, layoutRectSizeEquals, - layoutRectsOverlap, moveLayoutRect, + rectsOverlap, } from '../layout-rect'; import {startsWith} from '../string'; import {toWin} from '../types'; @@ -416,8 +416,10 @@ export class Resource { /** * Measures the resource's boundaries. An upgraded element will be * transitioned to the "ready for layout" state. + * @param {!ClientRect=} opt_premeasuredRect If provided, use this + * premeasured ClientRect instead of calling getBoundingClientRect. */ - measure() { + measure(opt_premeasuredRect) { // Check if the element is ready to be measured. // Placeholders are special. They are technically "owned" by parent AMP // elements, sized by parents, but laid out independently. This means @@ -448,14 +450,14 @@ export class Resource { this.isMeasureRequested_ = false; const oldBox = this.layoutBox_; - this.measureViaResources_(); - const box = this.layoutBox_; + this.computeMeasurements_(opt_premeasuredRect); + const newBox = this.layoutBox_; // Note that "left" doesn't affect readiness for the layout. - const sizeChanges = !layoutRectSizeEquals(oldBox, box); + const sizeChanges = !layoutRectSizeEquals(oldBox, newBox); if ( this.state_ == ResourceState.NOT_LAID_OUT || - oldBox.top != box.top || + oldBox.top != newBox.top || sizeChanges ) { if ( @@ -469,17 +471,20 @@ export class Resource { } if (!this.hasBeenMeasured()) { - this.initialLayoutBox_ = box; + this.initialLayoutBox_ = newBox; } - this.element.updateLayoutBox(box, sizeChanges); + this.element.updateLayoutBox(newBox, sizeChanges); } - /** Use resources for measurement */ - measureViaResources_() { + /** + * Computes the current layout box and position-fixed state of the element. + * @param {!ClientRect=} opt_premeasuredRect + * @private + */ + computeMeasurements_(opt_premeasuredRect) { const viewport = Services.viewportForDoc(this.element); - const box = viewport.getLayoutRect(this.element); - this.layoutBox_ = box; + this.layoutBox_ = viewport.getLayoutRect(this.element, opt_premeasuredRect); // Calculate whether the element is currently is or in `position:fixed`. let isFixed = false; @@ -507,7 +512,7 @@ export class Resource { // viewport. When accessing the layoutBox through #getLayoutBox, we'll // return the new absolute position. this.layoutBox_ = moveLayoutRect( - box, + this.layoutBox_, -viewport.getScrollLeft(), -viewport.getScrollTop() ); @@ -621,12 +626,13 @@ export class Resource { /** * Whether the resource is displayed, i.e. if it has non-zero width and * height. + * @param {!ClientRect=} opt_premeasuredRect If provided, use this + * premeasured ClientRect instead of using the cached layout box. * @return {boolean} */ - isDisplayed() { + isDisplayed(opt_premeasuredRect) { const isFluid = this.element.getLayout() == Layout.FLUID; - // TODO(jridgewell): #getSize - const box = this.getLayoutBox(); + const box = opt_premeasuredRect || this.getLayoutBox(); const hasNonZeroSize = box.height > 0 && box.width > 0; return ( (isFluid || hasNonZeroSize) && @@ -649,7 +655,7 @@ export class Resource { * @return {boolean} */ overlaps(rect) { - return layoutRectsOverlap(this.getLayoutBox(), rect); + return rectsOverlap(this.getLayoutBox(), rect); } /** diff --git a/src/service/resources-impl.js b/src/service/resources-impl.js index fd918813f7c5..75fd1ec80ca8 100644 --- a/src/service/resources-impl.js +++ b/src/service/resources-impl.js @@ -25,9 +25,9 @@ import {TaskQueue} from './task-queue'; import {VisibilityState} from '../visibility-state'; import {dev, devAssert} from '../log'; import {dict} from '../utils/object'; -import {expandLayoutRect} from '../layout-rect'; +import {expandLayoutRect, rectsOverlap} from '../layout-rect'; import {getSourceUrl} from '../url'; -import {hasNextNodeInDocumentOrder} from '../dom'; +import {hasNextNodeInDocumentOrder, isIframed} from '../dom'; import {checkAndFix as ieMediaCheckAndFix} from './ie-media-bug'; import {isBlockedByConsent, reportError} from '../error'; import {isExperimentOn} from '../experiments'; @@ -134,6 +134,9 @@ export class ResourcesImpl { /** @const @private {!Pass} */ this.remeasurePass_ = new Pass(this.win, () => { + // With IntersectionObserver, "remeasuring" hack no longer needed. + devAssert(!this.intersectionObserver_); + this.relayoutAll_ = true; this.schedulePass(); }); @@ -190,7 +193,41 @@ export class ResourcesImpl { this.ampdoc.getVisibilityState() ); - // When viewport is resized, we have to re-measure all elements. + /** @private {?IntersectionObserver} */ + this.intersectionObserver_ = null; + + if (isExperimentOn(this.win, 'intersect-resources')) { + const iframed = isIframed(this.win); + + // Classic IntersectionObserver doesn't support viewport tracking and + // rootMargin in x-origin iframes (#25428). As of 1/2020, only Chrome 81+ + // supports it via {root: document}, which throws on other browsers. + const root = + /** @type {?Element} */ (this.ampdoc.isSingleDoc() && iframed + ? /** @type {*} */ (this.win.document) + : null); + try { + this.intersectionObserver_ = new IntersectionObserver( + this.intersects_.bind(this), + { + root, + // TODO(willchou): Is 3x viewport loading rectangle too large given that + // IntersectionObserver is more responsive than scroll-bound measure? + // TODO(willchou): Support prerenderSize_ loading rectangle. + rootMargin: '200% 25%', + } + ); + + // Wait for intersection callback instead of measuring all elements + // during the first pass. + this.relayoutAll_ = false; + } catch (e) { + dev().warn(TAG_, 'Falling back to classic Resources:', e); + } + } + + // When user scrolling stops, run pass to check newly in-viewport elements. + // When viewport is resized, we have to re-measure everything. this.viewport_.onChanged(event => { this.lastScrollTime_ = Date.now(); this.lastVelocity_ = event.velocity; @@ -198,7 +235,10 @@ export class ResourcesImpl { this.relayoutAll_ = true; this.maybeChangeHeight_ = true; } - this.schedulePass(); + // With IntersectionObserver, we only need to handle viewport resize. + if (this.relayoutAll_ || !this.intersectionObserver_) { + this.schedulePass(); + } }); this.viewport_.onScroll(() => { this.lastScrollTime_ = Date.now(); @@ -239,6 +279,116 @@ export class ResourcesImpl { } } + /** @override */ + isIntersectionExperimentOn() { + return !!this.intersectionObserver_; + } + + /** + * @param {!Array} entries + * @param {!IntersectionObserver} unusedObserver + * @private + */ + intersects_(entries, unusedObserver) { + dev().fine(TAG_, 'intersect', entries); + + const toUnload = []; + + const promises = entries.map(entry => { + const {boundingClientRect, isIntersecting, target, rootBounds} = entry; + + // Strangely, JSC is missing x/y from typedefs of boundingClientRect and + // rootBounds despite them being DOMRectReadOnly (ClientRect) by spec. + const clientRect = /** @type {!ClientRect} */ (boundingClientRect); + const bounds = /** @type {!ClientRect} */ (rootBounds); + + devAssert(target.isUpgraded()); + const r = Resource.forElement(target); + + // discoverWork_(): + // [x] Phase 1: Build and relayout as needed. All mutations happen here. + // [x] 1A: Apply sizes/media-queries to un-measured/un-laid-out resources. + // [~] Phase 2: Remeasure if there were any relayouts. All reads happen here. + // [x] 2A: Unload non-displayed resources. + // [x] Phase 3: Trigger "viewport enter/exit" events. + // [x] Phase 4: Schedule elements for layout within a reasonable distance from current viewport. + // [x] 4A: Force build for all resources visible, measured, and in the viewport. + // [ ] Phase 5: Idle Render Outside Viewport layout: layout up to 4 items with idleRenderOutsideViewport true. + // [ ] Phase 6: Idle layout: layout more if we are otherwise not doing much. + + // Force all intersecting, non-zero-sized, non-owned elements to be built. + // E.g. ensures that all in-viewport elements are built in prerender mode. + if ( + !r.isBuilt() && + !r.isBuilding() && + isIntersecting && + r.isDisplayed(clientRect) && + !r.hasOwner() + ) { + // TODO(willchou): Can this cause scroll jank since we no longer wait + // for scrolling to stop? + this.buildOrScheduleBuildForResource_( + r, + /* checkForDupes */ true, + /* scheduleWhenBuilt */ false, + /* force */ true + ); + dev().fine(TAG_, 'force build:', r.debugid); + } + + // TODO(willchou): Risk of long task due to long microtask queue? + return r.whenBuilt().then(() => { + const wasIntersecting = r.isInViewport(); + let isDisplayed = this.measureResource_(r, clientRect); + + if (wasIntersecting && !isIntersecting) { + // Sometimes `isDisplayed` is incorrectly `true` when the element is + // actually hidden! This happens due to stale clientRect values during + // animations e.g. while an amp-accordion[animate] is collapsing. + // Override with the correct `isIntersecting` value in these cases. + if (isDisplayed && rectsOverlap(clientRect, bounds)) { + // TODO(willchou): Sometimes causes an unnecessary unload when + // expanding an accordion with [animate] due to extra intersection + // callbacks during the animation. + isDisplayed = false; + } + } + + if (!isDisplayed) { + toUnload.push(r); + return; + } + + if (r.hasOwner()) { + return; + } + + // For just-unloaded resources, setInViewport() will be called + // as part of Resource.unlayout(). + // TODO(willchou): Decouple toggleLoading(true) from viewportCallback() + // and make it lazier (only trigger on 1vp). + r.setInViewport(isIntersecting); + + // TODO(willchou): The lack of "update on scroll throttling" means + // that scrolled-over elements are no longer deferred, which results + // in longer delays for in-viewport elements after fast scrolling. + // Fix by queueing intersection entries when scroll velocity is high. + if ( + isIntersecting && + r.isDisplayed() && + r.getState() === ResourceState.READY_FOR_LAYOUT + ) { + this.scheduleLayoutOrPreload(r, /* layout */ true); + } + }); + }); + + Promise.all(promises).then(() => { + this.unloadResources_(toUnload); + this.signalIfReady_(); + }); + } + /** @private */ rebuildDomWhenReady_() { // Ensure that we attempt to rebuild things when DOM is ready. @@ -246,36 +396,41 @@ export class ResourcesImpl { this.documentReady_ = true; this.buildReadyResources_(); this.pendingBuildResources_ = null; - const fixPromise = ieMediaCheckAndFix(this.win); - const remeasure = () => this.remeasurePass_.schedule(); - if (fixPromise) { - fixPromise.then(remeasure); - } else { - // No promise means that there's no problem. - remeasure(); - } + const input = Services.inputFor(this.win); input.setupInputModeClasses(this.ampdoc); - // Safari 10 and under incorrectly estimates font spacing for - // `@font-face` fonts. This leads to wild measurement errors. The best - // course of action is to remeasure everything on window.onload or font - // timeout (3s), whichever is earlier. This has to be done on the global - // window because this is where the fonts are always added. - // Unfortunately, `document.fonts.ready` cannot be used here due to - // https://bugs.webkit.org/show_bug.cgi?id=174030. - // See https://bugs.webkit.org/show_bug.cgi?id=174031 for more details. - Promise.race([ - loadPromise(this.win), - Services.timerFor(this.win).promise(3100), - ]).then(remeasure); - - // Remeasure the document when all fonts loaded. - if ( - this.win.document.fonts && - this.win.document.fonts.status != 'loaded' - ) { - this.win.document.fonts.ready.then(remeasure); + // With IntersectionObserver, no need for remeasuring hacks. + if (!this.intersectionObserver_) { + const fixPromise = ieMediaCheckAndFix(this.win); + const remeasure = () => this.remeasurePass_.schedule(); + if (fixPromise) { + fixPromise.then(remeasure); + } else { + // No promise means that there's no problem. + remeasure(); + } + + // Safari 10 and under incorrectly estimates font spacing for + // `@font-face` fonts. This leads to wild measurement errors. The best + // course of action is to remeasure everything on window.onload or font + // timeout (3s), whichever is earlier. This has to be done on the global + // window because this is where the fonts are always added. + // Unfortunately, `document.fonts.ready` cannot be used here due to + // https://bugs.webkit.org/show_bug.cgi?id=174030. + // See https://bugs.webkit.org/show_bug.cgi?id=174031 for more details. + Promise.race([ + loadPromise(this.win), + Services.timerFor(this.win).promise(3100), + ]).then(remeasure); + + // Remeasure the document when all fonts loaded. + if ( + this.win.document.fonts && + this.win.document.fonts.status != 'loaded' + ) { + this.win.document.fonts.ready.then(remeasure); + } } }); } @@ -321,7 +476,11 @@ export class ResourcesImpl { resource.getState() != ResourceState.NOT_BUILT && !element.reconstructWhenReparented() ) { - resource.requestMeasure(); + // With IntersectionObserver, no need to request remeasure + // on reuse since initial intersection callback will trigger soon. + if (!this.intersectionObserver_) { + resource.requestMeasure(); + } dev().fine(TAG_, 'resource reused:', resource.debugid); } else { // Create and add a new resource. @@ -329,7 +488,18 @@ export class ResourcesImpl { dev().fine(TAG_, 'resource added:', resource.debugid); } this.resources_.push(resource); - this.remeasurePass_.schedule(1000); + + if (this.intersectionObserver_) { + // Wait until upgrade to start observing intersections to give + // the browser a chance to layout first. This results in fresher + // client rects in the intersection entry, e.g. [overflow] elements + // can affect element size since they're `position: relative`. + element.whenUpgraded().then(() => { + this.intersectionObserver_.observe(resource.element); + }); + } else { + this.remeasurePass_.schedule(1000); + } } /** @@ -448,7 +618,9 @@ export class ResourcesImpl { return null; } this.buildAttemptsCount_++; - if (!schedulePass) { + // With IntersectionObserver, no need to schedule measurements after build + // since these are handled in the initial intersection callback. + if (!schedulePass || this.intersectionObserver_) { return promise; } return promise.then( @@ -485,15 +657,18 @@ export class ResourcesImpl { if (resource.isBuilt()) { resource.pauseOnRemove(); } + if (this.intersectionObserver_) { + this.intersectionObserver_.unobserve(resource.element); + } this.cleanupTasks_(resource, /* opt_removePending */ true); - dev().fine(TAG_, 'element removed:', resource.debugid); + dev().fine(TAG_, 'resource removed:', resource.debugid); } /** @override */ upgraded(element) { const resource = Resource.forElement(element); this.buildOrScheduleBuildForResource_(resource); - dev().fine(TAG_, 'element upgraded:', resource.debugid); + dev().fine(TAG_, 'resource upgraded:', resource.debugid); } /** @override */ @@ -513,10 +688,7 @@ export class ResourcesImpl { } /** @override */ - schedulePass(opt_delay, opt_relayoutAll) { - if (opt_relayoutAll) { - this.relayoutAll_ = true; - } + schedulePass(opt_delay) { return this.pass_.schedule(opt_delay); } @@ -553,6 +725,7 @@ export class ResourcesImpl { /** @override */ ampInitComplete() { this.ampInitialized_ = true; + dev().fine(TAG_, 'ampInitComplete'); this.schedulePass(); } @@ -635,15 +808,11 @@ export class ResourcesImpl { this.vsyncScheduled_ = false; this.visibilityStateMachine_.setState(this.ampdoc.getVisibilityState()); - if ( - this.documentReady_ && - this.ampInitialized_ && - !this.ampdoc.signals().get(READY_SCAN_SIGNAL) - ) { - // This signal mainly signifies that most of elements have been measured - // by now. This is mostly used to avoid measuring too many elements - // individually. May not be called in shadow mode. - this.ampdoc.signals().signal(READY_SCAN_SIGNAL); + + // With IntersectionObserver, elements are not measured until the first + // intersection callback (vs. after first pass), so wait until then. + if (!this.intersectionObserver_) { + this.signalIfReady_(); } if (this.maybeChangeHeight_) { @@ -670,6 +839,26 @@ export class ResourcesImpl { this.passCallbacks_.length = 0; } + /** + * If (1) the document is fully parsed, (2) the AMP runtime (services etc.) + * is initialized, and (3) we did a first pass on element measurements, + * then fire the "ready" signal. + * @private + */ + signalIfReady_() { + if ( + this.documentReady_ && + this.ampInitialized_ && + !this.ampdoc.signals().get(READY_SCAN_SIGNAL) + ) { + // This signal mainly signifies that most of elements have been measured + // by now. This is mostly used to avoid measuring too many elements + // individually. May not be called in shadow mode. + this.ampdoc.signals().signal(READY_SCAN_SIGNAL); + dev().fine(TAG_, 'signal: ready-scan'); + } + } + /** * Returns `true` when there's mutate work currently batched. * @return {boolean} @@ -810,7 +999,7 @@ export class ResourcesImpl { // height decrease continue; } - // Can only resized when scrollinghas stopped, + // Can only resized when scrolling has stopped, // otherwise defer util next cycle. if (isScrollingStopped) { // These requests will be executed in the next animation cycle and @@ -978,6 +1167,37 @@ export class ResourcesImpl { return box.bottom >= threshold || initialBox.bottom >= threshold; } + /** + * Always returns true unless the resource was previously displayed but is + * not displayed now (i.e. the resource should be unloaded). + * @param {!Resource} r + * @param {!ClientRect=} opt_premeasuredRect + * @return {boolean} + * @private + */ + measureResource_(r, opt_premeasuredRect) { + const wasDisplayed = r.isDisplayed(); + r.measure(opt_premeasuredRect); + return !(wasDisplayed && !r.isDisplayed()); + } + + /** + * Unloads given resources in an async mutate phase. + * @param {!Array} resources + * @private + */ + unloadResources_(resources) { + if (resources.length) { + this.vsync_.mutate(() => { + resources.forEach(r => { + r.unload(); + this.cleanupTasks_(r); + }); + dev().fine(TAG_, 'unload:', resources); + }); + } + } + /** * Discovers work that needs to be done since the last pass. If viewport * has changed, it will try to build new elements, measure changed elements, @@ -991,6 +1211,58 @@ export class ResourcesImpl { * @private */ discoverWork_() { + if (this.intersectionObserver_) { + // With IntersectionObserver, we typically defer measurements to the + // intersection callback. However, we still need: + // 1. On viewport size changes (relayoutAll), apply sizes/media queries + // AND remeasure elements. The latter makes sure that we call + // onLayoutMeasure/onMeasureChanged e.g. for owner components to + // reposition children. + // 2. Support requested measures which can only happen via expand() + // and changeSize(). + + // TODO(willchou): Do we need to build _all_ elements (instead of + // just near-viewport elements) on page-ready? + + // Phase 1. + // We apply sizes/media query here before the first intersection callback + // so that the correct element size and hidden state will be measured + // by the observer (which avoids the need for a remeasure). + const numberOfResources = this.resources_.length; + for (let i = 0; i < numberOfResources; i++) { + const r = this.resources_[i]; + // NOT_LAID_OUT is the state after build() but before measure(). + if (this.relayoutAll_ || r.getState() == ResourceState.NOT_LAID_OUT) { + // TODO(willchou): May need to add another ResourceState to avoid + // multiple invocations before the first intersection callback. + r.applySizesAndMediaQuery(); + dev().fine(TAG_, 'apply sizes/media query:', r.debugid); + } + } + + // Phase 2. + // Remeasures for viewport size changes (relayoutAll) and requestMeasure. + const toUnload = []; + for (let i = 0; i < numberOfResources; i++) { + const r = this.resources_[i]; + if (r.hasOwner()) { + return; + } + if (this.relayoutAll_ || r.isMeasureRequested()) { + const isDisplayed = this.measureResource_(r); + if (!isDisplayed) { + toUnload.push(r); + } + dev().fine(TAG_, 'force remeasure:', r.debugid); + } + } + this.unloadResources_(toUnload); + + // Reset relayoutAll_ flag. + this.relayoutAll_ = false; + return; + } + // TODO(dvoytenko): vsync separation may be needed for different phases const now = Date.now(); @@ -1014,9 +1286,11 @@ export class ResourcesImpl { if ( relayoutAll || !r.hasBeenMeasured() || + // NOT_LAID_OUT is the state after build() but before measure(). r.getState() == ResourceState.NOT_LAID_OUT ) { r.applySizesAndMediaQuery(); + dev().fine(TAG_, 'apply sizes/media query:', r.debugid); relayoutCount++; } if (r.isMeasureRequested()) { @@ -1061,9 +1335,8 @@ export class ResourcesImpl { } if (needsMeasure) { - const wasDisplayed = r.isDisplayed(); - r.measure(); - if (wasDisplayed && !r.isDisplayed()) { + const isDisplayed = this.measureResource_(r); + if (!isDisplayed) { if (!toUnload) { toUnload = []; } @@ -1076,12 +1349,7 @@ export class ResourcesImpl { // Unload all in one cycle. if (toUnload) { - this.vsync_.mutate(() => { - toUnload.forEach(r => { - r.unload(); - this.cleanupTasks_(r); - }); - }); + this.unloadResources_(toUnload); } const viewportRect = this.viewport_.getRect(); @@ -1245,7 +1513,13 @@ export class ResourcesImpl { const reschedule = this.reschedule_.bind(this, task); executing.promise.then(reschedule, reschedule); } else { - task.resource.measure(); + // With IntersectionObserver, the element's client rect measurement + // is recent so immediate remeasuring shouldn't be necessary. + if (!this.intersectionObserver_) { + task.resource.measure(); + } + // Check if the element has exited the viewport or the page has changed + // visibility since the layout was scheduled. if (this.isLayoutAllowed_(task.resource, task.forceOutsideViewport)) { task.promise = task.callback(); task.startTime = now; @@ -1258,6 +1532,7 @@ export class ResourcesImpl { ) .catch(/** @type {function (*)} */ (reportError)); } else { + devAssert(!this.intersectionObserver_); dev().fine(TAG_, 'cancelled', task.id); task.resource.layoutCanceled(); } @@ -1267,8 +1542,13 @@ export class ResourcesImpl { timeout = -1; } - dev().fine(TAG_, 'queue size:', this.queue_.getSize()); - dev().fine(TAG_, 'exec size:', this.exec_.getSize()); + dev().fine( + TAG_, + 'queue size:', + this.queue_.getSize(), + 'exec size:', + this.exec_.getSize() + ); if (timeout >= 0) { // Still tasks in the queue, but we took too much time. @@ -1448,12 +1728,16 @@ export class ResourcesImpl { opt_parentPriority, opt_forceOutsideViewport ) { - devAssert( - resource.getState() != ResourceState.NOT_BUILT && resource.isDisplayed(), - 'Not ready for layout: %s (%s)', - resource.debugid, - resource.getState() - ); + const isBuilt = resource.getState() != ResourceState.NOT_BUILT; + const isDisplayed = resource.isDisplayed(); + if (!isBuilt || !isDisplayed) { + devAssert( + false, + 'Not ready for layout: %s (%s)', + resource.debugid, + resource.getState() + ); + } const forceOutsideViewport = opt_forceOutsideViewport || false; if (!this.isLayoutAllowed_(resource, forceOutsideViewport)) { return; @@ -1549,11 +1833,15 @@ export class ResourcesImpl { // If viewport size is 0, the manager will wait for the resize event. const viewportSize = this.viewport_.getSize(); if (viewportSize.height > 0 && viewportSize.width > 0) { + // 1. Handle all size-change requests. 1x mutate (+1 vsync measure/mutate for above-fold resizes). if (this.hasMutateWork_()) { this.mutateWork_(); } + // 2. Build/measure/in-viewport/schedule layouts. 1x mutate & measure. this.discoverWork_(); + // 3. Execute scheduled layouts and preloads. 1x mutate. let delay = this.work_(); + // 4. Deferred size-change requests (waiting for scrolling to stop) will shorten delay until next pass. if (this.hasMutateWork_()) { // Overflow mutate work. delay = Math.min(delay, MUTATE_DEFER_DELAY_); diff --git a/src/service/resources-interface.js b/src/service/resources-interface.js index 940ae9d834c9..e1ad536a735c 100644 --- a/src/service/resources-interface.js +++ b/src/service/resources-interface.js @@ -177,5 +177,12 @@ export class ResourcesInterface { * @param {number} newLayoutPriority */ updateLayoutPriority(element, newLayoutPriority) {} + + /** + * https://github.com/ampproject/amphtml/issues/25428 + * @return {boolean} + * @package + */ + isIntersectionExperimentOn() {} } /* eslint-enable no-unused-vars */ diff --git a/src/service/viewport/viewport-binding-def.js b/src/service/viewport/viewport-binding-def.js index 16a90d1eabdf..c5113de02d80 100644 --- a/src/service/viewport/viewport-binding-def.js +++ b/src/service/viewport/viewport-binding-def.js @@ -179,9 +179,15 @@ export class ViewportBindingDef { * pass in, if they cached these values and would like to avoid * remeasure. Requires appropriate updating the values on scroll. * @param {number=} unusedScrollTop Same comment as above. + * @param {!ClientRect=} unusedPremeasuredRect * @return {!../../layout-rect.LayoutRectDef} */ - getLayoutRect(unusedEl, unusedScrollLeft, unusedScrollTop) {} + getLayoutRect( + unusedEl, + unusedScrollLeft, + unusedScrollTop, + unusedPremeasuredRect + ) {} /** * Returns the client rect of the current window. diff --git a/src/service/viewport/viewport-binding-ios-embed-wrapper.js b/src/service/viewport/viewport-binding-ios-embed-wrapper.js index 523f94b5e34d..f72debd520cd 100644 --- a/src/service/viewport/viewport-binding-ios-embed-wrapper.js +++ b/src/service/viewport/viewport-binding-ios-embed-wrapper.js @@ -273,8 +273,8 @@ export class ViewportBindingIosEmbedWrapper_ { contentHeightChanged() {} /** @override */ - getLayoutRect(el, opt_scrollLeft, opt_scrollTop) { - const b = el./*OK*/ getBoundingClientRect(); + getLayoutRect(el, opt_scrollLeft, opt_scrollTop, opt_premeasuredRect) { + const b = opt_premeasuredRect || el./*OK*/ getBoundingClientRect(); const scrollTop = opt_scrollTop != undefined ? opt_scrollTop : this.getScrollTop(); const scrollLeft = diff --git a/src/service/viewport/viewport-binding-natural.js b/src/service/viewport/viewport-binding-natural.js index c034157e4d88..52f249a9a737 100644 --- a/src/service/viewport/viewport-binding-natural.js +++ b/src/service/viewport/viewport-binding-natural.js @@ -256,8 +256,8 @@ export class ViewportBindingNatural_ { } /** @override */ - getLayoutRect(el, opt_scrollLeft, opt_scrollTop) { - const b = el./*OK*/ getBoundingClientRect(); + getLayoutRect(el, opt_scrollLeft, opt_scrollTop, opt_premeasuredRect) { + const b = opt_premeasuredRect || el./*OK*/ getBoundingClientRect(); const scrollTop = opt_scrollTop != undefined ? opt_scrollTop : this.getScrollTop(); const scrollLeft = diff --git a/src/service/viewport/viewport-impl.js b/src/service/viewport/viewport-impl.js index b2a1f20e3493..f2b15b72895f 100644 --- a/src/service/viewport/viewport-impl.js +++ b/src/service/viewport/viewport-impl.js @@ -341,14 +341,14 @@ export class ViewportImpl { } /** @override */ - getLayoutRect(el) { + getLayoutRect(el, opt_premeasuredRect) { const scrollLeft = this.getScrollLeft(); const scrollTop = this.getScrollTop(); // Go up the window hierarchy through friendly iframes. const frameElement = getParentWindowFrameElement(el, this.ampdoc.win); if (frameElement) { - const b = this.binding_.getLayoutRect(el, 0, 0); + const b = this.binding_.getLayoutRect(el, 0, 0, opt_premeasuredRect); const c = this.binding_.getLayoutRect( frameElement, scrollLeft, @@ -362,7 +362,12 @@ export class ViewportImpl { ); } - return this.binding_.getLayoutRect(el, scrollLeft, scrollTop); + return this.binding_.getLayoutRect( + el, + scrollLeft, + scrollTop, + opt_premeasuredRect + ); } /** @override */ diff --git a/src/service/viewport/viewport-interface.js b/src/service/viewport/viewport-interface.js index c08da034c942..9fb1ebefd7db 100644 --- a/src/service/viewport/viewport-interface.js +++ b/src/service/viewport/viewport-interface.js @@ -142,11 +142,13 @@ export class ViewportInterface extends Disposable { /** * Returns the rect of the element within the document. * Note that this function should be called in vsync measure. Please consider - * using `getLayoutRectAsync` instead. + * using `getClientRectAsync` instead. * @param {!Element} el + * @param {!ClientRect=} opt_premeasuredRect If provided, use this + * premeasured ClientRect instead of calling getBoundingClientRect. * @return {!../../layout-rect.LayoutRectDef} */ - getLayoutRect(el) {} + getLayoutRect(el, opt_premeasuredRect) {} /** * Returns the clientRect of the element. diff --git a/test/unit/test-layout-rect.js b/test/unit/test-layout-rect.js index 0f46eb235f4d..0b6f63a15477 100644 --- a/test/unit/test-layout-rect.js +++ b/test/unit/test-layout-rect.js @@ -27,13 +27,13 @@ describe('LayoutRect', () => { expect(rect.right).to.equal(4); }); - it('layoutRectsOverlap', () => { + it('rectsOverlap', () => { const rect1 = lr.layoutRectLtwh(10, 20, 30, 40); const rect2 = lr.layoutRectLtwh(40, 60, 10, 10); const rect3 = lr.layoutRectLtwh(41, 60, 10, 10); - expect(lr.layoutRectsOverlap(rect1, rect2)).to.equal(true); - expect(lr.layoutRectsOverlap(rect1, rect3)).to.equal(false); - expect(lr.layoutRectsOverlap(rect2, rect3)).to.equal(true); + expect(lr.rectsOverlap(rect1, rect2)).to.equal(true); + expect(lr.rectsOverlap(rect1, rect3)).to.equal(false); + expect(lr.rectsOverlap(rect2, rect3)).to.equal(true); }); it('expandLayoutRect', () => { diff --git a/test/unit/test-viewport.js b/test/unit/test-viewport.js index da56cdb72a11..b8b3839f1154 100644 --- a/test/unit/test-viewport.js +++ b/test/unit/test-viewport.js @@ -1152,12 +1152,12 @@ describes.fakeWin('Viewport', {}, env => { iframeWin.document.body.appendChild(element); bindingMock .expects('getLayoutRect') - .withExactArgs(element, 0, 0) + .withArgs(element, 0, 0) .returns({left: 20, top: 10}) .once(); bindingMock .expects('getLayoutRect') - .withExactArgs(iframe, 0, 0) + .withArgs(iframe, 0, 0) .returns({left: 211, top: 111}) .once(); @@ -1173,12 +1173,12 @@ describes.fakeWin('Viewport', {}, env => { iframeWin.document.body.appendChild(element); bindingMock .expects('getLayoutRect') - .withExactArgs(element, 0, 0) + .withArgs(element, 0, 0) .returns({left: 20, top: 10}) .once(); bindingMock .expects('getLayoutRect') - .withExactArgs(iframe, 200, 100) + .withArgs(iframe, 200, 100) .returns({left: 211, top: 111}) .once(); diff --git a/tools/experiments/experiments-config.js b/tools/experiments/experiments-config.js index 5f67764a266d..ecf268206b66 100644 --- a/tools/experiments/experiments-config.js +++ b/tools/experiments/experiments-config.js @@ -296,6 +296,12 @@ export const EXPERIMENTS = [ spec: 'https://github.com/ampproject/amphtml/issues/24166', cleanupIssue: 'https://github.com/ampproject/amphtml/issues/24167', }, + { + id: 'intersect-resources', + name: 'Use IntersectionObserver for resource scheduling.', + spec: 'https://github.com/ampproject/amphtml/issues/25428', + cleanupIssue: 'https://github.com/ampproject/amphtml/issues/26233', + }, { id: 'layoutbox-invalidate-on-scroll', name: