Skip to content

Commit

Permalink
Adds performUpdate and defers update until connection (#368)
Browse files Browse the repository at this point in the history
* Adds `performUpdate` and defers update until connection

Fixes #290. The previous private `_validate` method has been made protected and named `performUpdate`. It's all now possible to return a promise from this method and doing so will block the update's completion until the promise is resolved. This allows control over the timing of update and integration with schedulers.

Fixes #258. Updates are now deferred until element connection. This ensures that microtask checkpoints that may occur during element construction do not generate errors or cause extra update cycles.

* Format.

* Address review feedback.

* Address review feedback.
  • Loading branch information
Steve Orvell authored and kevinpschaaf committed Dec 18, 2018
1 parent 47717a7 commit d29c351
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 112 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

<!-- ### Added -->
### Added
* Added the `performUpdate` method to allow control of update timing ([#290](https://github.com/Polymer/lit-element/issues/290)).
* Updates deferred until first connection ([#258](https://github.com/Polymer/lit-element/issues/258)).
### Changed
* [Breaking] Changes property options to add `converter`. This option works the same as the previous `type` option except that the `converter` methods now also get `type` as the second argument. This effectively changes `type` to be a hint for the `converter`. A default `converter` is used if none is provided and it now supports `Boolean`, `String`, `Number`, `Object`, and `Array` ([#264](https://github.com/Polymer/lit-element/issues/264)).
* [Breaking] Numbers and strings now become null if their reflected attribute is removed (https://github.com/Polymer/lit-element/issues/264)).
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ into the element.
By default, this method always returns `true`, but this can be customized as
an optimization to avoid updating work when changes occur, which should not be rendered.

* `performUpdate()` (protected): Implement to control the timing of an update, for example
to integrate with a scheduler. If a Promise is returned from `performUpdate` it will be
awaited before finishing the update.

* `update(changedProperties)` (protected): This method calls `render()` and then uses `lit-html`
in order to render the template DOM. It also updates any reflected attributes based on
property values. Setting properties inside this method will *not* trigger another update.
Expand Down Expand Up @@ -206,6 +210,7 @@ into the element.
update. If it returns `true`, `requestUpdate()` is called to schedule an update.
* `requestUpdate()`: Updates the element after awaiting a [microtask](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) (at the end
of the event loop, before the next paint).
* `performUpdate()`: Performs the update, calling the rest of the update API.
* `shouldUpdate(changedProperties)`: The update proceeds if this returns `true`, which
it does by default.
* `update(changedProperties)`: Updates the element. Setting properties inside this
Expand Down
116 changes: 79 additions & 37 deletions src/lib/updating-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,10 @@ const STATE_HAS_UPDATED = 1;
const STATE_UPDATE_REQUESTED = 1 << 2;
const STATE_IS_REFLECTING_TO_ATTRIBUTE = 1 << 3;
const STATE_IS_REFLECTING_TO_PROPERTY = 1 << 4;
const STATE_HAS_CONNECTED = 1 << 5;
type UpdateState = typeof STATE_HAS_UPDATED|typeof STATE_UPDATE_REQUESTED|
typeof STATE_IS_REFLECTING_TO_ATTRIBUTE|typeof STATE_IS_REFLECTING_TO_PROPERTY;
typeof STATE_IS_REFLECTING_TO_ATTRIBUTE|
typeof STATE_IS_REFLECTING_TO_PROPERTY|typeof STATE_HAS_CONNECTED;

/**
* Base element class which manages element properties and attributes. When
Expand Down Expand Up @@ -381,6 +383,7 @@ export abstract class UpdatingElement extends HTMLElement {
private _updateState: UpdateState = 0;
private _instanceProperties: PropertyValues|undefined = undefined;
private _updatePromise: Promise<unknown> = microtaskPromise;
private _hasConnectedResolver: (() => void)|undefined = undefined;

/**
* Map with keys for any properties that have changed since the last
Expand Down Expand Up @@ -466,13 +469,23 @@ export abstract class UpdatingElement extends HTMLElement {
* Uses ShadyCSS to keep element DOM updated.
*/
connectedCallback() {
if ((this._updateState & STATE_HAS_UPDATED)) {
if (window.ShadyCSS !== undefined) {
window.ShadyCSS.styleElement(this);
}
this._updateState = this._updateState | STATE_HAS_CONNECTED;
// Ensure connection triggers an update. Updates cannot complete before
// connection and if one is pending connection the `_hasConnectionResolver`
// will exist. If so, resolve it to complete the update, otherwise
// requestUpdate.
if (this._hasConnectedResolver) {
this._hasConnectedResolver();
this._hasConnectedResolver = undefined;
} else {
this.requestUpdate();
}
// Note, first update/render handles styleElement so we only call this if
// connected after first update.
if ((this._updateState & STATE_HAS_UPDATED) &&
window.ShadyCSS !== undefined) {
window.ShadyCSS.styleElement(this);
}
}

/**
Expand Down Expand Up @@ -555,56 +568,84 @@ export abstract class UpdatingElement extends HTMLElement {
* @returns {Promise} A Promise that is resolved when the update completes.
*/
requestUpdate(name?: PropertyKey, oldValue?: any) {
let shouldRequestUpdate = true;
// if we have a property key, perform property update steps.
if (name !== undefined && !this._changedProperties.has(name)) {
const ctor = this.constructor as typeof UpdatingElement;
const options =
ctor._classProperties.get(name) || defaultPropertyDeclaration;
if (!ctor._valueHasChanged(this[name as keyof this], oldValue,
options.hasChanged)) {
return this.updateComplete;
}
// track old value when changing.
this._changedProperties.set(name, oldValue);
// Add to reflecting properties set if `reflect` is true and the property
// is not reflecting to the property from the attribute
if (options.reflect === true &&
!(this._updateState & STATE_IS_REFLECTING_TO_PROPERTY)) {
if (this._reflectingProperties === undefined) {
this._reflectingProperties = new Map();
if (ctor._valueHasChanged(this[name as keyof this], oldValue,
options.hasChanged)) {
// track old value when changing.
this._changedProperties.set(name, oldValue);
// add to reflecting properties set
if (options.reflect === true &&
!(this._updateState & STATE_IS_REFLECTING_TO_PROPERTY)) {
if (this._reflectingProperties === undefined) {
this._reflectingProperties = new Map();
}
this._reflectingProperties.set(name, options);
}
this._reflectingProperties.set(name, options);
// abort the request if the property should not be considered changed.
} else {
shouldRequestUpdate = false;
}
}
return this._invalidate();
if (!this._hasRequestedUpdate && shouldRequestUpdate) {
this._enqueueUpdate();
}
return this.updateComplete;
}

/**
* Invalidates the element causing it to asynchronously update regardless
* of whether or not any property changes are pending. This method is
* automatically called when any registered property changes.
*/
private async _invalidate() {
if (!this._hasRequestedUpdate) {
// mark state updating...
this._updateState = this._updateState | STATE_UPDATE_REQUESTED;
let resolver: any;
const previousValidatePromise = this._updatePromise;
this._updatePromise = new Promise((r) => resolver = r);
await previousValidatePromise;
this._validate();
resolver!(!this._hasRequestedUpdate);
* Sets up the element to asynchronously update.
*/
private async _enqueueUpdate() {
// Mark state updating...
this._updateState = this._updateState | STATE_UPDATE_REQUESTED;
let resolve: (r: boolean) => void;
const previousUpdatePromise = this._updatePromise;
this._updatePromise = new Promise((res) => resolve = res);
// Ensure any previous update has resolved before updating.
// This `await` also ensures that property changes are batched.
await previousUpdatePromise;
// Make sure the element has connected before updating.
if (!this._hasConnected) {
await new Promise((res) => this._hasConnectedResolver = res);
}
return this.updateComplete;
// Allow `performUpdate` to be asynchronous to enable scheduling of updates.
const result = this.performUpdate();
// Note, this is to avoid delaying an additional microtask unless we need
// to.
if (result != null &&
typeof (result as PromiseLike<unknown>).then === 'function') {
await result;
}
resolve!(!this._hasRequestedUpdate);
}

private get _hasConnected() {
return (this._updateState & STATE_HAS_CONNECTED);
}

private get _hasRequestedUpdate() {
return (this._updateState & STATE_UPDATE_REQUESTED);
}

/**
* Validates the element by updating it.
*/
private _validate() {
* Performs an element update.
*
* You can override this method to change the timing of updates. For instance,
* to schedule updates to occur just before the next frame:
*
* ```
* protected async performUpdate(): Promise<unknown> {
* await new Promise((resolve) => requestAnimationFrame(() => resolve());
* super.performUpdate();
* }
* ```
*/
protected performUpdate(): void|Promise<unknown> {
// Mixin instance properties once, if they exist.
if (this._instanceProperties) {
this._applyInstanceProperties();
Expand All @@ -622,6 +663,7 @@ export abstract class UpdatingElement extends HTMLElement {
this._markUpdated();
}
}

private _markUpdated() {
this._changedProperties = new Map();
this._updateState = this._updateState & ~STATE_UPDATE_REQUESTED;
Expand Down
3 changes: 2 additions & 1 deletion src/test/lib/decorators_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ const supportsPassive = (function() {
}
};
f.contentDocument!.addEventListener(event, fn, options);
f.contentDocument!.removeEventListener(event, fn, options as AddEventListenerOptions);
f.contentDocument!.removeEventListener(event, fn,
options as AddEventListenerOptions);
document.body.removeChild(f);
return hasPassive;
})();
Expand Down
134 changes: 134 additions & 0 deletions src/test/lib/updating-element_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @license
* Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at
* http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at
* http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at
* http://polymer.github.io/PATENTS.txt
*/

import {property} from '../../lib/decorators.js';
import {UpdatingElement} from '../../lib/updating-element.js';
import {generateElementName} from '../test-helpers.js';

const assert = chai.assert;

suite('UpdatingElement', () => {
let container: HTMLElement;

setup(() => {
container = document.createElement('div');
document.body.appendChild(container);
});

teardown(() => {
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
});

test('can override performUpdate()', async () => {
class A extends UpdatingElement {
performUpdateCalled = false;
updateCalled = false;

async performUpdate() {
this.performUpdateCalled = true;
await new Promise((r) => setTimeout(r, 10));
await super.performUpdate();
}

update(changedProperties: Map<PropertyKey, unknown>) {
this.updateCalled = true;
super.update(changedProperties);
}
}
customElements.define(generateElementName(), A);

const a = new A();
container.appendChild(a);

// update is not called synchronously
assert.isFalse(a.updateCalled);

// update is not called after a microtask
await 0;
assert.isFalse(a.updateCalled);

// update is not called after short timeout
await new Promise((r) => setTimeout(r));
assert.isFalse(a.updateCalled);

// update is called after long timeout
await new Promise((r) => setTimeout(r, 20));
assert.isTrue(a.updateCalled);
});

test('overriding performUpdate() allows nested invalidations', async () => {
class A extends UpdatingElement {
performUpdateCalledCount = 0;
updatedCalledCount = 0;

async performUpdate() {
this.performUpdateCalledCount++;
await new Promise((r) => setTimeout(r));
super.performUpdate();
}

updated(_changedProperties: Map<PropertyKey, unknown>) {
this.updatedCalledCount++;
// trigger a nested invalidation just once
if (this.updatedCalledCount === 1) {
this.requestUpdate();
}
}
}
customElements.define(generateElementName(), A);

const a = new A();
container.appendChild(a);
assert.equal(a.updatedCalledCount, 0);

const updateComplete1 = a.updateComplete;
await updateComplete1;
assert.equal(a.updatedCalledCount, 1);
assert.equal(a.performUpdateCalledCount, 1);

const updateComplete2 = a.updateComplete;
assert.notStrictEqual(updateComplete1, updateComplete2);

await updateComplete2;
assert.equal(a.updatedCalledCount, 2);
assert.equal(a.performUpdateCalledCount, 2);
});

test('update does not occur before element is connected', async () => {
class A extends UpdatingElement {

updatedCalledCount = 0;

@property() foo = 5;

constructor() {
super();
this.requestUpdate();
}

updated(_changedProperties: Map<PropertyKey, unknown>) {
this.updatedCalledCount++;
}
}
customElements.define(generateElementName(), A);
const a = new A();
await new Promise((r) => setTimeout(r, 20));
assert.equal(a.updatedCalledCount, 0);
container.appendChild(a);
await a.updateComplete;
assert.equal(a.updatedCalledCount, 1);
});
});
Loading

0 comments on commit d29c351

Please sign in to comment.