diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js index 520aacba391a..eef89b25a2ce 100644 --- a/build-system/tasks/presubmit-checks.js +++ b/build-system/tasks/presubmit-checks.js @@ -274,14 +274,6 @@ const forbiddenTerms = { 'src/service/viewer-impl.js', ], }, - 'setViewerVisibilityState': { - message: privateServiceFactory, - whitelist: [ - 'src/runtime.js', - 'src/service/core-services.js', - 'src/service/viewer-impl.js', - ], - }, 'installViewportServiceForDoc': { message: privateServiceFactory, whitelist: [ @@ -607,6 +599,14 @@ const forbiddenTerms = { 'src/service/resources-impl.js', ], }, + 'overrideVisibilityState': { + message: 'overrideVisibilityState is a restricted API.', + whitelist: [ + 'src/runtime.js', + 'src/service/ampdoc-impl.js', + 'src/service/viewer-impl.js', + ], + }, '(win|Win)(dow)?(\\(\\))?\\.open\\W': { message: 'Use dom.openWindowDialog', whitelist: ['src/dom.js'], diff --git a/extensions/amp-a4a/0.1/test/test-amp-a4a.js b/extensions/amp-a4a/0.1/test/test-amp-a4a.js index aece2eeb33b3..6844b3b4b1f9 100644 --- a/extensions/amp-a4a/0.1/test/test-amp-a4a.js +++ b/extensions/amp-a4a/0.1/test/test-amp-a4a.js @@ -36,6 +36,11 @@ import { assignAdUrlToError, protectFunctionWrapper, } from '../amp-a4a'; +import { + AmpDoc, + installDocService, + updateFieModeForTesting, +} from '../../../../src/service/ampdoc-impl'; import {CONSENT_POLICY_STATE} from '../../../../src/consent-state'; import {Extensions} from '../../../../src/service/extensions-impl'; import {FetchMock, networkFailure} from './fetch-mock'; @@ -49,7 +54,6 @@ import { } from '../../../../ads/google/a4a/traffic-experiments'; import {Services} from '../../../../src/services'; import {Signals} from '../../../../src/utils/signals'; -import {Viewer} from '../../../../src/service/viewer-impl'; import {cancellation} from '../../../../src/error'; import {createElementWithAttributes} from '../../../../src/dom'; import {createIframePromise} from '../../../../testing/iframe'; @@ -59,10 +63,7 @@ import { incrementLoadingAds, is3pThrottled, } from '../../../amp-ad/0.1/concurrent-load'; -import { - installDocService, - updateFieModeForTesting, -} from '../../../../src/service/ampdoc-impl'; + import {layoutRectLtwh} from '../../../../src/layout-rect'; import {resetScheduledElementForTesting} from '../../../../src/service/custom-element-registry'; import {data as testFragments} from './testdata/test_fragments'; @@ -84,7 +85,7 @@ describe('amp-a4a', () => { let sandbox; let fetchMock; let getSigningServiceNamesMock; - let viewerWhenVisibleMock; + let whenVisibleMock; let adResponse; let onCreativeRenderSpy; let getResourceStub; @@ -98,8 +99,8 @@ describe('amp-a4a', () => { ); onCreativeRenderSpy = sandbox.spy(AmpA4A.prototype, 'onCreativeRender'); getSigningServiceNamesMock.returns(['google']); - viewerWhenVisibleMock = sandbox.stub(Viewer.prototype, 'whenFirstVisible'); - viewerWhenVisibleMock.returns(Promise.resolve()); + whenVisibleMock = sandbox.stub(AmpDoc.prototype, 'whenFirstVisible'); + whenVisibleMock.returns(Promise.resolve()); getResourceStub = sandbox.stub(AmpA4A.prototype, 'getResource'); getResourceStub.returns({ getUpgradeDelayMs: () => 12345, @@ -2322,7 +2323,7 @@ describe('amp-a4a', () => { return createIframePromise().then(fixture => { setupForAdTesting(fixture); let whenFirstVisibleResolve = null; - viewerWhenVisibleMock.returns( + whenVisibleMock.returns( new Promise(resolve => { whenFirstVisibleResolve = resolve; }) diff --git a/extensions/amp-ad/0.1/test/test-amp-ad-3p-impl.js b/extensions/amp-ad/0.1/test/test-amp-ad-3p-impl.js index 469f24e13a0f..1df6e5e1ba39 100644 --- a/extensions/amp-ad/0.1/test/test-amp-ad-3p-impl.js +++ b/extensions/amp-ad/0.1/test/test-amp-ad-3p-impl.js @@ -26,7 +26,6 @@ import {Services} from '../../../../src/services'; import {adConfig} from '../../../../ads/_config'; import {createElementWithAttributes} from '../../../../src/dom'; import {macroTask} from '../../../../testing/yield'; -import {stubService} from '../../../../testing/test-helper'; import {user} from '../../../../src/log'; function createAmpAd(win, attachToAmpdoc = false, ampdoc) { @@ -79,9 +78,7 @@ describes.realWin( win.document.body.appendChild(ad3p.element); ad3p.buildCallback(); // Turn the doc to visible so prefetch will be proceeded. - stubService(sandbox, win, 'viewer', 'whenFirstVisible').returns( - whenFirstVisible - ); + sandbox.stub(env.ampdoc, 'whenFirstVisible').returns(whenFirstVisible); }); afterEach(() => { diff --git a/extensions/amp-analytics/0.1/test/test-visibility-manager.js b/extensions/amp-analytics/0.1/test/test-visibility-manager.js index b1f88aaf91c3..35f2e3df936f 100644 --- a/extensions/amp-analytics/0.1/test/test-visibility-manager.js +++ b/extensions/amp-analytics/0.1/test/test-visibility-manager.js @@ -81,7 +81,7 @@ describes.fakeWin('VisibilityManagerForDoc', {amp: true}, env => { viewer = win.services.viewer.obj; sandbox.stub(viewer, 'getFirstVisibleTime').callsFake(() => 1); viewport = win.services.viewport.obj; - startVisibilityHandlerCount = viewer.visibilityObservable_.getHandlerCount(); + startVisibilityHandlerCount = getVisibilityHandlerCount(); root = new VisibilityManagerForDoc(ampdoc); @@ -94,6 +94,10 @@ describes.fakeWin('VisibilityManagerForDoc', {amp: true}, env => { }); }); + function getVisibilityHandlerCount() { + return ampdoc.visibilityStateHandlers_.getHandlerCount(); + } + it('should initialize correctly backgrounded', () => { viewer.setVisibilityState_(VisibilityState.HIDDEN); root = new VisibilityManagerForDoc(ampdoc); @@ -164,9 +168,7 @@ describes.fakeWin('VisibilityManagerForDoc', {amp: true}, env => { }); it('should switch visibility based on viewer for main doc', () => { - expect(viewer.visibilityObservable_.getHandlerCount()).equal( - startVisibilityHandlerCount + 1 - ); + expect(getVisibilityHandlerCount()).equal(startVisibilityHandlerCount + 1); expect(root.getRootVisibility()).to.equal(1); // Go prerender. @@ -338,9 +340,7 @@ describes.fakeWin('VisibilityManagerForDoc', {amp: true}, env => { expect(otherUnsubscribes.callCount).to.equal(2); // Viewer and viewport have been unsubscribed. - expect(viewer.visibilityObservable_.getHandlerCount()).equal( - startVisibilityHandlerCount - ); + expect(getVisibilityHandlerCount()).equal(startVisibilityHandlerCount); // Intersection observer disconnected. expect(inOb.disconnected).to.be.true; @@ -841,7 +841,7 @@ describes.realWin( expect(root.getRootVisibility()).to.equal(0); }); - it('should initialize correctly foregrounded', () => { + it('should initialize correctly in foreground', () => { expect(root.parent).to.equal(parentRoot); expect(root.ampdoc).to.equal(ampdoc); expect(root.getStartTime()).to.equal(embed.getStartTime()); diff --git a/extensions/amp-install-serviceworker/0.1/test/test-amp-install-serviceworker.js b/extensions/amp-install-serviceworker/0.1/test/test-amp-install-serviceworker.js index 48235948256b..0b7b72430cbe 100644 --- a/extensions/amp-install-serviceworker/0.1/test/test-amp-install-serviceworker.js +++ b/extensions/amp-install-serviceworker/0.1/test/test-amp-install-serviceworker.js @@ -25,7 +25,6 @@ import { } from '../../../../src/url'; import {loadPromise} from '../../../../src/event-helper'; import { - registerServiceBuilder, registerServiceBuilderForDoc, resetServiceForTesting, } from '../../../../src/service'; @@ -55,7 +54,6 @@ describes.realWin( let container; let ampdoc; let maybeInstallUrlRewriteStub; - let whenVisible; beforeEach(() => { doc = env.win.document; @@ -94,13 +92,9 @@ describes.realWin( }, }, }; - whenVisible = Promise.resolve(); - registerServiceBuilder(implementation.win, 'viewer', function() { - return { - whenFirstVisible: () => whenVisible, - isVisible: () => true, - }; - }); + const whenVisible = Promise.resolve(); + sandbox.stub(ampdoc, 'whenFirstVisible').returns(whenVisible); + sandbox.stub(ampdoc, 'isVisible').returns(true); implementation.buildCallback(); expect(calledSrc).to.be.undefined; return Promise.all([whenVisible, loadPromise(implementation.win)]).then( @@ -137,13 +131,9 @@ describes.realWin( }, }, }; - whenVisible = Promise.resolve(); - registerServiceBuilder(implementation.win, 'viewer', function() { - return { - whenFirstVisible: () => whenVisible, - isVisible: () => true, - }; - }); + const whenVisible = Promise.resolve(); + sandbox.stub(ampdoc, 'whenFirstVisible').returns(whenVisible); + sandbox.stub(ampdoc, 'isVisible').returns(true); implementation.buildCallback(); expect(calledSrc).to.be.undefined; return Promise.all([whenVisible, loadPromise(implementation.win)]).then( @@ -207,13 +197,9 @@ describes.realWin( }, }, }; - whenVisible = Promise.resolve(); - registerServiceBuilder(implementation.win, 'viewer', function() { - return { - whenFirstVisible: () => whenVisible, - isVisible: () => true, - }; - }); + const whenVisible = Promise.resolve(); + sandbox.stub(ampdoc, 'whenFirstVisible').returns(whenVisible); + sandbox.stub(ampdoc, 'isVisible').returns(true); implementation.buildCallback(); return Promise.all([whenVisible, loadPromise(implementation.win)]).then( () => { @@ -279,13 +265,9 @@ describes.realWin( }, }, }; - whenVisible = Promise.resolve(); - registerServiceBuilder(implementation.win, 'viewer', function() { - return { - whenFirstVisible: () => whenVisible, - isVisible: () => true, - }; - }); + const whenVisible = Promise.resolve(); + sandbox.stub(ampdoc, 'whenFirstVisible').returns(whenVisible); + sandbox.stub(ampdoc, 'isVisible').returns(true); implementation.buildCallback(); return Promise.all([whenVisible, loadPromise(implementation.win)]).then( () => { @@ -418,12 +400,8 @@ describes.realWin( }; }); whenVisible = Promise.resolve(); - registerServiceBuilder(win, 'viewer', function() { - return { - whenFirstVisible: () => whenVisible, - isVisible: () => true, - }; - }); + sandbox.stub(ampdoc, 'whenFirstVisible').returns(whenVisible); + sandbox.stub(ampdoc, 'isVisible').returns(true); }); function testIframe(callCount = 1) { diff --git a/extensions/amp-skimlinks/0.1/test/test-amp-skimlinks.js b/extensions/amp-skimlinks/0.1/test/test-amp-skimlinks.js index 550409e3edb3..64e5b7058cef 100644 --- a/extensions/amp-skimlinks/0.1/test/test-amp-skimlinks.js +++ b/extensions/amp-skimlinks/0.1/test/test-amp-skimlinks.js @@ -31,9 +31,10 @@ describes.fakeWin( }, }, env => { - let ampSkimlinks, helpers; + let ampSkimlinks, helpers, ampdoc; beforeEach(() => { + ampdoc = env.ampdoc; helpers = helpersFactory(env); ampSkimlinks = helpers.createAmpSkimlinks({ 'publisher-code': 'pubIdXdomainId', @@ -44,6 +45,10 @@ describes.fakeWin( env.sandbox.restore(); }); + function nextMicroTask() { + return Promise.resolve().then(() => Promise.resolve()); + } + describe('skimOptions', () => { it('Should raise an error if publisher-code is missing', () => { ampSkimlinks = helpers.createAmpSkimlinks(); @@ -293,29 +298,32 @@ describes.fakeWin( }); it('Should send the impression tracking if visible', () => { - return ampSkimlinks.onPageScanned_().then(() => { - const stub = ampSkimlinks.trackingService_.sendImpressionTracking; - expect(stub.calledOnce).to.be.true; - }); + return ampSkimlinks + .onPageScanned_() + .then(nextMicroTask) + .then(() => { + const stub = ampSkimlinks.trackingService_.sendImpressionTracking; + expect(stub.calledOnce).to.be.true; + }); }); it('Should wait until visible to send the impression tracking', () => { const isVisibleDefer = new Deferred(); - const fakeViewer = { - whenFirstVisible: env.sandbox - .stub() - .returns(isVisibleDefer.promise), - }; - helpers.mockServiceGetter('viewerForDoc', fakeViewer); - - return ampSkimlinks.onPageScanned_().then(() => { - const stub = ampSkimlinks.trackingService_.sendImpressionTracking; - expect(stub.called).to.be.false; - isVisibleDefer.resolve(); - return isVisibleDefer.promise.then(() => { - expect(stub.calledOnce).to.be.true; + sandbox + .stub(ampdoc, 'whenFirstVisible') + .returns(isVisibleDefer.promise); + + return ampSkimlinks + .onPageScanned_() + .then(nextMicroTask) + .then(() => { + const stub = ampSkimlinks.trackingService_.sendImpressionTracking; + expect(stub.called).to.be.false; + isVisibleDefer.resolve(); + return isVisibleDefer.promise.then(() => { + expect(stub.calledOnce).to.be.true; + }); }); - }); }); it('Should update tracking info with the guid', () => { @@ -350,10 +358,13 @@ describes.fakeWin( }); it('Should send the impression tracking if visible', () => { - return ampSkimlinks.onPageScanned_().then(() => { - const stub = ampSkimlinks.trackingService_.sendImpressionTracking; - expect(stub.calledOnce).to.be.true; - }); + return ampSkimlinks + .onPageScanned_() + .then(nextMicroTask) + .then(() => { + const stub = ampSkimlinks.trackingService_.sendImpressionTracking; + expect(stub.calledOnce).to.be.true; + }); }); it('Should wait until visible to send the impression tracking', () => { diff --git a/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js b/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js index 1996a8c3c70e..d7f4b6b39b25 100644 --- a/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js +++ b/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js @@ -114,9 +114,15 @@ describes.fakeWin( env.sandbox.spy(ampSmartlinks, 'getLinkmateOptions_'); env.sandbox.stub(xhr, 'fetchJson'); - return ampSmartlinks.buildCallback().then(() => { - expect(ampSmartlinks.getLinkmateOptions_.calledOnce).to.be.true; - }); + return ( + ampSmartlinks + .buildCallback() + // Skip microtask. + .then(() => Promise.resolve()) + .then(() => { + expect(ampSmartlinks.getLinkmateOptions_.calledOnce).to.be.true; + }) + ); }); }); diff --git a/src/preconnect.js b/src/preconnect.js index 0b5f1978a16b..3bbf4d6944b3 100644 --- a/src/preconnect.js +++ b/src/preconnect.js @@ -112,7 +112,7 @@ class PreconnectService { /** * Preconnects to a URL. Always also does a dns-prefetch because * browser support for that is better. - * @param {!./service/viewer-impl.Viewer} viewer + * @param {!./service/ampdoc-impl.AmpDoc} ampdoc * @param {string} url * @param {boolean=} opt_alsoConnecting Set this flag if you also just * did or are about to connect to this host. This is for the case @@ -122,16 +122,16 @@ class PreconnectService { * when it is more fully rendered, you already know that the connection * will be used very soon. */ - url(viewer, url, opt_alsoConnecting) { - viewer.whenFirstVisible().then(() => { - this.url_(viewer, url, opt_alsoConnecting); + url(ampdoc, url, opt_alsoConnecting) { + ampdoc.whenFirstVisible().then(() => { + this.url_(ampdoc, url, opt_alsoConnecting); }); } /** * Preconnects to a URL. Always also does a dns-prefetch because * browser support for that is better. - * @param {!./service/viewer-impl.Viewer} viewer + * @param {!./service/ampdoc-impl.AmpDoc} ampdoc * @param {string} url * @param {boolean=} opt_alsoConnecting Set this flag if you also just * did or are about to connect to this host. This is for the case @@ -140,8 +140,9 @@ class PreconnectService { * E.g. when you preconnect to a host that an embed will connect to * when it is more fully rendered, you already know that the connection * will be used very soon. + * @private */ - url_(viewer, url, opt_alsoConnecting) { + url_(ampdoc, url, opt_alsoConnecting) { if (!this.isInterestingUrl_(url)) { return; } @@ -185,18 +186,18 @@ class PreconnectService { } }, 10000); - this.preconnectPolyfill_(viewer, origin); + this.preconnectPolyfill_(ampdoc, origin); } /** * Asks the browser to preload a URL. Always also does a preconnect * because browser support for that is better. * - * @param {!./service/viewer-impl.Viewer} viewer + * @param {!./service/ampdoc-impl.AmpDoc} ampdoc * @param {string} url * @param {string=} opt_preloadAs */ - preload(viewer, url, opt_preloadAs) { + preload(ampdoc, url, opt_preloadAs) { if (!this.isInterestingUrl_(url)) { return; } @@ -204,7 +205,7 @@ class PreconnectService { return; } this.urls_[url] = true; - this.url(viewer, url, /* opt_alsoConnecting */ true); + this.url(ampdoc, url, /* opt_alsoConnecting */ true); if (!this.features_.preload) { return; } @@ -216,7 +217,7 @@ class PreconnectService { // as attribute). return; } - viewer.whenFirstVisible().then(() => { + ampdoc.whenFirstVisible().then(() => { this.performPreload_(url); }); } @@ -275,11 +276,11 @@ class PreconnectService { * This is expected and fine to leave as is. Its fine to send a non 404 * response, but please make it small :) * - * @param {!./service/viewer-impl.Viewer} viewer + * @param {!./service/ampdoc-impl.AmpDoc} ampdoc * @param {string} origin * @private */ - preconnectPolyfill_(viewer, origin) { + preconnectPolyfill_(ampdoc, origin) { // Unfortunately there is no reliable way to feature detect whether // preconnect is supported, so we do this only in Safari, which is // the most important browser without support for it. @@ -329,19 +330,19 @@ export class Preconnect { /** @const @private {!Element} */ this.element_ = element; - /** @private {?./service/viewer-impl.Viewer} */ - this.viewer_ = null; + /** @private {?./service/ampdoc-impl.AmpDoc} */ + this.ampdoc_ = null; } /** - * @return {!./service/viewer-impl.Viewer} + * @return {!./service/ampdoc-impl.AmpDoc} * @private */ - getViewer_() { - if (!this.viewer_) { - this.viewer_ = Services.viewerForDoc(this.element_); + getAmpdoc_() { + if (!this.ampdoc_) { + this.ampdoc_ = Services.ampdoc(this.element_); } - return this.viewer_; + return this.ampdoc_; } /** @@ -357,7 +358,7 @@ export class Preconnect { * will be used very soon. */ url(url, opt_alsoConnecting) { - this.preconnectService_.url(this.getViewer_(), url, opt_alsoConnecting); + this.preconnectService_.url(this.getAmpdoc_(), url, opt_alsoConnecting); } /** @@ -368,7 +369,7 @@ export class Preconnect { * @param {string=} opt_preloadAs */ preload(url, opt_preloadAs) { - this.preconnectService_.preload(this.getViewer_(), url, opt_preloadAs); + this.preconnectService_.preload(this.getAmpdoc_(), url, opt_preloadAs); } } diff --git a/src/runtime.js b/src/runtime.js index 371312bd15ad..5c72838911c0 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -58,7 +58,6 @@ import {isExperimentOn, toggleExperiment} from './experiments'; import {parseUrlDeprecated} from './url'; import {reportErrorForWin} from './error'; import {setStyle} from './style'; -import {setViewerVisibilityState} from './service/viewer-impl'; import {startupChunk} from './chunk'; import {stubElementsForDoc} from './service/custom-element-registry'; @@ -465,7 +464,7 @@ export class MultidocManager { * @param {!VisibilityState} state */ amp['setVisibilityState'] = function(state) { - setViewerVisibilityState(viewer, state); + ampdoc.overrideVisibilityState(state); }; // Messaging pipe. @@ -812,10 +811,7 @@ export class MultidocManager { const amp = shadowRoot.AMP; delete shadowRoot.AMP; const {ampdoc} = amp; - setViewerVisibilityState( - Services.viewerForDoc(ampdoc), - VisibilityState.INACTIVE - ); + ampdoc.overrideVisibilityState(VisibilityState.INACTIVE); disposeServicesForDoc(ampdoc); } diff --git a/src/service/ampdoc-impl.js b/src/service/ampdoc-impl.js index 6495fe99233a..e9a765bd84c2 100644 --- a/src/service/ampdoc-impl.js +++ b/src/service/ampdoc-impl.js @@ -15,7 +15,14 @@ */ import {Deferred} from '../utils/promise'; +import {Observable} from '../observable'; import {Signals} from '../utils/signals'; +import {VisibilityState} from '../visibility-state'; +import { + addDocumentVisibilityChangeListener, + getDocumentVisibilityState, + removeDocumentVisibilityChangeListener, +} from '../utils/document-visibility'; import {dev, devAssert} from '../log'; import {getParentWindowFrameElement, registerServiceBuilder} from '../service'; import {getShadowRootNode} from '../shadow-embed'; @@ -35,10 +42,22 @@ const PARAMS_SENTINEL = '__AMP__'; * @typedef {{ * params: (!Object|undefined), * signals: (?Signals|undefined), + * visibilityState: (?VisibilityState|undefined), * }} */ export let AmpDocOptions; +/** + * Private ampdoc signals. + * @enum {string} + */ +const AmpDocSignals = { + // Signals the document has become visible for the first time. + FIRST_VISIBLE: '-ampdoc-first-visible', + // Signals when the document becomes visible the next time. + NEXT_VISIBLE: '-ampdoc-next-visible', +}; + /** * This service helps locate an ampdoc (`AmpDoc` instance) for any node, * either in the single-doc or shadow-doc environments. @@ -263,12 +282,16 @@ export class AmpDocService { export class AmpDoc { /** * @param {!Window} win + * @param {?AmpDoc} parent * @param {!AmpDocOptions=} opt_options */ - constructor(win, opt_options) { + constructor(win, parent, opt_options) { /** @public @const {!Window} */ this.win = win; + /** @public @const {?AmpDoc} */ + this.parent_ = parent; + /** @private @const */ this.signals_ = (opt_options && opt_options.signals) || new Signals(); @@ -277,12 +300,58 @@ export class AmpDoc { /** @private @const {!Array} */ this.declaredExtensions_ = []; + + /** @private {?VisibilityState} */ + this.visibilityStateOverride_ = + (opt_options && opt_options.visibilityState) || + (this.params_['visibilityState'] && + dev().assertEnumValue( + VisibilityState, + this.params_['visibilityState'], + 'VisibilityState' + )) || + null; + + // Start with `null` to be updated by updateVisibilityState_ in the end + // of the constructor to ensure the correct "update" logic and promise + // resolution. + /** @private {?VisibilityState} */ + this.visibilityState_ = null; + + /** @private @const {!Observable} */ + this.visibilityStateHandlers_ = new Observable(); + + /** @private {?time} */ + this.lastVisibleTime_ = null; + + /** @private @const {!Array} */ + this.unsubsribes_ = []; + + const boundUpdateVisibilityState = this.updateVisibilityState_.bind(this); + if (this.parent_) { + this.unsubsribes_.push( + this.parent_.onVisibilityChanged(boundUpdateVisibilityState) + ); + } + addDocumentVisibilityChangeListener( + this.win.document, + boundUpdateVisibilityState + ); + this.unsubsribes_.push(() => + removeDocumentVisibilityChangeListener( + this.win.document, + boundUpdateVisibilityState + ) + ); + this.updateVisibilityState_(); } /** * Dispose the document. */ - dispose() {} + dispose() { + this.unsubsribes_.forEach(unsubsribe => unsubsribe()); + } /** * Whether the runtime in the single-doc mode. Alternative is the shadow-doc @@ -298,7 +367,7 @@ export class AmpDoc { * @return {?AmpDoc} */ getParent() { - return null; + return this.parent_; } /** @@ -441,6 +510,155 @@ export class AmpDoc { contains(node) { return this.getRootNode().contains(node); } + + /** + * @param {!VisibilityState} visibilityState + * @restricted + */ + overrideVisibilityState(visibilityState) { + if (this.visibilityStateOverride_ != visibilityState) { + this.visibilityStateOverride_ = visibilityState; + this.updateVisibilityState_(); + } + } + + /** @private */ + updateVisibilityState_() { + // Natural visibility state. + const naturalVisibilityState = getDocumentVisibilityState( + this.win.document + ); + + // Parent visibility: pick the first non-visible state. + let parentVisibilityState = VisibilityState.VISIBLE; + for (let p = this.parent_; p; p = p.getParent()) { + if (p.getVisibilityState() != VisibilityState.VISIBLE) { + parentVisibilityState = p.getVisibilityState(); + break; + } + } + + // Pick the most restricted visibility state. + let visibilityState; + const visibilityStateOverride = + this.visibilityStateOverride_ || VisibilityState.VISIBLE; + if ( + visibilityStateOverride == VisibilityState.VISIBLE && + parentVisibilityState == VisibilityState.VISIBLE && + naturalVisibilityState == VisibilityState.VISIBLE + ) { + visibilityState = VisibilityState.VISIBLE; + } else if ( + naturalVisibilityState == VisibilityState.HIDDEN && + visibilityStateOverride == VisibilityState.PAUSED + ) { + // Hidden document state overrides "paused". + visibilityState = naturalVisibilityState; + } else if ( + visibilityStateOverride == VisibilityState.PAUSED || + visibilityStateOverride == VisibilityState.INACTIVE + ) { + visibilityState = visibilityStateOverride; + } else if ( + parentVisibilityState == VisibilityState.PAUSED || + parentVisibilityState == VisibilityState.INACTIVE + ) { + visibilityState = parentVisibilityState; + } else if ( + visibilityStateOverride == VisibilityState.PRERENDER || + naturalVisibilityState == VisibilityState.PRERENDER || + parentVisibilityState == VisibilityState.PRERENDER + ) { + visibilityState = VisibilityState.PRERENDER; + } else { + visibilityState = VisibilityState.HIDDEN; + } + + if (this.visibilityState_ != visibilityState) { + this.visibilityState_ = visibilityState; + if (visibilityState == VisibilityState.VISIBLE) { + this.lastVisibleTime_ = Date.now(); + this.signals_.signal(AmpDocSignals.FIRST_VISIBLE); + this.signals_.signal(AmpDocSignals.NEXT_VISIBLE); + } else { + this.signals_.reset(AmpDocSignals.NEXT_VISIBLE); + } + this.visibilityStateHandlers_.fire(); + } + } + + /** + * Returns a Promise that only ever resolved when the current + * AMP document first becomes visible. + * @return {!Promise} + */ + whenFirstVisible() { + return this.signals_ + .whenSignal(AmpDocSignals.FIRST_VISIBLE) + .then(() => undefined); + } + + /** + * Returns a Promise that resolve when current doc becomes visible. + * The promise resolves immediately if doc is already visible. + * @return {!Promise} + */ + whenNextVisible() { + return this.signals_ + .whenSignal(AmpDocSignals.NEXT_VISIBLE) + .then(() => undefined); + } + + /** + * Returns the time when the document has become visible for the first time. + * If document has not yet become visible, the returned value is `null`. + * @return {?time} + */ + getFirstVisibleTime() { + return /** @type {?number} */ (this.signals_.get( + AmpDocSignals.FIRST_VISIBLE + )); + } + + /** + * Returns the time when the document has become visible for the last time. + * If document has not yet become visible, the returned value is `null`. + * @return {?time} + */ + getLastVisibleTime() { + return this.lastVisibleTime_; + } + + /** + * Returns visibility state configured by the viewer. + * See {@link isVisible}. + * @return {!VisibilityState} + */ + getVisibilityState() { + return devAssert(this.visibilityState_); + } + + /** + * Whether the AMP document currently visible. The reasons why it might not + * be visible include user switching to another tab, browser running the + * document in the prerender mode or viewer running the document in the + * prerender mode. + * @return {boolean} + */ + isVisible() { + return this.visibilityState_ == VisibilityState.VISIBLE; + } + + /** + * Adds a "visibilitychange" event listener for viewer events. The + * callback can check {@link isVisible} and {@link getPrefetchCount} + * methods for more info. + * @param {function(!VisibilityState)} handler + * @return {!UnlistenDef} + */ + onVisibilityChanged(handler) { + return this.visibilityStateHandlers_.add(handler); + } } /** @@ -454,7 +672,7 @@ export class AmpDocSingle extends AmpDoc { * @param {!AmpDocOptions=} opt_options */ constructor(win, opt_options) { - super(win, opt_options); + super(win, /* parent */ null, opt_options); /** @private @const {!Promise} */ this.bodyPromise_ = this.win.document.body @@ -470,11 +688,6 @@ export class AmpDocSingle extends AmpDoc { return true; } - /** @override */ - getParent() { - return null; - } - /** @override */ getRootNode() { return this.win.document; @@ -529,7 +742,7 @@ export class AmpDocShadow extends AmpDoc { * @param {!AmpDocOptions=} opt_options */ constructor(win, url, shadowRoot, opt_options) { - super(win, opt_options); + super(win, /* parent */ null, opt_options); /** @private @const {string} */ this.url_ = url; /** @private @const {!ShadowRoot} */ @@ -563,11 +776,6 @@ export class AmpDocShadow extends AmpDoc { return false; } - /** @override */ - getParent() { - return null; - } - /** @override */ getRootNode() { return this.shadowRoot_; @@ -644,14 +852,11 @@ export class AmpDocFie extends AmpDoc { * @param {!AmpDocOptions=} opt_options */ constructor(win, url, parent, opt_options) { - super(win, opt_options); + super(win, parent, opt_options); /** @private @const {string} */ this.url_ = url; - /** @private @const {!AmpDoc} */ - this.parent_ = parent; - /** @private @const {!Promise} */ this.bodyPromise_ = this.win.document.body ? Promise.resolve(this.win.document.body) @@ -672,11 +877,6 @@ export class AmpDocFie extends AmpDoc { return false; } - /** @override */ - getParent() { - return this.parent_; - } - /** @override */ getRootNode() { return this.win.document; diff --git a/src/service/document-state.js b/src/service/document-state.js index 6bee4da4781f..14f36fffd216 100644 --- a/src/service/document-state.js +++ b/src/service/document-state.js @@ -15,10 +15,16 @@ */ import {Observable} from '../observable'; -import {getVendorJsPropertyName} from '../style'; +import { + addDocumentVisibilityChangeListener, + getDocumentVisibilityState, + isDocumentHidden, +} from '../utils/document-visibility'; import {registerServiceBuilder} from '../service'; /** + * INTENT TO DEPRECATE. + * TODO(#22733): deprecate/remove when ampdoc-fie is launched. */ export class DocumentState { /** @@ -31,44 +37,15 @@ export class DocumentState { /** @private @const {!Document} */ this.document_ = win.document; - /** @private {string|null} */ - this.hiddenProp_ = getVendorJsPropertyName(this.document_, 'hidden', true); - if (this.document_[this.hiddenProp_] === undefined) { - this.hiddenProp_ = null; - } - - /** @private {string|null} */ - this.visibilityStateProp_ = getVendorJsPropertyName( - this.document_, - 'visibilityState', - true - ); - if (this.document_[this.visibilityStateProp_] === undefined) { - this.visibilityStateProp_ = null; - } - /** @private @const {!Observable} */ this.visibilityObservable_ = new Observable(); - /** @private {string|null} */ - this.visibilityChangeEvent_ = null; - if (this.hiddenProp_) { - this.visibilityChangeEvent_ = 'visibilitychange'; - const vendorStop = this.hiddenProp_.indexOf('Hidden'); - if (vendorStop != -1) { - this.visibilityChangeEvent_ = - this.hiddenProp_.substring(0, vendorStop) + 'Visibilitychange'; - } - } - /** @private @const {!Function} */ this.boundOnVisibilityChanged_ = this.onVisibilityChanged_.bind(this); - if (this.visibilityChangeEvent_) { - this.document_.addEventListener( - this.visibilityChangeEvent_, - this.boundOnVisibilityChanged_ - ); - } + addDocumentVisibilityChangeListener( + this.document_, + this.boundOnVisibilityChanged_ + ); } /** @@ -78,10 +55,7 @@ export class DocumentState { * @return {boolean} */ isHidden() { - if (!this.hiddenProp_) { - return false; - } - return this.document_[this.hiddenProp_]; + return isDocumentHidden(this.document_); } /** @@ -90,10 +64,7 @@ export class DocumentState { * @return {string} */ getVisibilityState() { - if (!this.visibilityStateProp_) { - return this.isHidden() ? 'hidden' : 'visible'; - } - return this.document_[this.visibilityStateProp_]; + return getDocumentVisibilityState(this.document_); } /** diff --git a/src/service/viewer-impl.js b/src/service/viewer-impl.js index 0728f163ca57..869c3f615145 100644 --- a/src/service/viewer-impl.js +++ b/src/service/viewer-impl.js @@ -83,21 +83,12 @@ export class Viewer { /** @private @const {boolean} */ this.isIframed_ = isIframed(this.win); - /** @const {!./document-state.DocumentState} */ - this.docState_ = Services.globalDocumentStateFor(this.win); - /** @private {boolean} */ this.isRuntimeOn_ = true; /** @private {boolean} */ this.overtakeHistory_ = false; - /** @private {!VisibilityState} */ - this.visibilityState_ = VisibilityState.VISIBLE; - - /** @private {string} */ - this.viewerVisibilityState_ = this.visibilityState_; - /** @private {number} */ this.prerenderSize_ = 1; @@ -110,9 +101,6 @@ export class Viewer { /** @private {!Observable} */ this.runtimeOnObservable_ = new Observable(); - /** @private {!Observable} */ - this.visibilityObservable_ = new Observable(); - /** @private {!Observable} */ this.broadcastObservable_ = new Observable(); @@ -143,30 +131,6 @@ export class Viewer { */ this.hashParams_ = map(); - /** @private {?Promise} */ - this.nextVisiblePromise_ = null; - - /** @private {?function()} */ - this.nextVisibleResolve_ = null; - - /** @private {?time} */ - this.firstVisibleTime_ = null; - - /** @private {?time} */ - this.lastVisibleTime_ = null; - - const deferred = new Deferred(); - /** - * This promise might be resolved right away if the current - * document is already visible. See end of this constructor where we call - * `this.onVisibilityChange_()`. - * @private @const {!Promise} - */ - this.whenFirstVisiblePromise_ = deferred.promise; - - /** @private {?function()} */ - this.whenFirstVisibleResolve_ = deferred.resolve; - if (ampdoc.isSingleDoc()) { Object.assign(this.hashParams_, parseQueryString(this.win.location.hash)); } @@ -179,8 +143,7 @@ export class Viewer { ); dev().fine(TAG_, '- history:', this.overtakeHistory_); - this.setVisibilityState_(ampdoc.getParam('visibilityState')); - dev().fine(TAG_, '- visibilityState:', this.getVisibilityState()); + dev().fine(TAG_, '- visibilityState:', this.ampdoc.getVisibilityState()); this.prerenderSize_ = parseInt(ampdoc.getParam('prerenderSize'), 10) || this.prerenderSize_; @@ -200,12 +163,6 @@ export class Viewer { parseUrlDeprecated(this.ampdoc.win.location.href) ); - /** @private {boolean} */ - this.hasBeenVisible_ = this.isVisible(); - - // Wait for document to become visible. - this.docState_.onVisibilityChanged(this.recheckVisibilityState_.bind(this)); - const messagingDeferred = new Deferred(); /** @const @private {!Function} */ this.messagingReadyResolver_ = messagingDeferred.resolve; @@ -301,11 +258,6 @@ export class Viewer { } } - // Check if by the time the `Viewer` - // instance is constructed, the document is already `visible`. - this.recheckVisibilityState_(); - this.onVisibilityChange_(); - // This fragment may get cleared by impression tracking. If so, it will be // restored afterward. this.whenFirstVisible().then(() => { @@ -359,24 +311,6 @@ export class Viewer { }); } - /** - * Handler for visibility change. - * @private - */ - onVisibilityChange_() { - if (this.isVisible()) { - const now = Date.now(); - if (!this.firstVisibleTime_) { - this.firstVisibleTime_ = now; - } - this.lastVisibleTime_ = now; - this.hasBeenVisible_ = true; - this.whenFirstVisibleResolve_(); - this.whenNextVisibleResolve_(); - } - this.visibilityObservable_.fire(); - } - /** * Returns the value of a viewer's startup parameter with the specified * name or "undefined" if the parameter wasn't defined at startup time. @@ -526,15 +460,10 @@ export class Viewer { * Returns visibility state configured by the viewer. * See {@link isVisible}. * @return {!VisibilityState} - * TODO(dvoytenko, #5285): Move public API to AmpDoc. + * TODO(#22733): deprecate/remove when ampdoc-fie is launched. */ getVisibilityState() { - return this.visibilityState_; - } - - /** @private */ - recheckVisibilityState_() { - this.setVisibilityState_(this.viewerVisibilityState_); + return this.ampdoc.getVisibilityState(); } /** @@ -546,7 +475,6 @@ export class Viewer { if (!state) { return; } - const oldState = this.visibilityState_; state = dev().assertEnumValue(VisibilityState, state, 'VisibilityState'); // The viewer is informing us we are not currently active because we are @@ -554,27 +482,14 @@ export class Viewer { // viewer). Unfortunately, the viewer sends HIDDEN instead of PRERENDER or // INACTIVE, though we know better. if (state === VisibilityState.HIDDEN) { - state = this.hasBeenVisible_ - ? VisibilityState.INACTIVE - : VisibilityState.PRERENDER; - } - - this.viewerVisibilityState_ = state; - - if ( - this.docState_.isHidden() && - (state === VisibilityState.VISIBLE || state === VisibilityState.PAUSED) - ) { - state = VisibilityState.HIDDEN; + state = + this.ampdoc.getLastVisibleTime() != null + ? VisibilityState.INACTIVE + : VisibilityState.PRERENDER; } - this.visibilityState_ = state; - + this.ampdoc.overrideVisibilityState(state); dev().fine(TAG_, 'visibilitychange event:', this.getVisibilityState()); - - if (oldState !== state) { - this.onVisibilityChange_(); - } } /** @@ -583,9 +498,10 @@ export class Viewer { * document in the prerender mode or viewer running the document in the * prerender mode. * @return {boolean} + * TODO(#22733): deprecate/remove when ampdoc-fie is launched. */ isVisible() { - return this.getVisibilityState() == VisibilityState.VISIBLE; + return this.ampdoc.isVisible(); } /** @@ -593,67 +509,50 @@ export class Viewer { * state of a document can be flipped back and forth we sometimes want to know * if a document has ever been visible. * @return {boolean} + * TODO(#22733): deprecate/remove when ampdoc-fie is launched. */ hasBeenVisible() { - return this.hasBeenVisible_; + return this.ampdoc.getLastVisibleTime() != null; } /** * Returns a Promise that only ever resolved when the current * AMP document first becomes visible. * @return {!Promise} + * TODO(#22733): deprecate/remove when ampdoc-fie is launched. */ whenFirstVisible() { - return this.whenFirstVisiblePromise_; + return this.ampdoc.whenFirstVisible(); } /** * Returns a Promise that resolve when current doc becomes visible. * The promise resolves immediately if doc is already visible. * @return {!Promise} + * TODO(#22733): deprecate/remove when ampdoc-fie is launched. */ whenNextVisible() { - if (this.isVisible()) { - return Promise.resolve(); - } - - if (this.nextVisiblePromise_) { - return this.nextVisiblePromise_; - } - - const deferred = new Deferred(); - this.nextVisibleResolve_ = deferred.resolve; - return (this.nextVisiblePromise_ = deferred.promise); - } - - /** - * Helper method to be called on visiblity change - * @private - */ - whenNextVisibleResolve_() { - if (this.nextVisibleResolve_) { - this.nextVisibleResolve_(); - this.nextVisibleResolve_ = null; - this.nextVisiblePromise_ = null; - } + return this.ampdoc.whenNextVisible(); } /** * Returns the time when the document has become visible for the first time. * If document has not yet become visible, the returned value is `null`. * @return {?time} + * TODO(#22733): deprecate/remove when ampdoc-fie is launched. */ getFirstVisibleTime() { - return this.firstVisibleTime_; + return this.ampdoc.getFirstVisibleTime(); } /** * Returns the time when the document has become visible for the last time. * If document has not yet become visible, the returned value is `null`. * @return {?time} + * TODO(#22733): deprecate/remove when ampdoc-fie is launched. */ getLastVisibleTime() { - return this.lastVisibleTime_; + return this.ampdoc.getLastVisibleTime(); } /** @@ -813,9 +712,10 @@ export class Viewer { * methods for more info. * @param {function()} handler * @return {!UnlistenDef} + * TODO(#22733): deprecate/remove when ampdoc-fie is launched. */ onVisibilityChanged(handler) { - return this.visibilityObservable_.add(handler); + return this.ampdoc.onVisibilityChanged(handler); } /** @@ -1101,16 +1001,6 @@ function getChannelError(opt_reason) { return new Error('No messaging channel: ' + opt_reason); } -/** - * Sets the viewer visibility state. This calls is restricted to runtime only. - * @param {!Viewer} viewer - * @param {!VisibilityState} state - * @restricted - */ -export function setViewerVisibilityState(viewer, state) { - viewer.setVisibilityState_(state); -} - /** * @param {!./ampdoc-impl.AmpDoc} ampdoc */ diff --git a/src/utils/document-visibility.js b/src/utils/document-visibility.js new file mode 100644 index 000000000000..75af13fbe0d2 --- /dev/null +++ b/src/utils/document-visibility.js @@ -0,0 +1,93 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {VisibilityState} from '../visibility-state'; +import {getVendorJsPropertyName} from '../style'; + +/** + * @param {!Document} doc + * @return {!VisibilityState} + */ +export function getDocumentVisibilityState(doc) { + // New API: `document.visibilityState` property. + const visibilityStateProp = getVendorJsPropertyName( + doc, + 'visibilityState', + true + ); + if (doc[visibilityStateProp]) { + return doc[visibilityStateProp]; + } + + // Old API: `document.hidden` property. + const hiddenProp = getVendorJsPropertyName(doc, 'hidden', true); + if (doc[hiddenProp]) { + return doc[hiddenProp] ? VisibilityState.HIDDEN : VisibilityState.VISIBLE; + } + + return VisibilityState.VISIBLE; +} + +/** + * Returns the value of "document.hidden" property. The reasons why it may + * not be visible include document in a non-active tab or when the document + * is being pre-rendered via link with rel="prerender". + * @param {!Document} doc + * @return {boolean} + */ +export function isDocumentHidden(doc) { + return getDocumentVisibilityState(doc) != VisibilityState.VISIBLE; +} + +/** + * @param {!Document} doc + * @param {function()} handler + */ +export function addDocumentVisibilityChangeListener(doc, handler) { + if (!doc.addEventListener) { + return; + } + const visibilityChangeEvent = getVisibilityChangeEvent(doc); + if (visibilityChangeEvent) { + doc.addEventListener(visibilityChangeEvent, handler); + } +} + +/** + * @param {!Document} doc + * @param {function()} handler + */ +export function removeDocumentVisibilityChangeListener(doc, handler) { + if (!doc.removeEventListener) { + return; + } + const visibilityChangeEvent = getVisibilityChangeEvent(doc); + if (visibilityChangeEvent) { + doc.removeEventListener(visibilityChangeEvent, handler); + } +} + +/** + * @param {!Document} doc + * @return {?string} + */ +function getVisibilityChangeEvent(doc) { + const hiddenProp = getVendorJsPropertyName(doc, 'hidden', true); + const vendorStop = hiddenProp.indexOf('Hidden'); + return vendorStop != -1 + ? hiddenProp.substring(0, vendorStop) + 'Visibilitychange' + : 'visibilitychange'; +} diff --git a/test/integration/test-visibility-states.js b/test/integration/test-visibility-states.js index c1a7f1f2c45c..132ca6db0404 100644 --- a/test/integration/test-visibility-states.js +++ b/test/integration/test-visibility-states.js @@ -46,6 +46,7 @@ t.run('Viewer Visibility State', () => { let pauseCallback; let resumeCallback; let docHidden; + let docVisibilityState; let unselect; let prerenderAllowed; @@ -61,8 +62,12 @@ t.run('Viewer Visibility State', () => { } return hiddenName.substr(0, index) + 'Visibilitychange'; } + function changeVisibility(vis) { - docHidden.returns(vis === 'hidden'); + if (docVisibilityState) { + docVisibilityState.value(vis); + } + docHidden.value(vis === 'hidden'); win.document.dispatchEvent( createCustomEvent(win, visChangeEventName(), /* detail */ null) ); @@ -110,8 +115,13 @@ t.run('Viewer Visibility State', () => { return Services.viewerPromiseForDoc(win.document) .then(v => { viewer = v; - const docState = Services.globalDocumentStateFor(win); - docHidden = sandbox.stub(docState, 'isHidden').returns(false); + + docHidden = sandbox.stub(win.document, 'hidden').value(false); + if ('visibilityState' in win.document) { + docVisibilityState = sandbox + .stub(win.document, 'visibilityState') + .value('visible'); + } resources = Services.resourcesForDoc(win.document); doPass_ = resources.doPass; diff --git a/test/unit/3p/test-3p-frame.js b/test/unit/3p/test-3p-frame.js index 608a5619d528..2995a5a531e6 100644 --- a/test/unit/3p/test-3p-frame.js +++ b/test/unit/3p/test-3p-frame.js @@ -414,34 +414,38 @@ describe it('should prefetch bootstrap frame and JS', () => { window.AMP_MODE = {localDev: true}; preloadBootstrap(window, preconnect); - // Wait for visible promise - return Promise.resolve().then(() => { - const fetches = document.querySelectorAll('link[rel=preload]'); - expect(fetches).to.have.length(2); - expect(fetches[0]).to.have.property( - 'href', - 'http://ads.localhost:9876/dist.3p/current/frame.max.html' - ); - expect(fetches[1]).to.have.property( - 'href', - 'http://ads.localhost:9876/dist.3p/current/integration.js' - ); - }); + // Wait for visible promise. + return Services.ampdoc(window.document) + .whenFirstVisible() + .then(() => { + const fetches = document.querySelectorAll('link[rel=preload]'); + expect(fetches).to.have.length(2); + expect(fetches[0]).to.have.property( + 'href', + 'http://ads.localhost:9876/dist.3p/current/frame.max.html' + ); + expect(fetches[1]).to.have.property( + 'href', + 'http://ads.localhost:9876/dist.3p/current/integration.js' + ); + }); }); it('should prefetch default bootstrap frame if custom disabled', () => { window.AMP_MODE = {localDev: true}; addCustomBootstrap('http://localhost:9876/boot/remote.html'); preloadBootstrap(window, preconnect, true); - // Wait for visible promise - return Promise.resolve().then(() => { - expect( - document.querySelectorAll( - 'link[rel=preload]' + - '[href="http://ads.localhost:9876/dist.3p/current/frame.max.html"]' - ) - ).to.be.ok; - }); + // Wait for visible promise. + return Services.ampdoc(window.document) + .whenFirstVisible() + .then(() => { + expect( + document.querySelectorAll( + 'link[rel=preload]' + + '[href="http://ads.localhost:9876/dist.3p/current/frame.max.html"]' + ) + ).to.be.ok; + }); }); it('should make sub domains (unique)', () => { diff --git a/test/unit/test-ampdoc.js b/test/unit/test-ampdoc.js index 814147273d8d..cd7189ca6ef3 100644 --- a/test/unit/test-ampdoc.js +++ b/test/unit/test-ampdoc.js @@ -33,15 +33,8 @@ import {createShadowRoot} from '../../src/shadow-embed'; import {setParentWindow} from '../../src/service'; import {toggleExperiment} from '../../src/experiments'; -describe('AmpDocService', () => { - let sandbox; - - beforeEach(() => { - sandbox = sinon.sandbox; - }); - +describes.sandboxed('AmpDocService', {}, () => { afterEach(() => { - sandbox.restore(); delete window.document['__AMPDOC']; }); @@ -452,17 +445,387 @@ describe('AmpDocService', () => { }); }); -describe('AmpDocSingle', () => { - let sandbox; - let ampdoc; +describes.sandboxed('AmpDoc.visibilityState', {}, () => { + const EMBED_URL = 'https://example.com/embed'; + let clock; + let win, doc; + let childWin, childDoc; + let top, embedSameWindow, embedOtherWindow, embedChild; beforeEach(() => { - sandbox = sinon.sandbox; - ampdoc = new AmpDocSingle(window); + clock = sandbox.useFakeTimers(); + clock.tick(1); + + doc = { + body: null, + visibilityState: 'visible', + addEventListener: sandbox.spy(), + removeEventListener: sandbox.spy(), + }; + win = {document: doc}; + + childDoc = { + body: null, + visibilityState: 'visible', + addEventListener: sandbox.spy(), + removeEventListener: sandbox.spy(), + }; + childWin = {document: childDoc}; + + top = new AmpDocSingle(win); + embedSameWindow = new AmpDocFie(win, EMBED_URL, top); + embedOtherWindow = new AmpDocFie(childWin, EMBED_URL, top); + embedChild = new AmpDocFie(childWin, EMBED_URL, embedOtherWindow); }); - afterEach(() => { - sandbox.restore(); + function updateDocumentVisibility(doc, visibilityState) { + doc.visibilityState = visibilityState; + if (doc.addEventListener.args.length > 0) { + doc.addEventListener.args[0][1](); + } + } + + it('should set up and destroy listeners', () => { + // 1 for top, 1 for embedSameWindow. + expect(doc.addEventListener.callCount).to.equal(2); + expect(doc.removeEventListener.callCount).to.equal(0); + // 1 for embedOtherWindow, 1 for embedChild. + expect(childDoc.addEventListener.callCount).to.equal(2); + expect(childDoc.removeEventListener.callCount).to.equal(0); + // 1 for embedSameWindow, 1 for embedOtherWindow. + expect(top.visibilityStateHandlers_.getHandlerCount()).to.equal(2); + // No children. + expect(embedSameWindow.visibilityStateHandlers_.getHandlerCount()).to.equal( + 0 + ); + // 1 for embedChild. + expect( + embedOtherWindow.visibilityStateHandlers_.getHandlerCount() + ).to.equal(1); + // No children. + expect(embedChild.visibilityStateHandlers_.getHandlerCount()).to.equal(0); + + // Destroy the nested child. + embedChild.dispose(); + expect(doc.removeEventListener.callCount).to.equal(0); + expect(childDoc.removeEventListener.callCount).to.equal(1); + expect(top.visibilityStateHandlers_.getHandlerCount()).to.equal(2); + expect( + embedOtherWindow.visibilityStateHandlers_.getHandlerCount() + ).to.equal(0); + + // Destroy the embedOtherWindow. + embedOtherWindow.dispose(); + expect(doc.removeEventListener.callCount).to.equal(0); + expect(childDoc.removeEventListener.callCount).to.equal(2); + expect(top.visibilityStateHandlers_.getHandlerCount()).to.equal(1); + + // Destroy the embedSameWindow. + embedSameWindow.dispose(); + expect(doc.removeEventListener.callCount).to.equal(1); + expect(top.visibilityStateHandlers_.getHandlerCount()).to.equal(0); + + // Destroy the top. + top.dispose(); + expect(doc.removeEventListener.callCount).to.equal(2); + }); + + it('should be visible by default', () => { + expect(top.getVisibilityState()).to.equal('visible'); + expect(embedSameWindow.getVisibilityState()).to.equal('visible'); + expect(embedOtherWindow.getVisibilityState()).to.equal('visible'); + expect(embedChild.getVisibilityState()).to.equal('visible'); + + expect(top.isVisible()).to.be.true; + expect(embedSameWindow.isVisible()).to.be.true; + expect(embedOtherWindow.isVisible()).to.be.true; + expect(embedChild.isVisible()).to.be.true; + + expect(top.getFirstVisibleTime()).to.equal(1); + expect(embedSameWindow.getFirstVisibleTime()).to.equal(1); + expect(embedOtherWindow.getFirstVisibleTime()).to.equal(1); + expect(embedChild.getFirstVisibleTime()).to.equal(1); + + expect(top.getLastVisibleTime()).to.equal(1); + expect(embedSameWindow.getLastVisibleTime()).to.equal(1); + expect(embedOtherWindow.getLastVisibleTime()).to.equal(1); + expect(embedChild.getLastVisibleTime()).to.equal(1); + + return Promise.all([ + top.whenFirstVisible(), + embedSameWindow.whenFirstVisible(), + embedOtherWindow.whenFirstVisible(), + embedChild.whenFirstVisible(), + ]).then(() => { + return Promise.all([ + top.whenNextVisible(), + embedSameWindow.whenNextVisible(), + embedOtherWindow.whenNextVisible(), + embedChild.whenNextVisible(), + ]); + }); + }); + + it('should override at construction time', () => { + top = new AmpDocSingle(win, {visibilityState: 'hidden'}); + embedSameWindow = new AmpDocFie(win, EMBED_URL, top); + embedOtherWindow = new AmpDocFie(childWin, EMBED_URL, top); + embedChild = new AmpDocFie(childWin, EMBED_URL, embedOtherWindow); + + expect(top.getVisibilityState()).to.equal('hidden'); + expect(embedSameWindow.getVisibilityState()).to.equal('hidden'); + expect(embedOtherWindow.getVisibilityState()).to.equal('hidden'); + expect(embedChild.getVisibilityState()).to.equal('hidden'); + + expect(top.isVisible()).to.be.false; + expect(embedSameWindow.isVisible()).to.be.false; + expect(embedOtherWindow.isVisible()).to.be.false; + expect(embedChild.isVisible()).to.be.false; + + expect(top.getFirstVisibleTime()).to.be.null; + expect(embedSameWindow.getFirstVisibleTime()).to.be.null; + expect(embedOtherWindow.getFirstVisibleTime()).to.be.null; + expect(embedChild.getFirstVisibleTime()).to.be.null; + + expect(top.getLastVisibleTime()).to.be.null; + expect(embedSameWindow.getLastVisibleTime()).to.be.null; + expect(embedOtherWindow.getLastVisibleTime()).to.be.null; + expect(embedChild.getLastVisibleTime()).to.be.null; + }); + + it('should override at construction time via params', () => { + top = new AmpDocSingle(win, { + params: {'visibilityState': 'hidden'}, + }); + embedSameWindow = new AmpDocFie(win, EMBED_URL, top); + embedOtherWindow = new AmpDocFie(childWin, EMBED_URL, top); + embedChild = new AmpDocFie(childWin, EMBED_URL, embedOtherWindow); + + expect(top.getVisibilityState()).to.equal('hidden'); + expect(embedSameWindow.getVisibilityState()).to.equal('hidden'); + expect(embedOtherWindow.getVisibilityState()).to.equal('hidden'); + expect(embedChild.getVisibilityState()).to.equal('hidden'); + + expect(top.isVisible()).to.be.false; + expect(embedSameWindow.isVisible()).to.be.false; + expect(embedOtherWindow.isVisible()).to.be.false; + expect(embedChild.isVisible()).to.be.false; + + expect(top.getFirstVisibleTime()).to.be.null; + expect(embedSameWindow.getFirstVisibleTime()).to.be.null; + expect(embedOtherWindow.getFirstVisibleTime()).to.be.null; + expect(embedChild.getFirstVisibleTime()).to.be.null; + + expect(top.getLastVisibleTime()).to.be.null; + expect(embedSameWindow.getLastVisibleTime()).to.be.null; + expect(embedOtherWindow.getLastVisibleTime()).to.be.null; + expect(embedChild.getLastVisibleTime()).to.be.null; + }); + + it('should override visibilityState after construction', () => { + top = new AmpDocSingle(win, {visibilityState: 'hidden'}); + embedSameWindow = new AmpDocFie(win, EMBED_URL, top); + embedOtherWindow = new AmpDocFie(childWin, EMBED_URL, top); + embedChild = new AmpDocFie(childWin, EMBED_URL, embedOtherWindow); + + clock.tick(1); + top.overrideVisibilityState('visible'); + + expect(top.getVisibilityState()).to.equal('visible'); + expect(embedSameWindow.getVisibilityState()).to.equal('visible'); + expect(embedOtherWindow.getVisibilityState()).to.equal('visible'); + expect(embedChild.getVisibilityState()).to.equal('visible'); + + expect(top.isVisible()).to.be.true; + expect(embedSameWindow.isVisible()).to.be.true; + expect(embedOtherWindow.isVisible()).to.be.true; + expect(embedChild.isVisible()).to.be.true; + + expect(top.getFirstVisibleTime()).to.equal(2); + expect(embedSameWindow.getFirstVisibleTime()).to.equal(2); + expect(embedOtherWindow.getFirstVisibleTime()).to.equal(2); + expect(embedChild.getFirstVisibleTime()).to.equal(2); + + expect(top.getLastVisibleTime()).to.equal(2); + expect(embedSameWindow.getLastVisibleTime()).to.equal(2); + expect(embedOtherWindow.getLastVisibleTime()).to.equal(2); + expect(embedChild.getLastVisibleTime()).to.equal(2); + + return Promise.all([ + top.whenFirstVisible(), + embedSameWindow.whenFirstVisible(), + embedOtherWindow.whenFirstVisible(), + embedChild.whenFirstVisible(), + ]).then(() => { + return Promise.all([ + top.whenNextVisible(), + embedSameWindow.whenNextVisible(), + embedOtherWindow.whenNextVisible(), + embedChild.whenNextVisible(), + ]); + }); + }); + + it('should update last visibility after construction', () => { + expect(top.getFirstVisibleTime()).to.equal(1); + expect(top.getLastVisibleTime()).to.equal(1); + + clock.tick(1); + top.overrideVisibilityState('hidden'); + + expect(top.getVisibilityState()).to.equal('hidden'); + expect(embedSameWindow.getVisibilityState()).to.equal('hidden'); + expect(embedOtherWindow.getVisibilityState()).to.equal('hidden'); + expect(embedChild.getVisibilityState()).to.equal('hidden'); + + expect(top.getFirstVisibleTime()).to.equal(1); + expect(top.getLastVisibleTime()).to.equal(1); + + clock.tick(1); + top.overrideVisibilityState('visible'); + + expect(top.getVisibilityState()).to.equal('visible'); + expect(embedSameWindow.getVisibilityState()).to.equal('visible'); + expect(embedOtherWindow.getVisibilityState()).to.equal('visible'); + expect(embedChild.getVisibilityState()).to.equal('visible'); + + expect(top.getFirstVisibleTime()).to.equal(1); + expect(top.getLastVisibleTime()).to.equal(3); + }); + + it('should update visibility in children', () => { + clock.tick(1); + embedOtherWindow.overrideVisibilityState('hidden'); + + expect(top.getVisibilityState()).to.equal('visible'); + expect(embedSameWindow.getVisibilityState()).to.equal('visible'); + expect(embedOtherWindow.getVisibilityState()).to.equal('hidden'); + expect(embedChild.getVisibilityState()).to.equal('hidden'); + + expect(top.getFirstVisibleTime()).to.equal(1); + expect(top.getLastVisibleTime()).to.equal(1); + expect(embedSameWindow.getFirstVisibleTime()).to.equal(1); + expect(embedSameWindow.getLastVisibleTime()).to.equal(1); + expect(embedOtherWindow.getFirstVisibleTime()).to.equal(1); + expect(embedOtherWindow.getLastVisibleTime()).to.equal(1); + expect(embedChild.getFirstVisibleTime()).to.equal(1); + expect(embedChild.getLastVisibleTime()).to.equal(1); + + clock.tick(1); + embedOtherWindow.overrideVisibilityState('visible'); + + expect(embedOtherWindow.getVisibilityState()).to.equal('visible'); + expect(embedChild.getVisibilityState()).to.equal('visible'); + + expect(top.getFirstVisibleTime()).to.equal(1); + expect(top.getLastVisibleTime()).to.equal(1); + expect(embedSameWindow.getFirstVisibleTime()).to.equal(1); + expect(embedSameWindow.getLastVisibleTime()).to.equal(1); + expect(embedOtherWindow.getFirstVisibleTime()).to.equal(1); + expect(embedOtherWindow.getLastVisibleTime()).to.equal(3); + expect(embedChild.getFirstVisibleTime()).to.equal(1); + expect(embedChild.getLastVisibleTime()).to.equal(3); + }); + + it('should update when document visibility changes', () => { + clock.tick(1); + updateDocumentVisibility(doc, 'hidden'); + + expect(top.getVisibilityState()).to.equal('hidden'); + expect(embedSameWindow.getVisibilityState()).to.equal('hidden'); + expect(embedOtherWindow.getVisibilityState()).to.equal('hidden'); + expect(embedChild.getVisibilityState()).to.equal('hidden'); + + clock.tick(1); + updateDocumentVisibility(doc, 'visible'); + + expect(top.getVisibilityState()).to.equal('visible'); + expect(embedSameWindow.getVisibilityState()).to.equal('visible'); + expect(embedOtherWindow.getVisibilityState()).to.equal('visible'); + expect(embedChild.getVisibilityState()).to.equal('visible'); + }); + + it('should update embed document visibility', () => { + clock.tick(1); + updateDocumentVisibility(childDoc, 'hidden'); + + expect(top.getVisibilityState()).to.equal('visible'); + expect(embedSameWindow.getVisibilityState()).to.equal('visible'); + expect(embedOtherWindow.getVisibilityState()).to.equal('hidden'); + expect(embedChild.getVisibilityState()).to.equal('hidden'); + + clock.tick(1); + updateDocumentVisibility(childDoc, 'visible'); + + expect(top.getVisibilityState()).to.equal('visible'); + expect(embedSameWindow.getVisibilityState()).to.equal('visible'); + expect(embedOtherWindow.getVisibilityState()).to.equal('visible'); + expect(embedChild.getVisibilityState()).to.equal('visible'); + }); + + it('should override to prerender/inactive/paused', () => { + top.overrideVisibilityState('prerender'); + expect(top.getVisibilityState()).to.equal('prerender'); + expect(embedSameWindow.getVisibilityState()).to.equal('prerender'); + expect(embedOtherWindow.getVisibilityState()).to.equal('prerender'); + expect(embedChild.getVisibilityState()).to.equal('prerender'); + + top.overrideVisibilityState('inactive'); + expect(top.getVisibilityState()).to.equal('inactive'); + expect(embedSameWindow.getVisibilityState()).to.equal('inactive'); + expect(embedOtherWindow.getVisibilityState()).to.equal('inactive'); + expect(embedChild.getVisibilityState()).to.equal('inactive'); + + top.overrideVisibilityState('paused'); + expect(top.getVisibilityState()).to.equal('paused'); + expect(embedSameWindow.getVisibilityState()).to.equal('paused'); + expect(embedOtherWindow.getVisibilityState()).to.equal('paused'); + expect(embedChild.getVisibilityState()).to.equal('paused'); + }); + + it('should prioritize document hidden for paused', () => { + updateDocumentVisibility(doc, 'hidden'); + top.overrideVisibilityState('paused'); + expect(top.getVisibilityState()).to.equal('hidden'); + expect(embedSameWindow.getVisibilityState()).to.equal('hidden'); + expect(embedOtherWindow.getVisibilityState()).to.equal('hidden'); + expect(embedChild.getVisibilityState()).to.equal('hidden'); + }); + + it('should configure visibilityState for prerender', () => { + top = new AmpDocSingle(win, { + params: { + 'visibilityState': 'prerender', + 'prerenderSize': '3', + }, + }); + expect(top.getVisibilityState()).to.equal('prerender'); + expect(top.isVisible()).to.equal(false); + expect(top.getParam('prerenderSize')).to.equal('3'); + }); + + it('should be hidden when the browser document is unknown state', () => { + updateDocumentVisibility(doc, 'what is this'); + expect(top.getVisibilityState()).to.equal('hidden'); + expect(embedSameWindow.getVisibilityState()).to.equal('hidden'); + expect(embedOtherWindow.getVisibilityState()).to.equal('hidden'); + expect(embedChild.getVisibilityState()).to.equal('hidden'); + }); + + it('should yield undefined for whenVisible methods', () => { + return Promise.all([top.whenFirstVisible(), top.whenNextVisible()]).then( + results => { + expect(results).to.deep.equal([undefined, undefined]); + } + ); + }); +}); + +describes.sandboxed('AmpDocSingle', {}, () => { + let ampdoc; + + beforeEach(() => { + ampdoc = new AmpDocSingle(window); }); it('should return window', () => { @@ -499,7 +862,11 @@ describe('AmpDocSingle', () => { }); it('should wait for body and ready state', () => { - const doc = {body: null}; + const doc = { + body: null, + addEventListener: function() {}, + removeEventListener: function() {}, + }; const win = {document: doc}; let bodyCallback; @@ -568,15 +935,13 @@ describe('AmpDocSingle', () => { }); }); -describe('AmpDocShadow', () => { +describes.sandboxed('AmpDocShadow', {}, () => { const URL = 'https://example.org/document'; - let sandbox; let content, host, shadowRoot; let ampdoc; beforeEach(() => { - sandbox = sinon.sandbox; content = document.createElement('div'); host = document.createElement('div'); shadowRoot = createShadowRoot(host); @@ -584,10 +949,6 @@ describe('AmpDocShadow', () => { ampdoc = new AmpDocShadow(window, URL, shadowRoot); }); - afterEach(() => { - sandbox.restore(); - }); - it('should return window', () => { if (!ampdoc) { return; diff --git a/test/unit/test-chunk.js b/test/unit/test-chunk.js index 039247d1937d..87caa95078ef 100644 --- a/test/unit/test-chunk.js +++ b/test/unit/test-chunk.js @@ -162,8 +162,8 @@ describe('chunk2', () => { describe('visible', () => { beforeEach(() => { - const viewer = Services.viewerForDoc(env.win.document); - env.sandbox.stub(viewer, 'isVisible').callsFake(() => { + const {ampdoc} = env; + env.sandbox.stub(ampdoc, 'isVisible').callsFake(() => { return true; }); }); @@ -184,8 +184,8 @@ describe('chunk2', () => { beforeEach(() => { fakeWin = env.win; - const viewer = Services.viewerForDoc(env.win.document); - env.sandbox.stub(viewer, 'isVisible').callsFake(() => { + const {ampdoc} = env; + env.sandbox.stub(ampdoc, 'isVisible').callsFake(() => { return true; }); window.addEventListener('unhandledrejection', onReject); @@ -207,8 +207,8 @@ describe('chunk2', () => { describe('invisible', () => { beforeEach(() => { - const viewer = Services.viewerForDoc(env.win.document); - env.sandbox.stub(viewer, 'isVisible').callsFake(() => { + const {ampdoc} = env; + env.sandbox.stub(ampdoc, 'isVisible').callsFake(() => { return false; }); env.win.requestIdleCallback = resolvingIdleCallbackWithTimeRemaining( @@ -227,8 +227,8 @@ describe('chunk2', () => { describe('invisible but deactivated', () => { beforeEach(() => { deactivateChunking(); - const viewer = Services.viewerForDoc(env.win.document); - env.sandbox.stub(viewer, 'isVisible').callsFake(() => { + const {ampdoc} = env; + env.sandbox.stub(ampdoc, 'isVisible').callsFake(() => { return false; }); env.win.requestIdleCallback = () => { @@ -242,8 +242,8 @@ describe('chunk2', () => { describe('invisible via document.hidden', () => { beforeEach(() => { - const viewer = Services.viewerForDoc(env.win.document); - env.sandbox.stub(viewer, 'isVisible').callsFake(() => { + const {ampdoc} = env; + env.sandbox.stub(ampdoc, 'isVisible').callsFake(() => { return false; }); env.win.requestIdleCallback = resolvingIdleCallbackWithTimeRemaining( @@ -262,15 +262,15 @@ describe('chunk2', () => { describe('invisible to visible', () => { beforeEach(() => { env.win.location.resetHref('test#visibilityState=hidden'); - const viewer = Services.viewerForDoc(env.win.document); + const {ampdoc} = env; let visible = false; - env.sandbox.stub(viewer, 'isVisible').callsFake(() => { + env.sandbox.stub(ampdoc, 'isVisible').callsFake(() => { return visible; }); env.win.requestIdleCallback = () => { // Don't call the callback, but transition to visible visible = true; - viewer.onVisibilityChange_(); + ampdoc.visibilityStateHandlers_.fire(); }; }); @@ -280,15 +280,15 @@ describe('chunk2', () => { describe('invisible to visible', () => { beforeEach(() => { env.win.location.resetHref('test#visibilityState=prerender'); - const viewer = Services.viewerForDoc(env.win.document); + const {ampdoc} = env; let visible = false; - env.sandbox.stub(viewer, 'isVisible').callsFake(() => { + env.sandbox.stub(ampdoc, 'isVisible').callsFake(() => { return visible; }); env.win.requestIdleCallback = () => { // Don't call the callback, but transition to visible visible = true; - viewer.onVisibilityChange_(); + ampdoc.visibilityStateHandlers_.fire(); }; }); @@ -298,16 +298,16 @@ describe('chunk2', () => { describe('invisible to visible after a while', () => { beforeEach(() => { env.win.location.resetHref('test#visibilityState=hidden'); - const viewer = Services.viewerForDoc(env.win.document); + const {ampdoc} = env; let visible = false; - env.sandbox.stub(viewer, 'isVisible').callsFake(() => { + env.sandbox.stub(ampdoc, 'isVisible').callsFake(() => { return visible; }); env.win.requestIdleCallback = () => { // Don't call the callback, but transition to visible setTimeout(() => { visible = true; - viewer.onVisibilityChange_(); + ampdoc.visibilityStateHandlers_.fire(); }, 10); }; }); @@ -341,8 +341,8 @@ describe('chunk2', () => { beforeEach(() => { env.win.requestIdleCallback = null; expect(env.win.requestIdleCallback).to.be.null; - const viewer = Services.viewerForDoc(env.win.document); - env.sandbox.stub(viewer, 'isVisible').callsFake(() => { + const {ampdoc} = env; + env.sandbox.stub(ampdoc, 'isVisible').callsFake(() => { return false; }); env.sandbox.defineProperty(env.win.document, 'hidden', { @@ -362,7 +362,6 @@ describe('long tasks', () => { }, env => { let subscriptions; - let sandbox; let clock; let progress; let postMessageCalls; @@ -386,7 +385,6 @@ describe('long tasks', () => { beforeEach(() => { postMessageCalls = 0; subscriptions = {}; - sandbox = sinon.sandbox; clock = sandbox.useFakeTimers(); toggleExperiment(env.win, 'macro-after-long-task', true); @@ -407,10 +405,6 @@ describe('long tasks', () => { progress = ''; }); - afterEach(() => { - sandbox.restore(); - }); - it('should not run macro tasks with invisible bodys', done => { startupChunk(env.win.document, complete('init', true)); startupChunk(env.win.document, complete('a', true)); diff --git a/test/unit/test-document-state.js b/test/unit/test-document-state.js index f560c1af9c0d..bf9815656661 100644 --- a/test/unit/test-document-state.js +++ b/test/unit/test-document-state.js @@ -16,15 +16,13 @@ import {DocumentState} from '../../src/service/document-state'; -describe('DocumentState', () => { - let sandbox; +describes.sandboxed('DocumentState', {}, () => { let eventListeners; let testDoc; let windowApi; let docState; beforeEach(() => { - sandbox = sinon.sandbox; eventListeners = {}; testDoc = { readyState: 'complete', @@ -43,39 +41,26 @@ describe('DocumentState', () => { docState = new DocumentState(windowApi); }); - afterEach(() => { - sandbox.restore(); - }); - it('resolve non-vendor properties', () => { - expect(docState.hiddenProp_).to.equal('hidden'); - expect(docState.visibilityStateProp_).to.equal('visibilityState'); - expect(docState.visibilityChangeEvent_).to.equal('visibilitychange'); - expect(eventListeners['visibilitychange']).to.not.equal(undefined); + expect(docState.isHidden()).to.be.false; + expect(docState.getVisibilityState()).to.equal('visible'); + expect(eventListeners['visibilitychange']).to.be.ok; }); it('resolve vendor-prefixed properties', () => { + const otherEventListeners = {}; const otherDoc = { webkitHidden: false, webkitVisibilityState: 'visible', - addEventListener: (unusedEventType, unusedHandler) => {}, - removeEventListener: (unusedEventType, unusedHandler) => {}, - }; - const other = new DocumentState({document: otherDoc}); - expect(other.hiddenProp_).to.equal('webkitHidden'); - expect(other.visibilityStateProp_).to.equal('webkitVisibilityState'); - expect(other.visibilityChangeEvent_).to.equal('webkitVisibilitychange'); - }); - - it('resolve no properties', () => { - const otherDoc = { - addEventListener: (unusedEventType, unusedHandler) => {}, + addEventListener: (eventType, handler) => { + otherEventListeners[eventType] = handler; + }, removeEventListener: (unusedEventType, unusedHandler) => {}, }; const other = new DocumentState({document: otherDoc}); - expect(other.hiddenProp_).to.equal(null); - expect(other.visibilityStateProp_).to.equal(null); - expect(other.visibilityChangeEvent_).to.equal(null); + expect(other.isHidden()).to.be.false; + expect(other.getVisibilityState()).to.equal('visible'); + expect(otherEventListeners['webkitVisibilitychange']).to.be.ok; }); it('should default hidden and visibilityState if unknown', () => { @@ -84,7 +69,7 @@ describe('DocumentState', () => { removeEventListener: (unusedEventType, unusedHandler) => {}, }; const other = new DocumentState({document: otherDoc}); - expect(other.isHidden()).to.equal(false); + expect(other.isHidden()).to.be.false; expect(other.getVisibilityState()).to.equal('visible'); }); diff --git a/test/unit/test-performance.js b/test/unit/test-performance.js index 7826d54774bc..0e7636e67891 100644 --- a/test/unit/test-performance.js +++ b/test/unit/test-performance.js @@ -281,7 +281,10 @@ describes.realWin('performance', {amp: true}, env => { expect(flushSpy).to.have.callCount(1); expect(perf.events_.length).to.equal(2); - return perf.coreServicesAvailable().then(() => { + return Promise.all([ + perf.coreServicesAvailable(), + viewer.whenFirstVisible(), + ]).then(() => { expect(flushSpy).to.have.callCount(4); expect(perf.isMessagingReady_).to.be.false; const count = 5; @@ -476,6 +479,9 @@ describes.realWin('performance', {amp: true}, env => { }); it('should call the flush callback', () => { + // Make sure "first visible" arrives after "channel ready". + const firstVisiblePromise = new Promise(() => {}); + sandbox.stub(viewer, 'whenFirstVisible').returns(firstVisiblePromise); expect(viewerSendMessageStub.withArgs('sendCsi')).to.have.callCount( 0 ); diff --git a/test/unit/test-preconnect.js b/test/unit/test-preconnect.js index 841157209eea..e02d57ba0a7f 100644 --- a/test/unit/test-preconnect.js +++ b/test/unit/test-preconnect.js @@ -29,7 +29,7 @@ describe('preconnect', () => { let preloadSupported; let preconnectSupported; let isSafari; - let visible; + let visiblePromise; // Factored out to make our linter happy since we don't allow // bare javascript URLs. @@ -55,18 +55,15 @@ describe('preconnect', () => { const element = document.createElement('div'); iframe.win.document.body.appendChild(element); preconnect = preconnectForElement(element); - preconnect.viewer_ = { - whenFirstVisible: () => {}, - }; - sandbox.stub(preconnect.viewer_, 'whenFirstVisible').callsFake(() => { - return visible; + sandbox.stub(preconnect, 'getAmpdoc_').returns({ + whenFirstVisible: () => visiblePromise, }); return iframe; }); } beforeEach(() => { - visible = Promise.resolve(); + visiblePromise = Promise.resolve(); isSafari = undefined; // Default mock to not support preload/preconnect - override in cases // to test for preload/preconnect support. @@ -92,7 +89,7 @@ describe('preconnect', () => { expect( iframe.doc.querySelectorAll('link[rel=dns-prefetch]') ).to.have.length(0); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=dns-prefetch]') ).to.have.length(1); @@ -129,7 +126,7 @@ describe('preconnect', () => { expect( iframe.doc.querySelectorAll('link[rel=preconnect]') ).to.have.length(0); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=dns-prefetch]') ).to.have.length(0); @@ -164,7 +161,7 @@ describe('preconnect', () => { expect( iframe.doc.querySelectorAll('link[rel=dns-prefetch]') ).to.have.length(0); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=dns-prefetch]') ).to.have.length(1); @@ -194,7 +191,7 @@ describe('preconnect', () => { it('should cleanup', () => { return getPreconnectIframe().then(iframe => { preconnect.url('https://c.preconnect.com/foo/bar'); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=dns-prefetch]') ).to.have.length(1); @@ -224,7 +221,7 @@ describe('preconnect', () => { preconnect.url('https://d.preconnect.com/foo/bar'); // Different origin preconnect.url('https://e.preconnect.com/other'); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=dns-prefetch]') ).to.have.length(2); @@ -244,13 +241,13 @@ describe('preconnect', () => { it('should timeout preconnects', () => { return getPreconnectIframe().then(iframe => { preconnect.url('https://x.preconnect.com/foo/bar'); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=preconnect]') ).to.have.length(1); iframeClock.tick(9000); preconnect.url('https://x.preconnect.com/foo/bar'); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=preconnect]') ).to.have.length(1); @@ -261,7 +258,7 @@ describe('preconnect', () => { // After timeout preconnect creates a new tag. clock.tick(10000); preconnect.url('https://x.preconnect.com/foo/bar'); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=preconnect]') ).to.have.length(1); @@ -277,7 +274,7 @@ describe('preconnect', () => { 'https://y.preconnect.com/foo/bar', /* opt_alsoConnecting */ true ); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=preconnect]') ).to.have.length(1); @@ -286,13 +283,13 @@ describe('preconnect', () => { iframe.doc.querySelectorAll('link[rel=preconnect]') ).to.have.length(0); clock.tick(10000); - return visible.then(() => { + return visiblePromise.then(() => { preconnect.url('https://y.preconnect.com/foo/bar'); expect( iframe.doc.querySelectorAll('link[rel=preconnect]') ).to.have.length(0); clock.tick(180 * 1000); - return visible.then(() => { + return visiblePromise.then(() => { preconnect.url('https://y.preconnect.com/foo/bar'); expect( iframe.doc.querySelectorAll('link[rel=preconnect]') @@ -317,7 +314,7 @@ describe('preconnect', () => { preconnect.preload(javascriptUrlPrefix + ':alert()'); const fetches = iframe.doc.querySelectorAll('link[rel=preload]'); expect(fetches).to.have.length(0); - return visible.then(() => { + return visiblePromise.then(() => { expect( iframe.doc.querySelectorAll('link[rel=preconnect]') ).to.have.length(1); @@ -340,7 +337,7 @@ describe('preconnect', () => { preconnect.preload('https://a.prefetch.com/foo/bar'); preconnect.preload('https://a.prefetch.com/other', 'style'); preconnect.preload(javascriptUrlPrefix + ':alert()'); - return visible.then(() => { + return visiblePromise.then(() => { // Also preconnects. expect( iframe.doc.querySelectorAll('link[rel=dns-prefetch]') diff --git a/test/unit/test-viewer.js b/test/unit/test-viewer.js index 4c63e5826a87..3c836c0aa24f 100644 --- a/test/unit/test-viewer.js +++ b/test/unit/test-viewer.js @@ -69,6 +69,7 @@ describes.sandboxed('Viewer', {}, () => { beforeEach(() => { clock = sandbox.useFakeTimers(); + events = {}; const WindowApi = function() {}; windowApi = new WindowApi(); windowApi.Math = window.Math; @@ -122,7 +123,6 @@ describes.sandboxed('Viewer', {}, () => { installPlatformService(windowApi); installTimerService(windowApi); installDocumentInfoServiceForDoc(windowApi.document); - events = {}; errorStub = sandbox.stub(dev(), 'error'); expectedErrorStub = sandbox.stub(dev(), 'expectedError'); windowMock = sandbox.mock(windowApi); @@ -325,56 +325,44 @@ describes.sandboxed('Viewer', {}, () => { return promise; }); - it('should initialize firstVisibleTime for initially visible doc', () => { - clock.tick(1); - const viewer = new Viewer(ampdoc); - expect(viewer.isVisible()).to.be.true; - expect(viewer.getFirstVisibleTime()).to.equal(1); - expect(viewer.getLastVisibleTime()).to.equal(1); - }); - it('should initialize firstVisibleTime when doc becomes visible', () => { - clock.tick(1); - params['visibilityState'] = 'prerender'; params['prerenderSize'] = '3'; const viewer = new Viewer(ampdoc); - expect(viewer.isVisible()).to.be.false; - expect(viewer.getFirstVisibleTime()).to.be.null; - expect(viewer.getLastVisibleTime()).to.be.null; - - // Becomes visible. - viewer.receiveMessage('visibilitychange', { - state: 'visible', - }); expect(viewer.isVisible()).to.be.true; - expect(viewer.getFirstVisibleTime()).to.equal(1); - expect(viewer.getLastVisibleTime()).to.equal(1); + expect(viewer.getFirstVisibleTime()).to.equal(0); + expect(viewer.getLastVisibleTime()).to.equal(0); - // Back to invisible. + // Becomes invisible. clock.tick(1); viewer.receiveMessage('visibilitychange', { state: 'hidden', }); expect(viewer.isVisible()).to.be.false; - expect(viewer.getFirstVisibleTime()).to.equal(1); - expect(viewer.getLastVisibleTime()).to.equal(1); + expect(viewer.getFirstVisibleTime()).to.equal(0); + expect(viewer.getLastVisibleTime()).to.equal(0); - // Back to visible again. + // Back to visible. clock.tick(1); viewer.receiveMessage('visibilitychange', { state: 'visible', }); expect(viewer.isVisible()).to.be.true; - expect(viewer.getFirstVisibleTime()).to.equal(1); - expect(viewer.getLastVisibleTime()).to.equal(3); + expect(viewer.getFirstVisibleTime()).to.equal(0); + expect(viewer.getLastVisibleTime()).to.equal(2); + + // Back to invisible again. + clock.tick(1); + viewer.receiveMessage('visibilitychange', { + state: 'hidden', + }); + expect(viewer.isVisible()).to.be.false; + expect(viewer.getFirstVisibleTime()).to.equal(0); + expect(viewer.getLastVisibleTime()).to.equal(2); }); - it('should configure visibilityState and prerender', () => { - params['visibilityState'] = 'prerender'; + it('should configure prerenderSize', () => { params['prerenderSize'] = '3'; const viewer = new Viewer(ampdoc); - expect(viewer.getVisibilityState()).to.equal('prerender'); - expect(viewer.isVisible()).to.equal(false); expect(viewer.getPrerenderSize()).to.equal(3); }); @@ -534,7 +522,7 @@ describes.sandboxed('Viewer', {}, () => { }); it('should parse "hidden" as "prerender" before first visible', () => { - viewer.hasBeenVisible_ = false; + sandbox.stub(ampdoc, 'getLastVisibleTime').callsFake(() => null); viewer.receiveMessage('visibilitychange', { state: 'hidden', }); @@ -543,7 +531,7 @@ describes.sandboxed('Viewer', {}, () => { }); it('should parse "hidden" as "inactive" after first visible', () => { - viewer.hasBeenVisible_ = true; + sandbox.stub(ampdoc, 'getLastVisibleTime').callsFake(() => 1); viewer.receiveMessage('visibilitychange', { state: 'hidden', }); @@ -629,17 +617,6 @@ describes.sandboxed('Viewer', {}, () => { expect(viewer.isVisible()).to.equal(true); }); - it('should be hidden when the browser document is unknown state', () => { - changeVisibility('what is this'); - expect(viewer.getVisibilityState()).to.equal('hidden'); - expect(viewer.isVisible()).to.equal(false); - viewer.receiveMessage('visibilitychange', { - state: 'paused', - }); - expect(viewer.getVisibilityState()).to.equal('hidden'); - expect(viewer.isVisible()).to.equal(false); - }); - it('should change visibility on visibilitychange event', () => { changeVisibility('hidden'); expect(viewer.getVisibilityState()).to.equal('hidden'); @@ -648,6 +625,7 @@ describes.sandboxed('Viewer', {}, () => { expect(viewer.getVisibilityState()).to.equal('visible'); expect(viewer.isVisible()).to.equal(true); + clock.tick(1); viewer.receiveMessage('visibilitychange', { state: 'hidden', }); diff --git a/test/unit/utils/test-document-visibility.js b/test/unit/utils/test-document-visibility.js new file mode 100644 index 000000000000..cf142759e023 --- /dev/null +++ b/test/unit/utils/test-document-visibility.js @@ -0,0 +1,114 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + addDocumentVisibilityChangeListener, + getDocumentVisibilityState, + isDocumentHidden, + removeDocumentVisibilityChangeListener, +} from '../../../src/utils/document-visibility'; + +describes.sandboxed('document-visibility', {}, env => { + let doc; + + beforeEach(() => { + doc = { + addEventListener: sandbox.spy(), + removeEventListener: sandbox.spy(), + }; + }); + + function overridePropertyForDoc(name, value) { + env.sandbox.defineProperty(doc, name, {value}); + } + + it('should be visible when no properties defined', () => { + expect(isDocumentHidden(doc)).to.be.false; + expect(getDocumentVisibilityState(doc)).to.equal('visible'); + }); + + it('should resolve non-vendor hidden property', () => { + overridePropertyForDoc('hidden', true); + expect(isDocumentHidden(doc)).to.be.true; + expect(getDocumentVisibilityState(doc)).to.equal('hidden'); + }); + + it('should resolve non-vendor visibilityState property', () => { + overridePropertyForDoc('visibilityState', 'hidden'); + expect(isDocumentHidden(doc)).to.be.true; + expect(getDocumentVisibilityState(doc)).to.equal('hidden'); + }); + + it('should prefer visibilityState property to hidden', () => { + overridePropertyForDoc('hidden', true); + overridePropertyForDoc('visibilityState', 'visible'); + expect(isDocumentHidden(doc)).to.be.false; + expect(getDocumentVisibilityState(doc)).to.equal('visible'); + }); + + it('should consider prerender as visible', () => { + overridePropertyForDoc('visibilityState', 'prerender'); + expect(isDocumentHidden(doc)).to.be.true; + expect(getDocumentVisibilityState(doc)).to.equal('prerender'); + }); + + it('should resolve non-vendor visibilitychange event', () => { + function handler() {} + overridePropertyForDoc('onvisibilitychange', null); + + addDocumentVisibilityChangeListener(doc, handler); + expect(doc.addEventListener).to.be.calledOnce.calledWith( + 'visibilitychange', + handler + ); + + removeDocumentVisibilityChangeListener(doc, handler); + expect(doc.removeEventListener).to.be.calledOnce.calledWith( + 'visibilitychange', + handler + ); + }); + + it('should resolve vendor hidden property', () => { + overridePropertyForDoc('webkitHidden', true); + expect(isDocumentHidden(doc)).to.be.true; + expect(getDocumentVisibilityState(doc)).to.equal('hidden'); + }); + + it('should resolve vendor visibilityState property', () => { + overridePropertyForDoc('webkitVisibilityState', 'prerender'); + expect(isDocumentHidden(doc)).to.be.true; + expect(getDocumentVisibilityState(doc)).to.equal('prerender'); + }); + + it('should resolve vendor visibilitychange event', () => { + function handler() {} + overridePropertyForDoc('webkitHidden', true); + overridePropertyForDoc('onwebkitvisibilitychange', null); + + addDocumentVisibilityChangeListener(doc, handler); + expect(doc.addEventListener).to.be.calledOnce.calledWith( + 'webkitVisibilitychange', + handler + ); + + removeDocumentVisibilityChangeListener(doc, handler); + expect(doc.removeEventListener).to.be.calledOnce.calledWith( + 'webkitVisibilitychange', + handler + ); + }); +});