Skip to content

Instantly share code, notes, and snippets.

@jdurand
Last active July 22, 2021 19:46
Show Gist options
  • Save jdurand/cebb2e5ad16fe3bb227b53eeb065caf4 to your computer and use it in GitHub Desktop.
Save jdurand/cebb2e5ad16fe3bb227b53eeb065caf4 to your computer and use it in GitHub Desktop.
List With Deferred Rendering
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;
}
}
}
<div>
{{#let (or this.items @items) as |items|}}
{{#let (hash
renderProgress=this.renderProgress
rendered=this.rendered
renderDuration=this.renderDuration
Item=(component 'some-list/list-item')
) as |params|}}
<div local-class="list-header">
{{yield params to='header'}}
</div>
<div local-class="list-wrapper">
{{#each items as |item|}}
{{#let (await item) as |item|}}{{#if item}}
{{yield params item to='items'}}
{{/if}}{{/let}}
{{else}}
{{yield params to='empty'}}
{{/each}}
</div>
<div local-class="list-footer">
{{yield params to='footer'}}
</div>
{{/let}}
{{/let}}
</div>
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