diff --git a/lib/elements/dom-repeat.js b/lib/elements/dom-repeat.js index 0a88c81d0f..3a2c7d9175 100644 --- a/lib/elements/dom-repeat.js +++ b/lib/elements/dom-repeat.js @@ -245,12 +245,12 @@ export class DomRepeat extends domRepeatBase { }, /** - * Defines an initial count of template instances to render after setting - * the `items` array, before the next paint, and puts the `dom-repeat` - * into "chunking mode". The remaining items (and any future items as a - * result of pushing onto the array) will be created and rendered - * incrementally at each animation frame thereof until all instances have - * been rendered. + * When greater than zero, defines an initial count of template instances + * to render after setting the `items` array, before the next paint, and + * puts the `dom-repeat` into "chunking mode". The remaining items (and + * any future items as a result of pushing onto the array) will be created + * and rendered incrementally at each animation frame thereof until all + * instances have been rendered. */ initialCount: { type: Number @@ -317,7 +317,6 @@ export class DomRepeat extends domRepeatBase { constructor() { super(); this.__instances = []; - this.__limit = Infinity; this.__renderDebouncer = null; this.__itemsIdxToInstIdx = {}; this.__chunkCount = null; @@ -325,6 +324,7 @@ export class DomRepeat extends domRepeatBase { this.__itemsArrayChanged = false; this.__shouldMeasureChunk = false; this.__shouldContinueChunking = false; + this.__chunkingId = 0; this.__sortFn = null; this.__filterFn = null; this.__observePaths = null; @@ -542,18 +542,20 @@ export class DomRepeat extends domRepeatBase { const isntIdxToItemsIdx = this.__sortAndFilterItems(items); // If we're chunking, increase the limit if there are new instances to // create and schedule the next chunk - if (this.initialCount) { - this.__updateLimit(isntIdxToItemsIdx.length); - } + const limit = this.__calculateLimit(isntIdxToItemsIdx.length); // Create, update, and/or remove instances - this.__updateInstances(items, isntIdxToItemsIdx); - // Set rendered item count - this._setRenderedItemCount(this.__instances.length); - // If we're chunking, schedule a rAF task to measure/continue chunking + this.__updateInstances(items, limit, isntIdxToItemsIdx); + // If we're chunking, schedule a rAF task to measure/continue chunking. + // Do this before any notifying events (renderedItemCount & dom-change) + // since those could modify items and enqueue a new full render which will + // pre-empt this measurement. if (this.initialCount && (this.__shouldMeasureChunk || this.__shouldContinueChunking)) { - this.__debounceRender(this.__continueChunkingAfterRaf); + cancelAnimationFrame(this.__chunkingId); + this.__chunkingId = requestAnimationFrame(() => this.__continueChunking()); } + // Set rendered item count + this._setRenderedItemCount(this.__instances.length); // Notify users if (!suppressTemplateNotifications || this.notifyDomChange) { this.dispatchEvent(new CustomEvent('dom-change', { @@ -581,36 +583,40 @@ export class DomRepeat extends domRepeatBase { return isntIdxToItemsIdx; } - __updateLimit(filteredItemCount) { - let newCount; - if (!this.__chunkCount || - (this.__itemsArrayChanged && !this.reuseChunkedInstances)) { - // Limit next render to the initial count - this.__limit = Math.min(filteredItemCount, this.initialCount); - // Subtract off any existing instances to determine the number of - // instances that will be created - newCount = Math.max(this.__limit - this.__instances.length, 0); - // Initialize the chunk size with how many items we're creating - this.__chunkCount = newCount || 1; - } else { - // The number of new instances that will be created is based on the - // existing instances, the new list size, and the maximum chunk size - newCount = Math.min( - Math.max(filteredItemCount - this.__instances.length, 0), - this.__chunkCount); - // Update the limit based on how many new items we're making, limited - // buy the total size of the list - this.__limit = Math.min(this.__limit + newCount, filteredItemCount); + __calculateLimit(filteredItemCount) { + let limit = filteredItemCount; + const currentCount = this.__instances.length; + // When chunking, we increase the limit from the currently rendered count + // by the chunk count that is re-calculated after each rAF (with special + // cases for reseting the limit to initialCount after changing items) + if (this.initialCount) { + let newCount; + if (!this.__chunkCount || + (this.__itemsArrayChanged && !this.reuseChunkedInstances)) { + // Limit next render to the initial count + limit = Math.min(filteredItemCount, this.initialCount); + // Subtract off any existing instances to determine the number of + // instances that will be created + newCount = Math.max(limit - currentCount, 0); + // Initialize the chunk size with how many items we're creating + this.__chunkCount = newCount || 1; + } else { + // The number of new instances that will be created is based on the + // existing instances, the new list size, and the chunk size + newCount = Math.min( + Math.max(filteredItemCount - currentCount, 0), + this.__chunkCount); + // Update the limit based on how many new items we're making, limited + // buy the total size of the list + limit = Math.min(currentCount + newCount, filteredItemCount); + } + // Record some state about chunking for use in `__continueChunking` + this.__shouldMeasureChunk = newCount === this.__chunkCount; + this.__shouldContinueChunking = limit < filteredItemCount; + this.__renderStartTime = performance.now(); } this.__itemsArrayChanged = false; - // Record some state about chunking for use in `__continueChunking` - this.__shouldMeasureChunk = newCount === this.__chunkCount; - this.__shouldContinueChunking = this.__limit < filteredItemCount; - this.__renderStartTime = performance.now(); - } - - __continueChunkingAfterRaf() { - requestAnimationFrame(() => this.__continueChunking()); + return limit; } __continueChunking() { @@ -631,13 +637,12 @@ export class DomRepeat extends domRepeatBase { } } - __updateInstances(items, isntIdxToItemsIdx) { + __updateInstances(items, limit, isntIdxToItemsIdx) { // items->inst map kept for item path forwarding const itemsIdxToInstIdx = this.__itemsIdxToInstIdx = {}; - let instIdx = 0; + let instIdx; // Generate instances and assign items - const limit = Math.min(isntIdxToItemsIdx.length, this.__limit); - for (; instIdxx-repeat-limit suite('limit', function() { - var checkItemOrder = function(stamped) { - for (var i=0; i { + limited.$.repeater.__calculateLimit = function() { + return this.__limit; + }; }); - test('negative limit', function() { - limited.$.repeater.__limit = -10; - limited.$.repeater.render(); - var stamped = limited.root.querySelectorAll('*:not(template):not(dom-repeat)'); - assert.equal(stamped.length, 0); - }); + suite('basic', function() { -}); + var checkItemOrder = function(stamped) { + for (var i=0; ix-repeat-limit suite('chunked rendering', function() { let chunked; - let verifyAfterChange; - const verify = () => verifyAfterChange && verifyAfterChange(); + let resolve; + let targetCount; + const handleChange = () => { + if (!targetCount || chunked.$.repeater.renderedItemCount >= targetCount) { + resolve(Array.from(chunked.root.querySelectorAll('*:not(template):not(dom-repeat)'))); + } + }; + const waitUntilRendered = async (count) => { + targetCount = count; + return await new Promise(r => resolve = r); + }; setup(() => { chunked = document.createElement('x-repeat-chunked'); - chunked.addEventListener('dom-change', verify); + chunked.addEventListener('dom-change', handleChange); document.body.appendChild(chunked); }); teardown(() => { - chunked.removeEventListener('dom-change', verify); + chunked.removeEventListener('dom-change', handleChange); document.body.removeChild(chunked); chunked = null; - verifyAfterChange = null; }); // Framerate=25, element cost = 4ms: should never make more than // (1000/25) / 4 = 10 elements per frame const MAX_PER_FRAME = (1000 / 25) / 4; - test('basic chunked rendering', function(done) { + test('basic chunked rendering', async () => { var checkItemOrder = function(stamped) { for (var i=0; ix-repeat-limit } }; + // Set items to chunk + chunked.items = chunked.preppedItems.slice(); + + let stamped = []; let lastStamped; let frameCount = 0; - verifyAfterChange = function() { - var stamped = Array.from(chunked.root.querySelectorAll('*:not(template):not(dom-repeat)')); + // Loop until chunking is complete + while (stamped.length < chunked.items.length) { + frameCount++; + stamped = await waitUntilRendered(); checkItemOrder(stamped); if (!lastStamped) { // Initial rendering of initial count @@ -3991,21 +3990,44 @@

x-repeat-limit

assert.isAtMost((stamped.length - lastStamped.length), MAX_PER_FRAME, `list should not render more than ${MAX_PER_FRAME} per frame`); } - if (stamped.length < chunked.items.length) { - frameCount++; - lastStamped = stamped; - } else { - // Final rendering at exact item count - assert.equal(stamped.length, 100, 'final count wrong'); - assert.isAtLeast(frameCount, 10, 'should have taken at least 10 frames to render'); - done(); - } - }; - chunked.items = chunked.preppedItems.slice(); + lastStamped = stamped; + } + // Final rendering at exact item count + assert.equal(stamped.length, 100, 'final count wrong'); + assert.isAtLeast(frameCount, 10, 'should have taken at least 10 frames to render'); + + // Set less than initial count, should trim list in one frame + chunked.items = chunked.preppedItems.slice(0, 5); + stamped = await waitUntilRendered(); + assert.equal(stamped.length, 5, 'final count wrong'); + assert.deepEqual(lastStamped.slice(0, stamped.length), stamped, + 'list should not re-render instances during mutation'); + lastStamped = stamped; + + // Set at initial count, should render in one frame + chunked.items = chunked.preppedItems.slice(0, 10); + stamped = await waitUntilRendered(); + assert.equal(stamped.length, 10, 'final count wrong'); + assert.deepEqual(lastStamped, stamped.slice(0, lastStamped.length), + 'list should not re-render instances during mutation'); + lastStamped = stamped; + + // Set over initial count, should render in more than one frame + chunked.items = chunked.preppedItems.slice(0, 10 + MAX_PER_FRAME * 2); + stamped = await waitUntilRendered(); + assert.deepEqual(lastStamped, stamped.slice(0, 10), + 'list should not re-render instances during mutation'); + frameCount = 0; + while (stamped.length < chunked.items.length) { + stamped = await waitUntilRendered(); + frameCount++; + } + assert.isAtLeast(frameCount, 2, 'should have taken at least 2 frames to render'); + assert.equal(stamped.length, chunked.items.length, 'final count wrong'); }); - test('mutations during chunked rendering', function(done) { + test('mutations during chunked rendering', async () => { var checkItemOrder = function(stamped) { var last = -1; @@ -4038,10 +4060,15 @@

x-repeat-limit

} }; + // Set items to chunk + chunked.items = chunked.preppedItems.slice(); + + let stamped = []; let lastStamped; - var frameCount = 0; - verifyAfterChange = function() { - var stamped = Array.from(chunked.root.querySelectorAll('*:not(template):not(dom-repeat)')); + let frameCount = 0; + // Loop until chunking is complete + while (stamped.length < chunked.items.length) { + stamped = await waitUntilRendered(); checkItemOrder(stamped); if (!lastStamped) { // Initial rendering of initial count @@ -4055,24 +4082,20 @@

x-repeat-limit

assert.isAtMost((stamped.length - lastStamped.length), MAX_PER_FRAME, `list should not render more than ${MAX_PER_FRAME} per frame`); } - if (stamped.length < chunked.items.length) { - if (frameCount++ < 5) { - mutateArray(chunked, stamped); - } - lastStamped = stamped; - } else { - // Final rendering at exact item count - assert.equal(stamped.length, chunked.items.length, 'final count wrong'); - assert.isAtLeast(frameCount, 10, 'should have taken at least 10 frames to render'); - done(); + if (stamped.length < chunked.items.length && frameCount < 5) { + mutateArray(chunked, stamped); } - }; - chunked.items = chunked.preppedItems.slice(); + lastStamped = stamped; + frameCount++; + } + // Final rendering at exact item count + assert.equal(stamped.length, chunked.items.length, 'final count wrong'); + assert.isAtLeast(frameCount, 10, 'should have taken at least 10 frames to render'); }); - test('mutations during chunked rendering, sort & filtered', function(done) { + test('mutations during chunked rendering, sort & filtered', async () => { var checkItemOrder = function(stamped) { var last = Infinity; @@ -4094,12 +4117,23 @@

x-repeat-limit

chunked.splice('items', Math.round(stamped.length/2), 3); }; - var lastStamped; - var frameCount = 0; - verifyAfterChange = function() { - var stamped = Array.from(chunked.root.querySelectorAll('*:not(template):not(dom-repeat)')); + // Set items to chunk + chunked.$.repeater.sort = function(a, b) { + return b.prop - a.prop; + }; + chunked.$.repeater.filter = function(a) { + return (a.prop % 2) === 0; + }; + chunked.items = chunked.preppedItems.slice(); + + let stamped = []; + let lastStamped; + let frameCount = 0; + let filteredLength = chunked.items.filter(chunked.$.repeater.filter).length; + // Loop until chunking is complete + while (stamped.length < filteredLength) { + stamped = await waitUntilRendered(); checkItemOrder(stamped); - var filteredLength = chunked.items.filter(chunked.$.repeater.filter).length; if (!lastStamped) { // Initial rendering of initial count assert.equal(stamped.length, 10); @@ -4112,37 +4146,35 @@

x-repeat-limit

assert.isAtMost((stamped.length - lastStamped.length), MAX_PER_FRAME, `list should not render more than ${MAX_PER_FRAME} per frame`); } - if (stamped.length < filteredLength) { - if (frameCount++ < 4) { - mutateArray(chunked, stamped); - } - lastStamped = stamped; - } else { - assert.equal(stamped.length, filteredLength, 'final count wrong'); - assert.isAtLeast(frameCount, 5, 'should have taken at least 5 frames to render'); - done(); + if (stamped.length < chunked.items.length && frameCount < 4) { + mutateArray(chunked, stamped); + filteredLength = chunked.items.filter(chunked.$.repeater.filter).length; } - }; - chunked.$.repeater.sort = function(a, b) { - return b.prop - a.prop; - }; - chunked.$.repeater.filter = function(a) { - return (a.prop % 2) === 0; - }; - chunked.items = chunked.preppedItems.slice(); - + lastStamped = stamped; + frameCount++; + } + // Final rendering at exact item count + assert.equal(stamped.length, filteredLength, 'final count wrong'); + assert.isAtLeast(frameCount, 5, 'should have taken at least 5 frames to render'); }); suite('resetting items array', () => { [false, true].forEach(reuse => { - test(`reuseChunkedInstances=${reuse}`, (done) => { + test(`reuseChunkedInstances=${reuse}`, async () => { - let i = 3; + // Set items to chunk + chunked.$.repeater.reuseChunkedInstances = reuse; + chunked.items = chunked.preppedItems.slice(); + + let resetCount = 3; + let stamped = []; let lastStamped; - verifyAfterChange = function() { - var stamped = Array.from(chunked.root.querySelectorAll('*:not(template):not(dom-repeat)')); + let frameCount = 0; + // Loop until chunking is complete + while (stamped.length < chunked.items.length) { + stamped = await waitUntilRendered(); if (!lastStamped) { // Initial rendering of initial count assert.equal(stamped.length, 10); @@ -4155,30 +4187,78 @@

x-repeat-limit

assert.isAtMost((stamped.length - lastStamped.length), MAX_PER_FRAME, `list should not render more than ${MAX_PER_FRAME} per frame`); } - if (stamped.length < chunked.items.length) { - lastStamped = stamped; - } else { - assert.equal(stamped.length, chunked.items.length, 'final count wrong'); - if (--i > 0) { - if (!reuse) { - lastStamped = null; - } - chunked.items = chunked.preppedItems.slice(); - } else { - done(); + lastStamped = stamped; + frameCount++; + if (--resetCount > 0) { + if (!reuse) { + lastStamped = null; } + chunked.items = chunked.preppedItems.slice(); } - }; - - chunked.$.repeater.reuseChunkedInstances = reuse; - chunked.items = chunked.preppedItems.slice(); - + } + // Final rendering at exact item count + assert.equal(stamped.length, chunked.items.length, 'final count wrong'); + assert.isAtLeast(frameCount, 5, 'should have taken at least 5 frames to render'); + }); }); }); + test('changing to/from initialCount=0', async () => { + // Render all + chunked.items = chunked.preppedItems.slice(); + let stamped = await waitUntilRendered(chunked.preppedItems.length); + assert.equal(stamped.length, chunked.preppedItems.length); + // Clear the list + chunked.items = []; + stamped = await waitUntilRendered(0); + assert.equal(stamped.length, 0); + // Disable chunking + chunked.$.repeater.initialCount = 0; + // Render all + chunked.items = chunked.preppedItems.slice(); + stamped = await waitUntilRendered(chunked.preppedItems.length); + assert.equal(stamped.length, chunked.preppedItems.length); + // Clear the list + chunked.items = []; + stamped = await waitUntilRendered(0); + assert.equal(stamped.length, 0); + // Re-enable chunking + chunked.$.repeater.initialCount = 10; + // Render all; the initial render should have the initial count, and then + // chunk until the end of the list + let frameCount = 0; + chunked.items = chunked.preppedItems.slice(); + while (stamped.length < chunked.items.length) { + stamped = await waitUntilRendered(); + if (frameCount === 0) { + assert.equal(stamped.length, 10); + } + frameCount++; + } + assert.equal(stamped.length, chunked.preppedItems.length); + assert.isAtLeast(frameCount, 2); + // Disable chunking + chunked.$.repeater.initialCount = 0; + // Render some + chunked.items = chunked.preppedItems.slice(0, 20); + stamped = await waitUntilRendered(); + assert.equal(stamped.length, chunked.items.length); + // Re-enable chunking + chunked.$.repeater.initialCount = 10; + // Push remaining; these should chunk out + frameCount = 0; + chunked.push('items', ...chunked.preppedItems.slice(20)); + while (stamped.length < chunked.items.length) { + stamped = await waitUntilRendered(); + frameCount++; + } + assert.equal(stamped.length, chunked.items.length); + assert.isAtLeast(frameCount, 2); + }); + }); suite('misc', function() { @@ -4233,7 +4313,6 @@

