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 {!PromiseHost 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