From 43a7725394f6ff5c1f455e950ed98dec562f003f Mon Sep 17 00:00:00 2001 From: Dima Voytenko Date: Mon, 15 Mar 2021 12:27:20 -0700 Subject: [PATCH] V1: mount/unmount concept and automatic scheduling --- builtins/amp-img.js | 57 ++-- src/base-element.js | 32 +++ src/common-signals.js | 5 + src/custom-element.js | 250 ++++++++++++++---- src/preact/base-element.js | 9 +- src/ready-state.js | 5 + src/service/resource.js | 6 + src/service/{builder.js => scheduler.js} | 14 +- test/unit/test-amp-img-v1.js | 30 ++- .../{test-builder.js => test-scheduler.js} | 148 +++++------ testing/element-v1.js | 43 ++- 11 files changed, 437 insertions(+), 162 deletions(-) rename src/service/{builder.js => scheduler.js} (95%) rename test/unit/{test-builder.js => test-scheduler.js} (74%) diff --git a/builtins/amp-img.js b/builtins/amp-img.js index fc5480b7cc1de..1bf54b4451bd0 100644 --- a/builtins/amp-img.js +++ b/builtins/amp-img.js @@ -56,6 +56,11 @@ export class AmpImg extends BaseElement { return true; } + /** @override @nocollapse */ + static load() { + return true; + } + /** @override @nocollapse */ static getPreconnects(element) { const src = element.getAttribute('src'); @@ -176,10 +181,11 @@ export class AmpImg extends BaseElement { /** * Create the actual image element and set up instance variables. * Called lazily in the first `#layoutCallback`. + * @return {!Image} */ initialize_() { if (this.img_) { - return; + return this.img_; } // If this amp-img IS the fallback then don't allow it to have its own // fallback to stop from nested fallback abuse. @@ -219,6 +225,7 @@ export class AmpImg extends BaseElement { propagateObjectFitStyles(this.element, this.img_); this.element.appendChild(this.img_); + return this.img_; } /** @@ -294,29 +301,43 @@ export class AmpImg extends BaseElement { } /** @override */ - buildCallback() { - if (!AmpImg.V1()) { - return; + mountCallback() { + const initialized = !!this.img_; + const img = this.initialize_(); + if (!initialized) { + listen(img, 'load', () => { + this.setReadyState(ReadyState.COMPLETE); + this.firstLayoutCompleted(); + this.hideFallbackImg_(); + }); + listen(img, 'error', (reason) => { + this.setReadyState(ReadyState.ERROR, reason); + this.onImgLoadingError_(); + }); } - - // A V1 amp-img loads and reloads automatically. - this.setReadyState(ReadyState.LOADING); - this.initialize_(); - const img = dev().assertElement(this.img_); if (img.complete) { this.setReadyState(ReadyState.COMPLETE); this.firstLayoutCompleted(); this.hideFallbackImg_(); + } else { + this.setReadyState(ReadyState.LOADING); } - listen(img, 'load', () => { - this.setReadyState(ReadyState.COMPLETE); - this.firstLayoutCompleted(); - this.hideFallbackImg_(); - }); - listen(img, 'error', (reason) => { - this.setReadyState(ReadyState.ERROR, reason); - this.onImgLoadingError_(); - }); + } + + /** @override */ + unmountCallback() { + // Interrupt retrieval of incomplete images to free network resources when + // navigating pages in a PWA. Opt for tiny dataURI image instead of empty + // src to prevent the viewer from detecting a load error. + const img = this.img_; + if (img && !img.complete) { + img.src = + 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs='; + removeElement(img); + this.img_ = null; + } + + return true; } /** @override */ diff --git a/src/base-element.js b/src/base-element.js index bdc7b03e5f033..3f35d8ee13f75 100644 --- a/src/base-element.js +++ b/src/base-element.js @@ -153,6 +153,20 @@ export class BaseElement { return false; } + /** + * Subclasses can override this method to indicate that an element can load + * network resources. + * + * Such elements can have their `ensureLoaded` method called. + * + * @param {!AmpElement} unusedElement + * @return {boolean} + * @nocollapse + */ + static load(unusedElement) { + return false; + } + /** * Subclasses can override this method to provide a svg logo that will be * displayed as the loader. @@ -513,6 +527,24 @@ export class BaseElement { this.element.setReadyStateInternal(state, opt_failure); } + /** + * Load heavy elements, perform expensive operations, add global + * listeners/observers, etc. + * + * @param {!AbortSignal=} opt_abortSignal + * @return {?Promise|undefined} + */ + mountCallback(opt_abortSignal) {} + + /** + * Unload heavy elements, remove global listeners, etc. + * + * @return {boolean} + */ + unmountCallback() { + return false; + } + /** * Subclasses can override this method to opt-in into receiving additional * {@link layoutCallback} calls. Note that this method is not consulted for diff --git a/src/common-signals.js b/src/common-signals.js index 248e934d6a03e..0f85a63e9ad4e 100644 --- a/src/common-signals.js +++ b/src/common-signals.js @@ -34,6 +34,11 @@ export const CommonSignals = { */ BUILT: 'built', + /** + * The element has been mounted. + */ + MOUNTED: 'mounted', + /** * The element has started loading. * LOAD_START triggers at the start of the layoutCallback. diff --git a/src/custom-element.js b/src/custom-element.js index 025092f4fa121..ab2e8fd379a31 100644 --- a/src/custom-element.js +++ b/src/custom-element.js @@ -34,12 +34,13 @@ import { blockedByConsentError, cancellation, isBlockedByConsent, + isCancellation, reportError, } from './error'; import {dev, devAssert, rethrowAsync, user, userAssert} from './log'; -import {getBuilderForDoc} from './service/builder'; import {getIntersectionChangeEntry} from './utils/intersection-observer-3p-host'; import {getMode} from './mode'; +import {getSchedulerForDoc} from './service/scheduler'; import {isExperimentOn} from './experiments'; import {setStyle} from './style'; import {shouldBlockOnConsentByMeta} from './consent'; @@ -146,6 +147,12 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { /** @private {?Promise} */ this.buildingPromise_ = null; + /** @private {?Promise} */ + this.mountPromise_ = null; + + /** @private {?AbortController} */ + this.mountAbortController_ = null; + /** @private {!ReadyState} */ this.readyState_ = ReadyState.UPGRADING; @@ -530,13 +537,7 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { this.classList.remove('amp-notbuilt'); this.signals_.signal(CommonSignals.BUILT); - if (this.V1()) { - // If the implementation hasn't changed the readyState to, e.g., - // "loading", then update the state to "complete". - if (this.readyState_ == ReadyState.BUILDING) { - this.setReadyStateInternal(ReadyState.COMPLETE); - } - } else { + if (!this.V1()) { this.setReadyStateInternal(ReadyState.LOADING); this.preconnect(/* onLayout */ false); } @@ -591,13 +592,145 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { ); return readyPromise.then(() => { if (this.V1()) { - const builder = getBuilderForDoc(this.getAmpDoc()); - builder.scheduleAsap(this); + const scheduler = getSchedulerForDoc(this.getAmpDoc()); + scheduler.scheduleAsap(this); } return this.whenBuilt(); }); } + /** + * Mounts the element by calling the `BaseElement.mountCallback` method. + * + * Can only be called on a upgraded element. May only be called from + * scheduler.js. + * + * @return {!Promise} + * @final + * @restricted + */ + mountInternal() { + if (this.mountPromise_) { + return this.mountPromise_; + } + this.mountAbortController_ = new AbortController(); + const {signal} = this.mountAbortController_; + return (this.mountPromise_ = this.buildInternal() + .then(() => { + devAssert(this.V1()); + if (signal.aborted) { + // Mounting has been canceled. + return; + } + this.setReadyStateInternal( + this.implClass_.load(this) + ? ReadyState.LOADING + : ReadyState.MOUNTING + ); + return this.impl_.mountCallback(signal); + }) + .then(() => { + this.mountAbortController_ = null; + if (signal.aborted) { + throw cancellation(); + } + this.signals_.signal(CommonSignals.MOUNTED); + if ( + this.implClass_.load(this) && + this.readyState_ !== ReadyState.COMPLETE + ) { + this.setReadyStateInternal(ReadyState.LOADING); + } else { + this.setReadyStateInternal(ReadyState.COMPLETE); + } + }) + .catch((reason) => { + this.mountAbortController_ = null; + if (isCancellation(reason)) { + this.mountPromise_ = null; + } else { + this.signals_.rejectSignal( + CommonSignals.MOUNTED, + /** @type {!Error} */ (reason) + ); + this.setReadyStateInternal(ReadyState.ERROR, reason); + } + throw reason; + })); + } + + /** + * Requests the element to be mounted as soon as possible. + * @return {!Promise} + * @final + */ + mount() { + if (this.mountPromise_) { + return this.mountPromise_; + } + return this.build().then(() => { + devAssert(this.V1()); + const scheduler = getSchedulerForDoc(this.getAmpDoc()); + scheduler.scheduleAsap(this); + return this.whenMounted(); + }); + } + + /** + * Unmounts the element and makes it ready for the next mounting + * operation. + * @final + */ + unmount() { + // Hasn't been mounted yet. + if (!this.mountPromise_) { + return; + } + + devAssert(this.V1()); + + // Cancel the currently mounting operation. + if (this.mountAbortController_) { + this.mountAbortController_.abort(); + this.mountAbortController_ = null; + } + + if (this.isConnected_) { + // Ensure that the element is paused. + this.pause(); + } + + // Try to unmount. Not every element can be unmounted or has anything + // to unmount. + const unmounted = this.impl_.unmountCallback(); + if (!unmounted) { + return; + } + + this.mountPromise_ = null; + this.signals_.reset(CommonSignals.MOUNTED); + + if (this.isConnected_) { + // Update the ready state. + this.setReadyStateInternal( + this.implClass_.load(this) ? ReadyState.LOADING : ReadyState.MOUNTING + ); + + // Schedule to mount again when the mounting conditions trigger. + const scheduler = getSchedulerForDoc(this.getAmpDoc()); + scheduler.schedule(this); + } + } + + /** + * Returns the promise that's resolved when the element has been mounted. If + * the mount fails, the resulting promise is rejected. + * @return {!Promise} + */ + whenMounted() { + return this.signals_.whenSignal(CommonSignals.MOUNTED); + } + /** * @return {!Promise} * @final @@ -616,7 +749,9 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { ensureLoaded(opt_parentPriority) { return this.build().then(() => { if (this.V1()) { - this.impl_.ensureLoaded(); + if (this.implClass_.load(this)) { + this.impl_.ensureLoaded(); + } return this.whenLoaded(); } @@ -648,6 +783,21 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { }); } + /** + * Pauses the element. + */ + pause() { + if (!this.impl_) { + // Not upgraded yet. + return; + } + if (this.V1()) { + this.impl_.pauseCallback(); + } else { + this.getResource_().pause(); + } + } + /** * Update the internal ready state. * @@ -988,7 +1138,8 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { } this.connected_(); this.dispatchCustomEventForTesting(AmpEvents.ATTACHED); - } else if (this.implClass_ && this.V1()) { + } + if (this.implClass_ && this.V1()) { this.upgradeOrSchedule_(); } } else { @@ -1040,33 +1191,41 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { this.tryUpgrade_(); return; } - if (this.buildingPromise_) { - // Already building. + + if (this.mountPromise_) { + // Already mounting. return; } - // Schedule build. - this.setReadyStateInternal(ReadyState.BUILDING); - const builder = getBuilderForDoc(this.getAmpDoc()); - builder.schedule(this); + // Schedule build and mount. + const scheduler = getSchedulerForDoc(this.getAmpDoc()); + scheduler.schedule(this); - // Schedule preconnects. - const urls = this.implClass_.getPreconnects(this); - if (urls && urls.length > 0) { - // If we do early preconnects we delay them a bit. This is kind of - // an unfortunate trade off, but it seems faster, because the DOM - // operations themselves are not free and might delay - const ampdoc = this.getAmpDoc(); - startupChunk(ampdoc, () => { - const {win} = ampdoc; - if (!win) { - return; - } - const preconnect = Services.preconnectFor(win); - urls.forEach((url) => - preconnect.url(ampdoc, url, /* alsoConnecting */ false) - ); - }); + if (this.buildingPromise_) { + // Already built. Just needs to be mounted. + this.setReadyStateInternal(ReadyState.MOUNTING); + } else { + // Not built yet. Execute prebuild steps. + this.setReadyStateInternal(ReadyState.BUILDING); + + // Schedule preconnects. + const urls = this.implClass_.getPreconnects(this); + if (urls && urls.length > 0) { + // If we do early preconnects we delay them a bit. This is kind of + // an unfortunate trade off, but it seems faster, because the DOM + // operations themselves are not free and might delay + const ampdoc = this.getAmpDoc(); + startupChunk(ampdoc, () => { + const {win} = ampdoc; + if (!win) { + return; + } + const preconnect = Services.preconnectFor(win); + urls.forEach((url) => + preconnect.url(ampdoc, url, /* alsoConnecting */ false) + ); + }); + } } } @@ -1169,9 +1328,10 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { if (this.impl_) { this.impl_.detachedCallback(); } - if (!this.built_ && this.V1()) { - const builder = getBuilderForDoc(this.getAmpDoc()); - builder.unschedule(this); + if (this.V1()) { + const scheduler = getSchedulerForDoc(this.getAmpDoc()); + scheduler.unschedule(this); + this.unmount(); } this.toggleLoading(false); this.disposeMediaAttrs_(); @@ -1322,6 +1482,7 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { * should be called again when layout changes. * @return {boolean} * @package @final + * TODO(#31915): remove once V1 migration is complete. */ isRelayoutNeeded() { return this.impl_ ? this.impl_.isRelayoutNeeded() : false; @@ -1384,6 +1545,7 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { * @param {!AbortSignal} signal * @return {!Promise} * @package @final + * TODO(#31915): remove once V1 migration is complete. */ layoutCallback(signal) { assertNotTemplate(this); @@ -1445,21 +1607,13 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { ); } - /** - * Whether the resource is currently paused. - * @return {boolean} - * @final @package - */ - isPaused() { - return this.paused_; - } - /** * Requests the resource to stop its activity when the document goes into * inactive state. The scope is up to the actual component. Among other * things the active playback of video or audio content must be stopped. * * @package @final + * TODO(#31915): remove once V1 migration is complete. */ pauseCallback() { assertNotTemplate(this); @@ -1479,6 +1633,7 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { * resumed. * * @package @final + * TODO(#31915): remove once V1 migration is complete. */ resumeCallback() { assertNotTemplate(this); @@ -1499,6 +1654,7 @@ function createBaseCustomElementClass(win, elementConnectedCallback) { * * @return {boolean} * @package @final + * TODO(#31915): remove once V1 migration is complete. */ unlayoutCallback() { assertNotTemplate(this); diff --git a/src/preact/base-element.js b/src/preact/base-element.js index 0ec22bad94ec3..567b3a56da345 100644 --- a/src/preact/base-element.js +++ b/src/preact/base-element.js @@ -202,11 +202,18 @@ export class PreactBaseElement extends AMP.BaseElement { return this['usesShadowDom']; } + /** @override @nocollapse */ + static load() { + // eslint-disable-next-line local/no-static-this + const Ctor = this; + return Ctor['loadable']; + } + /** @override @nocollapse */ static prerenderAllowed() { // eslint-disable-next-line local/no-static-this const Ctor = this; - return !Ctor['loadable']; + return !Ctor.load(); } /** @param {!AmpElement} element */ diff --git a/src/ready-state.js b/src/ready-state.js index 3dd7e4206b4b7..b8e0f4cd37da8 100644 --- a/src/ready-state.js +++ b/src/ready-state.js @@ -30,6 +30,11 @@ export const ReadyState = { */ BUILDING: 'building', + /** + * The element has been built and waiting to be mounted. + */ + MOUNTING: 'mounting', + /** * The element has been built and waiting to be loaded. */ diff --git a/src/service/resource.js b/src/service/resource.js index 98111cd6543a9..9dc0746b602c1 100644 --- a/src/service/resource.js +++ b/src/service/resource.js @@ -1093,6 +1093,9 @@ export class Resource { if (this.element.unlayoutOnPause()) { this.unlayout(); } + if (this.element.V1()) { + this.element.pause(); + } } /** @@ -1115,6 +1118,9 @@ export class Resource { unload() { this.pause(); this.unlayout(); + if (this.element.V1()) { + this.element.unmount(); + } } /** diff --git a/src/service/builder.js b/src/service/scheduler.js similarity index 95% rename from src/service/builder.js rename to src/service/scheduler.js index 0459615507dbd..9d73b9963353f 100644 --- a/src/service/builder.js +++ b/src/service/scheduler.js @@ -21,10 +21,10 @@ import {getServiceForDoc, registerServiceBuilderForDoc} from '../service'; import {hasNextNodeInDocumentOrder, isIframed} from '../dom'; import {removeItem} from '../utils/array'; -const ID = 'builder'; +const ID = 'scheduler'; /** @implements {../service.Disposable} */ -export class Builder { +export class Scheduler { /** @param {!./ampdoc-impl.AmpDoc} ampdoc */ constructor(ampdoc) { /** @private @const */ @@ -227,15 +227,15 @@ export class Builder { asap || target.getBuildPriority() <= LayoutPriority.CONTENT ? win.setTimeout : win.requestIdleCallback || win.setTimeout; - scheduler(() => target.buildInternal()); + scheduler(() => target.mountInternal()); } } /** * @param {!./ampdoc-impl.AmpDoc} ampdoc - * @return {!Builder} + * @return {!Scheduler} */ -export function getBuilderForDoc(ampdoc) { - registerServiceBuilderForDoc(ampdoc, ID, Builder); - return /** @type {!Builder} */ (getServiceForDoc(ampdoc, ID)); +export function getSchedulerForDoc(ampdoc) { + registerServiceBuilderForDoc(ampdoc, ID, Scheduler); + return /** @type {!Scheduler} */ (getServiceForDoc(ampdoc, ID)); } diff --git a/test/unit/test-amp-img-v1.js b/test/unit/test-amp-img-v1.js index b818def67f5b4..ac8a3f485654b 100644 --- a/test/unit/test-amp-img-v1.js +++ b/test/unit/test-amp-img-v1.js @@ -61,13 +61,18 @@ describes.realWin('amp-img V1', {amp: true}, (env) => { img.onerror = sandbox.spy(); doc.body.appendChild(img); - await img.build(); + await img.mount(); return img; } it('testElementV1', () => { testElementV1(AmpImg, { - exceptions: ['Must not use getLayoutSize'], + exceptions: [ + 'Must not have preconnectCallback', + 'Must not have layoutCallback', + 'Must not have unlayoutCallback', + 'Must not use getLayoutSize', + ], }); }); @@ -106,6 +111,27 @@ describes.realWin('amp-img V1', {amp: true}, (env) => { expect(togglePlaceholderSpy).to.be.calledOnce.calledWith(false); }); + it('should set eager loading on ensureLoaded', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 300, + height: 200, + alt: 'An image', + title: 'Image title', + referrerpolicy: 'origin', + }); + + const img = ampImg.querySelector('img'); + expect(img.loading == 'auto' || !img.loading).to.be.true; + + const promise = ampImg.ensureLoaded(); + await new Promise(setTimeout); + expect(img.loading).to.equal('eager'); + + dispatchCustomEvent(img, 'load', null, {bubbles: false}); + await promise; + }); + it('should fail when img fails', async () => { const ampImg = await getImg({ src: 'non-existent.jpg', diff --git a/test/unit/test-builder.js b/test/unit/test-scheduler.js similarity index 74% rename from test/unit/test-builder.js rename to test/unit/test-scheduler.js index e6ffd9ed4aba9..7a47ca4739dbf 100644 --- a/test/unit/test-builder.js +++ b/test/unit/test-scheduler.js @@ -15,18 +15,18 @@ */ import * as fakeTimers from '@sinonjs/fake-timers'; -import {Builder} from '../../src/service/builder'; +import {Scheduler} from '../../src/service/scheduler'; import {LayoutPriority} from '../../src/layout'; import {READY_SCAN_SIGNAL} from '../../src/service/resources-interface'; import {createElementWithAttributes} from '../../src/dom'; import {installIntersectionObserverStub} from '../../testing/intersection-observer-stub'; -describes.realWin('Builder', {amp: true}, (env) => { +describes.realWin('Scheduler', {amp: true}, (env) => { let win, doc, ampdoc; let setAmpdocReady; let clock; let intersectionObserverStub; - let builder; + let scheduler; beforeEach(() => { win = env.win; @@ -57,7 +57,7 @@ describes.realWin('Builder', {amp: true}, (env) => { win ); - builder = new Builder(ampdoc); + scheduler = new Scheduler(ampdoc); }); afterEach(() => { @@ -70,29 +70,29 @@ describes.realWin('Builder', {amp: true}, (env) => { element.prerenderAllowed = () => options.prerenderAllowed || false; element.getBuildPriority = () => options.buildPriority || LayoutPriority.CONTENT; - element.buildInternal = env.sandbox.stub(); + element.mountInternal = env.sandbox.stub(); return element; } describe('schedule', () => { it('should schedule a deferredBuild element', () => { const element = createAmpElement({deferredBuild: true}); - builder.schedule(element); + scheduler.schedule(element); expect(intersectionObserverStub.isObserved(element)).to.be.true; - builder.unschedule(element); + scheduler.unschedule(element); expect(intersectionObserverStub.isObserved(element)).to.be.false; }); it('should schedule a non-deferredBuild element', () => { const element = createAmpElement({deferredBuild: false}); - builder.schedule(element); + scheduler.schedule(element); expect(intersectionObserverStub.isObserved(element)).to.be.false; }); it('should unschedule when built', async () => { const element = createAmpElement({deferredBuild: true}); - builder.schedule(element); + scheduler.schedule(element); expect(intersectionObserverStub.isObserved(element)).to.be.true; await setAmpdocReady(); @@ -106,7 +106,7 @@ describes.realWin('Builder', {amp: true}, (env) => { it('should NOT signal READY_SCAN_SIGNAL until document is ready', async () => { ampdoc.signals().reset(READY_SCAN_SIGNAL); const element = createAmpElement({deferredBuild: false}); - builder.schedule(element); + scheduler.schedule(element); expect(ampdoc.signals().get(READY_SCAN_SIGNAL)).to.be.null; clock.tick(50); @@ -125,78 +125,78 @@ describes.realWin('Builder', {amp: true}, (env) => { it('should build when document ready', async () => { await setAmpdocReady(); const element = createAmpElement({deferredBuild: false}); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should build when document becomes ready', async () => { const element = createAmpElement({deferredBuild: false}); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.be.not.called; + expect(element.mountInternal).to.be.not.called; await setAmpdocReady(); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should build asap when document ready', async () => { await setAmpdocReady(); const element = createAmpElement({deferredBuild: true}); - builder.scheduleAsap(element); + scheduler.scheduleAsap(element); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should build asap when document becomes ready', async () => { const element = createAmpElement({deferredBuild: true}); - builder.scheduleAsap(element); + scheduler.scheduleAsap(element); clock.tick(1); - expect(element.buildInternal).to.be.not.called; + expect(element.mountInternal).to.be.not.called; await setAmpdocReady(); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should build when has next siblings', async () => { const element = createAmpElement({deferredBuild: false}); doc.body.appendChild(element); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; const element2 = createAmpElement({deferredBuild: false}); doc.body.appendChild(element2); - builder.schedule(element2); + scheduler.schedule(element2); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; - expect(element2.buildInternal).to.not.be.called; + expect(element.mountInternal).to.be.calledOnce; + expect(element2.mountInternal).to.not.be.called; }); it('should build asap when has next siblings', async () => { const element = createAmpElement({deferredBuild: false}); doc.body.appendChild(element); - builder.scheduleAsap(element); + scheduler.scheduleAsap(element); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; const element2 = createAmpElement({deferredBuild: false}); doc.body.appendChild(element2); - builder.scheduleAsap(element2); + scheduler.scheduleAsap(element2); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; - expect(element2.buildInternal).to.not.be.called; + expect(element.mountInternal).to.be.calledOnce; + expect(element2.mountInternal).to.not.be.called; }); it('should wait the deferred even when parsed', async () => { await setAmpdocReady(); const element = createAmpElement({deferredBuild: true}); doc.body.appendChild(element); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; }); }); @@ -211,9 +211,9 @@ describes.realWin('Builder', {amp: true}, (env) => { deferredBuild: false, prerenderAllowed: true, }); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should build asap if prerenderAllowed', () => { @@ -221,9 +221,9 @@ describes.realWin('Builder', {amp: true}, (env) => { deferredBuild: true, prerenderAllowed: true, }); - builder.scheduleAsap(element); + scheduler.scheduleAsap(element); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should NOT build if not prerenderAllowed', () => { @@ -231,9 +231,9 @@ describes.realWin('Builder', {amp: true}, (env) => { deferredBuild: false, prerenderAllowed: false, }); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.be.not.called; + expect(element.mountInternal).to.be.not.called; }); it('should NOT build asap if not prerenderAllowed', () => { @@ -241,72 +241,72 @@ describes.realWin('Builder', {amp: true}, (env) => { deferredBuild: true, prerenderAllowed: false, }); - builder.scheduleAsap(element); + scheduler.scheduleAsap(element); clock.tick(1); - expect(element.buildInternal).to.be.not.called; + expect(element.mountInternal).to.be.not.called; }); it('should build when becomes visible', () => { const element = createAmpElement({prerenderAllowed: false}); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; ampdoc.overrideVisibilityState('visible'); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should build when becomes hidden', () => { const element = createAmpElement({prerenderAllowed: false}); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; ampdoc.overrideVisibilityState('hidden'); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should NOT build when becomes paused or inactive', () => { const element = createAmpElement({prerenderAllowed: false}); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; ampdoc.overrideVisibilityState('paused'); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; ampdoc.overrideVisibilityState('inactive'); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; }); it('should NOT build when scheduled in paused', () => { ampdoc.overrideVisibilityState('paused'); const element = createAmpElement({prerenderAllowed: false}); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; ampdoc.overrideVisibilityState('visible'); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should NOT build when scheduled in inactive', () => { ampdoc.overrideVisibilityState('inactive'); const element = createAmpElement({prerenderAllowed: false}); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; ampdoc.overrideVisibilityState('visible'); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); }); @@ -317,40 +317,40 @@ describes.realWin('Builder', {amp: true}, (env) => { it('should wait for intersection when deferred', () => { const element = createAmpElement({deferredBuild: true}); - builder.schedule(element); + scheduler.schedule(element); expect(intersectionObserverStub.isObserved(element)).to.be.true; clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; intersectionObserverStub.notifySync({ target: element, isIntersecting: false, }); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; intersectionObserverStub.notifySync({ target: element, isIntersecting: true, }); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should not wait for intersection when not deferred', () => { const element = createAmpElement({deferredBuild: false}); - builder.schedule(element); + scheduler.schedule(element); expect(intersectionObserverStub.isObserved(element)).to.be.false; clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should not wait for intersection when asap', () => { const element = createAmpElement({deferredBuild: true}); - builder.scheduleAsap(element); + scheduler.scheduleAsap(element); expect(intersectionObserverStub.isObserved(element)).to.be.false; clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); }); @@ -361,13 +361,13 @@ describes.realWin('Builder', {amp: true}, (env) => { it('should run deferred CONTENT at high priority', () => { const element = createAmpElement({deferredBuild: true}); - builder.schedule(element); + scheduler.schedule(element); intersectionObserverStub.notifySync({ target: element, isIntersecting: true, }); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should run deferred METADATA at low priority', () => { @@ -375,16 +375,16 @@ describes.realWin('Builder', {amp: true}, (env) => { deferredBuild: true, buildPriority: LayoutPriority.METADATA, }); - builder.schedule(element); + scheduler.schedule(element); intersectionObserverStub.notifySync({ target: element, isIntersecting: true, }); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; clock.tick(100); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should run non-deferred METADATA at low priority', () => { @@ -392,12 +392,12 @@ describes.realWin('Builder', {amp: true}, (env) => { deferredBuild: false, buildPriority: LayoutPriority.METADATA, }); - builder.schedule(element); + scheduler.schedule(element); clock.tick(1); - expect(element.buildInternal).to.not.be.called; + expect(element.mountInternal).to.not.be.called; clock.tick(100); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); it('should run asap METADATA at high priority', () => { @@ -405,9 +405,9 @@ describes.realWin('Builder', {amp: true}, (env) => { deferredBuild: false, buildPriority: LayoutPriority.METADATA, }); - builder.scheduleAsap(element); + scheduler.scheduleAsap(element); clock.tick(1); - expect(element.buildInternal).to.be.calledOnce; + expect(element.mountInternal).to.be.calledOnce; }); }); }); diff --git a/testing/element-v1.js b/testing/element-v1.js index e1a337ae3aedc..cd4d9b71abb7f 100644 --- a/testing/element-v1.js +++ b/testing/element-v1.js @@ -29,7 +29,8 @@ const RULES = [ }, { - name: 'If has getLayoutPriority, must also have getBuildPriority', + name: 'Must not have getLayoutPriority', + notes: 'Can be replaced with getBuildPriority', test: (implClass) => { const hasLayoutPriority = implClass.prototype.getLayoutPriority !== @@ -39,28 +40,44 @@ const RULES = [ return !hasLayoutPriority || hasBuildPriority; }, }, - { - name: 'If has preconnectCallback, must also have getPreconnects', + name: 'Must not have preconnectCallback', + notes: 'Can be replaced with getPreconnects', test: (implClass) => { - const hasPreconnectCallback = + const hasCallback = implClass.prototype.preconnectCallback !== BaseElement.prototype.preconnectCallback; - const hasGetPreconnects = - implClass.getPreconnects !== BaseElement.getPreconnects; - return !hasPreconnectCallback || hasGetPreconnects; + return !hasCallback; }, }, - { - name: 'If has layoutCallback, must also have ensureLoaded', + name: 'Must not have layoutCallback', + notes: 'Can be replaced with mountCallback', test: (implClass) => { - const hasLayoutCallback = + const hasCallback = implClass.prototype.layoutCallback !== BaseElement.prototype.layoutCallback; + return !hasCallback; + }, + }, + { + name: 'Must not have unlayoutCallback', + notes: 'Can be replaced with unmountCallback', + test: (implClass) => { + const hasCallback = + implClass.prototype.unlayoutCallback !== + BaseElement.prototype.unlayoutCallback; + return !hasCallback; + }, + }, + + { + name: 'If load==true, must also have ensureLoaded', + test: (implClass) => { + const load = implClass.load(); const hasEnsureLoaded = implClass.prototype.ensureLoaded !== BaseElement.prototype.ensureLoaded; - return !hasLayoutCallback || hasEnsureLoaded; + return !load || hasEnsureLoaded; }, }, @@ -114,11 +131,11 @@ const RULES = [ */ export function testElementV1(implClass, options = {}) { const exceptions = options.exceptions || []; - RULES.forEach(({name, test}) => { + RULES.forEach(({name, notes, test}) => { if (exceptions.includes(name)) { expect(test(implClass), 'unused exception: ' + name).to.be.false; } else { - expect(test(implClass), name).to.be.true; + expect(test(implClass), name + (notes ? `. ${notes}` : '')).to.be.true; } }); }