x-repeat-limit

test('paths update on observed properties', function() { let simple = fixture('simple'); flush(); - //debugger; var stamped = simple.root.querySelectorAll('*:not(template):not(dom-repeat)'); assert.equal(stamped[0].itemaProp, 'prop-1'); simple.$.repeat.observe = 'prop'; diff --git a/util/travis-sauce-test.sh b/util/travis-sauce-test.sh index c2f80dd32b..ee4eb92a61 100755 --- a/util/travis-sauce-test.sh +++ b/util/travis-sauce-test.sh @@ -9,4 +9,4 @@ # subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt # set -x -node ./node_modules/.bin/polymer test --npm --module-resolution=node -s 'windows 10/microsoftedge@15' -s 'windows 10/microsoftedge@17' -s 'windows 8.1/internet explorer@11' -s 'os x 10.11/safari@9' -s 'macos 10.12/safari@10' -s 'macos 10.13/safari@11' -s 'Linux/chrome@41' \ No newline at end of file +node ./node_modules/.bin/polymer test --npm --module-resolution=node -s 'windows 10/microsoftedge@15' -s 'windows 10/microsoftedge@17' -s 'windows 8.1/internet explorer@11' -s 'macos 10.13/safari@11' -s 'macos 10.13/safari@12' -s 'macos 10.13/safari@13' -s 'Linux/chrome@41' \ No newline at end of file