Last active
July 22, 2021 19:46
-
-
Save jdurand/cebb2e5ad16fe3bb227b53eeb065caf4 to your computer and use it in GitHub Desktop.
List With Deferred Rendering
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import BaseListComponent from 'app/pods/components/base/list/component'; | |
import { withEvents } from 'app/decorators/with-events'; | |
import { withDeferredRendering } from 'app/decorators/list/with-deferred-rendering'; | |
import { inject as service } from '@ember/service'; | |
@withEvents | |
@withDeferredRendering({ chunkSize: 3, chunkDelay: 100 }) | |
export default class SomeListComponent extends BaseListComponent { | |
@service isMobile; | |
constructor() { | |
super(...arguments); | |
// slow down rendering on slower devices to prevent UI blocking | |
if (this.isMobile.any && !this.isMobile.apple.device) { | |
this.chunkSize = 1; | |
this.chunkDelay = 250; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { A } from '@ember/array'; | |
import { assert } from '@ember/debug'; | |
import { setProperties } from '@ember/object'; | |
import { tracked } from '@glimmer/tracking'; | |
import { later, cancel } from '@ember/runloop'; | |
import { isEmpty, typeOf } from '@ember/utils'; | |
import { omitBy, isNil } from 'lodash'; | |
import { areEqual } from 'app/lib/utils/array'; | |
function withDeferredRendering(attributes = {}) { | |
return (Class) => { | |
return class WithDeferredRendering extends Class { | |
rendering = false; | |
deliveringItems = A([]); | |
renderedItems = A([]); // duplicate of deliveredItems, needed to avoid a Glimmer error | |
@tracked deliveredItems = A([]); | |
@tracked renderDuration = 0.0; | |
// | |
// "Public" API | |
// | |
get items() { | |
const { items } = this.args; | |
const slice = items.slice(0, this.renderedItems.length); | |
const renderedInSlice = areEqual(slice, this.renderedItems); | |
// TODO: figure out a workflow where we avoid invoking | |
// startRendering in a getter... | |
if (isEmpty(slice)) { | |
// when we're not currently rendering | |
this.startRendering(); | |
} else { | |
if (renderedInSlice) { | |
// when what's currently rendered is valid | |
if (!this.rendering && this.args.items.length > this.renderedItems.length) { | |
// append what's not yet rendered | |
this.resumeRendering(); | |
} | |
} else { | |
// when rendered items (part of the slice) are added | |
// or removed from the list during rendering | |
// TODO: find a way remove items from the list | |
// find a way to insert added items in place | |
this.startRendering(); | |
} | |
} | |
return this.deliveringItems.mapBy('promise'); | |
} | |
get renderProgress() { | |
let progress = this.deliveredItems.length / this.args.items.length; | |
if (progress >= 1.0 || isEmpty(this.args.items) || isNaN(progress)) { | |
progress = 1.0; | |
} | |
return progress; | |
} | |
get rendered() { | |
return this.renderProgress >= 1.0; | |
} | |
// | |
// "Private" API | |
// | |
constructor() { | |
super(...arguments); | |
// define default attributes and allow overriding | |
const { chunkDelay, chunkSize } = { | |
chunkSize: 5, | |
chunkDelay: 25, | |
...attributes, | |
...this | |
}; | |
setProperties(this, { chunkSize, chunkDelay }); | |
assert('withDeferredRendering requires this.args.items to be an array', | |
typeOf(this.args.items) === 'array' || ( | |
typeOf(this.args.items) === 'instance' && | |
typeOf(this.args.items.length) === 'number' | |
) | |
); | |
} | |
startRendering() { | |
this.resetState(); | |
this.resumeRendering(); | |
return this.deliveredItems; | |
} | |
resumeRendering() { | |
const { items } = this.args; | |
const { chunkSize, chunkDelay } = { ...this, | |
...omitBy(this.args, isNil) | |
}; | |
setProperties(this, { | |
renderStart: new Date() | |
}); | |
const cursorStart = this.renderedItems.length; | |
for (let cursor = cursorStart; cursor < items.length; cursor += chunkSize) { | |
items.slice(cursor, cursor + chunkSize).map((item) => { | |
let abort; | |
const promise = new Promise(function(resolve, reject) { | |
const delay = ((cursor - cursorStart) / chunkSize) * chunkDelay; | |
const deferred = later(() => { | |
this.deliveredItems.pushObject(item); | |
this.renderedItems.pushObject(item); | |
resolve(item); | |
if (this.deliveredItems.length === this.args.items.length) { | |
this.renderingDone(); | |
} | |
}, delay) | |
abort = () => { | |
if (cancel(deferred)) { | |
reject(); | |
} | |
}; | |
}.bind(this)); | |
this.deliveringItems.push({ promise, abort }) | |
}); | |
} | |
return this.deliveringItems; | |
} | |
abortRendering() { | |
this.deliveringItems.map(({ abort }) => abort()); | |
this.deliveringItems = A([]); | |
} | |
resetState() { | |
this.abortRendering(); | |
setProperties(this, { | |
rendering: true, | |
deliveringItems: A([]), | |
deliveredItems: A([]), | |
renderedItems: A([]), | |
renderDuration: 0.0 | |
}); | |
return A([]); | |
} | |
renderingDone() { | |
if (this.isDestroyed || this.isDestroying) { return; } | |
setProperties(this, { | |
rendering: false, | |
renderDuration: (new Date()) - this.renderStart | |
}); | |
} | |
} | |
} | |
} | |
export { withDeferredRendering } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment