From 0e5ebe116d9f4d9fd7412621763404a4dc4693eb Mon Sep 17 00:00:00 2001 From: Erwin Mombay Date: Mon, 16 May 2016 10:36:54 -0700 Subject: [PATCH] feature(amp-live-list): add update feature --- build-system/server.js | 31 ++- examples/img/ampicon.png | Bin 0 -> 9074 bytes examples/live-list-update.amp.html | 109 ++++++++++ extensions/amp-live-list/0.1/amp-live-list.js | 199 ++++++++++++++---- .../0.1/test/test-amp-live-list.js | 143 ++++++++++++- gulpfile.js | 1 + package.json | 5 +- 7 files changed, 435 insertions(+), 53 deletions(-) create mode 100644 examples/img/ampicon.png create mode 100644 examples/live-list-update.amp.html diff --git a/build-system/server.js b/build-system/server.js index ca46bed51587..e9b3c4a6b731 100644 --- a/build-system/server.js +++ b/build-system/server.js @@ -24,11 +24,12 @@ var bodyParser = require('body-parser'); var clr = require('connect-livereload'); var finalhandler = require('finalhandler'); var fs = BBPromise.promisifyAll(require('fs')); +var jsdom = require('jsdom'); var path = require('path'); -var url = require('url'); var request = require('request'); var serveIndex = require('serve-index'); var serveStatic = require('serve-static'); +var url = require('url'); var args = Array.prototype.slice.call(process.argv, 2, 4); var paths = args[0]; @@ -103,6 +104,34 @@ app.use('/examples.build/live-list.amp.max.html', function(req, res) { }); }); +var liveListUpdateFile = '/examples.build/live-list-update.amp.max.html'; +var liveListUpdateFullPath = `${process.cwd()}${liveListUpdateFile}`; +var liveListFile = fs.readFileSync(liveListUpdateFullPath); +var ctr = 0; +var doc = jsdom.jsdom(liveListFile); +var win = doc.defaultView; +app.use(liveListUpdateFile, function(req, res) { + var doctype = '\n'; + res.setHeader('Content-Type', 'text/html'); + res.statusCode = 200; + if (ctr != 0) { + if (Math.random() < .7) { + var item1 = doc.querySelector('#list-item-1'); + var item1Content = item1.querySelectorAll('.content'); + item1.setAttribute('data-update-time', Date.now()); + item1Content[0].textContent = Math.floor(Math.random() * 10); + item1Content[1].textContent = Math.floor(Math.random() * 10); + } else { + // Sometimes we want an empty response to simulate no changes. + res.end(`${doctype}`); + return; + } + } + var outerHTML = doc.documentElement./*OK*/outerHTML; + res.end(`${doctype}${outerHTML}`); + ctr++; +}); + // Proxy with unminified JS. // Example: // http://localhost:8000/max/s/www.washingtonpost.com/amphtml/news/post-politics/wp/2016/02/21/bernie-sanders-says-lower-turnout-contributed-to-his-nevada-loss-to-hillary-clinton/ diff --git a/examples/img/ampicon.png b/examples/img/ampicon.png new file mode 100644 index 0000000000000000000000000000000000000000..a2e8b92ae879aaf7a498db09768f453304d052c4 GIT binary patch literal 9074 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 + + + + Lorem Ipsum | PublisherName + + + + + + + + + + +
+ Scoreboard refresh takes 15 seconds. +
+ + +
+
+
+
+ +
2
+
+
+
2
+ +
+
+
+
+
+
+ +
2
+
+
+
2
+ +
+
+
+
+
+ + + diff --git a/extensions/amp-live-list/0.1/amp-live-list.js b/extensions/amp-live-list/0.1/amp-live-list.js index 27512f92e2f5..3c20537ef35a 100644 --- a/extensions/amp-live-list/0.1/amp-live-list.js +++ b/extensions/amp-live-list/0.1/amp-live-list.js @@ -38,7 +38,7 @@ const classes = { /** * @typedef {{ * insert: !Array, - * update: !Array, + * replace: !Array, * tombstone: !Array * }} */ @@ -54,6 +54,7 @@ export class LiveListInterface { * Update the underlying live list dom structure. * * @param {?Element} element + * @return {time} */ update(unusedElement) { } @@ -85,6 +86,7 @@ export function getNumberMaxOrDefault(value, defaultValue) { /** * Component class that handles updates to its underlying children dom * structure. + * * @implements {LiveListInterface} */ export class AmpLiveList extends AMP.BaseElement { @@ -140,7 +142,11 @@ export class AmpLiveList extends AMP.BaseElement { this.manager_.register(this.liveListId_, this); - this.insertFragment_ = this.win.document.createDocumentFragment(); + /** @private @const {!Array} */ + this.pendingItemsInsert_ = []; + + /** @private @const {!Array} */ + this.pendingItemsReplace_ = []; this.updateSlot_ = user.assert( this.getUpdateSlot_(this.element), @@ -158,6 +164,9 @@ export class AmpLiveList extends AMP.BaseElement { this.validateLiveListItems_(this.itemsSlot_, true); this.registerAction('update', this.updateAction_.bind(this)); + + /** @private @const {function(!Element, !Element): number} */ + this.comparator_ = this.sortByDataSortTime_.bind(this); } /** @override */ @@ -167,22 +176,17 @@ export class AmpLiveList extends AMP.BaseElement { this.validateLiveListItems_(container); const mutateItems = this.getUpdates_(container); - // Insert/new items will be contiguous at the top even though they - // weren't in the actual request DOM structure. - const comparator = this.sortByDataSortTime_.bind(this); - mutateItems.insert.sort(comparator).forEach(child => { - child.classList.add(classes.ITEM); - child.classList.add(classes.NEW_ITEM); - // Since we only manipulate the DocumentFragment instance and not the - // live dom we don't need to be inside a vsync.mutate context. - this.insertFragment_.insertBefore(child, - this.insertFragment_.firstElementChild); - }); + this.preparePendingItemsInsert_(mutateItems.insert); + this.preparePendingItemsReplace_(mutateItems.replace); - if (mutateItems.insert.length > 0) { + // We prefer user interaction if we have pending items to insert at the + // top of the component. + if (this.pendingItemsInsert_.length > 0) { this.deferMutate(() => { this.updateSlot_.classList.remove('-amp-hidden'); }); + } else if (this.pendingItemsReplace_.length > 0) { + this.updateAction_(); } return this.updateTime_; @@ -191,39 +195,148 @@ export class AmpLiveList extends AMP.BaseElement { /** * Mutates the current elements dom and compensates for scroll * change if necessary. + * Makes sure to zero out the pending items arrays after flushing + * server DOM to live client DOM. + * * @return {!Promise} * @private */ updateAction_() { - if (this.insertFragment_.childElementCount == 0) { - return Promise.resolve(); - } + const hasNewInsert = this.pendingItemsInsert_.length > 0; // TODO(erwinm): do in place update as well as sorting, // correct insertion, tombstoning etc. - return this.mutateElement(() => { - // Remove the new class from the previously inserted items. - this.eachChildElement_(this.itemsSlot_, child => { - child.classList.remove(classes.NEW_ITEM); - }); - // Items are reparented from the document fragment to the live DOM - // by the `insertBefore` and `appendChild` operations, so we can reuse - // the same document fragment instance safely. - this.itemsSlot_.insertBefore(this.insertFragment_, - this.itemsSlot_.firstElementChild); + let promise = this.mutateElement(() => { - // Hide the update button in case we previously displayed it. + if (hasNewInsert) { + // Remove the new class from the previously inserted items if + // we are inserting new items. + this.eachChildElement_(this.itemsSlot_, child => { + child.classList.remove(classes.NEW_ITEM); + }); + + this.insert_(this.itemsSlot_, this.pendingItemsInsert_); + this.pendingItemsInsert_.length = 0; + } + + if (this.pendingItemsReplace_.length > 0) { + this.replace_(this.itemsSlot_, this.pendingItemsReplace_); + this.pendingItemsReplace_.length = 0; + } + + // Always hide update slot after mutation operation. this.updateSlot_.classList.add('-amp-hidden'); + }); - // TODO(erwinm): Handle updates - }).then(() => { - this.getVsync().mutate(() => { - // Should scroll into view be toggleable - this.viewport_./*OK*/scrollIntoView(this.element); + if (hasNewInsert) { + promise = promise.then(() => { + this.getVsync().mutate(() => { + // Should scroll into view be toggleable + this.viewport_./*OK*/scrollIntoView(this.element); + }); }); + } + return promise; + } + + /** + * Reparents the html from the server to the live DOM. + * + * @param {!Element} parent + * @param {!Array} orphans + * @private + */ + insert_(parent, orphans) { + const fragment = this.win.document.createDocumentFragment(); + orphans.forEach(elem => { + fragment.insertBefore(elem, fragment.firstElementChild); + }); + parent.insertBefore(fragment, parent.firstElementChild); + } + + /** + * Does an inline replace of a list item using the element ID. + * Does nothing if item has already been tombstoned or removed from the + * live DOM. + * + * @param {!Element} parent + * @param {!Array} orphans + * @private + */ + replace_(parent, orphans) { + orphans.forEach(orphan => { + const orphanId = orphan.getAttribute('id'); + const liveElement = parent.querySelector(`#${orphanId}`); + // Don't bother updating if live element is tombstoned or + // if we can't find it. + if (!liveElement || liveElement.hasAttribute('data-tombstone')) { + return; + } + parent.replaceChild(orphan, liveElement); }); } + /** + * Prepares the items from the server to be inserted into the DOM + * by sorting using `data-sort-time` and adding the needed list items + * classes for styling. + * + * @param {!Array} items + * @private + */ + preparePendingItemsInsert_(items) { + // Insert/new items will be contiguous at the top even though they + // weren't in the actual request DOM structure as it doesn't make sense + // to insert new items between old items. + // Order matters as this is how it will be appended into the DOM. + items.sort(this.comparator_).forEach(elem => { + elem.classList.add(classes.ITEM); + elem.classList.add(classes.NEW_ITEM); + }); + this.pendingItemsInsert_.push.apply(this.pendingItemsInsert_, items); + } + + /** + * Prepares the items from the server to directly replace an item in the + * live DOM. If item has a counterpart in the current pending changes + * that hasn't been flushed yet we just swap it out directly, else + * we push it into the array. + * Makes sure to add the `amp-live-list-item` class for items styling. + * + * @param {!Array} items + * @private + */ + preparePendingItemsReplace_(items) { + // Order doesn't matter since we do an in place replacement. + items.forEach(elem => { + const hasPendingCounterpart = this.hasMatchingPendingElement_( + this.pendingItemsReplace_, elem); + elem.classList.add('amp-live-list-item'); + if (hasPendingCounterpart == -1) { + this.pendingItemsReplace_.push(elem); + } else { + this.pendingItemsReplace_[hasPendingCounterpart] = elem; + } + }); + } + + /** + * Returns the index of the matching element from the queue using the + * element id. Otherwise returns -1 for not found. + * + * @param {!Array} pendingQueue + * @param {!Element} elem + * @return {number} + */ + hasMatchingPendingElement_(pendingQueue, elem) { + for (let i = 0; i < pendingQueue.length; i++) { + if (pendingQueue[i].getAttribute('id') == elem.getAttribute('id')) { + return i; + } + } + return -1; + } + /** @override */ getInterval() { return this.pollInterval_; @@ -243,7 +356,7 @@ export class AmpLiveList extends AMP.BaseElement { // trigger `createdCallback`s even though we won't actually be // reparenting those nodes to this tree. const insert = []; - const updates = []; + const replace = []; const tombstone = []; for (let child = updatedElement.firstElementChild; child; @@ -261,15 +374,16 @@ export class AmpLiveList extends AMP.BaseElement { if (updateTime > this.updateTime_) { this.updateTime_ = updateTime; } - updates.push(orphan); + replace.push(orphan); } } - return {insert, updates, tombstone}; + return {insert, replace, tombstone}; } /** * Predicate to check if the child passed in is new. + * * @param {!Element} elem * @return {boolean} * @private @@ -282,6 +396,7 @@ export class AmpLiveList extends AMP.BaseElement { /** * Predicate to check if the child passed in is an update, determined * by data-update-time attribute. + * * @param {!Element} elem * @return {boolean} * @private @@ -370,6 +485,7 @@ export class AmpLiveList extends AMP.BaseElement { /** * Iterates over the child elements and invokes the callback with * the current child element passed in as the first argument. + * * @param {!Element} parent * @param {function(!Element)} cb * @private @@ -400,16 +516,17 @@ export class AmpLiveList extends AMP.BaseElement { /** * @param {!Element} a * @param {!Element} b - * @return {number} + * @return {time} * @private */ sortByDataSortTime_(a, b) { - return this.getSortTime_(a) - this.getSortTime_(b); + // Sort from newest to oldest so we don't have to reverse + return this.getSortTime_(b) - this.getSortTime_(a); } /** * @param {!Element} elem - * @return {number} + * @return {time} * @private */ getSortTime_(elem) { @@ -418,7 +535,7 @@ export class AmpLiveList extends AMP.BaseElement { /** * @param {!Element} elem - * @return {number} + * @return {time} * @private */ getUpdateTime_(elem) { @@ -430,7 +547,7 @@ export class AmpLiveList extends AMP.BaseElement { /** * @param {!Element} elem - * @return {number} + * @return {time} * @private */ getTimeAttr_(elem, attr) { diff --git a/extensions/amp-live-list/0.1/test/test-amp-live-list.js b/extensions/amp-live-list/0.1/test/test-amp-live-list.js index 65889f40d535..de1239f9ea3e 100644 --- a/extensions/amp-live-list/0.1/test/test-amp-live-list.js +++ b/extensions/amp-live-list/0.1/test/test-amp-live-list.js @@ -259,7 +259,7 @@ describe('amp-live-list', () => { const fromServer1 = createFromServer([{id: 'id0'}]); liveList.update(fromServer1); - expect(liveList.insertFragment_.childElementCount).to.equal(0); + expect(liveList.pendingItemsInsert_).to.have.length(0); const fromServer2 = createFromServer([ {id: 'id0'}, {id: 'id1'}, {id: 'id2'}, @@ -267,9 +267,9 @@ describe('amp-live-list', () => { const fromServer2ItemsCont = fromServer2 .querySelector('[items]'); expect(fromServer2ItemsCont.childElementCount).to.equal(3); - expect(liveList.insertFragment_.childElementCount).to.equal(0); + expect(liveList.pendingItemsInsert_).to.have.length(0); liveList.update(fromServer2); - expect(liveList.insertFragment_.childElementCount).to.equal(2); + expect(liveList.pendingItemsInsert_).to.have.length(2); }); it('should wait for user interaction before inserting', () => { @@ -278,9 +278,9 @@ describe('amp-live-list', () => { expect(liveList.itemsSlot_.childElementCount).to.equal(0); const fromServer1 = createFromServer([{id: 'id0'}]); liveList.update(fromServer1); - expect(liveList.insertFragment_.childElementCount).to.equal(1); + expect(liveList.pendingItemsInsert_).to.have.length(1); return liveList.updateAction_().then(() => { - expect(liveList.insertFragment_.childElementCount).to.equal(0); + expect(liveList.pendingItemsInsert_).to.have.length(0); expect(liveList.itemsSlot_.childElementCount).to.equal(1); }); }); @@ -371,7 +371,7 @@ describe('amp-live-list', () => { const fromServer1 = createFromServer([{id: 'id0'}]); liveList.update(fromServer1); - expect(liveList.insertFragment_.childElementCount).to.equal(1); + expect(liveList.pendingItemsInsert_).to.have.length(1); expect(liveList.updateSlot_).to.not.have.class('-amp-hidden'); return liveList.updateAction_(fromServer1).then(() => { @@ -395,11 +395,11 @@ describe('amp-live-list', () => { liveList.update(fromServer1); - expect(liveList.insertFragment_.children[0].getAttribute('id')) + expect(liveList.pendingItemsInsert_[0].getAttribute('id')) .to.equal('unique-id-num-4'); - expect(liveList.insertFragment_.children[1].getAttribute('id')) + expect(liveList.pendingItemsInsert_[1].getAttribute('id')) .to.equal('unique-id-num-3'); - expect(liveList.insertFragment_.children[2].getAttribute('id')) + expect(liveList.pendingItemsInsert_[2].getAttribute('id')) .to.equal('unique-id-num-5'); }); }); @@ -454,6 +454,131 @@ describe('amp-live-list', () => { expect(liveList.update(fromServer5)).to.equal(600); }); + it('should be able to accumulate insert items', () => { + const child1 = document.createElement('div'); + const child2 = document.createElement('div'); + child1.setAttribute('id', 'id1'); + child2.setAttribute('id', 'id2'); + child1.setAttribute('data-sort-time', '123'); + child2.setAttribute('data-sort-time', '124'); + itemsSlot.appendChild(child1); + itemsSlot.appendChild(child2); + buildElement(elem, dftAttrs); + liveList.buildCallback(); + + const fromServer1 = createFromServer([ + {id: 'id1', updateTime: 125}, + {id: 'id3'}, + ]); + + const spy = sandbox.spy(liveList, 'updateAction_'); + liveList.update(fromServer1); + + expect(liveList.pendingItemsInsert_).to.have.length(1); + expect(spy.callCount).to.equal(0); + + const fromServer2 = createFromServer([ + {id: 'id4'}, + {id: 'id7'}, + {id: 'id9'}, + ]); + liveList.update(fromServer2); + expect(liveList.pendingItemsInsert_).to.have.length(4); + expect(spy.callCount).to.equal(0); + }); + + it('should have pending replace items', () => { + const child1 = document.createElement('div'); + const child2 = document.createElement('div'); + child1.setAttribute('id', 'id1'); + child2.setAttribute('id', 'id2'); + child1.setAttribute('data-sort-time', '123'); + child2.setAttribute('data-sort-time', '124'); + itemsSlot.appendChild(child1); + itemsSlot.appendChild(child2); + buildElement(elem, dftAttrs); + liveList.buildCallback(); + + const fromServer1 = createFromServer([ + {id: 'id1', updateTime: 125}, + {id: 'id3'}, + ]); + + const spy = sandbox.spy(liveList, 'updateAction_'); + liveList.update(fromServer1); + + expect(liveList.pendingItemsInsert_).to.have.length(1); + expect(liveList.pendingItemsReplace_).to.have.length(1); + // Should wait for user action until `updateAction_` + expect(spy.callCount).to.equal(0); + }); + + it('should have pending replace items even w/o new inserts', () => { + const child1 = document.createElement('div'); + const child2 = document.createElement('div'); + child1.setAttribute('id', 'id1'); + child2.setAttribute('id', 'id2'); + child1.setAttribute('data-sort-time', '123'); + child2.setAttribute('data-sort-time', '124'); + itemsSlot.appendChild(child1); + itemsSlot.appendChild(child2); + buildElement(elem, dftAttrs); + liveList.buildCallback(); + + const fromServer1 = createFromServer([ + {id: 'id1', updateTime: 125}, + ]); + + const spy = sandbox.spy(liveList, 'updateAction_'); + liveList.update(fromServer1); + + expect(liveList.pendingItemsInsert_).to.have.length(0); + expect(liveList.pendingItemsReplace_).to.have.length(1); + // If there is no pending items to insert, flush the replace items + // right away. + expect(spy.callCount).to.equal(1); + }); + + it('should always use latest update to replace when in pending state', () => { + const child1 = document.createElement('div'); + const child2 = document.createElement('div'); + child1.setAttribute('id', 'id1'); + child2.setAttribute('id', 'id2'); + child1.setAttribute('data-sort-time', '123'); + child2.setAttribute('data-sort-time', '124'); + itemsSlot.appendChild(child1); + itemsSlot.appendChild(child2); + buildElement(elem, dftAttrs); + liveList.buildCallback(); + + const fromServer1 = createFromServer([ + {id: 'id1', updateTime: 125}, + {id: 'id3'}, + ]); + + const spy = sandbox.spy(liveList, 'updateAction_'); + liveList.update(fromServer1); + + expect(liveList.pendingItemsReplace_).to.have.length(1); + expect(liveList.pendingItemsReplace_[0].getAttribute('id')).to.equal('id1'); + expect(liveList.pendingItemsReplace_[0].getAttribute('data-update-time')) + .to.equal('125'); + // Should wait for user action until `updateAction_` + expect(spy.callCount).to.equal(0); + + const fromServer2 = createFromServer([ + {id: 'id1', updateTime: 127}, + ]); + liveList.update(fromServer2); + + expect(liveList.pendingItemsReplace_).to.have.length(1); + expect(liveList.pendingItemsReplace_[0].getAttribute('id')).to.equal('id1'); + expect(liveList.pendingItemsReplace_[0].getAttribute('data-update-time')) + .to.equal('127'); + + expect(spy.callCount).to.equal(0); + }); + describe('#getNumberMaxOrDefault', () => { it('should return correct value', () => { diff --git a/gulpfile.js b/gulpfile.js index 4d657e7ec15f..1ab2f51ee40c 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -314,6 +314,7 @@ function buildExamples(watch) { buildExample('csp.amp.html'); buildExample('layout-flex-item.amp.html'); buildExample('live-list.amp.html'); + buildExample('live-list-update.amp.html'); buildExample('metadata-examples/article-json-ld.amp.html'); buildExample('metadata-examples/article-microdata.amp.html'); buildExample('metadata-examples/recipe-json-ld.amp.html'); diff --git a/package.json b/package.json index 70fb14ebab59..e231f63125e5 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "heroku-postbuild": "gulp clean && gulp build --fortesting && gulp dist --fortesting" }, "dependencies": { - "promise-pjs": "1.1.2", - "document-register-element": "0.5.4" + "document-register-element": "0.5.4", + "promise-pjs": "1.1.2" }, "devDependencies": { "autoprefixer": "6.1.1", @@ -62,6 +62,7 @@ "gulp-wrap": "0.11.0", "gzip-size": "3.0.0", "jison": "0.4.15", + "jsdom": "9.1.0", "karma": "0.13.21", "karma-browserify": "5.0.2", "karma-chai": "0.1.0",