Skip to content

Commit

Permalink
Refactor x-element mixins.
Browse files Browse the repository at this point in the history
1. Postpone more work until initial connect (closes #14 & closes #22).
2. Upgrade all properties at initialization time (closes #15).
3. Initialize properties before initial render (closes #17).
4. Improve handling of property effects (closes #19).
3. Robustify test suite.
  • Loading branch information
theengineear committed Mar 19, 2019
1 parent bbbb68a commit 78e4b3d
Show file tree
Hide file tree
Showing 23 changed files with 1,211 additions and 400 deletions.
98 changes: 98 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# The x-element spec

This describes the expected behavior of x-element.

## Mixin hierarchy

1. `element-mixin`: Provides base functionality.
2. `properties-mixin`: Allows you to declare the `properties` block.
3. `property-effects-mixin`: Allows you to declare effects that should happen
when a property changes.
4. `lit-html-mixin`: Chooses `lit-html` as the templating engine.

It's currently the case that you can omit mixins from the tail, but things will
break down if you choose to omit something from the middle/head. I.e., you
cannot have `property-effects-mixin` without `properties-mixin`, but the other
way around is OK.

## Phases of `x-element`

### Analysis

Analysis takes place once per class and is cached. This allows all future
instances to share common setup work. The result of the analysis phase is made
available again during initialization.

### Initialization

The initialization phase takes place once per instance. This allows each class
to leverage cached information in the analysis phase.

## Lifecycle

### Construction time

- static analysis

### On first connected callback

- handle post-definition upgrade scenario
- initialize render root
- initialize property values
- compute properties
- render
- enable property effects
- reflect properties
- observe properties

### On subsequent property changes

- reflect property if needed
- observe property if needed
- compute dependent properties if needed, causes subsequent property changes

## Properties

The properties block allows you to define the following:

- `type` [Function]: type associated with the property
- `value` [Funciton|Any Literal]: _initial_ value for the property or getter
- `readOnly` [Boolean]: prevent property updates via normal setter?
- `reflect` [Boolean]: reflect property to attribute?
- `observer` [String]: DSL used to resolve an observer callback
- `computed` [String]: DSL used to resolve computed callback and dependencies

## References

- [WHATWG Custom Elements Spec](https://html.spec.whatwg.org/multipage/custom-elements.html)


## Computed properties and graphs

Consider the following properties:

```
{
a: { type: Boolean },
b: { type: Boolean, computed: 'computeB(a)' },
c: { type: Boolean, computed: 'computeC(a, b)' }
}
```

This properties block declares that `c` depends on `a` and `b` and that `b`
depends on `a`. However, the _order_ in which we resolve `b` and `c` when `a`
changes is important. In general, computed properties form a Directed, Acyclic
Graph (DAG). The DAG looks like this:

```
a
↙ ↘
b → c
```

DAGs can be solved using a topological sorting algorithm and this computation
can be done at analysis-time to prevent repeating expensive work at runtime.

Note that DAGs can have multiple solutions. For completeness, the solution for
this DAG is `[a, b, c]`. This means that if `a` changes, you need to then update
`b` and then update `c`--in that order.
68 changes: 68 additions & 0 deletions etc/topological-sort.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
function makeGraphLoop(n, mapping, edges, nodes) {
if (nodes.includes(n) === false) {
// Don't traverse nodes we've already traversed. Prevents infinite loop.
nodes.push(n);
if (n in mapping) {
for (const m of mapping[n]) {
edges.push([n, m]);
makeGraphLoop(m, mapping, edges, nodes);
}
}
}
}

export function makeGraph(node, mapping) {
const edges = [];
const nodes = [];
makeGraphLoop(node, mapping, edges, nodes);
return { edges, nodes };
}

export function topologicalSort(graph) {
// Implements Kahn's algorithm for topological sorting:
//
// L ← Empty list that will contain the sorted elements
// S ← Set of all nodes with no incoming edge
// while S is non-empty do
// remove a node n from S
// add n to tail of L
// for each node m with an edge e from n to m do
// remove edge e from the graph
// if m has no other incoming edges then
// insert m into S
// if graph has edges then
// return error (graph has at least one cycle)
// else
// return L (a topologically sorted order)
//
// Assumptions:
//
// 1. Each node in each edge of "edges" is declared in "nodes".
// 2. Each node in "nodes" is unique in the list.
// 3. Each edge in "edges" is unique in the list.
const nodes = [...graph.nodes];
const edges = [...graph.edges];
const L = [];
const isInS = n => edges.every(e => e[1] !== n);
const S = new Set(nodes.filter(isInS));
while (S.size) {
const n = S.values().next().value;
S.delete(n);
L.push(n);
// Loop over a copy of "edges" to prevent mutation while looping.
for (const e of [...edges]) {
if (e[0] === n) {
const m = e[1];
edges.splice(edges.indexOf(e), 1);
if (isInS(m)) {
S.add(m);
}
}
}
}
if (edges.length) {
// Graph is cyclic.
return;
}
return L;
}
55 changes: 35 additions & 20 deletions mixins/element-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
// TODO: come closer to parity with LitElement.
// * consider mimicking createRenderRoot (instead of shadowRootInit) which
// returns a root instance.
const DIRTY = Symbol.for('__dirty__');
const HAS_CONNECTED = Symbol.for('__hasConnected__');

export default superclass =>
class extends superclass {
constructor() {
Expand All @@ -12,7 +15,10 @@ export default superclass =>
}

connectedCallback() {
this.constructor.upgradeObservedAttributes(this);
if (!this[HAS_CONNECTED]) {
this[HAS_CONNECTED] = true;
this.constructor.initialize(this);
}
}

disconnectedCallback() {}
Expand All @@ -27,13 +33,28 @@ export default superclass =>

static setup(target) {
target.attachShadow(this.shadowRootInit);
}

static beforeInitialRender(target) {
// Hook for subclasses.
}

static afterInitialRender(target) {
// Hook for subclasses.
}

static initialize(target) {
this.upgradeOwnProperties(target);
this.beforeInitialRender(target);
// cause the template to perform an initial synchronous render
target.render();
this.afterInitialRender(target);
}

render() {
const proxy = this.constructor.renderProxy(this);
this.shadowRoot.innerHTML = this.constructor.template()(proxy, this);
this[DIRTY] = false;
}

/**
Expand All @@ -42,13 +63,15 @@ export default superclass =>
* changes. All the changes will be batched in a single render.
*/
async invalidate() {
const symbol = Symbol.for('__dirty__');
if (!this[symbol]) {
this[symbol] = true;
if (!this[DIRTY]) {
this[DIRTY] = true;
// schedule microtask, which runs before requestAnimationFrame
// https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
this[symbol] = await false;
this.render();
if ((await true) && this[DIRTY]) {
// This guard checks if a synchronous render happened while awaiting.
this.render();
this[DIRTY] = false;
}
}
}

Expand Down Expand Up @@ -98,23 +121,15 @@ export default superclass =>
return () => ``;
}

static upgradeObservedAttributes(target) {
const attrs = this.observedAttributes;
if (Array.isArray(attrs)) {
attrs.forEach(attr => this.upgradeProperty(target, attr));
}
}

/**
* Prevent shadowing from properties added to element instance pre-upgrade.
* @see https://developers.google.com/web/fundamentals/web-components/best-practices#lazy-properties
*/
static upgradeProperty(target, prop) {
if (target.hasOwnProperty(prop)) {
const value = target[prop];
// delete property so it does not shadow the element post-upgrade
// noop if the property is not configurable (e.g. already has accessors)
Reflect.deleteProperty(target, prop);
target[prop] = value;
static upgradeOwnProperties(target) {
for (const key of Reflect.ownKeys(target)) {
const value = Reflect.get(target, key);
Reflect.deleteProperty(target, key);
Reflect.set(target, key, value);
}
}
};
Loading

0 comments on commit 78e4b3d

Please sign in to comment.