diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js index eaf1ccb83179..9a9eea5c1ff3 100644 --- a/build-system/tasks/presubmit-checks.js +++ b/build-system/tasks/presubmit-checks.js @@ -224,6 +224,14 @@ 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: [ @@ -233,6 +241,14 @@ 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: [ @@ -284,15 +300,6 @@ 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: [ @@ -573,7 +580,7 @@ const forbiddenTerms = { }, '\\.schedulePass\\(': { message: 'schedulePass is heavy, think twice before using it', - whitelist: ['src/service/resources-impl.js'], + whitelist: ['src/service/mutator-impl.js', 'src/service/resources-impl.js'], }, '\\.requireLayout\\(': { message: @@ -850,6 +857,7 @@ 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/src/inabox/inabox-mutator.js b/src/inabox/inabox-mutator.js index 72070ab4c80f..76cc93e8707d 100644 --- a/src/inabox/inabox-mutator.js +++ b/src/inabox/inabox-mutator.js @@ -15,6 +15,7 @@ */ import {Services} from '../services'; +import {registerServiceBuilderForDoc} from '../service'; /** * @implements {../service/mutator-interface.MutatorInterface} @@ -22,11 +23,10 @@ import {Services} from '../services'; export class InaboxMutator { /** * @param {!../service/ampdoc-impl.AmpDoc} ampdoc - * @param {!../service/resources-interface.ResourcesInterface} resources */ - constructor(ampdoc, resources) { + constructor(ampdoc) { /** @const @private {!../service/resources-interface.ResourcesInterface} */ - this.resources_ = resources; + this.resources_ = Services.resourcesForDoc(ampdoc); /** @private @const {!../service/vsync-impl.Vsync} */ this.vsync_ = Services./*OK*/ vsyncFor(ampdoc.win); @@ -101,3 +101,10 @@ 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 35b6d6c2f3cb..683ec5d1a410 100644 --- a/src/inabox/inabox-resources.js +++ b/src/inabox/inabox-resources.js @@ -15,7 +15,6 @@ */ 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'; @@ -57,9 +56,6 @@ 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); } @@ -129,6 +125,12 @@ export class InaboxResources { return this.pass_.schedule(opt_delay); } + /** @override */ + updateOrEnqueueMutateTask(unusedResource, unusedNewRequest) {} + + /** @override */ + schedulePassVsync() {} + /** @override */ onNextPass(callback) { this.passObservable_.add(callback); @@ -143,55 +145,10 @@ export class InaboxResources { } /** @override */ - changeSize(element, newHeight, newWidth, opt_callback, opt_newMargins) { - this.mutator_./*OK*/ changeSize( - element, - newHeight, - newWidth, - opt_callback, - opt_newMargins - ); - } + setRelayoutTop(unusedRelayoutTop) {} /** @override */ - 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); - } + maybeHeightChanged() {} /** * @return {!Promise} when first pass executed. diff --git a/src/inabox/inabox-services.js b/src/inabox/inabox-services.js index 98cbe33ec21a..f3846fdcee8b 100644 --- a/src/inabox/inabox-services.js +++ b/src/inabox/inabox-services.js @@ -22,6 +22,7 @@ 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'; @@ -47,6 +48,7 @@ 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 b83ec346cd53..e2a42b10b433 100644 --- a/src/service/core-services.js +++ b/src/service/core-services.js @@ -27,6 +27,7 @@ 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'; @@ -106,6 +107,9 @@ 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 new file mode 100644 index 000000000000..683abac0acfc --- /dev/null +++ b/src/service/mutator-impl.js @@ -0,0 +1,414 @@ +/** + * 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 a506764cbac3..fd918813f7c5 100644 --- a/src/service/resources-impl.js +++ b/src/service/resources-impl.js @@ -23,13 +23,11 @@ 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 {getMode} from '../mode'; +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'; @@ -51,29 +49,6 @@ 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} */ @@ -173,7 +148,7 @@ export class ResourcesImpl { this.boundTaskScorer_ = this.calcTaskScore_.bind(this); /** - * @private {!Array} + * @private {!Array} */ this.requestsChangeSize_ = []; @@ -244,10 +219,6 @@ 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, () => { @@ -542,206 +513,36 @@ export class ResourcesImpl { } /** @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 */ - 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]); - if (typeof r === 'undefined') { - dev().error( - TAG_, - 'AMP Element is missing an associated resource. Element: %s, Runtime: %s', - ampElements[i], - getMode().runtime - ); - } - 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; + schedulePass(opt_delay, opt_relayoutAll) { + if (opt_relayoutAll) { + this.relayoutAll_ = true; } - this.schedulePass(FOUR_FRAME_DELAY_, relayoutAll); - } - - /** @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')); - } - } - ); - }); + return this.pass_.schedule(opt_delay); } /** @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); + 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; } } - resource.completeCollapse(); - this.schedulePass(FOUR_FRAME_DELAY_); - } - - /** @override */ - expandElement(element) { - const resource = Resource.forElement(element); - resource.completeExpand(); - - const owner = resource.getOwner(); - if (owner) { - owner.expandedCallback(element); + 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); } - - this.schedulePass(FOUR_FRAME_DELAY_); } /** @override */ - 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_() { + schedulePassVsync() { if (this.vsyncScheduled_) { return; } @@ -755,6 +556,20 @@ 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); @@ -908,7 +723,7 @@ export class ResourcesImpl { const { resource, event, - } = /** @type {!ChangeSizeRequestDef} */ (request); + } = /** @type {!./resources-interface.ChangeSizeRequestDef} */ (request); const box = resource.getLayoutBox(); let topMarginDiff = 0; @@ -1098,7 +913,7 @@ export class ResourcesImpl { } if (minTop != -1) { - this.setRelayoutTop_(minTop); + this.setRelayoutTop(minTop); } // Execute scroll-adjusting resize requests, if any. @@ -1126,7 +941,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(); @@ -1163,45 +978,6 @@ 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, @@ -1624,160 +1400,6 @@ 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 b0c9e4d7ede8..b7a6b255a4d8 100644 --- a/src/service/resources-interface.js +++ b/src/service/resources-interface.js @@ -14,16 +14,37 @@ * 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 extends MutatorInterface { +export class ResourcesInterface { /** * Returns a list of resources. * @return {!Array} @@ -106,6 +127,18 @@ export class ResourcesInterface extends MutatorInterface { */ 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 @@ -123,6 +156,16 @@ export class ResourcesInterface extends MutatorInterface { */ 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 8aebcd4d2374..693b8e2c091d 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, - 'resources' + 'mutator' )); } diff --git a/test/unit/test-mutator.js b/test/unit/test-mutator.js new file mode 100644 index 000000000000..15021e6af119 --- /dev/null +++ b/test/unit/test-mutator.js @@ -0,0 +1,1742 @@ +/** + * 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 69862a4d4068..80f37e3b1ac6 100644 --- a/test/unit/test-resources.js +++ b/test/unit/test-resources.js @@ -21,15 +21,10 @@ 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; @@ -1279,1707 +1274,6 @@ 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;