From d8da5a5a874c240c657288ec7f085ee0240e5ea2 Mon Sep 17 00:00:00 2001 From: Shihua Zheng Date: Sat, 25 Jan 2020 08:47:27 -0800 Subject: [PATCH] Revert 'Move mutator implementations out to a standalone service' (#26479) --- build-system/tasks/presubmit-checks.js | 28 +- extensions/amp-access-poool/0.1/poool-impl.js | 8 +- .../0.1/story-ad-button-text-fitter.js | 8 +- src/inabox/inabox-mutator.js | 13 +- src/inabox/inabox-resources.js | 59 +- src/inabox/inabox-services.js | 2 - src/service/core-services.js | 4 - src/service/mutator-impl.js | 414 ---- src/service/resources-impl.js | 448 ++++- src/service/resources-interface.js | 49 +- src/services.js | 2 +- test/unit/test-mutator.js | 1742 ----------------- test/unit/test-resources.js | 1706 ++++++++++++++++ 13 files changed, 2190 insertions(+), 2293 deletions(-) delete mode 100644 src/service/mutator-impl.js delete mode 100644 test/unit/test-mutator.js diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js index c7d92faafaaf..5ac5945f5e49 100644 --- a/build-system/tasks/presubmit-checks.js +++ b/build-system/tasks/presubmit-checks.js @@ -224,14 +224,6 @@ const forbiddenTerms = { 'testing/iframe.js', ], }, - 'installMutatorServiceForDoc': { - message: privateServiceFactory, - whitelist: [ - 'src/inabox/inabox-services.js', - 'src/service/core-services.js', - 'src/service/mutator-impl.js', - ], - }, 'installPerformanceService': { message: privateServiceFactory, whitelist: [ @@ -241,14 +233,6 @@ const forbiddenTerms = { 'src/service/performance-impl.js', ], }, - 'installResourcesServiceForDoc': { - message: privateServiceFactory, - whitelist: [ - 'src/inabox/inabox-services.js', - 'src/service/core-services.js', - 'src/service/resources-impl.js', - ], - }, 'installStorageServiceForDoc': { message: privateServiceFactory, whitelist: [ @@ -300,6 +284,15 @@ const forbiddenTerms = { 'src/service/vsync-impl.js', ], }, + 'installResourcesServiceForDoc': { + message: privateServiceFactory, + whitelist: [ + 'src/inabox/inabox-services.js', + 'src/service/core-services.js', + 'src/service/resources-impl.js', + 'src/service/standard-actions-impl.js', + ], + }, 'installXhrService': { message: privateServiceFactory, whitelist: [ @@ -579,7 +572,7 @@ const forbiddenTerms = { }, '\\.schedulePass\\(': { message: 'schedulePass is heavy, think twice before using it', - whitelist: ['src/service/mutator-impl.js', 'src/service/resources-impl.js'], + whitelist: ['src/service/resources-impl.js'], }, '\\.requireLayout\\(': { message: @@ -856,7 +849,6 @@ const forbiddenTerms = { 'test/unit/test-mode.js', 'test/unit/test-motion.js', 'test/unit/test-mustache.js', - 'test/unit/test-mutator.js', 'test/unit/test-object.js', 'test/unit/test-observable.js', 'test/unit/test-pass.js', diff --git a/extensions/amp-access-poool/0.1/poool-impl.js b/extensions/amp-access-poool/0.1/poool-impl.js index 518c5bb5ba6e..b38a954a8ff8 100644 --- a/extensions/amp-access-poool/0.1/poool-impl.js +++ b/extensions/amp-access-poool/0.1/poool-impl.js @@ -64,8 +64,8 @@ export class PooolVendor { /** @private {!../../amp-access/0.1/amp-access-source.AccessSource} */ this.accessSource_ = accessSource; - /** @const @private {!../../../src/service/mutator-interface.MutatorInterface} */ - this.mutator_ = Services.mutatorForDoc(this.ampdoc); + /** @const @private {!../../../src/service/resources-interface.ResourcesInterface} */ + this.resources_ = Services.resourcesForDoc(this.ampdoc); /** @private {string} */ this.accessUrl_ = ACCESS_CONFIG['authorization']; @@ -216,7 +216,7 @@ export class PooolVendor { .querySelector('[poool-access-preview]'); if (articlePreview) { - this.mutator_.mutateElement(articlePreview, () => { + this.resources_.mutateElement(articlePreview, () => { articlePreview.setAttribute('amp-access-hide', ''); }); } @@ -226,7 +226,7 @@ export class PooolVendor { .querySelector('[poool-access-content]'); if (articleContent) { - this.mutator_.mutateElement(articleContent, () => { + this.resources_.mutateElement(articleContent, () => { articleContent.removeAttribute('amp-access-hide'); }); } diff --git a/extensions/amp-story-auto-ads/0.1/story-ad-button-text-fitter.js b/extensions/amp-story-auto-ads/0.1/story-ad-button-text-fitter.js index 8bcad6de9ded..23e0ed56b3d1 100644 --- a/extensions/amp-story-auto-ads/0.1/story-ad-button-text-fitter.js +++ b/extensions/amp-story-auto-ads/0.1/story-ad-button-text-fitter.js @@ -31,8 +31,8 @@ export class ButtonTextFitter { * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc */ constructor(ampdoc) { - /** @const @private {!../../../src/service/mutator-interface.MutatorInterface} */ - this.mutator_ = Services.mutatorForDoc(ampdoc); + /** @private @const {!../../../src/service/resources-interface.ResourcesInterface} */ + this.resources_ = Services.resourcesForDoc(ampdoc); /** @private {!Document} */ this.doc_ = ampdoc.win.document; @@ -40,7 +40,7 @@ export class ButtonTextFitter { /** @private {!Element} */ this.measurer_ = this.doc_.createElement('div'); - this.mutator_.mutateElement(this.measurer_, () => { + this.resources_.mutateElement(this.measurer_, () => { this.doc_.body.appendChild(this.measurer_); setStyles(this.measurer_, { position: 'absolute', @@ -62,7 +62,7 @@ export class ButtonTextFitter { */ fit(pageElement, container, content) { let success = false; - return this.mutator_ + return this.resources_ .mutateElement(container, () => { this.measurer_.textContent = content; const fontSize = calculateFontSize( diff --git a/src/inabox/inabox-mutator.js b/src/inabox/inabox-mutator.js index 76cc93e8707d..72070ab4c80f 100644 --- a/src/inabox/inabox-mutator.js +++ b/src/inabox/inabox-mutator.js @@ -15,7 +15,6 @@ */ import {Services} from '../services'; -import {registerServiceBuilderForDoc} from '../service'; /** * @implements {../service/mutator-interface.MutatorInterface} @@ -23,10 +22,11 @@ import {registerServiceBuilderForDoc} from '../service'; export class InaboxMutator { /** * @param {!../service/ampdoc-impl.AmpDoc} ampdoc + * @param {!../service/resources-interface.ResourcesInterface} resources */ - constructor(ampdoc) { + constructor(ampdoc, resources) { /** @const @private {!../service/resources-interface.ResourcesInterface} */ - this.resources_ = Services.resourcesForDoc(ampdoc); + this.resources_ = resources; /** @private @const {!../service/vsync-impl.Vsync} */ this.vsync_ = Services./*OK*/ vsyncFor(ampdoc.win); @@ -101,10 +101,3 @@ export class InaboxMutator { }); } } - -/** - * @param {!../service/ampdoc-impl.AmpDoc} ampdoc - */ -export function installInaboxMutatorServiceForDoc(ampdoc) { - registerServiceBuilderForDoc(ampdoc, 'mutator', InaboxMutator); -} diff --git a/src/inabox/inabox-resources.js b/src/inabox/inabox-resources.js index 683ec5d1a410..35b6d6c2f3cb 100644 --- a/src/inabox/inabox-resources.js +++ b/src/inabox/inabox-resources.js @@ -15,6 +15,7 @@ */ import {Deferred} from '../utils/promise'; +import {InaboxMutator} from './inabox-mutator'; import {Observable} from '../observable'; import {Pass} from '../pass'; import {READY_SCAN_SIGNAL} from '../service/resources-interface'; @@ -56,6 +57,9 @@ export class InaboxResources { /** @const @private {!Deferred} */ this.firstPassDone_ = new Deferred(); + /** @const @private {!InaboxMutator} */ + this.mutator_ = new InaboxMutator(ampdoc, this); + const input = Services.inputFor(this.win); input.setupInputModeClasses(ampdoc); } @@ -125,12 +129,6 @@ export class InaboxResources { return this.pass_.schedule(opt_delay); } - /** @override */ - updateOrEnqueueMutateTask(unusedResource, unusedNewRequest) {} - - /** @override */ - schedulePassVsync() {} - /** @override */ onNextPass(callback) { this.passObservable_.add(callback); @@ -145,10 +143,55 @@ export class InaboxResources { } /** @override */ - setRelayoutTop(unusedRelayoutTop) {} + changeSize(element, newHeight, newWidth, opt_callback, opt_newMargins) { + this.mutator_./*OK*/ changeSize( + element, + newHeight, + newWidth, + opt_callback, + opt_newMargins + ); + } /** @override */ - maybeHeightChanged() {} + attemptChangeSize(element, newHeight, newWidth, opt_newMargins) { + return this.mutator_.attemptChangeSize( + element, + newHeight, + newWidth, + opt_newMargins + ); + } + + /** @override */ + expandElement(element) { + this.mutator_.expandElement(element); + } + + /** @override */ + attemptCollapse(element) { + return this.mutator_.attemptCollapse(element); + } + + /** @override */ + collapseElement(element) { + this.mutator_.collapseElement(element); + } + + /** @override */ + measureElement(measurer) { + return this.mutator_.measureElement(measurer); + } + + /** @override */ + mutateElement(element, mutator) { + return this.mutator_.mutateElement(element, mutator); + } + + /** @override */ + measureMutateElement(element, measurer, mutator) { + return this.mutator_.measureMutateElement(element, measurer, mutator); + } /** * @return {!Promise} when first pass executed. diff --git a/src/inabox/inabox-services.js b/src/inabox/inabox-services.js index f3846fdcee8b..98cbe33ec21a 100644 --- a/src/inabox/inabox-services.js +++ b/src/inabox/inabox-services.js @@ -22,7 +22,6 @@ import {installHiddenObserverForDoc} from '../service/hidden-observer-impl'; import {installHistoryServiceForDoc} from '../service/history-impl'; import {installIframeMessagingClient} from './inabox-iframe-messaging-client'; import {installInaboxCidService} from './inabox-cid'; -import {installInaboxMutatorServiceForDoc} from './inabox-mutator'; import {installInaboxResourcesServiceForDoc} from './inabox-resources'; import {installInaboxViewerServiceForDoc} from './inabox-viewer'; import {installInaboxViewportService} from './inabox-viewport'; @@ -48,7 +47,6 @@ export function installAmpdocServicesForInabox(ampdoc) { installHistoryServiceForDoc(ampdoc); installInaboxResourcesServiceForDoc(ampdoc); installOwnersServiceForDoc(ampdoc); - installInaboxMutatorServiceForDoc(ampdoc); installUrlReplacementsServiceForDoc(ampdoc); installActionServiceForDoc(ampdoc); installStandardActionsForDoc(ampdoc); diff --git a/src/service/core-services.js b/src/service/core-services.js index e2a42b10b433..b83ec346cd53 100644 --- a/src/service/core-services.js +++ b/src/service/core-services.js @@ -27,7 +27,6 @@ import {installHistoryServiceForDoc} from './history-impl'; import {installImg} from '../../builtins/amp-img'; import {installInputService} from '../input'; import {installLayout} from '../../builtins/amp-layout'; -import {installMutatorServiceForDoc} from './mutator-impl'; import {installOwnersServiceForDoc} from './owners-impl'; import {installPixel} from '../../builtins/amp-pixel'; import {installPlatformService} from './platform-impl'; @@ -107,9 +106,6 @@ export function installAmpdocServices(ampdoc) { isEmbedded ? adoptServiceForEmbedDoc(ampdoc, 'owners') : installOwnersServiceForDoc(ampdoc); - isEmbedded - ? adoptServiceForEmbedDoc(ampdoc, 'mutator') - : installMutatorServiceForDoc(ampdoc); isEmbedded ? adoptServiceForEmbedDoc(ampdoc, 'url-replace') : installUrlReplacementsServiceForDoc(ampdoc); diff --git a/src/service/mutator-impl.js b/src/service/mutator-impl.js deleted file mode 100644 index 683abac0acfc..000000000000 --- a/src/service/mutator-impl.js +++ /dev/null @@ -1,414 +0,0 @@ -/** - * 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 {FocusHistory} from '../focus-history'; -import {MutatorInterface} from './mutator-interface'; -import {Resource} from './resource'; -import {Services} from '../services'; -import {areMarginsChanged} from '../layout-rect'; -import {closest} from '../dom'; -import {computedStyle} from '../style'; -import {dev} from '../log'; -import {isExperimentOn} from '../experiments'; -import {registerServiceBuilderForDoc} from '../service'; - -const FOUR_FRAME_DELAY_ = 70; -const FOCUS_HISTORY_TIMEOUT_ = 1000 * 60; // 1min -const TAG_ = 'Mutator'; - -/** - * @implements {MutatorInterface} - */ -export class MutatorImpl { - /** - * @param {!./ampdoc-impl.AmpDoc} ampdoc - */ - constructor(ampdoc) { - /** @const {!./ampdoc-impl.AmpDoc} */ - this.ampdoc = ampdoc; - - /** @const {!Window} */ - this.win = ampdoc.win; - - /** @private @const {!./resources-interface.ResourcesInterface} */ - this.resources_ = Services.resourcesForDoc(ampdoc); - - /** @private @const {!./viewport/viewport-interface.ViewportInterface} */ - this.viewport_ = Services.viewportForDoc(this.ampdoc); - - /** @private @const {!./vsync-impl.Vsync} */ - this.vsync_ = Services./*OK*/ vsyncFor(this.win); - - /** @private @const {!FocusHistory} */ - this.activeHistory_ = new FocusHistory(this.win, FOCUS_HISTORY_TIMEOUT_); - - this.activeHistory_.onFocus(element => { - this.checkPendingChangeSize_(element); - }); - } - - /** @override */ - changeSize(element, newHeight, newWidth, opt_callback, opt_newMargins) { - this.scheduleChangeSize_( - Resource.forElement(element), - newHeight, - newWidth, - opt_newMargins, - /* event */ undefined, - /* force */ true, - opt_callback - ); - } - - /** @override */ - attemptChangeSize(element, newHeight, newWidth, opt_newMargins, opt_event) { - return new Promise((resolve, reject) => { - this.scheduleChangeSize_( - Resource.forElement(element), - newHeight, - newWidth, - opt_newMargins, - opt_event, - /* force */ false, - success => { - if (success) { - resolve(); - } else { - reject(new Error('changeSize attempt denied')); - } - } - ); - }); - } - - /** @override */ - expandElement(element) { - const resource = Resource.forElement(element); - resource.completeExpand(); - - const owner = resource.getOwner(); - if (owner) { - owner.expandedCallback(element); - } - - this.resources_.schedulePass(FOUR_FRAME_DELAY_); - } - - /** @override */ - attemptCollapse(element) { - return new Promise((resolve, reject) => { - this.scheduleChangeSize_( - Resource.forElement(element), - 0, - 0, - /* newMargin */ undefined, - /* event */ undefined, - /* force */ false, - success => { - if (success) { - const resource = Resource.forElement(element); - resource.completeCollapse(); - resolve(); - } else { - reject(dev().createExpectedError('collapse attempt denied')); - } - } - ); - }); - } - - /** @override */ - collapseElement(element) { - const box = this.viewport_.getLayoutRect(element); - const resource = Resource.forElement(element); - if (box.width != 0 && box.height != 0) { - if (isExperimentOn(this.win, 'dirty-collapse-element')) { - this.dirtyElement(element); - } else { - this.resources_.setRelayoutTop(box.top); - } - } - resource.completeCollapse(); - this.resources_.schedulePass(FOUR_FRAME_DELAY_); - } - - /** @override */ - measureElement(measurer) { - return this.vsync_.measurePromise(measurer); - } - - /** @override */ - mutateElement(element, mutator) { - return this.measureMutateElement(element, null, mutator); - } - - /** @override */ - measureMutateElement(element, measurer, mutator) { - return this.measureMutateElementResources_(element, measurer, mutator); - } - - /** - * Returns the layout margins for the resource. - * @param {!Resource} resource - * @return {!../layout-rect.LayoutMarginsDef} - * @private - */ - getLayoutMargins_(resource) { - const style = computedStyle(this.win, resource.element); - return { - top: parseInt(style.marginTop, 10) || 0, - right: parseInt(style.marginRight, 10) || 0, - bottom: parseInt(style.marginBottom, 10) || 0, - left: parseInt(style.marginLeft, 10) || 0, - }; - } - - /** - * Handles element mutation (and measurement) APIs in the Resources system. - * - * @param {!Element} element - * @param {?function()} measurer - * @param {function()} mutator - * @return {!Promise} - */ - measureMutateElementResources_(element, measurer, mutator) { - const calcRelayoutTop = () => { - const box = this.viewport_.getLayoutRect(element); - if (box.width != 0 && box.height != 0) { - return box.top; - } - return -1; - }; - let relayoutTop = -1; - // TODO(jridgewell): support state - return this.vsync_.runPromise({ - measure: () => { - if (measurer) { - measurer(); - } - relayoutTop = calcRelayoutTop(); - }, - mutate: () => { - mutator(); - - if (element.classList.contains('i-amphtml-element')) { - const r = Resource.forElement(element); - r.requestMeasure(); - } - const ampElements = element.getElementsByClassName('i-amphtml-element'); - for (let i = 0; i < ampElements.length; i++) { - const r = Resource.forElement(ampElements[i]); - r.requestMeasure(); - } - if (relayoutTop != -1) { - this.resources_.setRelayoutTop(relayoutTop); - } - this.resources_.schedulePass(FOUR_FRAME_DELAY_); - - // Need to measure again in case the element has become visible or - // shifted. - this.vsync_.measure(() => { - const updatedRelayoutTop = calcRelayoutTop(); - if (updatedRelayoutTop != -1 && updatedRelayoutTop != relayoutTop) { - this.resources_.setRelayoutTop(updatedRelayoutTop); - this.resources_.schedulePass(FOUR_FRAME_DELAY_); - } - this.resources_.maybeHeightChanged(); - }); - }, - }); - } - - /** - * Dirties the cached element measurements after a mutation occurs. - * - * TODO(jridgewell): This API needs to be audited. Common practice is - * to pass the amp-element in as the root even though we are only - * mutating children. If the amp-element is passed, we invalidate - * everything in the parent layer above it, where only invalidating the - * amp-element was necessary (only children were mutated, only - * amp-element's scroll box is affected). - * - * @param {!Element} element - */ - dirtyElement(element) { - let relayoutAll = false; - const isAmpElement = element.classList.contains('i-amphtml-element'); - if (isAmpElement) { - const r = Resource.forElement(element); - this.resources_.setRelayoutTop(r.getLayoutBox().top); - } else { - relayoutAll = true; - } - this.resources_.schedulePass(FOUR_FRAME_DELAY_, relayoutAll); - } - - /** - * Reschedules change size request when an overflown element is activated. - * @param {!Element} element - * @private - */ - checkPendingChangeSize_(element) { - const resourceElement = closest( - element, - el => !!Resource.forElementOptional(el) - ); - if (!resourceElement) { - return; - } - const resource = Resource.forElement(resourceElement); - const pendingChangeSize = resource.getPendingChangeSize(); - if (pendingChangeSize !== undefined) { - this.scheduleChangeSize_( - resource, - pendingChangeSize.height, - pendingChangeSize.width, - pendingChangeSize.margins, - /* event */ undefined, - /* force */ true - ); - } - } - - /** - * Schedules change of the element's height. - * @param {!Resource} resource - * @param {number|undefined} newHeight - * @param {number|undefined} newWidth - * @param {!../layout-rect.LayoutMarginsChangeDef|undefined} newMargins - * @param {?Event|undefined} event - * @param {boolean} force - * @param {function(boolean)=} opt_callback A callback function - * @private - */ - scheduleChangeSize_( - resource, - newHeight, - newWidth, - newMargins, - event, - force, - opt_callback - ) { - if (resource.hasBeenMeasured() && !newMargins) { - this.completeScheduleChangeSize_( - resource, - newHeight, - newWidth, - undefined, - event, - force, - opt_callback - ); - } else { - // This is a rare case since most of times the element itself schedules - // resize requests. However, this case is possible when another element - // requests resize of a controlled element. This also happens when a - // margin size change is requested, since existing margins have to be - // measured in this instance. - this.vsync_.measure(() => { - if (!resource.hasBeenMeasured()) { - resource.measure(); - } - const marginChange = newMargins - ? { - newMargins, - currentMargins: this.getLayoutMargins_(resource), - } - : undefined; - this.completeScheduleChangeSize_( - resource, - newHeight, - newWidth, - marginChange, - event, - force, - opt_callback - ); - }); - } - } - - /** - * @param {!Resource} resource - * @param {number|undefined} newHeight - * @param {number|undefined} newWidth - * @param {!./resources-interface.MarginChangeDef|undefined} marginChange - * @param {?Event|undefined} event - * @param {boolean} force - * @param {function(boolean)=} opt_callback A callback function - * @private - */ - completeScheduleChangeSize_( - resource, - newHeight, - newWidth, - marginChange, - event, - force, - opt_callback - ) { - resource.resetPendingChangeSize(); - const layoutBox = resource.getPageLayoutBox(); - if ( - (newHeight === undefined || newHeight == layoutBox.height) && - (newWidth === undefined || newWidth == layoutBox.width) && - (marginChange === undefined || - !areMarginsChanged( - marginChange.currentMargins, - marginChange.newMargins - )) - ) { - if ( - newHeight === undefined && - newWidth === undefined && - marginChange === undefined - ) { - dev().error( - TAG_, - 'attempting to change size with undefined dimensions', - resource.debugid - ); - } - // Nothing to do. - if (opt_callback) { - opt_callback(/* success */ true); - } - return; - } - - this.resources_.updateOrEnqueueMutateTask( - resource, - /** {!ChangeSizeRequestDef} */ { - resource, - newHeight, - newWidth, - marginChange, - event, - force, - callback: opt_callback, - } - ); - this.resources_.schedulePassVsync(); - } -} - -/** - * @param {!./ampdoc-impl.AmpDoc} ampdoc - */ -export function installMutatorServiceForDoc(ampdoc) { - registerServiceBuilderForDoc(ampdoc, 'mutator', MutatorImpl); -} diff --git a/src/service/resources-impl.js b/src/service/resources-impl.js index 394ec603466f..fa4320274678 100644 --- a/src/service/resources-impl.js +++ b/src/service/resources-impl.js @@ -23,11 +23,12 @@ import {Resource, ResourceState} from './resource'; import {Services} from '../services'; import {TaskQueue} from './task-queue'; import {VisibilityState} from '../visibility-state'; +import {areMarginsChanged, expandLayoutRect} from '../layout-rect'; +import {closest, hasNextNodeInDocumentOrder} from '../dom'; +import {computedStyle} from '../style'; import {dev, devAssert} from '../log'; import {dict} from '../utils/object'; -import {expandLayoutRect} from '../layout-rect'; import {getSourceUrl} from '../url'; -import {hasNextNodeInDocumentOrder} from '../dom'; import {checkAndFix as ieMediaCheckAndFix} from './ie-media-bug'; import {isBlockedByConsent, reportError} from '../error'; import {isExperimentOn} from '../experiments'; @@ -49,6 +50,29 @@ const MUTATE_DEFER_DELAY_ = 500; const FOCUS_HISTORY_TIMEOUT_ = 1000 * 60; // 1min const FOUR_FRAME_DELAY_ = 70; +/** + * The internal structure of a ChangeHeightRequest. + * @typedef {{ + * newMargins: !../layout-rect.LayoutMarginsChangeDef, + * currentMargins: !../layout-rect.LayoutMarginsDef + * }} + */ +let MarginChangeDef; + +/** + * The internal structure of a ChangeHeightRequest. + * @typedef {{ + * resource: !Resource, + * newHeight: (number|undefined), + * newWidth: (number|undefined), + * marginChange: (!MarginChangeDef|undefined), + * event: (?Event|undefined), + * force: boolean, + * callback: (function(boolean)|undefined), + * }} + */ +let ChangeSizeRequestDef; + /** * @implements {ResourcesInterface} */ @@ -148,7 +172,7 @@ export class ResourcesImpl { this.boundTaskScorer_ = this.calcTaskScore_.bind(this); /** - * @private {!Array} + * @private {!Array} */ this.requestsChangeSize_ = []; @@ -219,6 +243,10 @@ export class ResourcesImpl { this.schedulePass(1); }); + this.activeHistory_.onFocus(element => { + this.checkPendingChangeSize_(element); + }); + // Schedule initial passes. This must happen in a startup task // to avoid blocking body visible. startupChunk(this.ampdoc, () => { @@ -513,37 +541,198 @@ export class ResourcesImpl { } /** @override */ - schedulePass(opt_delay, opt_relayoutAll) { - if (opt_relayoutAll) { - this.relayoutAll_ = true; + changeSize(element, newHeight, newWidth, opt_callback, opt_newMargins) { + this.scheduleChangeSize_( + Resource.forElement(element), + newHeight, + newWidth, + opt_newMargins, + /* event */ undefined, + /* force */ true, + opt_callback + ); + } + + /** @override */ + attemptChangeSize(element, newHeight, newWidth, opt_newMargins, opt_event) { + return new Promise((resolve, reject) => { + this.scheduleChangeSize_( + Resource.forElement(element), + newHeight, + newWidth, + opt_newMargins, + opt_event, + /* force */ false, + success => { + if (success) { + resolve(); + } else { + reject(new Error('changeSize attempt denied')); + } + } + ); + }); + } + + /** @override */ + measureElement(measurer) { + return this.vsync_.measurePromise(measurer); + } + + /** @override */ + mutateElement(element, mutator) { + return this.measureMutateElement(element, null, mutator); + } + + /** @override */ + measureMutateElement(element, measurer, mutator) { + return this.measureMutateElementResources_(element, measurer, mutator); + } + + /** + * Handles element mutation (and measurement) APIs in the Resources system. + * + * @param {!Element} element + * @param {?function()} measurer + * @param {function()} mutator + * @return {!Promise} + */ + measureMutateElementResources_(element, measurer, mutator) { + const calcRelayoutTop = () => { + const box = this.viewport_.getLayoutRect(element); + if (box.width != 0 && box.height != 0) { + return box.top; + } + return -1; + }; + let relayoutTop = -1; + // TODO(jridgewell): support state + return this.vsync_.runPromise({ + measure: () => { + if (measurer) { + measurer(); + } + relayoutTop = calcRelayoutTop(); + }, + mutate: () => { + mutator(); + + if (element.classList.contains('i-amphtml-element')) { + const r = Resource.forElement(element); + r.requestMeasure(); + } + const ampElements = element.getElementsByClassName('i-amphtml-element'); + for (let i = 0; i < ampElements.length; i++) { + const r = Resource.forElement(ampElements[i]); + r.requestMeasure(); + } + if (relayoutTop != -1) { + this.setRelayoutTop_(relayoutTop); + } + this.schedulePass(FOUR_FRAME_DELAY_); + + // Need to measure again in case the element has become visible or + // shifted. + this.vsync_.measure(() => { + const updatedRelayoutTop = calcRelayoutTop(); + if (updatedRelayoutTop != -1 && updatedRelayoutTop != relayoutTop) { + this.setRelayoutTop_(updatedRelayoutTop); + this.schedulePass(FOUR_FRAME_DELAY_); + } + this.maybeChangeHeight_ = true; + }); + }, + }); + } + + /** + * Dirties the cached element measurements after a mutation occurs. + * + * TODO(jridgewell): This API needs to be audited. Common practice is + * to pass the amp-element in as the root even though we are only + * mutating children. If the amp-element is passed, we invalidate + * everything in the parent layer above it, where only invalidating the + * amp-element was necessary (only children were mutated, only + * amp-element's scroll box is affected). + * + * @param {!Element} element + */ + dirtyElement(element) { + let relayoutAll = false; + const isAmpElement = element.classList.contains('i-amphtml-element'); + if (isAmpElement) { + const r = Resource.forElement(element); + this.setRelayoutTop_(r.getLayoutBox().top); + } else { + relayoutAll = true; } - return this.pass_.schedule(opt_delay); + this.schedulePass(FOUR_FRAME_DELAY_, relayoutAll); } /** @override */ - updateOrEnqueueMutateTask(resource, newRequest) { - let request = null; - for (let i = 0; i < this.requestsChangeSize_.length; i++) { - if (this.requestsChangeSize_[i].resource == resource) { - request = this.requestsChangeSize_[i]; - break; + attemptCollapse(element) { + return new Promise((resolve, reject) => { + this.scheduleChangeSize_( + Resource.forElement(element), + 0, + 0, + /* newMargin */ undefined, + /* event */ undefined, + /* force */ false, + success => { + if (success) { + const resource = Resource.forElement(element); + resource.completeCollapse(); + resolve(); + } else { + reject(dev().createExpectedError('collapse attempt denied')); + } + } + ); + }); + } + + /** @override */ + collapseElement(element) { + const box = this.viewport_.getLayoutRect(element); + const resource = Resource.forElement(element); + if (box.width != 0 && box.height != 0) { + if (isExperimentOn(this.win, 'dirty-collapse-element')) { + this.dirtyElement(element); + } else { + this.setRelayoutTop_(box.top); } } + resource.completeCollapse(); + this.schedulePass(FOUR_FRAME_DELAY_); + } - if (request) { - request.newHeight = newRequest.newHeight; - request.newWidth = newRequest.newWidth; - request.marginChange = newRequest.marginChange; - request.event = newRequest.event; - request.force = newRequest.force || request.force; - request.callback = newRequest.callback; - } else { - this.requestsChangeSize_.push(newRequest); + /** @override */ + expandElement(element) { + const resource = Resource.forElement(element); + resource.completeExpand(); + + const owner = resource.getOwner(); + if (owner) { + owner.expandedCallback(element); } + + this.schedulePass(FOUR_FRAME_DELAY_); } /** @override */ - schedulePassVsync() { + schedulePass(opt_delay, opt_relayoutAll) { + if (opt_relayoutAll) { + this.relayoutAll_ = true; + } + return this.pass_.schedule(opt_delay); + } + + /** + * Schedules the work pass at the latest with the specified delay. + * @private + */ + schedulePassVsync_() { if (this.vsyncScheduled_) { return; } @@ -557,20 +746,6 @@ export class ResourcesImpl { this.schedulePass(); } - /** @override */ - setRelayoutTop(relayoutTop) { - if (this.relayoutTop_ == -1) { - this.relayoutTop_ = relayoutTop; - } else { - this.relayoutTop_ = Math.min(relayoutTop, this.relayoutTop_); - } - } - - /** @override */ - maybeHeightChanged() { - this.maybeChangeHeight_ = true; - } - /** @override */ onNextPass(callback) { this.passCallbacks_.push(callback); @@ -724,7 +899,7 @@ export class ResourcesImpl { const { resource, event, - } = /** @type {!./resources-interface.ChangeSizeRequestDef} */ (request); + } = /** @type {!ChangeSizeRequestDef} */ (request); const box = resource.getLayoutBox(); let topMarginDiff = 0; @@ -914,7 +1089,7 @@ export class ResourcesImpl { } if (minTop != -1) { - this.setRelayoutTop(minTop); + this.setRelayoutTop_(minTop); } // Execute scroll-adjusting resize requests, if any. @@ -942,7 +1117,7 @@ export class ResourcesImpl { } }); if (minTop != -1) { - this.setRelayoutTop(minTop); + this.setRelayoutTop_(minTop); } // Sync is necessary here to avoid UI jump in the next frame. const newScrollHeight = this.viewport_./*OK*/ getScrollHeight(); @@ -979,6 +1154,45 @@ export class ResourcesImpl { return box.bottom >= threshold || initialBox.bottom >= threshold; } + /** + * @param {number} relayoutTop + * @private + */ + setRelayoutTop_(relayoutTop) { + if (this.relayoutTop_ == -1) { + this.relayoutTop_ = relayoutTop; + } else { + this.relayoutTop_ = Math.min(relayoutTop, this.relayoutTop_); + } + } + + /** + * Reschedules change size request when an overflown element is activated. + * @param {!Element} element + * @private + */ + checkPendingChangeSize_(element) { + const resourceElement = closest( + element, + el => !!Resource.forElementOptional(el) + ); + if (!resourceElement) { + return; + } + const resource = Resource.forElement(resourceElement); + const pendingChangeSize = resource.getPendingChangeSize(); + if (pendingChangeSize !== undefined) { + this.scheduleChangeSize_( + resource, + pendingChangeSize.height, + pendingChangeSize.width, + pendingChangeSize.margins, + /* event */ undefined, + /* force */ true + ); + } + } + /** * Discovers work that needs to be done since the last pass. If viewport * has changed, it will try to build new elements, measure changed elements, @@ -1401,6 +1615,160 @@ export class ResourcesImpl { } } + /** + * Schedules change of the element's height. + * @param {!Resource} resource + * @param {number|undefined} newHeight + * @param {number|undefined} newWidth + * @param {!../layout-rect.LayoutMarginsChangeDef|undefined} newMargins + * @param {?Event|undefined} event + * @param {boolean} force + * @param {function(boolean)=} opt_callback A callback function + * @private + */ + scheduleChangeSize_( + resource, + newHeight, + newWidth, + newMargins, + event, + force, + opt_callback + ) { + if (resource.hasBeenMeasured() && !newMargins) { + this.completeScheduleChangeSize_( + resource, + newHeight, + newWidth, + undefined, + event, + force, + opt_callback + ); + } else { + // This is a rare case since most of times the element itself schedules + // resize requests. However, this case is possible when another element + // requests resize of a controlled element. This also happens when a + // margin size change is requested, since existing margins have to be + // measured in this instance. + this.vsync_.measure(() => { + if (!resource.hasBeenMeasured()) { + resource.measure(); + } + const marginChange = newMargins + ? { + newMargins, + currentMargins: this.getLayoutMargins_(resource), + } + : undefined; + this.completeScheduleChangeSize_( + resource, + newHeight, + newWidth, + marginChange, + event, + force, + opt_callback + ); + }); + } + } + + /** + * Returns the layout margins for the resource. + * @param {!Resource} resource + * @return {!../layout-rect.LayoutMarginsDef} + * @private + */ + getLayoutMargins_(resource) { + const style = computedStyle(this.win, resource.element); + return { + top: parseInt(style.marginTop, 10) || 0, + right: parseInt(style.marginRight, 10) || 0, + bottom: parseInt(style.marginBottom, 10) || 0, + left: parseInt(style.marginLeft, 10) || 0, + }; + } + + /** + * @param {!Resource} resource + * @param {number|undefined} newHeight + * @param {number|undefined} newWidth + * @param {!MarginChangeDef|undefined} marginChange + * @param {?Event|undefined} event + * @param {boolean} force + * @param {function(boolean)=} opt_callback A callback function + * @private + */ + completeScheduleChangeSize_( + resource, + newHeight, + newWidth, + marginChange, + event, + force, + opt_callback + ) { + resource.resetPendingChangeSize(); + const layoutBox = resource.getPageLayoutBox(); + if ( + (newHeight === undefined || newHeight == layoutBox.height) && + (newWidth === undefined || newWidth == layoutBox.width) && + (marginChange === undefined || + !areMarginsChanged( + marginChange.currentMargins, + marginChange.newMargins + )) + ) { + if ( + newHeight === undefined && + newWidth === undefined && + marginChange === undefined + ) { + dev().error( + TAG_, + 'attempting to change size with undefined dimensions', + resource.debugid + ); + } + // Nothing to do. + if (opt_callback) { + opt_callback(/* success */ true); + } + return; + } + + let request = null; + for (let i = 0; i < this.requestsChangeSize_.length; i++) { + if (this.requestsChangeSize_[i].resource == resource) { + request = this.requestsChangeSize_[i]; + break; + } + } + + if (request) { + request.newHeight = newHeight; + request.newWidth = newWidth; + request.marginChange = marginChange; + request.event = event; + request.force = force || request.force; + request.callback = opt_callback; + } else { + this.requestsChangeSize_.push( + /** {!ChangeSizeRequestDef} */ { + resource, + newHeight, + newWidth, + marginChange, + event, + force, + callback: opt_callback, + } + ); + } + this.schedulePassVsync_(); + } + /** * Returns whether the resource should be preloaded at this time. * The element must be measured by this time. diff --git a/src/service/resources-interface.js b/src/service/resources-interface.js index b7a6b255a4d8..b0c9e4d7ede8 100644 --- a/src/service/resources-interface.js +++ b/src/service/resources-interface.js @@ -14,37 +14,16 @@ * limitations under the License. */ +import {MutatorInterface} from './mutator-interface'; + /** @const {string} */ export const READY_SCAN_SIGNAL = 'ready-scan'; -/** - * The internal structure of a ChangeHeightRequest. - * @typedef {{ - * newMargins: !../layout-rect.LayoutMarginsChangeDef, - * currentMargins: !../layout-rect.LayoutMarginsDef - * }} - */ -export let MarginChangeDef; - -/** - * The internal structure of a ChangeHeightRequest. - * @typedef {{ - * resource: !./resource.Resource, - * newHeight: (number|undefined), - * newWidth: (number|undefined), - * marginChange: (!MarginChangeDef|undefined), - * event: (?Event|undefined), - * force: boolean, - * callback: (function(boolean)|undefined), - * }} - */ -export let ChangeSizeRequestDef; - /* eslint-disable no-unused-vars */ /** * @interface */ -export class ResourcesInterface { +export class ResourcesInterface extends MutatorInterface { /** * Returns a list of resources. * @return {!Array} @@ -127,18 +106,6 @@ export class ResourcesInterface { */ schedulePass(opt_delay, opt_relayoutAll) {} - /** - * Enqueue, or update if already exists, a mutation task for a resource. - * @param {./resource.Resource} resource - * @param {ChangeSizeRequestDef} newRequest - */ - updateOrEnqueueMutateTask(resource, newRequest) {} - - /** - * Schedules the work pass at the latest with the specified delay. - */ - schedulePassVsync() {} - /** * Registers a callback to be called when the next pass happens. * @param {function()} callback @@ -156,16 +123,6 @@ export class ResourcesInterface { */ ampInitComplete() {} - /** - * @param {number} relayoutTop - */ - setRelayoutTop(relayoutTop) {} - - /** - * Flag that the height could have been changed. - */ - maybeHeightChanged() {} - /** * Updates the priority of the resource. If there are tasks currently * scheduled, their priority is updated as well. diff --git a/src/services.js b/src/services.js index 693b8e2c091d..8aebcd4d2374 100644 --- a/src/services.js +++ b/src/services.js @@ -359,7 +359,7 @@ export class Services { static mutatorForDoc(elementOrAmpDoc) { return /** @type {!./service/mutator-interface.MutatorInterface} */ (getServiceForDoc( elementOrAmpDoc, - 'mutator' + 'resources' )); } diff --git a/test/unit/test-mutator.js b/test/unit/test-mutator.js deleted file mode 100644 index 15021e6af119..000000000000 --- a/test/unit/test-mutator.js +++ /dev/null @@ -1,1742 +0,0 @@ -/** - * Copyright 2015 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 {AmpDocSingle} from '../../src/service/ampdoc-impl'; -import {LayoutPriority} from '../../src/layout'; -import {MutatorImpl} from '../../src/service/mutator-impl'; -import {Resource, ResourceState} from '../../src/service/resource'; -import {ResourcesImpl} from '../../src/service/resources-impl'; -import {Services} from '../../src/services'; -import {Signals} from '../../src/utils/signals'; -import {VisibilityState} from '../../src/visibility-state'; -import {installInputService} from '../../src/input'; -import {installPlatformService} from '../../src/service/platform-impl'; -import {layoutRectLtwh} from '../../src/layout-rect'; - -/** @type {?Event|undefined} */ -const NO_EVENT = undefined; - -describe('mutator changeSize', () => { - function createElement(rect) { - const signals = new Signals(); - return { - ownerDocument: {defaultView: window}, - tagName: 'amp-test', - isBuilt: () => { - return true; - }, - isUpgraded: () => { - return true; - }, - getAttribute: () => { - return null; - }, - hasAttribute: () => false, - getBoundingClientRect: () => rect, - applySizesAndMediaQuery: () => {}, - layoutCallback: () => Promise.resolve(), - viewportCallback: window.sandbox.spy(), - prerenderAllowed: () => true, - renderOutsideViewport: () => false, - unlayoutCallback: () => true, - pauseCallback: () => {}, - unlayoutOnPause: () => true, - isRelayoutNeeded: () => true, - /* eslint-disable google-camelcase/google-camelcase */ - contains: unused_otherElement => false, - updateLayoutBox: () => {}, - togglePlaceholder: () => window.sandbox.spy(), - overflowCallback: ( - unused_overflown, - unused_requestedHeight, - unused_requestedWidth - /* eslint-enable google-camelcase/google-camelcase */ - ) => {}, - getLayoutPriority: () => LayoutPriority.CONTENT, - signals: () => signals, - fakeComputedStyle: { - marginTop: '0px', - marginRight: '0px', - marginBottom: '0px', - marginLeft: '0px', - }, - }; - } - - function createResource(id, rect) { - const resource = new Resource(id, createElement(rect), resources); - resource.element['__AMP__RESOURCE'] = resource; - resource.state_ = ResourceState.READY_FOR_LAYOUT; - resource.initialLayoutBox_ = resource.layoutBox_ = rect; - resource.changeSize = window.sandbox.spy(); - return resource; - } - - let clock; - let viewportMock; - let resources, mutator; - let resource1, resource2; - - beforeEach(() => { - clock = window.sandbox.useFakeTimers(); - const ampdoc = new AmpDocSingle(window); - resources = new ResourcesImpl(ampdoc); - resources.isRuntimeOn_ = false; - resources.win = { - location: { - href: 'https://example.org/doc1', - }, - getComputedStyle: el => { - return el.fakeComputedStyle - ? el.fakeComputedStyle - : window.getComputedStyle(el); - }, - }; - mutator = new MutatorImpl(ampdoc); - mutator.win = resources.win; - mutator.resources_ = resources; - - installPlatformService(resources.win); - const platform = Services.platformFor(resources.win); - window.sandbox.stub(platform, 'isIe').returns(false); - - installInputService(resources.win); - - viewportMock = window.sandbox.mock(mutator.viewport_); - - resource1 = createResource(1, layoutRectLtwh(10, 10, 100, 100)); - resource2 = createResource(2, layoutRectLtwh(10, 1010, 100, 100)); - resources.owners_ = [resource1, resource2]; - }); - - afterEach(() => { - viewportMock.verify(); - }); - - it('should schedule separate requests', () => { - mutator.scheduleChangeSize_( - resource1, - 111, - 100, - undefined, - NO_EVENT, - false - ); - mutator.scheduleChangeSize_( - resource2, - 222, - undefined, - undefined, - NO_EVENT, - true - ); - - expect(resources.requestsChangeSize_.length).to.equal(2); - expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); - expect(resources.requestsChangeSize_[0].newHeight).to.equal(111); - expect(resources.requestsChangeSize_[0].newWidth).to.equal(100); - expect(resources.requestsChangeSize_[0].force).to.equal(false); - - expect(resources.requestsChangeSize_[1].resource).to.equal(resource2); - expect(resources.requestsChangeSize_[1].newHeight).to.equal(222); - expect(resources.requestsChangeSize_[1].newWidth).to.be.undefined; - expect(resources.requestsChangeSize_[1].force).to.equal(true); - }); - - it('should schedule height only size change', () => { - mutator.scheduleChangeSize_( - resource1, - 111, - undefined, - undefined, - NO_EVENT, - false - ); - expect(resources.requestsChangeSize_.length).to.equal(1); - expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); - expect(resources.requestsChangeSize_[0].newHeight).to.equal(111); - expect(resources.requestsChangeSize_[0].newWidth).to.be.undefined; - expect(resources.requestsChangeSize_[0].newMargins).to.be.undefined; - expect(resources.requestsChangeSize_[0].force).to.equal(false); - }); - - it('should remove request change size for unloaded resources', () => { - mutator.scheduleChangeSize_( - resource1, - 111, - undefined, - undefined, - NO_EVENT, - false - ); - mutator.scheduleChangeSize_( - resource2, - 111, - undefined, - undefined, - NO_EVENT, - false - ); - expect(resources.requestsChangeSize_.length).to.equal(2); - resource1.unload(); - resources.cleanupTasks_(resource1); - expect(resources.requestsChangeSize_.length).to.equal(1); - expect(resources.requestsChangeSize_[0].resource).to.equal(resource2); - }); - - it('should schedule width only size change', () => { - mutator.scheduleChangeSize_( - resource1, - undefined, - 111, - undefined, - NO_EVENT, - false - ); - expect(resources.requestsChangeSize_.length).to.equal(1); - expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); - expect(resources.requestsChangeSize_[0].newWidth).to.equal(111); - expect(resources.requestsChangeSize_[0].newHeight).to.be.undefined; - expect(resources.requestsChangeSize_[0].marginChange).to.be.undefined; - expect(resources.requestsChangeSize_[0].force).to.equal(false); - }); - - it('should schedule margin only size change', () => { - mutator.scheduleChangeSize_( - resource1, - undefined, - undefined, - {top: 1, right: 2, bottom: 3, left: 4}, - NO_EVENT, - false - ); - resources.vsync_.runScheduledTasks_(); - expect(resources.requestsChangeSize_.length).to.equal(1); - expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); - expect(resources.requestsChangeSize_[0].newWidth).to.be.undefined; - expect(resources.requestsChangeSize_[0].newHeight).to.be.undefined; - expect(resources.requestsChangeSize_[0].marginChange).to.eql({ - newMargins: {top: 1, right: 2, bottom: 3, left: 4}, - currentMargins: {top: 0, right: 0, bottom: 0, left: 0}, - }); - expect(resources.requestsChangeSize_[0].force).to.equal(false); - }); - - it('should only schedule latest request for the same resource', () => { - mutator.scheduleChangeSize_(resource1, 111, 100, undefined, NO_EVENT, true); - mutator.scheduleChangeSize_( - resource1, - 222, - 300, - undefined, - NO_EVENT, - false - ); - - expect(resources.requestsChangeSize_.length).to.equal(1); - expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); - expect(resources.requestsChangeSize_[0].newHeight).to.equal(222); - expect(resources.requestsChangeSize_[0].newWidth).to.equal(300); - expect(resources.requestsChangeSize_[0].force).to.equal(true); - }); - - it("should NOT change size if it didn't change", () => { - mutator.scheduleChangeSize_(resource1, 100, 100, undefined, NO_EVENT, true); - resources.mutateWork_(); - expect(resources.relayoutTop_).to.equal(-1); - expect(resources.requestsChangeSize_.length).to.equal(0); - expect(resource1.changeSize).to.have.not.been.called; - }); - - it('should change size', () => { - mutator.scheduleChangeSize_(resource1, 111, 222, undefined, NO_EVENT, true); - resources.mutateWork_(); - expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); - expect(resources.requestsChangeSize_.length).to.equal(0); - expect(resource1.changeSize).to.be.calledOnce; - expect(resource1.changeSize.firstCall.args[0]).to.equal(111); - expect(resource1.changeSize.firstCall.args[1]).to.equal(222); - }); - - it('should change size when only width changes', () => { - mutator.scheduleChangeSize_(resource1, 111, 100, undefined, NO_EVENT, true); - resources.mutateWork_(); - expect(resource1.changeSize).to.be.calledOnce; - expect(resource1.changeSize.firstCall).to.have.been.calledWith(111, 100); - }); - - it('should change size when only height changes', () => { - mutator.scheduleChangeSize_(resource1, 100, 111, undefined, NO_EVENT, true); - resources.mutateWork_(); - expect(resource1.changeSize).to.be.calledOnce; - expect(resource1.changeSize.firstCall).to.have.been.calledWith(100, 111); - }); - - it('should pick the smallest relayoutTop', () => { - mutator.scheduleChangeSize_(resource2, 111, 222, undefined, NO_EVENT, true); - mutator.scheduleChangeSize_(resource1, 111, 222, undefined, NO_EVENT, true); - resources.mutateWork_(); - expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); - }); - - it('should measure non-measured elements', () => { - resource1.initialLayoutBox_ = null; - resource1.measure = window.sandbox.spy(); - resource2.measure = window.sandbox.spy(); - - mutator.scheduleChangeSize_(resource1, 111, 200, undefined, NO_EVENT, true); - mutator.scheduleChangeSize_(resource2, 111, 222, undefined, NO_EVENT, true); - expect(resource1.hasBeenMeasured()).to.be.false; - expect(resource2.hasBeenMeasured()).to.be.true; - - // Not yet scheduled, will wait until vsync. - expect(resource1.measure).to.not.be.called; - - // Scheduling is done after vsync. - resources.vsync_.runScheduledTasks_(); - expect(resource1.measure).to.be.calledOnce; - expect(resource2.measure).to.not.be.called; - - // Notice that the `resource2` was scheduled first since it didn't - // require vsync. - expect(resources.requestsChangeSize_).to.have.length(2); - expect(resources.requestsChangeSize_[0].resource).to.equal(resource2); - expect(resources.requestsChangeSize_[1].resource).to.equal(resource1); - }); - - describe('attemptChangeSize rules wrt viewport', () => { - let overflowCallbackSpy; - let vsyncSpy; - let viewportRect; - - beforeEach(() => { - overflowCallbackSpy = window.sandbox.spy(); - resource1.element.overflowCallback = overflowCallbackSpy; - - viewportRect = {top: 2, left: 0, right: 100, bottom: 200, height: 200}; - viewportMock - .expects('getRect') - .returns(viewportRect) - .atLeast(1); - resource1.layoutBox_ = { - top: 10, - left: 0, - right: 100, - bottom: 50, - height: 50, - }; - vsyncSpy = window.sandbox.stub(mutator.vsync_, 'run'); - resources.visible_ = true; - }); - - it('should NOT change size when height is unchanged', () => { - const callback = window.sandbox.spy(); - resource1.layoutBox_ = { - top: 10, - left: 0, - right: 100, - bottom: 210, - height: 50, - }; - mutator.scheduleChangeSize_( - resource1, - 50, - /* width */ undefined, - undefined, - NO_EVENT, - false, - callback - ); - resources.mutateWork_(); - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.not.been.called; - expect(callback).to.be.calledOnce; - expect(callback.args[0][0]).to.be.true; - }); - - it('should NOT change size when height and margins are unchanged', () => { - const callback = window.sandbox.spy(); - resource1.layoutBox_ = { - top: 10, - left: 0, - right: 100, - bottom: 210, - height: 50, - }; - resource1.element.fakeComputedStyle = { - marginTop: '1px', - marginRight: '2px', - marginBottom: '3px', - marginLeft: '4px', - }; - mutator.scheduleChangeSize_( - resource1, - 50, - /* width */ undefined, - {top: 1, right: 2, bottom: 3, left: 4}, - NO_EVENT, - false, - callback - ); - - expect(vsyncSpy).to.be.calledOnce; - const task = vsyncSpy.lastCall.args[0]; - task.measure({}); - - resources.mutateWork_(); - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.not.been.called; - expect(callback).to.be.calledOnce; - expect(callback.args[0][0]).to.be.true; - }); - - it('should change size when margins but not height changed', () => { - const callback = window.sandbox.spy(); - resource1.layoutBox_ = { - top: 10, - left: 0, - right: 100, - bottom: 210, - height: 50, - }; - resource1.element.fakeComputedStyle = { - marginTop: '1px', - marginRight: '2px', - marginBottom: '3px', - marginLeft: '4px', - }; - mutator.scheduleChangeSize_( - resource1, - 50, - /* width */ undefined, - {top: 1, right: 2, bottom: 4, left: 4}, - NO_EVENT, - false, - callback - ); - - expect(vsyncSpy).to.be.calledOnce; - const task = vsyncSpy.lastCall.args[0]; - task.measure({}); - - resources.mutateWork_(); - expect(resource1.changeSize).to.be.calledOnce; - }); - - it('should change size when forced', () => { - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - true - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); - }); - - // TODO (#16156): duplicate stub for getVisibilityState on Safari - it.configure() - .skipSafari() - .run('should change size when document is invisible', () => { - resources.visible_ = false; - window.sandbox - .stub(resources.ampdoc, 'getVisibilityState') - .returns(VisibilityState.PRERENDER); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); - }); - - it('should change size when active', () => { - resource1.element.contains = () => true; - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); - }); - - it('should NOT change size via activation if has not been active', () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(0); - const event = { - userActivation: { - hasBeenActive: false, - }, - }; - mutator.scheduleChangeSize_(resource1, 111, 222, undefined, event, false); - resources.mutateWork_(); - expect(resource1.changeSize).to.not.be.called; - expect(overflowCallbackSpy).to.be.calledOnce.calledWith(true); - }); - - it('should change size via activation if has been active', () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(0); - const event = { - userActivation: { - hasBeenActive: true, - }, - }; - mutator.scheduleChangeSize_(resource1, 111, 222, undefined, event, false); - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledOnce.calledWith(false); - }); - - it('should change size when below the viewport', () => { - resource1.layoutBox_ = { - top: 10, - left: 0, - right: 100, - bottom: 1050, - height: 50, - }; - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); - }); - - it('should change size when below the viewport and top margin also changed', () => { - resource1.layoutBox_ = { - top: 200, - left: 0, - right: 100, - bottom: 300, - height: 100, - }; - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - {top: 20}, - NO_EVENT, - false - ); - - expect(vsyncSpy).to.be.calledOnce; - const marginsTask = vsyncSpy.lastCall.args[0]; - marginsTask.measure({}); - - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); - }); - - it( - 'should change size when box top below the viewport but top margin ' + - 'boundary is above viewport but top margin in unchanged', - () => { - resource1.layoutBox_ = { - top: 200, - left: 0, - right: 100, - bottom: 300, - height: 100, - }; - resource1.element.fakeComputedStyle = { - marginTop: '100px', - marginRight: '0px', - marginBottom: '0px', - marginLeft: '0px', - }; - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - {top: 100}, - NO_EVENT, - false - ); - - expect(vsyncSpy).to.be.calledOnce; - const marginsTask = vsyncSpy.lastCall.args[0]; - marginsTask.measure({}); - - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); - } - ); - - it( - 'should NOT change size when top margin boundary within viewport ' + - 'and top margin changed', - () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - - const callback = window.sandbox.spy(); - resource1.layoutBox_ = { - top: 100, - left: 0, - right: 100, - bottom: 300, - height: 200, - }; - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - {top: 20}, - NO_EVENT, - false, - callback - ); - - expect(vsyncSpy).to.be.calledOnce; - const task = vsyncSpy.lastCall.args[0]; - task.measure({}); - - resources.mutateWork_(); - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.not.been.called; - expect(callback).to.be.calledOnce; - expect(callback.args[0][0]).to.be.false; - } - ); - - it('should defer when above the viewport and scrolling on', () => { - resource1.layoutBox_ = { - top: -1200, - left: 0, - right: 100, - bottom: -1050, - height: 50, - }; - resources.lastVelocity_ = 10; - resources.lastScrollTime_ = Date.now(); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_.length).to.equal(1); - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.not.been.called; - }); - - it( - 'should defer change size if just inside viewport and viewport ' + - 'scrolled by user.', - () => { - viewportRect.top = 2; - resource1.layoutBox_ = { - top: -50, - left: 0, - right: 100, - bottom: 1, - height: 51, - }; - resources.lastVelocity_ = 10; - resources.lastScrollTime_ = Date.now(); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_.length).to.equal(1); - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.not.been.called; - } - ); - - it( - 'should NOT change size and call overflow callback if viewport not ' + - 'scrolled by user.', - () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - viewportRect.top = 1; - resource1.layoutBox_ = { - top: -50, - left: 0, - right: 100, - bottom: 0, - height: 51, - }; - resources.lastVelocity_ = 10; - resources.lastScrollTime_ = Date.now(); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_.length).to.equal(0); - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledWith(true, 111, 222); - } - ); - - it('should change size when above the vp and adjust scrolling', () => { - viewportMock - .expects('getScrollHeight') - .returns(2999) - .once(); - viewportMock - .expects('getScrollTop') - .returns(1777) - .once(); - resource1.layoutBox_ = { - top: -1200, - left: 0, - right: 100, - bottom: -1050, - height: 50, - }; - resources.lastVelocity_ = 0; - clock.tick(5000); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.not.been.called; - - expect(vsyncSpy.callCount).to.be.greaterThan(1); - const task = vsyncSpy.lastCall.args[0]; - const state = {}; - task.measure(state); - expect(state.scrollTop).to.equal(1777); - expect(state.scrollHeight).to.equal(2999); - - viewportMock - .expects('getScrollHeight') - .returns(3999) - .once(); - viewportMock - .expects('setScrollTop') - .withExactArgs(2777) - .once(); - task.mutate(state); - expect(resource1.changeSize).to.be.calledOnce; - expect(resource1.changeSize).to.be.calledWith(111, 222); - expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); - }); - - it('should NOT resize when above vp but cannot adjust scrolling', () => { - resource1.layoutBox_ = { - top: -1200, - left: 0, - right: 100, - bottom: -1100, - height: 100, - }; - resources.lastVelocity_ = 0; - clock.tick(5000); - mutator.scheduleChangeSize_( - resource1, - 0, - 222, - undefined, - NO_EVENT, - false - ); - expect(vsyncSpy).to.be.calledOnce; - vsyncSpy.resetHistory(); - resources.mutateWork_(); - - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.not.be.called; - expect(vsyncSpy).to.not.be.called; - }); - - it('should resize if multi request above vp can adjust scroll', () => { - resource1.layoutBox_ = { - top: -1200, - left: 0, - right: 100, - bottom: -1100, - height: 100, - }; - resource2.layoutBox_ = { - top: -1300, - left: 0, - right: 100, - bottom: -1200, - height: 100, - }; - resources.lastVelocity_ = 0; - clock.tick(5000); - mutator.scheduleChangeSize_( - resource2, - 200, - 222, - undefined, - NO_EVENT, - false - ); - mutator.scheduleChangeSize_( - resource1, - 0, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - - const task = vsyncSpy.lastCall.args[0]; - const state = {}; - task.mutate(state); - - expect(resource1.changeSize).to.be.calledOnce; - expect(resource2.changeSize).to.be.calledOnce; - }); - - it('should NOT resize if multi req above vp cannot adjust scroll', () => { - // Only to satisfy expectation in beforeEach - resources.viewport_.getRect(); - - viewportMock.expects('getRect').returns({ - top: 10, - left: 0, - right: 100, - bottom: 210, - height: 200, - }); - resource1.layoutBox_ = { - top: -1200, - left: 0, - right: 100, - bottom: -1100, - height: 100, - }; - resource2.layoutBox_ = { - top: -1300, - left: 0, - right: 100, - bottom: -1200, - height: 100, - }; - resources.lastVelocity_ = 0; - clock.tick(5000); - mutator.scheduleChangeSize_( - resource1, - 92, - 222, - undefined, - NO_EVENT, - false - ); - mutator.scheduleChangeSize_( - resource2, - 92, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - const task = vsyncSpy.lastCall.args[0]; - const state = {}; - task.mutate(state); - expect(resource1.changeSize).to.be.calledOnce; - expect(resource2.changeSize).to.not.be.called; - }); - - it('should NOT adjust scrolling if height not change above vp', () => { - viewportMock - .expects('getScrollHeight') - .returns(2999) - .once(); - viewportMock - .expects('getScrollTop') - .returns(1777) - .once(); - resource1.layoutBox_ = { - top: -1200, - left: 0, - right: 100, - bottom: -1050, - height: 50, - }; - resources.lastVelocity_ = 0; - clock.tick(5000); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.not.been.called; - - expect(vsyncSpy.callCount).to.be.greaterThan(1); - const task = vsyncSpy.lastCall.args[0]; - const state = {}; - task.measure(state); - expect(state.scrollTop).to.equal(1777); - expect(state.scrollHeight).to.equal(2999); - - viewportMock - .expects('getScrollHeight') - .returns(2999) - .once(); - viewportMock.expects('setScrollTop').never(); - task.mutate(state); - expect(resource1.changeSize).to.be.calledOnce; - expect(resource1.changeSize).to.be.calledWith(111, 222); - expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); - }); - - it('should adjust scrolling if height change above vp', () => { - viewportMock - .expects('getScrollHeight') - .returns(2999) - .once(); - viewportMock - .expects('getScrollTop') - .returns(1000) - .once(); - resource1.layoutBox_ = { - top: -1200, - left: 0, - right: 100, - bottom: -1050, - height: 50, - }; - resources.lastVelocity_ = 0; - clock.tick(5000); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - const task = vsyncSpy.lastCall.args[0]; - const state = {}; - task.measure(state); - viewportMock - .expects('getScrollHeight') - .returns(2000) - .once(); - viewportMock - .expects('setScrollTop') - .withExactArgs(1) - .once(); - task.mutate(state); - }); - - it('in vp should NOT call overflowCallback if new height smaller', () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - mutator.scheduleChangeSize_( - resource1, - 10, - 11, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.not.been.called; - }); - - // TODO(#25518): investigate failure on Travis Safari - it.configure().skipSafari( - 'in viewport should change size if in the last 15% and ' + - 'in the last 1000px', - () => { - viewportRect.top = 9600; - viewportRect.bottom = 9800; - resource1.layoutBox_ = { - top: 9650, - left: 0, - right: 100, - bottom: 9700, - height: 50, - }; - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - {top: 1, right: 2, bottom: 3, left: 4}, - NO_EVENT, - false - ); - - expect(vsyncSpy).to.be.calledOnce; - const marginsTask = vsyncSpy.lastCall.args[0]; - marginsTask.measure({}); - - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); - } - ); - - it( - 'in viewport should NOT change size if in the last 15% but NOT ' + - 'in the last 1000px', - () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - viewportRect.top = 8600; - viewportRect.bottom = 8800; - resource1.layoutBox_ = { - top: 8650, - left: 0, - right: 100, - bottom: 8700, - height: 50, - }; - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - {top: 1, right: 2, bottom: 3, left: 4}, - NO_EVENT, - false - ); - - expect(vsyncSpy).to.be.calledOnce; - const marginsTask = vsyncSpy.lastCall.args[0]; - marginsTask.measure({}); - - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledWith(true, 111, 222, { - top: 1, - right: 2, - bottom: 3, - left: 4, - }); - } - ); - - it('in viewport should NOT change size and calls overflowCallback', () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - {top: 1, right: 2, bottom: 3, left: 4}, - NO_EVENT, - false - ); - - expect(vsyncSpy).to.be.calledOnce; - const task = vsyncSpy.lastCall.args[0]; - task.measure({}); - - resources.mutateWork_(); - expect(resources.requestsChangeSize_.length).to.equal(0); - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledWith(true, 111, 222, { - top: 1, - right: 2, - bottom: 3, - left: 4, - }); - expect(resource1.getPendingChangeSize()).to.jsonEqual({ - height: 111, - width: 222, - margins: {top: 1, right: 2, bottom: 3, left: 4}, - }); - }); - - it( - 'should change size if in viewport, but only modifying width and ' + - 'reflow is not possible', - () => { - const parent = document.createElement('div'); - parent.style.width = '222px'; - parent.getLayoutWidth = () => 222; - const element = document.createElement('div'); - element.overflowCallback = overflowCallbackSpy; - parent.appendChild(element); - document.body.appendChild(parent); - - resource1.element = element; - resource1.layoutBox_ = { - top: 0, - left: 0, - right: 222, - bottom: 50, - height: 50, - width: 222, - }; - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - mutator.scheduleChangeSize_( - resource1, - 50, - 222, - {top: 1, right: 2, bottom: 3, left: 4}, - NO_EVENT, - false - ); - - expect(vsyncSpy).to.be.calledOnce; - let task = vsyncSpy.lastCall.args[0]; - task.measure({}); - - resources.mutateWork_(); - - expect(vsyncSpy).to.be.calledThrice; - task = vsyncSpy.lastCall.args[0]; - const state = {}; - task.measure(state); - task.mutate(state); - expect(resource1.changeSize).to.be.calledOnce; - expect(resource1.changeSize).to.be.calledWith(50, 222); - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); - document.body.removeChild(parent); - } - ); - - it( - 'should NOT change size if in viewport, only modifying width and ' + - 'reflow is possible', - () => { - const parent = document.createElement('div'); - parent.style.width = '222px'; - parent.getLayoutWidth = () => 222; - const element = document.createElement('div'); - const sibling = document.createElement('div'); - sibling.style.width = '1px'; - sibling.id = 'sibling'; - element.overflowCallback = overflowCallbackSpy; - parent.appendChild(element); - parent.appendChild(sibling); - document.body.appendChild(parent); - - resource1.element = element; - resource1.layoutBox_ = { - top: 0, - left: 0, - right: 222, - bottom: 50, - height: 50, - width: 222, - }; - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - mutator.scheduleChangeSize_( - resource1, - 50, - 222, - {top: 1, right: 2, bottom: 3, left: 4}, - NO_EVENT, - false - ); - - expect(vsyncSpy).to.be.calledOnce; - let task = vsyncSpy.lastCall.args[0]; - task.measure({}); - - resources.mutateWork_(); - - expect(vsyncSpy).to.be.calledThrice; - task = vsyncSpy.lastCall.args[0]; - const state = {}; - task.measure(state); - task.mutate(state); - expect(resource1.changeSize).to.not.be.called; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy.firstCall.args[0]).to.equal(true); - document.body.removeChild(parent); - } - ); - - it( - 'should NOT change size when resized margin in viewport and should ' + - 'call overflowCallback', - () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - resource1.layoutBox_ = { - top: -48, - left: 0, - right: 100, - bottom: 2, - height: 50, - }; - resource1.element.fakeComputedStyle = { - marginBottom: '21px', - }; - - mutator.scheduleChangeSize_( - resource1, - undefined, - undefined, - {bottom: 22}, - NO_EVENT, - false - ); - - expect(vsyncSpy).to.be.calledOnce; - const task = vsyncSpy.lastCall.args[0]; - task.measure({}); - - resources.mutateWork_(); - expect(resources.requestsChangeSize_.length).to.equal(0); - expect(resource1.changeSize).to.not.been.called; - expect(overflowCallbackSpy).to.be.calledOnce; - expect(overflowCallbackSpy).to.be.calledWith( - true, - undefined, - undefined, - {bottom: 22} - ); - expect(resource1.getPendingChangeSize()).to.jsonEqual({ - height: undefined, - width: undefined, - margins: {bottom: 22}, - }); - } - ); - - it('should change size when resized margin above viewport', () => { - resource1.layoutBox_ = { - top: -49, - left: 0, - right: 100, - bottom: 1, - height: 50, - }; - resource1.element.fakeComputedStyle = { - marginBottom: '21px', - }; - viewportMock - .expects('getScrollHeight') - .returns(2999) - .once(); - viewportMock - .expects('getScrollTop') - .returns(1777) - .once(); - - resources.lastVelocity_ = 0; - clock.tick(5000); - mutator.scheduleChangeSize_( - resource1, - undefined, - undefined, - {top: 1}, - NO_EVENT, - false - ); - - expect(vsyncSpy).to.be.calledOnce; - const marginsTask = vsyncSpy.lastCall.args[0]; - marginsTask.measure({}); - - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.not.been.called; - - expect(vsyncSpy.callCount).to.be.greaterThan(2); - const scrollAdjustTask = vsyncSpy.lastCall.args[0]; - const state = {}; - scrollAdjustTask.measure(state); - expect(state.scrollTop).to.equal(1777); - expect(state.scrollHeight).to.equal(2999); - - viewportMock - .expects('getScrollHeight') - .returns(3999) - .once(); - viewportMock - .expects('setScrollTop') - .withExactArgs(2777) - .once(); - scrollAdjustTask.mutate(state); - expect(resource1.changeSize).to.be.calledOnce; - expect(resource1.changeSize).to.be.calledWith(undefined, undefined, { - top: 1, - }); - expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); - }); - - it('should reset pending change size when rescheduling', () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resource1.getPendingChangeSize().height).to.equal(111); - expect(resource1.getPendingChangeSize().width).to.equal(222); - - mutator.scheduleChangeSize_( - resource1, - 112, - 223, - undefined, - NO_EVENT, - false - ); - expect(resource1.getPendingChangeSize()).to.be.undefined; - }); - - it('should force resize after focus', () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .atLeast(1); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resource1.getPendingChangeSize()).to.jsonEqual({ - height: 111, - width: 222, - }); - expect(resources.requestsChangeSize_).to.be.empty; - - mutator.checkPendingChangeSize_(resource1.element); - expect(resource1.getPendingChangeSize()).to.be.undefined; - expect(resources.requestsChangeSize_.length).to.equal(1); - - resources.mutateWork_(); - expect(resources.requestsChangeSize_).to.be.empty; - expect(resource1.changeSize).to.be.calledOnce; - expect(resource1.changeSize).to.be.calledWith(111, 222); - expect(overflowCallbackSpy).to.be.calledTwice; - expect(overflowCallbackSpy.lastCall.args[0]).to.equal(false); - }); - }); - - describe('attemptChangeSize rules for element wrt document', () => { - beforeEach(() => { - viewportMock - .expects('getRect') - .returns({top: 0, left: 0, right: 100, bottom: 10000, height: 200}); - resource1.layoutBox_ = resource1.initialLayoutBox_ = layoutRectLtwh( - 0, - 10, - 100, - 100 - ); - }); - - it('should NOT change size when far the bottom of the document', () => { - viewportMock - .expects('getContentHeight') - .returns(10000) - .once(); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resource1.changeSize).to.not.been.called; - }); - - it('should change size when close to the bottom of the document', () => { - viewportMock - .expects('getContentHeight') - .returns(110) - .once(); - mutator.scheduleChangeSize_( - resource1, - 111, - 222, - undefined, - NO_EVENT, - false - ); - resources.mutateWork_(); - expect(resource1.changeSize).to.be.calledOnce; - }); - }); -}); - -describes.realWin('mutator mutateElement and collapse', {amp: true}, env => { - function createElement(rect, isAmp) { - const element = env.win.document.createElement(isAmp ? 'amp-test' : 'div'); - if (isAmp) { - element.classList.add('i-amphtml-element'); - } - element.signals = () => new Signals(); - element.whenBuilt = () => Promise.resolve(); - element.isBuilt = () => true; - element.build = () => Promise.resolve(); - element.isUpgraded = () => true; - element.updateLayoutBox = () => {}; - element.getPlaceholder = () => null; - element.getLayoutPriority = () => LayoutPriority.CONTENT; - element.dispatchCustomEvent = () => {}; - element.getLayout = () => 'fixed'; - - element.isInViewport = () => false; - element.getAttribute = () => null; - element.hasAttribute = () => false; - element.getBoundingClientRect = () => rect; - element.applySizesAndMediaQuery = () => {}; - element.layoutCallback = () => Promise.resolve(); - element.viewportCallback = env.sandbox.spy(); - element.prerenderAllowed = () => true; - element.renderOutsideViewport = () => true; - element.isRelayoutNeeded = () => true; - element.pauseCallback = () => {}; - element.unlayoutCallback = () => true; - element.unlayoutOnPause = () => true; - element.togglePlaceholder = () => env.sandbox.spy(); - - env.win.document.body.appendChild(element); - return element; - } - - function createResource(id, rect) { - const resource = new Resource( - id, - createElement(rect, /* isAmp */ true), - resources - ); - resource.element['__AMP__RESOURCE'] = resource; - resource.state_ = ResourceState.READY_FOR_LAYOUT; - resource.layoutBox_ = rect; - resource.changeSize = env.sandbox.spy(); - resource.completeCollapse = env.sandbox.spy(); - return resource; - } - - let viewportMock; - let resources, mutator; - let resource1, resource2; - let parent1, parent2; - let relayoutTopStub; - let resource1RequestMeasureStub, resource2RequestMeasureStub; - - beforeEach(() => { - resources = new ResourcesImpl(env.ampdoc); - resources.isRuntimeOn_ = false; - viewportMock = env.sandbox.mock(resources.viewport_); - resources.vsync_ = { - mutate: callback => callback(), - measure: callback => callback(), - runPromise: task => { - const state = {}; - if (task.measure) { - task.measure(state); - } - if (task.mutate) { - task.mutate(state); - } - return Promise.resolve(); - }, - run: task => { - const state = {}; - if (task.measure) { - task.measure(state); - } - if (task.mutate) { - task.mutate(state); - } - }, - }; - relayoutTopStub = env.sandbox.stub(resources, 'setRelayoutTop'); - env.sandbox.stub(resources, 'schedulePass'); - - mutator = new MutatorImpl(env.ampdoc); - mutator.resources_ = resources; - mutator.viewport_ = resources.viewport_; - mutator.vsync_ = resources.vsync_; - - resource1 = createResource(1, layoutRectLtwh(10, 10, 100, 100)); - resource2 = createResource(2, layoutRectLtwh(10, 1010, 100, 100)); - resources.owners_ = [resource1, resource2]; - - resource1RequestMeasureStub = env.sandbox.stub(resource1, 'requestMeasure'); - resource2RequestMeasureStub = env.sandbox.stub(resource2, 'requestMeasure'); - - parent1 = createElement( - layoutRectLtwh(10, 10, 100, 100), - /* isAmp */ false - ); - parent2 = createElement( - layoutRectLtwh(10, 1010, 100, 100), - /* isAmp */ false - ); - - parent1.getElementsByClassName = className => { - if (className == 'i-amphtml-element') { - return [resource1.element]; - } - }; - parent2.getElementsByClassName = className => { - if (className == 'i-amphtml-element') { - return [resource2.element]; - } - }; - }); - - afterEach(() => { - viewportMock.verify(); - }); - - it('should mutate from visible to invisible', () => { - const mutateSpy = env.sandbox.spy(); - const promise = mutator.mutateElement(parent1, () => { - parent1.getBoundingClientRect = () => layoutRectLtwh(0, 0, 0, 0); - mutateSpy(); - }); - return promise.then(() => { - expect(mutateSpy).to.be.calledOnce; - expect(resource1RequestMeasureStub).to.be.calledOnce; - expect(resource2RequestMeasureStub).to.have.not.been.called; - expect(relayoutTopStub).to.be.calledOnce; - expect(relayoutTopStub.getCall(0).args[0]).to.equal(10); - }); - }); - - it('should mutate from visible to invisible on itself', () => { - const mutateSpy = env.sandbox.spy(); - const promise = mutator.mutateElement(resource1.element, () => { - resource1.element.getBoundingClientRect = () => - layoutRectLtwh(0, 0, 0, 0); - mutateSpy(); - }); - return promise.then(() => { - expect(mutateSpy).to.be.calledOnce; - expect(resource1RequestMeasureStub).to.be.calledOnce; - expect(resource2RequestMeasureStub).to.have.not.been.called; - expect(relayoutTopStub).to.be.calledOnce; - expect(relayoutTopStub.getCall(0).args[0]).to.equal(10); - }); - }); - - it('should mutate from invisible to visible', () => { - const mutateSpy = env.sandbox.spy(); - parent1.getBoundingClientRect = () => layoutRectLtwh(0, 0, 0, 0); - const promise = mutator.mutateElement(parent1, () => { - parent1.getBoundingClientRect = () => layoutRectLtwh(10, 10, 100, 100); - mutateSpy(); - }); - return promise.then(() => { - expect(mutateSpy).to.be.calledOnce; - expect(resource1RequestMeasureStub).to.be.calledOnce; - expect(resource2RequestMeasureStub).to.have.not.been.called; - expect(relayoutTopStub).to.be.calledOnce; - expect(relayoutTopStub.getCall(0).args[0]).to.equal(10); - }); - }); - - it('should mutate from visible to visible', () => { - const mutateSpy = env.sandbox.spy(); - parent1.getBoundingClientRect = () => layoutRectLtwh(10, 10, 100, 100); - const promise = mutator.mutateElement(parent1, () => { - parent1.getBoundingClientRect = () => layoutRectLtwh(10, 1010, 100, 100); - mutateSpy(); - }); - return promise.then(() => { - expect(mutateSpy).to.be.calledOnce; - expect(resource1RequestMeasureStub).to.be.calledOnce; - expect(resource2RequestMeasureStub).to.have.not.been.called; - expect(relayoutTopStub).to.have.callCount(2); - expect(relayoutTopStub.getCall(0).args[0]).to.equal(10); - expect(relayoutTopStub.getCall(1).args[0]).to.equal(1010); - }); - }); - - it('attemptCollapse should not call attemptChangeSize', () => { - // This test ensure that #attemptCollapse won't do any optimization or - // refactor by calling attemptChangeSize. - // This to support collapsing element above viewport - // When attemptChangeSize succeed, resources manager will measure the new - // scrollHeight, and we need to make sure the newScrollHeight is measured - // after setting element display:none - env.sandbox.stub(resources.viewport_, 'getRect').callsFake(() => { - return { - top: 1500, - bottom: 1800, - left: 0, - right: 500, - width: 500, - height: 300, - }; - }); - let promiseResolve = null; - const promise = new Promise(resolve => { - promiseResolve = resolve; - }); - let index = 0; - env.sandbox.stub(resources.viewport_, 'getScrollHeight').callsFake(() => { - // In change element size above viewport path, getScrollHeight will be - // called twice. And we care that the last measurement is correct, - // which requires it to be measured after element dispaly set to none. - if (index == 1) { - expect(resource1.completeCollapse).to.be.calledOnce; - promiseResolve(); - return; - } - expect(resource1.completeCollapse).to.not.been.called; - index++; - }); - - resource1.layoutBox_ = { - top: 1000, - left: 0, - right: 100, - bottom: 1050, - height: 50, - }; - resources.lastVelocity_ = 0; - mutator.attemptCollapse(resource1.element); - resources.mutateWork_(); - return promise; - }); - - it('attemptCollapse should complete collapse if resize succeed', () => { - env.sandbox - .stub(mutator, 'scheduleChangeSize_') - .callsFake( - (resource, newHeight, newWidth, newMargins, event, force, callback) => { - callback(true); - } - ); - mutator.attemptCollapse(resource1.element); - expect(resource1.completeCollapse).to.be.calledOnce; - }); - - it('attemptCollapse should NOT complete collapse if resize fail', () => { - env.sandbox - .stub(mutator, 'scheduleChangeSize_') - .callsFake( - (resource, newHeight, newWidth, newMargins, event, force, callback) => { - callback(false); - } - ); - mutator.attemptCollapse(resource1.element); - expect(resource1.completeCollapse).to.not.been.called; - }); - - it('should complete collapse and trigger relayout', () => { - const oldTop = resource1.getLayoutBox().top; - mutator.collapseElement(resource1.element); - expect(resource1.completeCollapse).to.be.calledOnce; - expect(relayoutTopStub).to.be.calledOnce; - expect(relayoutTopStub.args[0][0]).to.equal(oldTop); - }); - - it('should ignore relayout on an already collapsed element', () => { - resource1.layoutBox_.width = 0; - resource1.layoutBox_.height = 0; - mutator.collapseElement(resource1.element); - expect(resource1.completeCollapse).to.be.calledOnce; - expect(relayoutTopStub).to.have.not.been.called; - }); -}); diff --git a/test/unit/test-resources.js b/test/unit/test-resources.js index 80f37e3b1ac6..69862a4d4068 100644 --- a/test/unit/test-resources.js +++ b/test/unit/test-resources.js @@ -21,10 +21,15 @@ import {ResourcesImpl} from '../../src/service/resources-impl'; import {Services} from '../../src/services'; import {Signals} from '../../src/utils/signals'; import {VisibilityState} from '../../src/visibility-state'; +import {installInputService} from '../../src/input'; +import {installPlatformService} from '../../src/service/platform-impl'; import {layoutRectLtwh} from '../../src/layout-rect'; import {loadPromise} from '../../src/event-helper'; import {toggleExperiment} from '../../src/experiments'; +/** @type {?Event|undefined} */ +const NO_EVENT = undefined; + /*eslint "google-camelcase/google-camelcase": 0*/ describe('Resources', () => { let clock; @@ -1274,6 +1279,1707 @@ describes.realWin( } ); +describe('Resources changeSize', () => { + function createElement(rect) { + const signals = new Signals(); + return { + ownerDocument: {defaultView: window}, + tagName: 'amp-test', + isBuilt: () => { + return true; + }, + isUpgraded: () => { + return true; + }, + getAttribute: () => { + return null; + }, + hasAttribute: () => false, + getBoundingClientRect: () => rect, + applySizesAndMediaQuery: () => {}, + layoutCallback: () => Promise.resolve(), + viewportCallback: window.sandbox.spy(), + prerenderAllowed: () => true, + renderOutsideViewport: () => false, + unlayoutCallback: () => true, + pauseCallback: () => {}, + unlayoutOnPause: () => true, + isRelayoutNeeded: () => true, + contains: unused_otherElement => false, + updateLayoutBox: () => {}, + togglePlaceholder: () => window.sandbox.spy(), + overflowCallback: ( + unused_overflown, + unused_requestedHeight, + unused_requestedWidth + ) => {}, + getLayoutPriority: () => LayoutPriority.CONTENT, + signals: () => signals, + fakeComputedStyle: { + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + }, + }; + } + + function createResource(id, rect) { + const resource = new Resource(id, createElement(rect), resources); + resource.element['__AMP__RESOURCE'] = resource; + resource.state_ = ResourceState.READY_FOR_LAYOUT; + resource.initialLayoutBox_ = resource.layoutBox_ = rect; + resource.changeSize = window.sandbox.spy(); + return resource; + } + + let clock; + let viewportMock; + let resources; + let resource1, resource2; + + beforeEach(() => { + clock = window.sandbox.useFakeTimers(); + resources = new ResourcesImpl(new AmpDocSingle(window)); + resources.isRuntimeOn_ = false; + resources.win = { + location: { + href: 'https://example.org/doc1', + }, + getComputedStyle: el => { + return el.fakeComputedStyle + ? el.fakeComputedStyle + : window.getComputedStyle(el); + }, + }; + installPlatformService(resources.win); + const platform = Services.platformFor(resources.win); + window.sandbox.stub(platform, 'isIe').returns(false); + + installInputService(resources.win); + + viewportMock = window.sandbox.mock(resources.viewport_); + + resource1 = createResource(1, layoutRectLtwh(10, 10, 100, 100)); + resource2 = createResource(2, layoutRectLtwh(10, 1010, 100, 100)); + resources.owners_ = [resource1, resource2]; + }); + + afterEach(() => { + viewportMock.verify(); + }); + + it('should schedule separate requests', () => { + resources.scheduleChangeSize_( + resource1, + 111, + 100, + undefined, + NO_EVENT, + false + ); + resources.scheduleChangeSize_( + resource2, + 222, + undefined, + undefined, + NO_EVENT, + true + ); + + expect(resources.requestsChangeSize_.length).to.equal(2); + expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); + expect(resources.requestsChangeSize_[0].newHeight).to.equal(111); + expect(resources.requestsChangeSize_[0].newWidth).to.equal(100); + expect(resources.requestsChangeSize_[0].force).to.equal(false); + + expect(resources.requestsChangeSize_[1].resource).to.equal(resource2); + expect(resources.requestsChangeSize_[1].newHeight).to.equal(222); + expect(resources.requestsChangeSize_[1].newWidth).to.be.undefined; + expect(resources.requestsChangeSize_[1].force).to.equal(true); + }); + + it('should schedule height only size change', () => { + resources.scheduleChangeSize_( + resource1, + 111, + undefined, + undefined, + NO_EVENT, + false + ); + expect(resources.requestsChangeSize_.length).to.equal(1); + expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); + expect(resources.requestsChangeSize_[0].newHeight).to.equal(111); + expect(resources.requestsChangeSize_[0].newWidth).to.be.undefined; + expect(resources.requestsChangeSize_[0].newMargins).to.be.undefined; + expect(resources.requestsChangeSize_[0].force).to.equal(false); + }); + + it('should remove request change size for unloaded resources', () => { + resources.scheduleChangeSize_( + resource1, + 111, + undefined, + undefined, + NO_EVENT, + false + ); + resources.scheduleChangeSize_( + resource2, + 111, + undefined, + undefined, + NO_EVENT, + false + ); + expect(resources.requestsChangeSize_.length).to.equal(2); + resource1.unload(); + resources.cleanupTasks_(resource1); + expect(resources.requestsChangeSize_.length).to.equal(1); + expect(resources.requestsChangeSize_[0].resource).to.equal(resource2); + }); + + it('should schedule width only size change', () => { + resources.scheduleChangeSize_( + resource1, + undefined, + 111, + undefined, + NO_EVENT, + false + ); + expect(resources.requestsChangeSize_.length).to.equal(1); + expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); + expect(resources.requestsChangeSize_[0].newWidth).to.equal(111); + expect(resources.requestsChangeSize_[0].newHeight).to.be.undefined; + expect(resources.requestsChangeSize_[0].marginChange).to.be.undefined; + expect(resources.requestsChangeSize_[0].force).to.equal(false); + }); + + it('should schedule margin only size change', () => { + resources.scheduleChangeSize_( + resource1, + undefined, + undefined, + {top: 1, right: 2, bottom: 3, left: 4}, + NO_EVENT, + false + ); + resources.vsync_.runScheduledTasks_(); + expect(resources.requestsChangeSize_.length).to.equal(1); + expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); + expect(resources.requestsChangeSize_[0].newWidth).to.be.undefined; + expect(resources.requestsChangeSize_[0].newHeight).to.be.undefined; + expect(resources.requestsChangeSize_[0].marginChange).to.eql({ + newMargins: {top: 1, right: 2, bottom: 3, left: 4}, + currentMargins: {top: 0, right: 0, bottom: 0, left: 0}, + }); + expect(resources.requestsChangeSize_[0].force).to.equal(false); + }); + + it('should only schedule latest request for the same resource', () => { + resources.scheduleChangeSize_( + resource1, + 111, + 100, + undefined, + NO_EVENT, + true + ); + resources.scheduleChangeSize_( + resource1, + 222, + 300, + undefined, + NO_EVENT, + false + ); + + expect(resources.requestsChangeSize_.length).to.equal(1); + expect(resources.requestsChangeSize_[0].resource).to.equal(resource1); + expect(resources.requestsChangeSize_[0].newHeight).to.equal(222); + expect(resources.requestsChangeSize_[0].newWidth).to.equal(300); + expect(resources.requestsChangeSize_[0].force).to.equal(true); + }); + + it("should NOT change size if it didn't change", () => { + resources.scheduleChangeSize_( + resource1, + 100, + 100, + undefined, + NO_EVENT, + true + ); + resources.mutateWork_(); + expect(resources.relayoutTop_).to.equal(-1); + expect(resources.requestsChangeSize_.length).to.equal(0); + expect(resource1.changeSize).to.have.not.been.called; + }); + + it('should change size', () => { + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + true + ); + resources.mutateWork_(); + expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); + expect(resources.requestsChangeSize_.length).to.equal(0); + expect(resource1.changeSize).to.be.calledOnce; + expect(resource1.changeSize.firstCall.args[0]).to.equal(111); + expect(resource1.changeSize.firstCall.args[1]).to.equal(222); + }); + + it('should change size when only width changes', () => { + resources.scheduleChangeSize_( + resource1, + 111, + 100, + undefined, + NO_EVENT, + true + ); + resources.mutateWork_(); + expect(resource1.changeSize).to.be.calledOnce; + expect(resource1.changeSize.firstCall).to.have.been.calledWith(111, 100); + }); + + it('should change size when only height changes', () => { + resources.scheduleChangeSize_( + resource1, + 100, + 111, + undefined, + NO_EVENT, + true + ); + resources.mutateWork_(); + expect(resource1.changeSize).to.be.calledOnce; + expect(resource1.changeSize.firstCall).to.have.been.calledWith(100, 111); + }); + + it('should pick the smallest relayoutTop', () => { + resources.scheduleChangeSize_( + resource2, + 111, + 222, + undefined, + NO_EVENT, + true + ); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + true + ); + resources.mutateWork_(); + expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); + }); + + it('should measure non-measured elements', () => { + resource1.initialLayoutBox_ = null; + resource1.measure = window.sandbox.spy(); + resource2.measure = window.sandbox.spy(); + + resources.scheduleChangeSize_( + resource1, + 111, + 200, + undefined, + NO_EVENT, + true + ); + resources.scheduleChangeSize_( + resource2, + 111, + 222, + undefined, + NO_EVENT, + true + ); + expect(resource1.hasBeenMeasured()).to.be.false; + expect(resource2.hasBeenMeasured()).to.be.true; + + // Not yet scheduled, will wait until vsync. + expect(resource1.measure).to.not.be.called; + + // Scheduling is done after vsync. + resources.vsync_.runScheduledTasks_(); + expect(resource1.measure).to.be.calledOnce; + expect(resource2.measure).to.not.be.called; + + // Notice that the `resource2` was scheduled first since it didn't + // require vsync. + expect(resources.requestsChangeSize_).to.have.length(2); + expect(resources.requestsChangeSize_[0].resource).to.equal(resource2); + expect(resources.requestsChangeSize_[1].resource).to.equal(resource1); + }); + + describe('attemptChangeSize rules wrt viewport', () => { + let overflowCallbackSpy; + let vsyncSpy; + let viewportRect; + + beforeEach(() => { + overflowCallbackSpy = window.sandbox.spy(); + resource1.element.overflowCallback = overflowCallbackSpy; + + viewportRect = {top: 2, left: 0, right: 100, bottom: 200, height: 200}; + viewportMock + .expects('getRect') + .returns(viewportRect) + .atLeast(1); + resource1.layoutBox_ = { + top: 10, + left: 0, + right: 100, + bottom: 50, + height: 50, + }; + vsyncSpy = window.sandbox.stub(resources.vsync_, 'run'); + resources.visible_ = true; + }); + + it('should NOT change size when height is unchanged', () => { + const callback = window.sandbox.spy(); + resource1.layoutBox_ = { + top: 10, + left: 0, + right: 100, + bottom: 210, + height: 50, + }; + resources.scheduleChangeSize_( + resource1, + 50, + /* width */ undefined, + undefined, + NO_EVENT, + false, + callback + ); + resources.mutateWork_(); + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.not.been.called; + expect(callback).to.be.calledOnce; + expect(callback.args[0][0]).to.be.true; + }); + + it('should NOT change size when height and margins are unchanged', () => { + const callback = window.sandbox.spy(); + resource1.layoutBox_ = { + top: 10, + left: 0, + right: 100, + bottom: 210, + height: 50, + }; + resource1.element.fakeComputedStyle = { + marginTop: '1px', + marginRight: '2px', + marginBottom: '3px', + marginLeft: '4px', + }; + resources.scheduleChangeSize_( + resource1, + 50, + /* width */ undefined, + {top: 1, right: 2, bottom: 3, left: 4}, + NO_EVENT, + false, + callback + ); + + expect(vsyncSpy).to.be.calledOnce; + const task = vsyncSpy.lastCall.args[0]; + task.measure({}); + + resources.mutateWork_(); + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.not.been.called; + expect(callback).to.be.calledOnce; + expect(callback.args[0][0]).to.be.true; + }); + + it('should change size when margins but not height changed', () => { + const callback = window.sandbox.spy(); + resource1.layoutBox_ = { + top: 10, + left: 0, + right: 100, + bottom: 210, + height: 50, + }; + resource1.element.fakeComputedStyle = { + marginTop: '1px', + marginRight: '2px', + marginBottom: '3px', + marginLeft: '4px', + }; + resources.scheduleChangeSize_( + resource1, + 50, + /* width */ undefined, + {top: 1, right: 2, bottom: 4, left: 4}, + NO_EVENT, + false, + callback + ); + + expect(vsyncSpy).to.be.calledOnce; + const task = vsyncSpy.lastCall.args[0]; + task.measure({}); + + resources.mutateWork_(); + expect(resource1.changeSize).to.be.calledOnce; + }); + + it('should change size when forced', () => { + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + true + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); + }); + + // TODO (#16156): duplicate stub for getVisibilityState on Safari + it.configure() + .skipSafari() + .run('should change size when document is invisible', () => { + resources.visible_ = false; + window.sandbox + .stub(resources.ampdoc, 'getVisibilityState') + .returns(VisibilityState.PRERENDER); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); + }); + + it('should change size when active', () => { + resource1.element.contains = () => true; + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); + }); + + it('should NOT change size via activation if has not been active', () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(0); + const event = { + userActivation: { + hasBeenActive: false, + }, + }; + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + event, + false + ); + resources.mutateWork_(); + expect(resource1.changeSize).to.not.be.called; + expect(overflowCallbackSpy).to.be.calledOnce.calledWith(true); + }); + + it('should change size via activation if has been active', () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(0); + const event = { + userActivation: { + hasBeenActive: true, + }, + }; + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + event, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledOnce.calledWith(false); + }); + + it('should change size when below the viewport', () => { + resource1.layoutBox_ = { + top: 10, + left: 0, + right: 100, + bottom: 1050, + height: 50, + }; + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); + }); + + it('should change size when below the viewport and top margin also changed', () => { + resource1.layoutBox_ = { + top: 200, + left: 0, + right: 100, + bottom: 300, + height: 100, + }; + resources.scheduleChangeSize_( + resource1, + 111, + 222, + {top: 20}, + NO_EVENT, + false + ); + + expect(vsyncSpy).to.be.calledOnce; + const marginsTask = vsyncSpy.lastCall.args[0]; + marginsTask.measure({}); + + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); + }); + + it( + 'should change size when box top below the viewport but top margin ' + + 'boundary is above viewport but top margin in unchanged', + () => { + resource1.layoutBox_ = { + top: 200, + left: 0, + right: 100, + bottom: 300, + height: 100, + }; + resource1.element.fakeComputedStyle = { + marginTop: '100px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', + }; + resources.scheduleChangeSize_( + resource1, + 111, + 222, + {top: 100}, + NO_EVENT, + false + ); + + expect(vsyncSpy).to.be.calledOnce; + const marginsTask = vsyncSpy.lastCall.args[0]; + marginsTask.measure({}); + + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); + } + ); + + it( + 'should NOT change size when top margin boundary within viewport ' + + 'and top margin changed', + () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(1); + + const callback = window.sandbox.spy(); + resource1.layoutBox_ = { + top: 100, + left: 0, + right: 100, + bottom: 300, + height: 200, + }; + resources.scheduleChangeSize_( + resource1, + 111, + 222, + {top: 20}, + NO_EVENT, + false, + callback + ); + + expect(vsyncSpy).to.be.calledOnce; + const task = vsyncSpy.lastCall.args[0]; + task.measure({}); + + resources.mutateWork_(); + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.not.been.called; + expect(callback).to.be.calledOnce; + expect(callback.args[0][0]).to.be.false; + } + ); + + it('should defer when above the viewport and scrolling on', () => { + resource1.layoutBox_ = { + top: -1200, + left: 0, + right: 100, + bottom: -1050, + height: 50, + }; + resources.lastVelocity_ = 10; + resources.lastScrollTime_ = Date.now(); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_.length).to.equal(1); + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.not.been.called; + }); + + it( + 'should defer change size if just inside viewport and viewport ' + + 'scrolled by user.', + () => { + viewportRect.top = 2; + resource1.layoutBox_ = { + top: -50, + left: 0, + right: 100, + bottom: 1, + height: 51, + }; + resources.lastVelocity_ = 10; + resources.lastScrollTime_ = Date.now(); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_.length).to.equal(1); + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.not.been.called; + } + ); + + it( + 'should NOT change size and call overflow callback if viewport not ' + + 'scrolled by user.', + () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(1); + viewportRect.top = 1; + resource1.layoutBox_ = { + top: -50, + left: 0, + right: 100, + bottom: 0, + height: 51, + }; + resources.lastVelocity_ = 10; + resources.lastScrollTime_ = Date.now(); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_.length).to.equal(0); + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledWith(true, 111, 222); + } + ); + + it('should change size when above the vp and adjust scrolling', () => { + viewportMock + .expects('getScrollHeight') + .returns(2999) + .once(); + viewportMock + .expects('getScrollTop') + .returns(1777) + .once(); + resource1.layoutBox_ = { + top: -1200, + left: 0, + right: 100, + bottom: -1050, + height: 50, + }; + resources.lastVelocity_ = 0; + clock.tick(5000); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.not.been.called; + + expect(vsyncSpy.callCount).to.be.greaterThan(1); + const task = vsyncSpy.lastCall.args[0]; + const state = {}; + task.measure(state); + expect(state.scrollTop).to.equal(1777); + expect(state.scrollHeight).to.equal(2999); + + viewportMock + .expects('getScrollHeight') + .returns(3999) + .once(); + viewportMock + .expects('setScrollTop') + .withExactArgs(2777) + .once(); + task.mutate(state); + expect(resource1.changeSize).to.be.calledOnce; + expect(resource1.changeSize).to.be.calledWith(111, 222); + expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); + }); + + it('should NOT resize when above vp but cannot adjust scrolling', () => { + resource1.layoutBox_ = { + top: -1200, + left: 0, + right: 100, + bottom: -1100, + height: 100, + }; + resources.lastVelocity_ = 0; + clock.tick(5000); + resources.scheduleChangeSize_( + resource1, + 0, + 222, + undefined, + NO_EVENT, + false + ); + expect(vsyncSpy).to.be.calledOnce; + vsyncSpy.resetHistory(); + resources.mutateWork_(); + + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.not.be.called; + expect(vsyncSpy).to.not.be.called; + }); + + it('should resize if multi request above vp can adjust scroll', () => { + resource1.layoutBox_ = { + top: -1200, + left: 0, + right: 100, + bottom: -1100, + height: 100, + }; + resource2.layoutBox_ = { + top: -1300, + left: 0, + right: 100, + bottom: -1200, + height: 100, + }; + resources.lastVelocity_ = 0; + clock.tick(5000); + resources.scheduleChangeSize_( + resource2, + 200, + 222, + undefined, + NO_EVENT, + false + ); + resources.scheduleChangeSize_( + resource1, + 0, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + + const task = vsyncSpy.lastCall.args[0]; + const state = {}; + task.mutate(state); + + expect(resource1.changeSize).to.be.calledOnce; + expect(resource2.changeSize).to.be.calledOnce; + }); + + it('should NOT resize if multi req above vp cannot adjust scroll', () => { + // Only to satisfy expectation in beforeEach + resources.viewport_.getRect(); + + viewportMock.expects('getRect').returns({ + top: 10, + left: 0, + right: 100, + bottom: 210, + height: 200, + }); + resource1.layoutBox_ = { + top: -1200, + left: 0, + right: 100, + bottom: -1100, + height: 100, + }; + resource2.layoutBox_ = { + top: -1300, + left: 0, + right: 100, + bottom: -1200, + height: 100, + }; + resources.lastVelocity_ = 0; + clock.tick(5000); + resources.scheduleChangeSize_( + resource1, + 92, + 222, + undefined, + NO_EVENT, + false + ); + resources.scheduleChangeSize_( + resource2, + 92, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + const task = vsyncSpy.lastCall.args[0]; + const state = {}; + task.mutate(state); + expect(resource1.changeSize).to.be.calledOnce; + expect(resource2.changeSize).to.not.be.called; + }); + + it('should NOT adjust scrolling if height not change above vp', () => { + viewportMock + .expects('getScrollHeight') + .returns(2999) + .once(); + viewportMock + .expects('getScrollTop') + .returns(1777) + .once(); + resource1.layoutBox_ = { + top: -1200, + left: 0, + right: 100, + bottom: -1050, + height: 50, + }; + resources.lastVelocity_ = 0; + clock.tick(5000); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.not.been.called; + + expect(vsyncSpy.callCount).to.be.greaterThan(1); + const task = vsyncSpy.lastCall.args[0]; + const state = {}; + task.measure(state); + expect(state.scrollTop).to.equal(1777); + expect(state.scrollHeight).to.equal(2999); + + viewportMock + .expects('getScrollHeight') + .returns(2999) + .once(); + viewportMock.expects('setScrollTop').never(); + task.mutate(state); + expect(resource1.changeSize).to.be.calledOnce; + expect(resource1.changeSize).to.be.calledWith(111, 222); + expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); + }); + + it('should adjust scrolling if height change above vp', () => { + viewportMock + .expects('getScrollHeight') + .returns(2999) + .once(); + viewportMock + .expects('getScrollTop') + .returns(1000) + .once(); + resource1.layoutBox_ = { + top: -1200, + left: 0, + right: 100, + bottom: -1050, + height: 50, + }; + resources.lastVelocity_ = 0; + clock.tick(5000); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + const task = vsyncSpy.lastCall.args[0]; + const state = {}; + task.measure(state); + viewportMock + .expects('getScrollHeight') + .returns(2000) + .once(); + viewportMock + .expects('setScrollTop') + .withExactArgs(1) + .once(); + task.mutate(state); + }); + + it('in vp should NOT call overflowCallback if new height smaller', () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(1); + resources.scheduleChangeSize_( + resource1, + 10, + 11, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.not.been.called; + }); + + // TODO(#25518): investigate failure on Travis Safari + it.configure().skipSafari( + 'in viewport should change size if in the last 15% and ' + + 'in the last 1000px', + () => { + viewportRect.top = 9600; + viewportRect.bottom = 9800; + resource1.layoutBox_ = { + top: 9650, + left: 0, + right: 100, + bottom: 9700, + height: 50, + }; + resources.scheduleChangeSize_( + resource1, + 111, + 222, + {top: 1, right: 2, bottom: 3, left: 4}, + NO_EVENT, + false + ); + + expect(vsyncSpy).to.be.calledOnce; + const marginsTask = vsyncSpy.lastCall.args[0]; + marginsTask.measure({}); + + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy.firstCall.args[0]).to.equal(false); + } + ); + + it( + 'in viewport should NOT change size if in the last 15% but NOT ' + + 'in the last 1000px', + () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(1); + viewportRect.top = 8600; + viewportRect.bottom = 8800; + resource1.layoutBox_ = { + top: 8650, + left: 0, + right: 100, + bottom: 8700, + height: 50, + }; + resources.scheduleChangeSize_( + resource1, + 111, + 222, + {top: 1, right: 2, bottom: 3, left: 4}, + NO_EVENT, + false + ); + + expect(vsyncSpy).to.be.calledOnce; + const marginsTask = vsyncSpy.lastCall.args[0]; + marginsTask.measure({}); + + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledWith(true, 111, 222, { + top: 1, + right: 2, + bottom: 3, + left: 4, + }); + } + ); + + it('in viewport should NOT change size and calls overflowCallback', () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(1); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + {top: 1, right: 2, bottom: 3, left: 4}, + NO_EVENT, + false + ); + + expect(vsyncSpy).to.be.calledOnce; + const task = vsyncSpy.lastCall.args[0]; + task.measure({}); + + resources.mutateWork_(); + expect(resources.requestsChangeSize_.length).to.equal(0); + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledWith(true, 111, 222, { + top: 1, + right: 2, + bottom: 3, + left: 4, + }); + expect(resource1.getPendingChangeSize()).to.jsonEqual({ + height: 111, + width: 222, + margins: {top: 1, right: 2, bottom: 3, left: 4}, + }); + }); + + it.skip( + 'should change size if in viewport, but only modifying width and ' + + 'reflow is impossible', + () => { + const parent = document.createElement('div'); + parent.getLayoutWidth = () => 222; + const element = document.createElement('div'); + element.overflowCallback = () => {}; + parent.appendChild(element); + resource1.element = element; + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(1); + resources.scheduleChangeSize_( + resource1, + 50, + 222, + {top: 1, right: 2, bottom: 3, left: 4}, + NO_EVENT, + false + ); + + expect(vsyncSpy).to.be.calledOnce; + const task = vsyncSpy.lastCall.args[0]; + task.measure({}); + + resources.mutateWork_(); + expect(resource1.changeSize).to.be.calledOnce; + expect(resource1.changeSize).to.be.calledWith(50, 222); + } + ); + + it( + 'should NOT change size when resized margin in viewport and should ' + + 'call overflowCallback', + () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(1); + resource1.layoutBox_ = { + top: -48, + left: 0, + right: 100, + bottom: 2, + height: 50, + }; + resource1.element.fakeComputedStyle = { + marginBottom: '21px', + }; + + resources.scheduleChangeSize_( + resource1, + undefined, + undefined, + {bottom: 22}, + NO_EVENT, + false + ); + + expect(vsyncSpy).to.be.calledOnce; + const task = vsyncSpy.lastCall.args[0]; + task.measure({}); + + resources.mutateWork_(); + expect(resources.requestsChangeSize_.length).to.equal(0); + expect(resource1.changeSize).to.not.been.called; + expect(overflowCallbackSpy).to.be.calledOnce; + expect(overflowCallbackSpy).to.be.calledWith( + true, + undefined, + undefined, + {bottom: 22} + ); + expect(resource1.getPendingChangeSize()).to.jsonEqual({ + height: undefined, + width: undefined, + margins: {bottom: 22}, + }); + } + ); + + it('should change size when resized margin above viewport', () => { + resource1.layoutBox_ = { + top: -49, + left: 0, + right: 100, + bottom: 1, + height: 50, + }; + resource1.element.fakeComputedStyle = { + marginBottom: '21px', + }; + viewportMock + .expects('getScrollHeight') + .returns(2999) + .once(); + viewportMock + .expects('getScrollTop') + .returns(1777) + .once(); + + resources.lastVelocity_ = 0; + clock.tick(5000); + resources.scheduleChangeSize_( + resource1, + undefined, + undefined, + {top: 1}, + NO_EVENT, + false + ); + + expect(vsyncSpy).to.be.calledOnce; + const marginsTask = vsyncSpy.lastCall.args[0]; + marginsTask.measure({}); + + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.not.been.called; + + expect(vsyncSpy.callCount).to.be.greaterThan(2); + const scrollAdjustTask = vsyncSpy.lastCall.args[0]; + const state = {}; + scrollAdjustTask.measure(state); + expect(state.scrollTop).to.equal(1777); + expect(state.scrollHeight).to.equal(2999); + + viewportMock + .expects('getScrollHeight') + .returns(3999) + .once(); + viewportMock + .expects('setScrollTop') + .withExactArgs(2777) + .once(); + scrollAdjustTask.mutate(state); + expect(resource1.changeSize).to.be.calledOnce; + expect(resource1.changeSize).to.be.calledWith(undefined, undefined, { + top: 1, + }); + expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); + }); + + it('should reset pending change size when rescheduling', () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(1); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resource1.getPendingChangeSize().height).to.equal(111); + expect(resource1.getPendingChangeSize().width).to.equal(222); + + resources.scheduleChangeSize_( + resource1, + 112, + 223, + undefined, + NO_EVENT, + false + ); + expect(resource1.getPendingChangeSize()).to.be.undefined; + }); + + it('should force resize after focus', () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .atLeast(1); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resource1.getPendingChangeSize()).to.jsonEqual({ + height: 111, + width: 222, + }); + expect(resources.requestsChangeSize_).to.be.empty; + + resources.checkPendingChangeSize_(resource1.element); + expect(resource1.getPendingChangeSize()).to.be.undefined; + expect(resources.requestsChangeSize_.length).to.equal(1); + + resources.mutateWork_(); + expect(resources.requestsChangeSize_).to.be.empty; + expect(resource1.changeSize).to.be.calledOnce; + expect(resource1.changeSize).to.be.calledWith(111, 222); + expect(overflowCallbackSpy).to.be.calledTwice; + expect(overflowCallbackSpy.lastCall.args[0]).to.equal(false); + }); + }); + + describe('attemptChangeSize rules for element wrt document', () => { + beforeEach(() => { + viewportMock + .expects('getRect') + .returns({top: 0, left: 0, right: 100, bottom: 10000, height: 200}); + resource1.layoutBox_ = resource1.initialLayoutBox_ = layoutRectLtwh( + 0, + 10, + 100, + 100 + ); + }); + + it('should NOT change size when far the bottom of the document', () => { + viewportMock + .expects('getContentHeight') + .returns(10000) + .once(); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resource1.changeSize).to.not.been.called; + }); + + it('should change size when close to the bottom of the document', () => { + viewportMock + .expects('getContentHeight') + .returns(110) + .once(); + resources.scheduleChangeSize_( + resource1, + 111, + 222, + undefined, + NO_EVENT, + false + ); + resources.mutateWork_(); + expect(resource1.changeSize).to.be.calledOnce; + }); + }); +}); + +describes.realWin('Resources mutateElement and collapse', {amp: true}, env => { + function createElement(rect, isAmp) { + const element = env.win.document.createElement(isAmp ? 'amp-test' : 'div'); + if (isAmp) { + element.classList.add('i-amphtml-element'); + } + element.signals = () => new Signals(); + element.whenBuilt = () => Promise.resolve(); + element.isBuilt = () => true; + element.build = () => Promise.resolve(); + element.isUpgraded = () => true; + element.updateLayoutBox = () => {}; + element.getPlaceholder = () => null; + element.getLayoutPriority = () => LayoutPriority.CONTENT; + element.dispatchCustomEvent = () => {}; + element.getLayout = () => 'fixed'; + + element.isInViewport = () => false; + element.getAttribute = () => null; + element.hasAttribute = () => false; + element.getBoundingClientRect = () => rect; + element.applySizesAndMediaQuery = () => {}; + element.layoutCallback = () => Promise.resolve(); + element.viewportCallback = env.sandbox.spy(); + element.prerenderAllowed = () => true; + element.renderOutsideViewport = () => true; + element.isRelayoutNeeded = () => true; + element.pauseCallback = () => {}; + element.unlayoutCallback = () => true; + element.unlayoutOnPause = () => true; + element.togglePlaceholder = () => env.sandbox.spy(); + + env.win.document.body.appendChild(element); + return element; + } + + function createResource(id, rect) { + const resource = new Resource( + id, + createElement(rect, /* isAmp */ true), + resources + ); + resource.element['__AMP__RESOURCE'] = resource; + resource.state_ = ResourceState.READY_FOR_LAYOUT; + resource.layoutBox_ = rect; + resource.changeSize = env.sandbox.spy(); + resource.completeCollapse = env.sandbox.spy(); + return resource; + } + + let viewportMock; + let resources; + let resource1, resource2; + let parent1, parent2; + let relayoutTopStub; + let resource1RequestMeasureStub, resource2RequestMeasureStub; + + beforeEach(() => { + resources = new ResourcesImpl(env.ampdoc); + resources.isRuntimeOn_ = false; + viewportMock = env.sandbox.mock(resources.viewport_); + resources.vsync_ = { + mutate: callback => callback(), + measure: callback => callback(), + runPromise: task => { + const state = {}; + if (task.measure) { + task.measure(state); + } + if (task.mutate) { + task.mutate(state); + } + return Promise.resolve(); + }, + run: task => { + const state = {}; + if (task.measure) { + task.measure(state); + } + if (task.mutate) { + task.mutate(state); + } + }, + }; + relayoutTopStub = env.sandbox.stub(resources, 'setRelayoutTop_'); + env.sandbox.stub(resources, 'schedulePass'); + + resource1 = createResource(1, layoutRectLtwh(10, 10, 100, 100)); + resource2 = createResource(2, layoutRectLtwh(10, 1010, 100, 100)); + resources.owners_ = [resource1, resource2]; + + resource1RequestMeasureStub = env.sandbox.stub(resource1, 'requestMeasure'); + resource2RequestMeasureStub = env.sandbox.stub(resource2, 'requestMeasure'); + + parent1 = createElement( + layoutRectLtwh(10, 10, 100, 100), + /* isAmp */ false + ); + parent2 = createElement( + layoutRectLtwh(10, 1010, 100, 100), + /* isAmp */ false + ); + + parent1.getElementsByClassName = className => { + if (className == 'i-amphtml-element') { + return [resource1.element]; + } + }; + parent2.getElementsByClassName = className => { + if (className == 'i-amphtml-element') { + return [resource2.element]; + } + }; + }); + + afterEach(() => { + viewportMock.verify(); + }); + + it('should mutate from visible to invisible', () => { + const mutateSpy = env.sandbox.spy(); + const promise = resources.mutateElement(parent1, () => { + parent1.getBoundingClientRect = () => layoutRectLtwh(0, 0, 0, 0); + mutateSpy(); + }); + return promise.then(() => { + expect(mutateSpy).to.be.calledOnce; + expect(resource1RequestMeasureStub).to.be.calledOnce; + expect(resource2RequestMeasureStub).to.have.not.been.called; + expect(relayoutTopStub).to.be.calledOnce; + expect(relayoutTopStub.getCall(0).args[0]).to.equal(10); + }); + }); + + it('should mutate from visible to invisible on itself', () => { + const mutateSpy = env.sandbox.spy(); + const promise = resources.mutateElement(resource1.element, () => { + resource1.element.getBoundingClientRect = () => + layoutRectLtwh(0, 0, 0, 0); + mutateSpy(); + }); + return promise.then(() => { + expect(mutateSpy).to.be.calledOnce; + expect(resource1RequestMeasureStub).to.be.calledOnce; + expect(resource2RequestMeasureStub).to.have.not.been.called; + expect(relayoutTopStub).to.be.calledOnce; + expect(relayoutTopStub.getCall(0).args[0]).to.equal(10); + }); + }); + + it('should mutate from invisible to visible', () => { + const mutateSpy = env.sandbox.spy(); + parent1.getBoundingClientRect = () => layoutRectLtwh(0, 0, 0, 0); + const promise = resources.mutateElement(parent1, () => { + parent1.getBoundingClientRect = () => layoutRectLtwh(10, 10, 100, 100); + mutateSpy(); + }); + return promise.then(() => { + expect(mutateSpy).to.be.calledOnce; + expect(resource1RequestMeasureStub).to.be.calledOnce; + expect(resource2RequestMeasureStub).to.have.not.been.called; + expect(relayoutTopStub).to.be.calledOnce; + expect(relayoutTopStub.getCall(0).args[0]).to.equal(10); + }); + }); + + it('should mutate from visible to visible', () => { + const mutateSpy = env.sandbox.spy(); + parent1.getBoundingClientRect = () => layoutRectLtwh(10, 10, 100, 100); + const promise = resources.mutateElement(parent1, () => { + parent1.getBoundingClientRect = () => layoutRectLtwh(10, 1010, 100, 100); + mutateSpy(); + }); + return promise.then(() => { + expect(mutateSpy).to.be.calledOnce; + expect(resource1RequestMeasureStub).to.be.calledOnce; + expect(resource2RequestMeasureStub).to.have.not.been.called; + expect(relayoutTopStub).to.have.callCount(2); + expect(relayoutTopStub.getCall(0).args[0]).to.equal(10); + expect(relayoutTopStub.getCall(1).args[0]).to.equal(1010); + }); + }); + + it('attemptCollapse should not call attemptChangeSize', () => { + // This test ensure that #attemptCollapse won't do any optimization or + // refactor by calling attemptChangeSize. + // This to support collapsing element above viewport + // When attemptChangeSize succeed, resources manager will measure the new + // scrollHeight, and we need to make sure the newScrollHeight is measured + // after setting element display:none + env.sandbox.stub(resources.viewport_, 'getRect').callsFake(() => { + return { + top: 1500, + bottom: 1800, + left: 0, + right: 500, + width: 500, + height: 300, + }; + }); + let promiseResolve = null; + const promise = new Promise(resolve => { + promiseResolve = resolve; + }); + let index = 0; + env.sandbox.stub(resources.viewport_, 'getScrollHeight').callsFake(() => { + // In change element size above viewport path, getScrollHeight will be + // called twice. And we care that the last measurement is correct, + // which requires it to be measured after element dispaly set to none. + if (index == 1) { + expect(resource1.completeCollapse).to.be.calledOnce; + promiseResolve(); + return; + } + expect(resource1.completeCollapse).to.not.been.called; + index++; + }); + + resource1.layoutBox_ = { + top: 1000, + left: 0, + right: 100, + bottom: 1050, + height: 50, + }; + resources.lastVelocity_ = 0; + resources.attemptCollapse(resource1.element); + resources.mutateWork_(); + return promise; + }); + + it('attemptCollapse should complete collapse if resize succeed', () => { + env.sandbox + .stub(resources, 'scheduleChangeSize_') + .callsFake( + (resource, newHeight, newWidth, newMargins, event, force, callback) => { + callback(true); + } + ); + resources.attemptCollapse(resource1.element); + expect(resource1.completeCollapse).to.be.calledOnce; + }); + + it('attemptCollapse should NOT complete collapse if resize fail', () => { + env.sandbox + .stub(resources, 'scheduleChangeSize_') + .callsFake( + (resource, newHeight, newWidth, newMargins, event, force, callback) => { + callback(false); + } + ); + resources.attemptCollapse(resource1.element); + expect(resource1.completeCollapse).to.not.been.called; + }); + + it('should complete collapse and trigger relayout', () => { + const oldTop = resource1.getLayoutBox().top; + resources.collapseElement(resource1.element); + expect(resource1.completeCollapse).to.be.calledOnce; + expect(relayoutTopStub).to.be.calledOnce; + expect(relayoutTopStub.args[0][0]).to.equal(oldTop); + }); + + it('should ignore relayout on an already collapsed element', () => { + resource1.layoutBox_.width = 0; + resource1.layoutBox_.height = 0; + resources.collapseElement(resource1.element); + expect(resource1.completeCollapse).to.be.calledOnce; + expect(relayoutTopStub).to.have.not.been.called; + }); +}); + describes.fakeWin('Resources.add/upgrade/remove', {amp: true}, env => { let resources; let parent;