Skip to content

Commit

Permalink
Simplify update cycle based on feedback
Browse files Browse the repository at this point in the history
* invalidate always completes in a microtask and should not be pushed out.
* properties set in `finishUpdate`are set after the next `updateComplete` resolves
* adds tests for customizing the timing of `updateComplete`
  • Loading branch information
Steven Orvell committed Aug 22, 2018
1 parent 25f4ca6 commit 9f8e145
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 155 deletions.
34 changes: 21 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,29 +155,36 @@ See the [source](https://github.com/PolymerLabs/lit-element/blob/master/src/lit-
during `update()` setting properties does not trigger `invalidate()`, allowing
property values to be computed and validated.

* `finishUpdate(changedProperties): Promise?` (protected): Called after element DOM has been updated and
* `finishUpdate(changedProperties)`: (protected): Called after element DOM has been updated and
before the `updateComplete` promise is resolved. Implement to directly control rendered DOM.
Typically this is not needed as `lit-html` can be used in the `render` method
to set properties, attributes, and event listeners. However, it is sometimes useful
for calling methods on rendered elements, for example focusing an input:
`this.shadowRoot.querySelector('input').focus()`. The `changedProps` argument is an object
with keys for the changed properties pointing to their previous values. If this function
returns a `Promise`, it will be *awaited* before resolving the `updateComplete` promise.
Setting properties in `finishUpdate()` does trigger `invalidate()` and blocks
the `updateComplete` promise.
with keys for the changed properties pointing to their previous values.

* `finishFirstUpdate(): Promise?` (protected) Called after element DOM has been
* `finishFirstUpdate()`: (protected) Called after element DOM has been
updated the first time. This method can be useful for capturing references to rendered static
nodes that must be directly acted upon, for example in `finishUpdate`.

* `updateComplete`: Returns a promise which resolves after the element next updates and renders.
* `updateComplete`: Returns a Promise that resolves when the element has finished updating
to a boolean value that is true if the element finished the update
without triggering another update. This can happen if a property
is set in `finishUpdate` for example.
This getter can be implemented to await additional state. For example, it
is sometimes useful to await a rendered element before fulfilling this
promise. To do this, first await `super.updateComplete` then any subsequent
state.

* `invalidate`: Call to request the element to asynchronously update regardless
of whether or not any property changes are pending.
of whether or not any property changes are pending. This should only be called
when an element should update based on some state not stored in properties,
since setting properties automically calls `invalidate`.

* `invalidateProperty(name, oldValue)`: Triggers an invalidation for a specific property.
This is useful when manually implementing a propert setter. Call `invalidateProperty`
instead of `invalidate` to ensure that any configured property options are honored.
* `invalidateProperty(name, oldValue)` (protected): Triggers an invalidation for
a specific property. This is useful when manually implementing a propert setter.
Call `invalidateProperty` instead of `invalidate` to ensure that any configured
property options are honored.

* `createRenderRoot()` (protected): Implement to customize where the
element's template is rendered by returning an element into which to
Expand All @@ -197,9 +204,10 @@ See the [source](https://github.com/PolymerLabs/lit-element/blob/master/src/lit-
will *not* trigger `invalidate()`. This calls
* `render()` which should return a `lit-html` TemplateResult
(e.g. <code>html\`Hello ${world}\`</code>)
* `finishFirstUpdate()` is then called to do post *first* update/render tasks.
Note, setting properties here will trigger `invalidate()`.
* `finishUpdate(changedProps)` is then called to do post update/render tasks.
Note, setting properties here will trigger `invalidate()` and block
the `updateComplete` promise.
Note, setting properties here will trigger `invalidate()`.
* `updateComplete` promise is resolved only if the element is
not in an invalid state.
* Any code awaiting the element's `updateComplete` promise runs and observes
Expand Down
25 changes: 16 additions & 9 deletions demo/lit-element.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,24 +104,31 @@ <h4 on-click="${(e) => console.log(this, e.target)}">Foo: ${foo}, Bar: ${bar}</h
console.log('updated!', changedProps);
}

async finishUpdate(changedProps) {
if (!this._inner) {
this._inner = this.shadowRoot.querySelector('x-inner');
finishFirstUpdate(changedProps) {
this._inner = this.shadowRoot.querySelector('x-inner');
}

finishUpdate() {
if (this.whales < 100) {
this.whales++;
}
await this._inner.updateComplete;
}
}

addEventListener('WebComponentsReady', function() { console.log('wcr - module')})
get updateComplete() {
return (async () => {
await super.updateComplete;
await this._inner.updateComplete;
while (!await super.updateComplete) {};
})();
};
}

console.log('defined class');
customElements.define('my-element', MyElement);
console.log('get', customElements.get('my-element'));

(async () => {
const x = document.querySelector('my-element');
await x.updateComplete;
console.log(x.shadowRoot.querySelector('x-inner').shadowRoot.textContent);
console.log('updateComplete!', x.shadowRoot.querySelector('x-inner').shadowRoot.textContent, x.whales);
})();
</script>
</body></html>
164 changes: 74 additions & 90 deletions src/lib/updating-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,30 +90,12 @@ type AttributeMap = Map<string, PropertyKey>;

export type PropertyValues = Map<PropertyKey, unknown>;

/**
* Creates and sets object used to memoize all class property values. Object
* is chained from superclass.
*/
const ensurePropertyStorage = (ctor: typeof UpdatingElement) => {
if (!ctor.hasOwnProperty('_classProperties')) {
ctor._classProperties = new Map();
// NOTE: Workaround IE11 not supporting Map constructor argument.
const superProperties = Object.getPrototypeOf(ctor)._classProperties;
if (superProperties !== undefined) {
superProperties.forEach((v: any, k: PropertyKey) =>
ctor._classProperties.set(k, v));
}
}
};

/**
* Decorator which creates a property. Optionally a `PropertyDeclaration` object
* can be supplied to describe how the property should be configured.
*/
export const property = (options?: PropertyDeclaration) => (proto: Object, name: string) => {
const ctor = proto.constructor as typeof UpdatingElement;
ensurePropertyStorage(ctor);
ctor.createProperty(name, options);
(proto.constructor as typeof UpdatingElement).createProperty(name, options);
};

// serializer/deserializers for boolean attribute
Expand All @@ -140,12 +122,12 @@ const defaultPropertyDeclaration: PropertyDeclaration = {
shouldInvalidate: notEqual
};

const microtaskPromise = Promise.resolve();
const microtaskPromise = new Promise((resolve) => resolve(true));

const STATE_HAS_UPDATED = 1;
const STATE_IS_VALID = 1 << 2;
const STATE_IS_UPDATING = 1 << 2;
const STATE_IS_REFLECTING = 1 << 3;
type ValidationState = typeof STATE_HAS_UPDATED | typeof STATE_IS_VALID | typeof STATE_IS_REFLECTING;
type ValidationState = typeof STATE_HAS_UPDATED | typeof STATE_IS_UPDATING | typeof STATE_IS_REFLECTING;

/**
* Base element class which manages element properties and attributes. When
Expand All @@ -170,11 +152,10 @@ export abstract class UpdatingElement extends HTMLElement {
*/
private static _observedAttributes: string[]|undefined;

// TODO(sorvell): intended to be private but called by decorator.
/**
* Memoized list of all class properties, including any superclass properties.
*/
static _classProperties: PropertyDeclarationMap = new Map();
private static _classProperties: PropertyDeclarationMap = new Map();

static properties: PropertyDeclarations = {};

Expand Down Expand Up @@ -204,6 +185,16 @@ export abstract class UpdatingElement extends HTMLElement {
* invalidation and update.
*/
static createProperty(name: PropertyKey, options: PropertyDeclaration = defaultPropertyDeclaration) {
// ensure private storage for property declarations.
if (!this.hasOwnProperty('_classProperties')) {
this._classProperties = new Map();
// NOTE: Workaround IE11 not supporting Map constructor argument.
const superProperties = Object.getPrototypeOf(this)._classProperties;
if (superProperties !== undefined) {
superProperties.forEach((v: any, k: PropertyKey) =>
this._classProperties.set(k, v));
}
}
this._classProperties.set(name, options);
// Allow user defined accessors by not replacing an existing own-property accessor.
if (this.prototype.hasOwnProperty(name)) {
Expand Down Expand Up @@ -238,7 +229,6 @@ export abstract class UpdatingElement extends HTMLElement {
superCtor._finalize();
}
this._finalized = true;
ensurePropertyStorage(this);
// initialize map populated in observedAttributes
this._attributeToPropertyMap = new Map();
// make any properties
Expand Down Expand Up @@ -266,7 +256,8 @@ export abstract class UpdatingElement extends HTMLElement {
* Called when a property value is set and uses the `shouldInvalidate`
* option for the property if present or a strict identity check.
*/
private static _propertyShouldInvalidate(value: unknown, old: unknown, shouldInvalidate: ShouldInvalidate = notEqual) {
private static _propertyShouldInvalidate(value: unknown, old: unknown,
shouldInvalidate: ShouldInvalidate = notEqual) {
return shouldInvalidate(value, old);
}

Expand Down Expand Up @@ -303,10 +294,9 @@ export abstract class UpdatingElement extends HTMLElement {
return (typeof toAttribute === 'function') ? toAttribute(value) : null;
}

private _validationState: ValidationState = STATE_IS_VALID;
private _validationState: ValidationState = 0;
private _instanceProperties: PropertyValues|undefined = undefined;
private _validatePromise: Promise<unknown>|undefined = undefined;
private _validateResolver: (() => void)|undefined = undefined;

/**
* Map with keys for any properties that have changed since the last
Expand Down Expand Up @@ -475,85 +465,79 @@ export abstract class UpdatingElement extends HTMLElement {
/**
* 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. Returns a Promise
* that resolves when the element has finished updating.
* automatically called when any registered property changes.
*/
async invalidate() {
// Do not re-queue validation if already invalid (pending) or currently updating.
if (this._isPendingUpdate) {
return this._validatePromise;
}
// mark state invalid...
this._validationState = this._validationState & ~STATE_IS_VALID;
// Make a new promise only if the current one is not pending resolution
// (resolver has not been set to undefined)
if (this._validateResolver === undefined) {
this._validatePromise = new Promise((resolve) => this._validateResolver = resolve);
if (!this._isUpdating) {
// mark state invalid...
this._validationState = this._validationState | STATE_IS_UPDATING;
let resolver: any;
this._validatePromise = new Promise((r) => {
this._validatePromise = undefined;
resolver = r;
});
await microtaskPromise;
this._validate();
resolver!(!this._isUpdating);
}
// Wait a tick to actually process changes (allows batching).
await 0;
return this._validatePromise;
}

private get _isUpdating() {
return (this._validationState & STATE_IS_UPDATING);
}

/**
* Validates the element by updating it via `update`, `finishUpdate`,
* and `finishFirstUpdate`.
*/
private _validate() {
// Mixin instance properties once, if they exist.
if (this._instanceProperties) {
this._applyInstanceProperties();
}
if (this.shouldUpdate(this._changedProperties)) {
// During update, setting properties does not trigger invalidation.
this.update(this._changedProperties);
// copy changedProperties to hand to finishUpdate.
let changedProperties;
const hasFinishUpdate = (typeof this.finishUpdate === 'function');
// clone changedProperties before resetting only if needed for finishUpdate.
if (hasFinishUpdate) {
changedProperties = new Map(this._changedProperties);
}
this._changedProperties.clear();
// mark state valid
this._validationState = this._validationState | STATE_IS_VALID;
if (!(this._validationState & STATE_HAS_UPDATED)) {
// mark state has updated
this._validationState = this._validationState | STATE_HAS_UPDATED;
if (typeof this.finishFirstUpdate === 'function') {
// During `finishFirstUpdate` (which is optional), setting properties triggers invalidation,
// and users may choose to await other state.
const result = this.finishFirstUpdate();
if (result != null && typeof (result as PromiseLike<unknown>).then === 'function') {
await result;
}
}
}
// During `finishUpdate` (which is optional), setting properties triggers invalidation,
// and users may choose to await other state, like children being updated.
if (hasFinishUpdate) {
const result = this.finishUpdate!(changedProperties as PropertyValues);
if (result != null && typeof (result as PromiseLike<unknown>).then === 'function') {
await result;
}
if (!this.shouldUpdate(this._changedProperties)) {
this._markUpdated();
return;
}
// During update, setting properties does not trigger invalidation.
this.update(this._changedProperties);
// copy changedProperties to hand to finishUpdate.
const changedProperties = this._changedProperties;
this._markUpdated();
// After update (finishFirstUpdate, finishUpdate), properties *do* trigger invalidation.
if (!(this._validationState & STATE_HAS_UPDATED)) {
// mark state has updated
this._validationState = this._validationState | STATE_HAS_UPDATED;
if (typeof this.finishFirstUpdate === 'function') {
this.finishFirstUpdate();
}
} else {
this._changedProperties.clear();
// mark state valid
this._validationState = this._validationState | STATE_IS_VALID;
}
// Only resolve the promise if we finish in a valid state (finishUpdate
// did not trigger more work). Note, if invalidate is triggered multiple
// times in `finishUpdate`, only the first time will resolve the promise
// by calling `_validateResolver`. This is why we guard for its existence.
if ((this._validationState & STATE_IS_VALID) && typeof this._validateResolver === 'function') {
this._validateResolver();
this._validateResolver = undefined;
if (typeof this.finishUpdate === 'function') {
this.finishUpdate(changedProperties);
}
return this._validatePromise;
}

private get _isPendingUpdate() {
return !(this._validationState & STATE_IS_VALID);
private _markUpdated() {
this._changedProperties = new Map();
this._validationState = this._validationState & ~STATE_IS_UPDATING;
}

/**
* Returns a Promise that resolves when the element has finished updating.
* Returns a Promise that resolves when the element has finished updating
* to a boolean value that is true if the element finished the update
* without triggering another update. This can happen if a property
* is set in `finishUpdate` for example.
* This getter can be implemented to await additional state. For example, it
* is sometimes useful to await a rendered element before fulfilling this
* promise. To do this, first await `super.updateComplete` then any subsequent
* state.
*
* @returns {Promise} The promise returns a boolean that indicates if the
* update resolved without triggering another update.
*/
get updateComplete() {
return this._isPendingUpdate ? this._validatePromise : microtaskPromise;
return this._validatePromise || microtaskPromise;
}

/**
Expand Down
Loading

0 comments on commit 9f8e145

Please sign in to comment.