Skip to content

Commit

Permalink
✨ Remote loading of pages for <amp-next-page> v2 (#26470)
Browse files Browse the repository at this point in the history
* Implements loading pages from remote urls

* Adds unit tests and fixes issues

* Fixes types

* Requested changes

* Add back tests

* Fixed linter warning
  • Loading branch information
wassgha committed Feb 3, 2020
1 parent 53cb630 commit 4fe68c2
Show file tree
Hide file tree
Showing 4 changed files with 249 additions and 92 deletions.
11 changes: 10 additions & 1 deletion extensions/amp-next-page/1.0/page.js
Expand Up @@ -31,10 +31,19 @@ export const PageState = {
export const VISIBLE_DOC_CLASS = 'amp-next-page-visible';
export const HIDDEN_DOC_CLASS = 'amp-next-page-hidden';

/**
* @typedef {{
* url: string,
* image: string,
* title: string,
* }}
*/
export let PageMeta;

export class Page {
/**
* @param {!./service.NextPageService} manager
* @param {{ url: string, title: string, image: string }} meta
* @param {!PageMeta} meta
*/
constructor(manager, meta) {
/** @private @const {!./service.NextPageService} */
Expand Down
200 changes: 133 additions & 67 deletions extensions/amp-next-page/1.0/service.js
Expand Up @@ -18,6 +18,10 @@ import {CSS} from '../../../build/amp-next-page-1.0.css';
import {HIDDEN_DOC_CLASS, HostPage, Page, PageState} from './page';
import {MultidocManager} from '../../../src/multidoc-manager';
import {Services} from '../../../src/services';
import {
UrlReplacementPolicy,
batchFetchJsonFor,
} from '../../../src/batched-json';
import {VisibilityState} from '../../../src/visibility-state';
import {
childElementByAttr,
Expand Down Expand Up @@ -89,7 +93,7 @@ export class NextPageService {
this.moreBox_ = null;

/** @private {?AmpElement} element */
this.element_ = null;
this.host_ = null;

/** @private {?VisibilityObserver} */
this.visibilityObserver_ = null;
Expand Down Expand Up @@ -117,6 +121,20 @@ export class NextPageService {

/** @private {!Object<string, !Element>} */
this.replaceableElements_ = {};

/** @private {boolean} */
this.hasDeepParsing_ = false;

/** @private {?string} */
this.nextSrc_ = null;

/** @private {?function()} */
this.readyResolver_ = null;

/** @private @const {!Promise} */
this.readyPromise_ = new Promise(resolve => {
this.readyResolver_ = resolve;
});
}

/**
Expand All @@ -129,15 +147,16 @@ export class NextPageService {
/**
* Builds the next-page service by fetching the required elements
* and the initial list of pages and installing scoll listeners
* @param {!AmpElement} element
* @param {!AmpElement} element <amp-next-page> element on the host page
*/
build(element) {
// Prevent multiple amp-next-page on the same document
if (this.isBuilt()) {
return;
}

this.element_ = element;
// Save the <amp-next-page> from the host page
this.host_ = element;

// Get the separator and more box (and remove the provided elements in the process)
this.separator_ = this.getSeparatorElement_(element);
Expand All @@ -159,16 +178,19 @@ export class NextPageService {
this.visibilityObserver_ = new VisibilityObserver(this.ampdoc_);

// Have the suggestion box be always visible
this.element_.appendChild(this.moreBox_);
this.host_.appendChild(this.moreBox_);

if (!this.pages_) {
this.pages_ = [this.hostPage_];
this.setLastFetchedPage(this.hostPage_);
}

this.parseAndQueuePages_();
this.nextSrc_ = this.getHost_().getAttribute('src');
this.hasDeepParsing_ =
this.getHost_().hasAttribute('deep-parsing') || !this.nextSrc_;
this.initializePageQueue_();

this.getHostNextPageElement_().classList.add(NEXT_PAGE_CLASS);
this.getHost_().classList.add(NEXT_PAGE_CLASS);

this.viewport_.onScroll(() => this.updateScroll_());
this.viewport_.onResize(() => this.updateScroll_());
Expand All @@ -179,16 +201,18 @@ export class NextPageService {
* @return {!AmpElement}
* @private
*/
getHostNextPageElement_() {
return dev().assertElement(this.element_);
getHost_() {
return dev().assertElement(this.host_);
}

/**
* @private
*/
updateScroll_() {
this.updateScrollDirection_();
this.maybeFetchNext();
this.readyPromise_.then(() => {
this.maybeFetchNext();
});
}

/**
Expand All @@ -205,9 +229,22 @@ export class NextPageService {
return Promise.resolve();
}

const pageCount = this.pages_.length;
const nextPage = this.pages_[this.getPageIndex_(this.lastFetchedPage_) + 1];
if (!nextPage) {
return Promise.resolve();
return (
this.getRemotePages_()
.then(pages => this.queuePages_(pages))
// Queuing pages can result in no new pages (in case the server
// returned an empty array or the suggestions already exist in the queue)
.then(() => {
if (this.pages_.length <= pageCount) {
// Remote server did not return any new pages
return Promise.resolve();
}
return this.maybeFetchNext(true /** force */);
})
);
}
return nextPage.fetch();
}
Expand All @@ -227,10 +264,11 @@ export class NextPageService {
}
this.hidePreviousPages_(index);
this.resumePausedPages_(index);
} else if (page.relativePos === ViewportRelativePos.OUTSIDE_VIEWPORT) {
if (page.isVisible()) {
page.setVisibility(VisibilityState.HIDDEN);
}
} else if (
page.relativePos === ViewportRelativePos.OUTSIDE_VIEWPORT &&
page.isVisible()
) {
page.setVisibility(VisibilityState.HIDDEN);
}
});

Expand Down Expand Up @@ -390,7 +428,7 @@ export class NextPageService {
createDocumentContainerForPage(page) {
const container = this.win_.document.createElement('div');
container.classList.add(DOC_CONTAINER_CLASS);
this.element_.insertBefore(container, dev().assertElement(this.moreBox_));
this.host_.insertBefore(container, dev().assertElement(this.moreBox_));

// Insert the separator
const separatorInstance = this.separator_.cloneNode(true);
Expand Down Expand Up @@ -536,7 +574,10 @@ export class NextPageService {

// Parse for more pages and queue them
toArray(doc.querySelectorAll('amp-next-page')).forEach(el => {
this.parseAndQueuePages_(el);
if (this.hasDeepParsing_) {
const pages = this.getInlinePages_(el);
this.queuePages_(pages);
}
removeElement(el);
});

Expand Down Expand Up @@ -649,72 +690,71 @@ 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
* adds them to the queue
* @private
* @return {!Promise}
*/
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);
initializePageQueue_() {
const inlinePages = this.getInlinePages_(this.getHost_());
userAssert(
inlinePages || this.nextSrc_,
'%s should contain a <script> child or a URL specified in [src]',
TAG
);
return this.getRemotePages_()
.then(remotePages => [].concat(inlinePages, remotePages))
.then(pages => {
if (pages.length === 0) {
user().warn(TAG, 'Could not find recommendations');
return Promise.resolve();
}
return this.queuePages_(pages).then(() => {
this.readyResolver_();
});
});
// 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<Array>} List of pages to fetch
* @private
* Add the provided page metadata into the queue of
* pages to fetch
* @param {!Array<!./page.PageMeta>} pages
* @return {!Promise}
*/
parsePages_(element) {
const inlinePages = this.getInlinePages_(element);
const src = element.getAttribute('src');
userAssert(
inlinePages || src,
'%s should contain a <script> child or a URL specified in [src]',
TAG
);

if (src) {
// TODO(wassgha): Implement loading pages from a URL
return Promise.resolve([]);
queuePages_(pages) {
if (!pages.length) {
return Promise.resolve();
}

// TODO(wassgha): Implement recursively loading pages from subsequent documents
return Promise.resolve(inlinePages);
// Queue the given pages
pages.forEach(meta => {
try {
validatePage(meta, 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(page => page.initialUrl == meta.url)) {
return;
}
// Queue the page for fetching
this.pages_.push(new Page(this, meta));
} catch (e) {
user().error(TAG, 'Failed to queue page due to error:', e);
}
});
// To be safe, if the pages were parsed after the user
// finished scrolling
return this.maybeFetchNext();
}

/**
* Reads the inline next pages from the element.
* @param {!Element} element the container of the amp-next-page extension
* @return {?Array} JSON object, or null if no inline pages specified.
* @return {!Array<!./page.PageMeta>} JSON object
* @private
*/
getInlinePages_(element) {
const scriptElements = childElementsByTag(element, 'SCRIPT');
if (!scriptElements.length) {
return null;
return [];
}
userAssert(
scriptElements.length === 1,
Expand All @@ -731,15 +771,41 @@ export class NextPageService {
user().error(TAG, 'failed to parse inline page list', error);
});

const pages = user().assertArray(
const pages = /** @type {!Array<!./page.PageMeta>} */ (user().assertArray(
parsed,
`${TAG} page list should be an array`
);
`${TAG} Page list expected an array, found: ${typeof parsed}`
));

removeElement(scriptElement);
return pages;
}

/**
* Fetches the next batch of page recommendations from the server (initially
* specified by the [src] attribute then obtained as a next pointer)
* @return {!Promise<!Array<!./page.PageMeta>>} Page information promise
* @private
*/
getRemotePages_() {
if (!this.nextSrc_) {
return Promise.resolve([]);
}
return batchFetchJsonFor(this.ampdoc_, this.getHost_(), {
urlReplacement: UrlReplacementPolicy.ALL,
xssiPrefix: this.getHost_().getAttribute('xssi-prefix') || undefined,
})
.then(result => {
this.nextSrc_ = result['next'] || null;
if (this.nextSrc_) {
this.getHost_().setAttribute('src', this.nextSrc_);
}
return result['pages'] || [];
})
.catch(error =>
user().error(TAG, 'error fetching page list from remote server', error)
);
}

/**
* Reads the developer-provided separator element or defaults
* to the internal implementation of it
Expand All @@ -760,7 +826,7 @@ export class NextPageService {
* @private
*/
buildDefaultSeparator_() {
const html = htmlFor(this.getHostNextPageElement_());
const html = htmlFor(this.getHost_());
return html`
<div
class="amp-next-page-default-separator"
Expand Down

0 comments on commit 4fe68c2

Please sign in to comment.