diff --git a/extensions/amp-next-page/1.0/amp-next-page.css b/extensions/amp-next-page/1.0/amp-next-page.css index a43d7b99a954..5368d0f8755d 100644 --- a/extensions/amp-next-page/1.0/amp-next-page.css +++ b/extensions/amp-next-page/1.0/amp-next-page.css @@ -27,3 +27,7 @@ opacity: 1; overflow: visible; } + +.i-amphtml-next-page-placeholder { + background: #eee; +} diff --git a/extensions/amp-next-page/1.0/page.js b/extensions/amp-next-page/1.0/page.js index b7f84fe06b94..c0ad09aa627b 100644 --- a/extensions/amp-next-page/1.0/page.js +++ b/extensions/amp-next-page/1.0/page.js @@ -16,6 +16,7 @@ import {ViewportRelativePos} from './visibility-observer'; import {VisibilityState} from '../../../src/visibility-state'; +import {devAssert} from '../../../src/log'; /** @enum {number} */ export const PageState = { @@ -24,6 +25,7 @@ export const PageState = { LOADED: 2, FAILED: 3, INSERTED: 4, + PAUSED: 5, }; const VISIBLE_DOC_CLASS = 'amp-next-page-document-visible'; @@ -40,11 +42,17 @@ export class Page { this.title_ = meta.title; /** @private {string} */ this.url_ = meta.url; + /** @private {string} */ + this.initialUrl_ = meta.url; /** @private @const {string} */ this.image_ = meta.image; /** @private {?../../../src/runtime.ShadowDoc} */ this.shadowDoc_ = null; + /** @private {?Element} */ + this.container_ = null; + /** @private {?Document} */ + this.cachedContent_ = null; /** @private {!PageState} */ this.state_ = PageState.QUEUED; /** @private {!VisibilityState} */ @@ -65,6 +73,11 @@ export class Page { this.url_ = url; } + /** @return {string} */ + get initialUrl() { + return this.initialUrl_; + } + /** @return {string} */ get image() { return this.image_; @@ -80,12 +93,22 @@ export class Page { return this.relativePos_; } - /** @return {!Document|undefined} */ + /** @return {!Document|!ShadowRoot|undefined} */ get document() { if (!this.shadowDoc_) { return; } - return /** @type {!Document} */ (this.shadowDoc_.ampdoc.getRootNode()); + return this.shadowDoc_.ampdoc.getRootNode(); + } + + /** @return {?Element} */ + get container() { + return this.container_; + } + + /** @return {?../../../src/runtime.ShadowDoc} */ + get shadowDoc() { + return this.shadowDoc_; } /** @param {!ViewportRelativePos} position */ @@ -100,6 +123,13 @@ export class Page { return this.visibilityState_ === VisibilityState.VISIBLE; } + /** + * @return {boolean} + */ + isPaused() { + return this.state_ === PageState.PAUSED; + } + /** * @return {!VisibilityState} * @visibleForTesting @@ -115,6 +145,12 @@ export class Page { if (visibilityState == this.visibilityState_) { return; } + + //Reload the page if necessary + if (this.isPaused() && visibilityState === VisibilityState.VISIBLE) { + this.resume(); + } + // Update visibility internally and at the shadow doc level this.visibilityState_ = visibilityState; if (this.shadowDoc_) { @@ -124,12 +160,38 @@ export class Page { .classList.toggle(VISIBLE_DOC_CLASS, this.isVisible()); } - // Switch the title and url of the page to reflect this page if (this.isVisible()) { + // Switch the title and url of the page to reflect this page this.manager_.setTitlePage(this); } } + /** + * Creates a placeholder in place of the original page and unloads + * the shadow root from memory + * @return {!Promise} + */ + pause() { + if (!this.shadowDoc_) { + return Promise.resolve(); + } + return this.shadowDoc_.close().then(() => { + this.manager_.closeDocument(this /** page */).then(() => { + this.shadowDoc_ = null; + this.visibilityState_ = VisibilityState.HIDDEN; + this.state_ = PageState.PAUSED; + }); + }); + } + + /** + * Removes the placeholder and re-renders the page after its shadow + * root has been removed + */ + resume() { + this.attach_(devAssert(this.cachedContent_)); + } + /** * @return {boolean} */ @@ -168,20 +230,42 @@ export class Page { .fetchPageDocument(this) .then(content => { this.state_ = PageState.LOADED; - - const shadowDoc = this.manager_.appendAndObservePage(this, content); - if (shadowDoc) { - this.shadowDoc_ = shadowDoc; - this.manager_.setLastFetchedPage(this); - this.state_ = PageState.INSERTED; - } else { - this.state_ = PageState.FAILED; - } + this.container_ = this.manager_.createDocumentContainerForPage( + this /** page */ + ); + // TODO(wassgha): To further optimize, this should ideally + // be parsed from the service worker instead of stored in memory + this.cachedContent_ = content; + this.attach_(content); }) .catch(() => { this.state_ = PageState.FAILED; + // TOOD(wassgha): Silently skips this page, should we re-try or show an error state? + this.manager_.setLastFetchedPage(this); }); } + + /** + * Inserts the fetched (or cached) HTML as the document's content + * @param {!Document} content + */ + attach_(content) { + const shadowDoc = this.manager_.attachDocumentToPage( + this /** page */, + content, + this.isPaused() /** force */ + ); + + if (shadowDoc) { + this.shadowDoc_ = shadowDoc; + if (!this.isPaused()) { + this.manager_.setLastFetchedPage(this); + } + this.state_ = PageState.INSERTED; + } else { + this.state_ = PageState.FAILED; + } + } } export class HostPage extends Page { diff --git a/extensions/amp-next-page/1.0/service.js b/extensions/amp-next-page/1.0/service.js index c85293ee2ef4..46208250da3b 100644 --- a/extensions/amp-next-page/1.0/service.js +++ b/extensions/amp-next-page/1.0/service.js @@ -24,16 +24,18 @@ import { childElementsByTag, isJsonScriptTag, removeElement, + scopedQuerySelector, } from '../../../src/dom'; import {dev, devAssert, user, userAssert} from '../../../src/log'; +import {escapeCssSelectorIdent} from '../../../src/css'; import {installStylesForDoc} from '../../../src/style-installer'; import { parseFavicon, parseOgImage, parseSchemaImage, } from '../../../src/mediasession-helper'; +import {setStyles, toggle} from '../../../src/style'; import {toArray} from '../../../src/types'; -import {toggle} from '../../../src/style'; import {tryParseJson} from '../../../src/json'; import {validatePage, validateUrl} from './utils'; import VisibilityObserver, {ViewportRelativePos} from './visibility-observer'; @@ -41,6 +43,13 @@ import VisibilityObserver, {ViewportRelativePos} from './visibility-observer'; const TAG = 'amp-next-page'; const PRERENDER_VIEWPORT_COUNT = 3; const NEAR_BOTTOM_VIEWPORT_COUNT = 1; +const PAUSE_PAGE_COUNT = 5; + +const NEXT_PAGE_CLASS = 'i-amphtml-next-page'; +const DOC_CLASS = 'i-amphtml-next-page-document'; +const DOC_CONTAINER_CLASS = 'i-amphtml-next-page-document-container'; +const SHADOW_ROOT_CLASS = 'i-amphtml-next-page-shadow-root'; +const PLACEHOLDER_CLASS = 'i-amphtml-next-page-placeholder'; /** @enum */ export const Direction = {UP: 1, DOWN: -1}; @@ -62,6 +71,12 @@ export class NextPageService { */ this.viewport_ = Services.viewportForDoc(ampdoc); + /** + * @private + * @const {!../../../src/service/mutator-interface.MutatorInterface} + */ + this.mutator_ = Services.mutatorForDoc(ampdoc); + /** @private {?Element} */ this.separator_ = null; @@ -136,7 +151,6 @@ export class NextPageService { Services.extensionsFor(this.win_), Services.timerFor(this.win_) ); - this.visibilityObserver_ = new VisibilityObserver(this.ampdoc_); // Have the suggestion box be always visible @@ -147,16 +161,9 @@ export class NextPageService { this.setLastFetchedPage(this.hostPage_); } - this.getPagesPromise_().then(pages => { - pages.forEach(page => { - validatePage(page, this.ampdoc_.getUrl()); - this.pages_.push( - new Page(this, {url: page.url, title: page.title, image: page.image}) - ); - }); - }); + this.parseAndQueuePages_(); - this.getHostNextPageElement_().classList.add('i-amphtml-next-page'); + this.getHostNextPageElement_().classList.add(NEXT_PAGE_CLASS); this.viewport_.onScroll(() => this.updateScroll_()); this.viewport_.onResize(() => this.updateScroll_()); @@ -188,15 +195,16 @@ export class NextPageService { if (this.pages_.some(page => page.isFetching())) { return Promise.resolve(); } + // If we're still too far from the bottom, early return + if (this.getViewportsAway_() > PRERENDER_VIEWPORT_COUNT && !force) { + return Promise.resolve(); + } - if (force || this.getViewportsAway_() <= PRERENDER_VIEWPORT_COUNT) { - const nextPage = this.pages_[ - this.getPageIndex_(this.lastFetchedPage_) + 1 - ]; - if (nextPage) { - return nextPage.fetch(); - } + const nextPage = this.pages_[this.getPageIndex_(this.lastFetchedPage_) + 1]; + if (!nextPage) { + return Promise.resolve(); } + return nextPage.fetch(); } /** @@ -212,7 +220,8 @@ export class NextPageService { if (!page.isVisible()) { page.setVisibility(VisibilityState.VISIBLE); } - this.hidePreviousPages(index); + this.hidePreviousPages_(index); + this.resumePausedPages_(index); } else if (page.relativePos === ViewportRelativePos.OUTSIDE_VIEWPORT) { if (page.isVisible()) { page.setVisibility(VisibilityState.HIDDEN); @@ -237,26 +246,80 @@ export class NextPageService { /** * Makes sure that all pages preceding the current page are - * marked hidden if they are out of the viewport + * marked hidden if they are out of the viewport and additionally + * paused if they are too far from the current page * @param {number} index index of the page to start at + * @param {number=} pausePageCountForTesting + * @return {!Promise} + * @private */ - hidePreviousPages(index) { + hidePreviousPages_(index, pausePageCountForTesting) { + // The distance (in pages) to the currently visible page after which + // we start unloading pages from memory + const pausePageCount = + pausePageCountForTesting === undefined + ? PAUSE_PAGE_COUNT + : pausePageCountForTesting; + + const scrollingDown = this.scrollDirection_ === Direction.DOWN; + // Hide the host (first) page if needed + if (scrollingDown && this.hostPage_.isVisible()) { + this.hostPage_.setVisibility(VisibilityState.HIDDEN); + } + // Get all the pages that the user scrolled past (or didn't see yet) - const previousPages = - this.scrollDirection_ === Direction.UP - ? this.pages_.slice(index + 1) - : this.pages_.slice(0, index); + const previousPages = scrollingDown + ? this.pages_.slice(1, index).reverse() + : this.pages_.slice(index + 1); // Find the ones that should be hidden (no longer inside the viewport) - previousPages - .filter(page => { - const shouldHide = - page.relativePos === ViewportRelativePos.LEAVING_VIEWPORT || - page.relativePos === ViewportRelativePos.OUTSIDE_VIEWPORT || - page === this.hostPage_; - return shouldHide && page.isVisible(); - }) - .forEach(page => page.setVisibility(VisibilityState.HIDDEN)); + return Promise.all( + previousPages + .filter(page => { + const shouldHide = + page.relativePos === ViewportRelativePos.LEAVING_VIEWPORT || + page.relativePos === ViewportRelativePos.OUTSIDE_VIEWPORT; + return shouldHide; + }) + .map((page, away) => { + // Hide all pages that are in the viewport + if (page.isVisible()) { + page.setVisibility(VisibilityState.HIDDEN); + } + // Pause those that are too far away + if (away >= pausePageCount) { + return page.pause(); + } + }) + ); + } + + /** + * Makes sure that all pages that are a few pages away from the + * currently visible page are re-inserted (if paused) and + * ready to become visible soon + * @param {number} index index of the page to start at + * @param {number=} pausePageCountForTesting + * @private + */ + resumePausedPages_(index, pausePageCountForTesting) { + // The distance (in pages) to the currently visible page after which + // we start unloading pages from memory + const pausePageCount = + pausePageCountForTesting === undefined + ? PAUSE_PAGE_COUNT + : pausePageCountForTesting; + + // Get all the pages that should be resumed + const nearViewportPages = this.pages_ + .slice(1) // Ignore host page + .slice( + Math.max(0, index - pausePageCount - 1), + Math.min(this.pages_.length, index + pausePageCount + 1) + ) + .filter(page => page.isPaused()); + + nearViewportPages.forEach(page => page.resume()); } /** @@ -291,7 +354,7 @@ export class NextPageService { } /** - * + * Creates the initial (host) page based on the window's metadata * @return {!Page} */ createHostPage() { @@ -314,44 +377,97 @@ export class NextPageService { } /** - * + * Create a container element for the document and insert it into + * the amp-next-page element * @param {!Page} page - * @param {!Document} doc + * @return {!Element} + */ + createDocumentContainerForPage(page) { + const container = this.win_.document.createElement('div'); + container.classList.add(DOC_CONTAINER_CLASS); + + const shadowRoot = this.win_.document.createElement('div'); + shadowRoot.classList.add(SHADOW_ROOT_CLASS); + container.appendChild(shadowRoot); + + // Insert the separator + container.appendChild(this.separator_.cloneNode(true)); + + // Insert the container + this.element_.insertBefore(container, this.moreBox_); + + // Observe this page's visibility + this.visibilityObserver_.observe( + shadowRoot /** element */, + container /** parent */, + position => { + page.relativePos = position; + this.updateVisibility(); + } + ); + + return container; + } + + /** + * Appends the given document to the host page and installs + * a visibility observer to monitor it + * @param {!Page} page + * @param {!Document} content + * @param {boolean=} force * @return {?../../../src/runtime.ShadowDoc} */ - appendAndObservePage(page, doc) { + attachDocumentToPage(page, content, force = false) { // If the user already scrolled to the bottom, prevent rendering - if (this.getViewportsAway_() <= NEAR_BOTTOM_VIEWPORT_COUNT) { + if (this.getViewportsAway_() <= NEAR_BOTTOM_VIEWPORT_COUNT && !force) { // TODO(wassgha): Append a "load next article" button? return null; } - const shadowRoot = this.win_.document.createElement('div'); + const container = dev().assertElement(page.container); + let shadowRoot = scopedQuerySelector( + container, + `> .${escapeCssSelectorIdent(SHADOW_ROOT_CLASS)}` + ); - // Handles extension deny-lists - this.sanitizeDoc(doc); + // Page has previously been deactivated so the shadow root + // will need to replace placeholder + // TODO(wassgha) This wouldn't be needed once we can resume a ShadowDoc + if (!shadowRoot) { + devAssert(page.isPaused()); + const placeholder = dev().assertElement( + scopedQuerySelector( + container, + `> .${escapeCssSelectorIdent(PLACEHOLDER_CLASS)}` + ), + 'Paused page does not have a placeholder' + ); - // Insert the separator - this.element_.insertBefore(this.separator_.cloneNode(true), this.moreBox_); + shadowRoot = this.win_.document.createElement('div'); + shadowRoot.classList.add(SHADOW_ROOT_CLASS); - // Insert the shadow doc and observe its position - this.element_.insertBefore(shadowRoot, this.moreBox_); - this.visibilityObserver_.observe(shadowRoot, this.element_, position => { - page.relativePos = position; - this.updateVisibility(); - }); + container.replaceChild(shadowRoot, placeholder); + } + + // Handles extension deny-lists + this.sanitizeDoc(content); // Try inserting the shadow document try { - const amp = this.multidocManager_.attachShadowDoc(shadowRoot, doc, '', { - visibilityState: VisibilityState.PRERENDER, - }); + const amp = this.multidocManager_.attachShadowDoc( + shadowRoot, + content, + '', + { + visibilityState: VisibilityState.PRERENDER, + } + ); const ampdoc = devAssert(amp.ampdoc); installStylesForDoc(ampdoc, CSS, null, false, TAG); const body = ampdoc.getBody(); - body.classList.add('i-amphtml-next-page-document'); + body.classList.add(DOC_CLASS); return amp; } catch (e) { @@ -360,15 +476,61 @@ export class NextPageService { } } + /** + * Closes the shadow document of an inserted page and replaces it + * with a placeholder + * @param {!Page} page + * @return {!Promise} + */ + closeDocument(page) { + if (page.isPaused()) { + return Promise.resolve(); + } + + const container = dev().assertElement(page.container); + const shadowRoot = dev().assertElement( + scopedQuerySelector( + container, + `> .${escapeCssSelectorIdent(SHADOW_ROOT_CLASS)}` + ) + ); + + // Create a placeholder that gets displayed when the document becomes inactive + const placeholder = this.win_.document.createElement('div'); + placeholder.classList.add(PLACEHOLDER_CLASS); + + let docHeight = 0; + let docWidth = 0; + return this.mutator_.measureMutateElement( + shadowRoot, + () => { + docHeight = shadowRoot./*REVIEW*/ offsetHeight; + docWidth = shadowRoot./*REVIEW*/ offsetWidth; + }, + () => { + setStyles(placeholder, { + 'height': `${docHeight}px`, + 'width': `${docWidth}px`, + }); + container.replaceChild(placeholder, shadowRoot); + } + ); + } + /** * Removes redundancies and unauthorized extensions and elements * @param {!Document} doc Document to attach. */ sanitizeDoc(doc) { - // TODO(wassgha): Parse for more pages to queue - // TODO(wassgha): Allow amp-analytics after bug bash toArray(doc.querySelectorAll('amp-analytics')).forEach(removeElement); + + // Parse for more pages and queue them + toArray(doc.querySelectorAll('amp-next-page')).forEach(el => { + this.parseAndQueuePages_(el); + removeElement(el); + }); + // Make sure all hidden elements are initially invisible this.toggleHiddenAndReplaceableElements(doc, false /** isVisible */); } @@ -393,24 +555,24 @@ export class NextPageService { } // Replace elements that have [amp-next-page-replace] - toArray(doc.querySelectorAll('[amp-next-page-replace]')).forEach( - element => { - let uniqueId = element.getAttribute('amp-next-page-replace'); - if (!uniqueId) { - uniqueId = String(Date.now() + Math.floor(Math.random() * 100)); - element.setAttribute('amp-next-page-replace', uniqueId); - } + toArray( + doc.querySelectorAll('*:not(amp-next-page) [amp-next-page-replace]') + ).forEach(element => { + let uniqueId = element.getAttribute('amp-next-page-replace'); + if (!uniqueId) { + uniqueId = String(Date.now() + Math.floor(Math.random() * 100)); + element.setAttribute('amp-next-page-replace', uniqueId); + } - if ( - this.replaceableElements_[uniqueId] && - this.replaceableElements_[uniqueId] !== element - ) { - toggle(this.replaceableElements_[uniqueId], false /** opt_display */); - } - this.replaceableElements_[uniqueId] = element; - toggle(element, true /** opt_display */); + if ( + this.replaceableElements_[uniqueId] && + this.replaceableElements_[uniqueId] !== element + ) { + toggle(this.replaceableElements_[uniqueId], false /** opt_display */); } - ); + this.replaceableElements_[uniqueId] = element; + toggle(element, true /** opt_display */); + }); } /** @@ -471,12 +633,48 @@ export class NextPageService { } /** + * Parses the amp-next-page element for inline or remote list of pages and + * add them to the queue + * @param {!Element=} element the container of the amp-next-page extension + * @private + */ + parseAndQueuePages_(element = this.getHostNextPageElement_()) { + this.parsePages_(element).then(pages => { + pages.forEach(page => { + try { + validatePage(page, this.ampdoc_.getUrl()); + // Prevent loops by checking if the page already exists + // we use initialUrl since the url can get updated if + // the page issues a redirect + if (this.pages_.some(p => p.initialUrl == page.url)) { + return; + } + // Queue the page for fetching + this.pages_.push( + new Page(this, { + url: page.url, + title: page.title, + image: page.image, + }) + ); + } catch (e) { + user().error(TAG, 'Failed to queue page', e); + } + }); + // To be safe, if the pages were parsed after the user + // finished scrolling + this.maybeFetchNext(); + }); + } + + /** + * @param {!Element} element the container of the amp-next-page extension * @return {!Promise} List of pages to fetch * @private */ - getPagesPromise_() { - const inlinePages = this.getInlinePages_(this.getHostNextPageElement_()); - const src = this.element_.getAttribute('src'); + parsePages_(element) { + const inlinePages = this.getInlinePages_(element); + const src = element.getAttribute('src'); userAssert( inlinePages || src, '%s should contain a + + `; const VALID_CONFIG = [ { 'image': '/examples/img/hero@1x.jpg', @@ -329,6 +354,131 @@ describes.realWin( }); }); + describe('infinite loading', () => { + let element; + let service; + + beforeEach(async () => { + element = await getAMPNextPage({ + inlineConfig: VALID_CONFIG, + }); + + await element.build(); + await element.layoutCallback(); + + service = Services.nextPageServiceForDoc(doc); + }); + + afterEach(async () => { + element.parentNode.removeChild(element); + }); + + it('recursively parses pages and avoids loops', async () => { + env.sandbox.stub(service, 'getViewportsAway_').returns(2); + + expect(service.pages_.length).to.equal(3); + + env.fetchMock.get(/\/document1/, MOCK_NEXT_PAGE_WITH_RECOMMENDATIONS); + await service.maybeFetchNext(); + + // Adds the two documents coming from Document 1's recommendations + expect(service.pages_.length).to.equal(5); + expect(service.pages_.some(page => page.title == 'Title 3')).to.be.true; + expect(service.pages_.some(page => page.title == 'Title 4')).to.be.true; + // Avoids loops (ignores previously inserted page) + expect( + service.pages_.filter(page => page.title == 'Title 2').length + ).to.equal(1); + }); + + it('unloads pages and replaces them with a placeholder', async () => { + env.sandbox.stub(service, 'getViewportsAway_').returns(2); + const secondPagePauseSpy = env.sandbox.spy(service.pages_[2], 'pause'); + + env.fetchMock.get(/\/document1/, MOCK_NEXT_PAGE); + env.fetchMock.get(/\/document2/, MOCK_NEXT_PAGE); + await service.maybeFetchNext(); + await service.maybeFetchNext(); + + const {container} = service.pages_[2]; + expect(container).to.be.ok; + expect(container.querySelector('.i-amphtml-next-page-placeholder')).to + .not.be.ok; + expect(container.querySelector('.i-amphtml-next-page-shadow-root')).to + .be.ok; + + service.pages_[2].visibilityState_ = VisibilityState.VISIBLE; + service.scrollDirection_ = Direction.UP; + + await service.hidePreviousPages_( + 0 /** index */, + 0 /** pausePageCountForTesting */ + ); + + // Internally changes the state to paused + expect(secondPagePauseSpy).to.be.calledOnce; + expect(service.pages_[2].state_).to.equal(PageState.PAUSED); + expect(service.pages_[2].visibilityState_).to.equal( + VisibilityState.HIDDEN + ); + + // Replaces the inserted shadow doc with a placeholder of equal height + expect(container.querySelector('.i-amphtml-next-page-placeholder')).to + .be.ok; + expect(container.querySelector('.i-amphtml-next-page-shadow-root')).to + .not.be.ok; + expect( + win.getComputedStyle( + container.querySelector('.i-amphtml-next-page-placeholder') + ).height + ).to.equal('1036px'); + }); + + it('reloads pages and removes the placeholder', async () => { + env.sandbox.stub(service, 'getViewportsAway_').returns(2); + const secondPageResumeSpy = env.sandbox.spy( + service.pages_[2], + 'resume' + ); + + env.fetchMock.get(/\/document1/, MOCK_NEXT_PAGE); + env.fetchMock.get(/\/document2/, MOCK_NEXT_PAGE); + await service.maybeFetchNext(); + await service.maybeFetchNext(); + + const {container} = service.pages_[2]; + expect(container).to.be.ok; + service.pages_[2].visibilityState_ = VisibilityState.VISIBLE; + service.scrollDirection_ = Direction.UP; + await service.hidePreviousPages_( + 0 /** index */, + 0 /** pausePageCountForTesting */ + ); + expect(service.pages_[2].state_).to.equal(PageState.PAUSED); + expect(service.pages_[2].visibilityState_).to.equal( + VisibilityState.HIDDEN + ); + + service.scrollDirection_ = Direction.DOWN; + await service.resumePausedPages_( + 1 /** index */, + 0 /** pausePageCountForTesting */ + ); + + // Replaces the inserted placeholder with the page's content + expect(secondPageResumeSpy).to.be.calledOnce; + expect(container.querySelector('.i-amphtml-next-page-placeholder')).to + .not.be.ok; + expect(container.querySelector('.i-amphtml-next-page-shadow-root')).to + .be.ok; + expect( + win.getComputedStyle( + container.querySelector('.i-amphtml-next-page-shadow-root') + ).height + ).to.equal('1036px'); + }); + }); + describe('remote config', () => { // TODO (wassgha): Implement once remote config is implemented }); diff --git a/extensions/amp-next-page/1.0/visibility-observer.js b/extensions/amp-next-page/1.0/visibility-observer.js index 622344809087..0536b6d9ad1b 100644 --- a/extensions/amp-next-page/1.0/visibility-observer.js +++ b/extensions/amp-next-page/1.0/visibility-observer.js @@ -56,7 +56,9 @@ export class VisibilityObserverEntry { */ observe(element, parent, callback) { const top = element.ownerDocument.createElement('div'); + top.classList.add('i-amphtml-next-page-document-top-sentinel'); const bottom = element.ownerDocument.createElement('div'); + bottom.classList.add('i-amphtml-next-page-document-bottom-sentinel'); parent.insertBefore(top, element); parent.insertBefore(bottom, element.nextSibling); diff --git a/test/manual/amp-next-page/1.0/amp-next-page.amp.html b/test/manual/amp-next-page/1.0/amp-next-page.amp.html index 480a530f0f54..586fd4d6d365 100644 --- a/test/manual/amp-next-page/1.0/amp-next-page.amp.html +++ b/test/manual/amp-next-page/1.0/amp-next-page.amp.html @@ -166,7 +166,6 @@

Host page

varius est suscipit vitae. Maecenas ut sapien diam. Vivamus viverra nisl at quam pellentesque posuere. Cras ut nibh non arcu dignissim elementum.

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean vitae libero porta nulla iaculis viverra. Vestibulum consectetur scelerisque