diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d94e3d6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] diff --git a/README.md b/README.md index 809ff43..36fafdb 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ # XElement -A base class for creating Custom Elements. Adapted from https://github.com/kenchris/lit-element - ``` yarn install && yarn start ``` diff --git a/SPEC.md b/SPEC.md index 2ee7456..f985c2a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,125 +1,358 @@ -# The `x-element` spec +[![Build Status](https://travis-ci.com/Netflix/x-element.svg?branch=master)](https://travis-ci.com/Netflix/x-element) -This describes the expected behavior of `x-element`. +# XElement -## Mixin hierarchy +A base class for custom elements. -The `x-element` code is organized in a set of progressively-enhancing mixins -which are intended to be used in order. You can only omit mixins from the tail, -not from the middle/head. The intent is to force the correct base hooks so that -custom extension is supported. +Define and register your element: -### `element-mixin` +```javascript +class HelloWorld extends XElement { + template(html) { + return () => html`Hello World!`; + } +} -Provides base functionality for creating custom elements with shadow roots and -hooks to re-render the element. +customElements.define('hello-world', HelloWorld); +``` -### `properties-mixin` +And use it in your markup: + +```html + + + + + Hello World + + + + + + +``` -Allows you to declare the `properties` block. This leverages the `element-mixin` -to observe and `invalidate` on property changes to cause a re-render. The -`properties` block allows you to declare the following via this mixin: +## Properties -- `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? +Properties and their related attributes are watched. When a property or related +attribute is updated, a render is queued. + +Property definitions have the following options: + +- `type` [Function]: associate properties with types. +- `attribute` [String]: override default attribute for properties. +- `dependencies`: [StringArray]: declare dependencies of resolved properties. +- `resolver` [Function]: resolve properties when other properties change. +- `reflect` [Boolean]: reflect properties to attributes. +- `observer` [Function]: watch changes to properties. +- `value` [Function|Any]: provide initial, default values for properties. +- `readOnly` [Boolean]: prevent setting properties on the host. +- `internal` [Boolean]: prevent getting / setting properties on the host. + +### Example + +```javascript +class RightTriangle extends XElement { + static get properties() { + return { + base: { + type: Number, + value: 3, + }, + height: { + type: Number, + value: 4, + }, + hypotenuse: { + type: Number, + dependencies: ['base', 'height'], + resolver: Math.hypot, + }, + }; + } + + static template(html) { + return ({ base, height, hypotenuse }) => html` + Math.hypot(${base}, ${height}) = ${hypotenuse} + `; + } +} +``` -### `property-effects-mixin` +## Reflected properties + +In general, attribute changes are synced to properties. However, you can also +ensure that property changes reflect to attributes by setting `reflect: true`. + +## Read-only and internal properties + +Sometimes you want a property to be part of the public interface (either for +attribute or property introspection), but you want to manage the value of that +property internally. You can set `readOnly: true` to achieve this. Such a +property can only be written to using `host.internal`. + +Other times, you don't want a property to be part of the public interface +(common for resolved properties). You can set `internal: true` to achieve this. +Such a property can only be read from or written to using `host.internal`. + +### Example + +```javascript +class MyElement extends XElement { + static get properties() { + return { + date: { + type: String, + readOnly: true, + value: () => new Date().toISOString(), + reflect: true, + }, + interval: { + internal: true, + }, + }; + } + + connectedCallback() { + super.connectedCallback(); + clearInterval(this.internal.interval); + this.internal.interval = setInterval(() => this.tick(), 100); + } + + disconnectedCallback() { + super.disconnectedCallback(); + clearInterval(this.internal.interval); + } + + tick() { + this.internal.date = new Date().toISOString(); + } + + static template(html) { + return ({ date }) => html`${date}`; + } +} +``` -Enhances the interface to properties block to handle _property effects_. I.e., -effects that can take place whenever a property updates. This adds the following -configuration to the properties block: +## Resolved properties -- `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 +Create a resolved property by providing a list of dependencies and a resolver. +XElement will manage the lifecycle of property resolution when dependencies are +updated. -### `listeners-mixin` +Resolvers will be called with the current values of the declared dependencies — +in the order given. Because resolvers are not called unless input arguments +change, resolvers should be pure functions. -Provides a declarative `listeners` block which adds bound listeners on connect -and removes them on disconnect. +If the given function is defined on the constructor, the context (`this`) is +guaranteed to be the constructor when called later. -### `lit-html-mixin` +## Observed properties -This mixin simply makes an opinion to use `lit-html` as the templating engine. +Create an observed property by providing an observer function. Whenever the +property changes, the given observer will be called. The observer will be called +with `host, value, oldValue` -## Lifecycle +If the given function is defined on the constructor, the context (`this`) is +guaranteed to be the constructor when called later. -### Analysis +## Listeners -Analysis should take place once per class on first construction. This allows all -future instances to share common setup work. The result of the analysis phase is -made available again during initialization. +XElement supports declarative, delegated event handlers via a `listeners` +block. Listeners are added during the `connectedCallback` and are removed during +the `disconnectedCallback` in the element's lifecycle. By default, listeners are +added to the render root. -Note: work to truly cache analysis work per-class is ongoing. Right now, this -happens per instance. +If the given function is defined on the constructor, the context (`this`) is +guaranteed to be the constructor when called later. The provided arguments will +be `host, event`. -### Initialization +### Example -Initialization should take place once per instance on first connection. This -allows each class to leverage cached information in the analysis phase and -leverage initialization work through disconnection and reconnection to the DOM. -Initialization should work in the following order: +```javascript +class MyElement extends XElement { + static get properties() { + return { + clicks: { + type: Number, + readOnly: true, + value: 0, + }, + }; + } -- handle post-definition upgrade scenario -- initialize render root -- initialize property values -- compute properties -- render -- enable property effects -- reflect properties -- observe properties + static get listeners() { + return { click: this.onClick }; + } -### Update + static onClick(host, event) { + host.internal.clicks++; + } -When properties update on an initialized element, the following should occur: + static template(html) { + return ({ clicks }) => { + return html`Clicks: ${clicks}`; + } + } +} +``` -- reflect property if needed -- observe property if needed -- compute dependent properties if needed, causes subsequent property changes +## Manually adding listeners + +If you need more fine-grain control over when listeners are added or removed, +you can use the `listen` and `unlisten` functions. + +If the given function is defined on the constructor, the context (`this`) is +guaranteed to be the constructor when called later. The provided arguments will +be `host, event`. + +If the given function is defined on the instance, the context (`this`) is +guaranteed to be the instance when called later. In this case, the only argument +provided will be the `event`. + +### Example + +```javascript +class MyElement extends XElement { + static get properties() { + return { + clicks: { + type: Number, + readOnly: true, + value: 0, + }, + }; + } + + static onClick(host, event) { + host.internal.clicks++; + } + + onClick(event) { + this.internal.clicks++; + } + + connectedCallback() { + super.connectedCallback(); + this.listen(this.shadowRoot, 'click', this.onClick); + this.listen(this.shadowRoot, 'click', this.constructor.onClick); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.unlisten(this.shadowRoot, 'click', this.onClick); + this.unlisten(this.shadowRoot, 'click', this.constructor.onClick); + } + + static template(html) { + return ({ clicks }) => { + return html`Clicks: ${clicks}`; + } + } +} +``` -## Properties +## Render Root -The properties block allows you to define the following: +By default, XElement will create an open shadow root. However, you can change +this behavior by overriding the `createRenderRoot` method. There are a few +reasons why you might want to do this as shown below. -- `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 +### No Shadow Root -## References +```javascript +class MyElement extends XElement { + static createRenderRoot(host) { + return host; + } +} +``` -- [WHATWG Custom Elements Spec](https://html.spec.whatwg.org/multipage/custom-elements.html) +### Focus Delegation +```javascript +class MyElement extends XElement { + static createRenderRoot(host) { + return host.attachShadowRoot({ mode: 'open', delegatesFocus: true }); + } +} +``` -## Computed properties and graphs +## Lifecycle -Consider the following properties: +### Constructor Analysis -``` -{ - a: { type: Boolean }, - b: { type: Boolean, computed: 'computeB(a)' }, - c: { type: Boolean, computed: 'computeC(a, b)' } -} -``` +Analysis takes place once per class. This allows all future instances to share +common setup work. Halting errors are thrown here to assist in development. -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: +### Instance Construction -``` - a - ↙ ↘ -b → c +Each instance undergoes one-time setup work in the `constructor` callback. + +### Instance Initialization + +Each instance is initialized once upon first connection. + +### Update + +When properties update on an initialized element, the following should occur: + +- await a queued microtask (prevents unnecessary, synchronous work) +- compute properties (this is implied and happens lazily) +- reflect properties +- render result +- observe properties + +## Recipes + +### Observing multiple properties + +In certain cases, you may want to observe multiple properties at once. One way +to achieve this is to resolve the properties into a new object and observe that +object. + +```javascript +class MyElement extends XElement { + static get properties() { + return { + tag: { + type: String, + value: 'div', + }, + text: { + type: String, + }, + element: { + type: HTMLElement, + internal: true, + dependencies: ['tag'], + resolver: tag => document.createElement(tag), + observer: (host, element) => { + const container = host.shadowRoot.getElementById('container'); + container.innerHTML = ''; + container.append(element); + } + }, + update: { + type: Object, + internal: true, + dependencies: ['element', 'text'], + resolver: (element, text) => ({ element, text }), + observer: (host, { element, text }) => { + element.textContent = text; + }, + }, + }; + } + + static template(html) { + return () => { + return html`
`; + } + } +} ``` -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. +## References -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. +- [WHATWG Custom Elements Spec](https://html.spec.whatwg.org/multipage/custom-elements.html) diff --git a/demo/demo-element-attributes.js b/demo/demo-element-attributes.js index 506a52d..ce7d256 100644 --- a/demo/demo-element-attributes.js +++ b/demo/demo-element-attributes.js @@ -1,26 +1,6 @@ -import XElementProperties from '../x-element-properties.js'; - -class DemoAttributesElement extends XElementProperties { - static template() { - return ({ hyphenatedValue }) => ` - -
${hyphenatedValue}
- `; - } +import XElement from '../x-element.js'; +class DemoAttributesElement extends XElement { static get properties() { return { hyphenatedValue: { @@ -31,6 +11,28 @@ class DemoAttributesElement extends XElementProperties { }, }; } + + static template(html) { + return ({ hyphenatedValue }) => { + return html` + +
${hyphenatedValue}
+ `; + }; + } } customElements.define('demo-element-attributes', DemoAttributesElement); diff --git a/demo/demo-element-properties.js b/demo/demo-element-properties.js index 7931b38..436642b 100644 --- a/demo/demo-element-properties.js +++ b/demo/demo-element-properties.js @@ -1,26 +1,6 @@ -import XElementProperties from '../x-element-properties.js'; - -class DemoPropertiesElement extends XElementProperties { - static template() { - return ({ reflected }) => ` - -
${reflected}
- `; - } +import XElement from '../x-element.js'; +class DemoPropertiesElement extends XElement { static get properties() { return { reflected: { @@ -34,6 +14,28 @@ class DemoPropertiesElement extends XElementProperties { }, }; } + + static template(html) { + return ({ reflected }) => { + return html` + +
${reflected}
+ `; + }; + } } customElements.define('demo-element-properties', DemoPropertiesElement); diff --git a/demo/demo-element.js b/demo/demo-element.js index 8c37f3c..2a1667b 100644 --- a/demo/demo-element.js +++ b/demo/demo-element.js @@ -1,57 +1,40 @@ -import XElementBasic from '../x-element-basic.js'; - -class DemoElement extends XElementBasic { - static template() { - return ({ reflected }) => ` - -
${reflected}
- `; - } - - static get observedAttributes() { - return ['reflected', 'booleanValue']; - } - - set reflected(value) { - if (value) { - this.setAttribute('reflected', value); - } else { - this.removeAttribute('reflected'); - } - } - - get reflected() { - return this.getAttribute('reflected'); - } - - set booleanValue(value) { - if (value) { - this.setAttribute('boolean-value', ''); - } else { - this.removeAttribute('boolean-value'); - } - } - - get booleanValue() { - return this.getAttribute('boolean-value'); +import XElement from '../x-element.js'; + +class DemoElement extends XElement { + static get properties() { + return { + reflected: { + type: String, + reflect: true, + }, + booleanValue: { + type: Boolean, + reflect: true, + value: true, + }, + }; } - connectedCallback() { - super.connectedCallback(); - this.booleanValue = true; + static template(html) { + return ({ reflected }) => { + return html` + +
${reflected}
+ `; + }; } } diff --git a/demo/index.html b/demo/index.html index 27f50a2..db5cb02 100644 --- a/demo/index.html +++ b/demo/index.html @@ -5,11 +5,13 @@ + + diff --git a/demo/index.js b/demo/index.js index fcf0092..27a63ff 100644 --- a/demo/index.js +++ b/demo/index.js @@ -1,2 +1,17 @@ document.querySelector('demo-element').reflected = 'ok'; document.querySelector('demo-element-properties').reflected = 'ok'; + +const rightTriangle = document.querySelector('right-triangle'); +const pythagoreanTriples = [ + { base: 5, height: 12 }, + { base: 7, height: 24 }, + { base: 8, height: 15 }, + { base: 9, height: 40 }, + { base: 11, height: 60 }, + { base: 0, height: 0 }, + { base: 3, height: 4 }, +]; +let count = 0; +setInterval(() => { + Object.assign(rightTriangle, pythagoreanTriples[count++ % pythagoreanTriples.length]); +}, 2000); diff --git a/demo/right-triangle.js b/demo/right-triangle.js new file mode 100644 index 0000000..2273922 --- /dev/null +++ b/demo/right-triangle.js @@ -0,0 +1,43 @@ +import XElement from '../x-element.js'; + +class RightTriangle extends XElement { + static get properties() { + return { + base: { + type: Number, + value: 3, + }, + height: { + type: Number, + value: 4, + }, + hypotenuse: { + type: Number, + dependencies: ['base', 'height'], + resolver: Math.hypot, + }, + valid: { + type: Boolean, + dependencies: ['hypotenuse'], + resolver: hypotenuse => !!hypotenuse, + reflect: true, + }, + }; + } + + static template(html) { + return ({ base, height, hypotenuse }) => html` + + Math.hypot(${base}, ${height}) = ${hypotenuse} + `; + } +} + +customElements.define('right-triangle', RightTriangle); diff --git a/etc/graph.js b/etc/graph.js deleted file mode 100644 index 4e165ee..0000000 --- a/etc/graph.js +++ /dev/null @@ -1,80 +0,0 @@ -export default class Graph { - constructor(nodes, edges) { - // Defend against mutations during sorting by freezing nodes and edges. - Reflect.defineProperty(this, 'nodes', { value: Object.freeze([...nodes]) }); - Reflect.defineProperty(this, 'edges', { value: Object.freeze([...edges]) }); - for (const edge of this.edges) { - Object.freeze(edge); - } - } - - static _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]); - this._makeGraphLoop(m, mapping, edges, nodes); - } - } - } - } - - static createFromNodeMapping(node, mapping) { - const edges = []; - const nodes = []; - this._makeGraphLoop(node, mapping, edges, nodes); - return new Graph(nodes, edges); - } - - static sort(graph) { - // Implements Kahn's algorithm for topological sorting: - // - // solution ← empty list that will contain the sorted elements - // nodes ← set of all nodes with no incoming edge - // while nodes is non-empty do - // remove a node currentNode from nodes - // add currentNode to tail of solution - // for each node testNode with an edge testEdge from currentNode to testNode do - // remove edge testEdge from edges - // if testNode has no other incoming edges then - // insert testNode into nodes - // if edges then - // return undefined (graph has at least one cycle) - // else - // return solution (a topologically sorted list) - // - // Assumptions: - // - // 1. Each node in each edge in "graph.edges" is in "graph.nodes". - // 2. Each node in "graph.nodes" is unique in the list. - // 3. Each edge in "graph.edges" is unique in the list. - if (graph instanceof Graph === false) { - throw new Error('Cannot call topologicalSort on non-Graph instance.'); - } - const edges = [...graph.edges]; - const solution = []; - const hasNoIncomingEdges = node => edges.every(edge => edge[1] !== node); - const nodes = new Set(graph.nodes.filter(hasNoIncomingEdges)); - while (nodes.size) { - const currentNode = nodes.values().next().value; - nodes.delete(currentNode); - solution.push(currentNode); - // Loop over a copy of "edges" to prevent mutation while looping. - for (const testEdge of [...edges]) { - if (testEdge[0] === currentNode) { - const testNode = testEdge[1]; - edges.splice(edges.indexOf(testEdge), 1); - if (hasNoIncomingEdges(testNode)) { - nodes.add(testNode); - } - } - } - } - // If there are remaining edges, the graph is cyclic; return undefined. - if (edges.length === 0) { - return solution; - } - } -} diff --git a/index.js b/index.js index b02564e..488c022 100644 --- a/index.js +++ b/index.js @@ -1,68 +1,6 @@ import XElement from './x-element.js'; class HelloElement extends XElement { - static template(html) { - return ({ rank }) => html` - -
${rank}
- `; - } - static get properties() { return { rank: { @@ -76,6 +14,70 @@ class HelloElement extends XElement { }, }; } + + static template(html) { + return ({ rank }) => { + return html` + +
${rank}
+ `; + }; + } } customElements.define('hello-world', HelloElement); diff --git a/mixins/element-mixin.js b/mixins/element-mixin.js deleted file mode 100644 index d8d4bc5..0000000 --- a/mixins/element-mixin.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Provides base functionality. - */ -// 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() { - super(); - this.constructor.setup(this); - } - - connectedCallback() { - if (!this[HAS_CONNECTED]) { - this[HAS_CONNECTED] = true; - this.constructor.initialize(this); - } - } - - disconnectedCallback() {} - - attributeChangedCallback() {} - - adoptedCallback() {} - - static get shadowRootInit() { - return { mode: 'open' }; - } - - static setup(target) { - target.attachShadow(this.shadowRootInit); - } - - // eslint-disable-next-line no-unused-vars - static beforeInitialRender(target) { - // Hook for subclasses. - } - - // eslint-disable-next-line no-unused-vars - 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; - } - - /** - * Used to flag element for async template render. This prevents the - * template from rendering more than once for multiple synchronous property - * changes. All the changes will be batched in a single render. - */ - async invalidate() { - if (!this[DIRTY]) { - this[DIRTY] = true; - // schedule microtask, which runs before requestAnimationFrame - // https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/ - if ((await true) && this[DIRTY]) { - // This guard checks if a synchronous render happened while awaiting. - this.render(); - this[DIRTY] = false; - } - } - } - - listen(el, type, cb, ...args) { - if (el instanceof EventTarget && type && cb instanceof Function) { - const bound = this[cb.name].bind(this); - el.addEventListener(type, bound, ...args); - // save reference to instance bound function - this[Symbol.for(cb.name)] = bound; - return true; - } - return false; - } - - unlisten(el, type, cb, ...args) { - const bound = this[Symbol.for(cb.name)]; - if (bound) { - el.removeEventListener(type, bound, ...args); - return true; - } - return false; - } - - dispatchError(err) { - const evt = new ErrorEvent('error', { - error: err, - message: err.message, - bubbles: true, - composed: true, - }); - this.dispatchEvent(evt); - } - - static renderProxy(target) { - const handler = { - get(host, key) { - // avoid rendering "null" and "undefined" strings in template, - // treat as empty instead - const value = host[key]; - return value === undefined || Object.is(value, null) ? '' : value; - }, - }; - return new Proxy(target, handler); - } - - static template() { - return () => ``; - } - - /** - * Prevent shadowing from properties added to element instance pre-upgrade. - * @see https://developers.google.com/web/fundamentals/web-components/best-practices#lazy-properties - */ - 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); - } - } - }; diff --git a/mixins/listeners-mixin.js b/mixins/listeners-mixin.js deleted file mode 100644 index 7bd77b9..0000000 --- a/mixins/listeners-mixin.js +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Provides declarative 'listeners' block. - */ - -export default superclass => - class extends superclass { - static get listeners() { - // Mapping of event type to method name. E.g., `{ click: 'onClick' }`. - return {}; - } - - static setupListeners(target) { - // Loops over mapping declared in this.listeners and calls listen. - for (const [type, methodName] of Object.entries(this.listeners)) { - const ok = target.listen(target.shadowRoot, type, target[methodName]); - if (ok === false) { - target.dispatchError( - new Error( - `"${type}" listener error: "${methodName}" does not exist` - ) - ); - } - } - } - - static teardownListeners(target) { - // Loops over mapping declared in this.listeners and calls unlisten. - for (const [type, methodName] of Object.entries(this.listeners)) { - const ok = target.unlisten(target.shadowRoot, type, target[methodName]); - if (ok === false) { - target.dispatchError( - new Error( - `Failed to unbind "${type}" listener: "${methodName}" does not exist` - ) - ); - } - } - } - - connectedCallback() { - super.connectedCallback(); - this.constructor.setupListeners(this); - } - - disconnectedCallback() { - super.disconnectedCallback(); - this.constructor.teardownListeners(this); - } - }; diff --git a/mixins/lit-html-mixin.js b/mixins/lit-html-mixin.js deleted file mode 100644 index 1e7e1d9..0000000 --- a/mixins/lit-html-mixin.js +++ /dev/null @@ -1,39 +0,0 @@ -import { render, html, directive } from '../../../lit-html/lit-html.js'; - -import { asyncAppend } from '../../../lit-html/directives/async-append.js'; -import { asyncReplace } from '../../../lit-html/directives/async-replace.js'; -import { cache } from '../../../lit-html/directives/cache.js'; -import { guard } from '../../../lit-html/directives/guard.js'; -import { ifDefined } from '../../../lit-html/directives/if-defined.js'; -import { repeat } from '../../../lit-html/directives/repeat.js'; -import { unsafeHTML } from '../../../lit-html/directives/unsafe-html.js'; -import { until } from '../../../lit-html/directives/until.js'; - -const directives = { - asyncAppend, - asyncReplace, - cache, - directive, - guard, - ifDefined, - repeat, - unsafeHTML, - until, -}; - -/** - * Injects lit-html's html and directives into template function. - */ -export default superclass => - class extends superclass { - render() { - const tmpl = this.constructor.template(html, directives); - const proxy = this.constructor.renderProxy(this); - render(tmpl(proxy, this), this.shadowRoot); - } - - /* eslint-disable no-shadow, no-unused-vars */ - static template(html, directives) { - return (proxy, original) => html``; - } - }; diff --git a/mixins/properties-mixin.js b/mixins/properties-mixin.js deleted file mode 100644 index e3c2bb6..0000000 --- a/mixins/properties-mixin.js +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Provides declarative 'properties' block. - */ - -const DASH_TO_CAMEL = /-[a-z]/g; -const CAMEL_TO_DASH = /([A-Z])/g; -const PROPERTY_DEFINITIONS = Symbol.for('__propertyDefinitions__'); -const PROPERTIES_INITIALIZED = Symbol.for('__propertiesInitialized__'); -const PROPERTY_VALUE_CACHE = Symbol.for('__propertyValueCache__'); - -const caseMap = new Map(); - -/** - * Provides property management via a declarative 'properties' block. - */ -export default superclass => - class extends superclass { - static get properties() { - return {}; - } - - /** - * Derives observed attributes using the `properties` definition block - * See https://developer.mozilla.org/en-US/docs/Web/Web_Components/Custom_Elements#Observed_attributes - */ - static get observedAttributes() { - const props = this.properties; - if (props) { - return Object.keys(props).map(this.camelToDashCase); - } - return undefined; - } - - get propertyDefinitions() { - // This is defined during analysis and should only be used thereafter. - return this[PROPERTY_DEFINITIONS]; - } - - static analyzeProperty(target, property, definition) { - return definition; - } - - static analyzeProperties(target, properties) { - const propertyDefinitions = {}; - for (const [property, definition] of Object.entries(properties)) { - propertyDefinitions[property] = this.analyzeProperty( - target, - property, - definition - ); - } - target[PROPERTY_DEFINITIONS] = propertyDefinitions; - target[PROPERTY_VALUE_CACHE] = {}; - } - - static getInitialValue(target, property, definition) { - // Process possible sources of initial state, with this priority: - // 1. imperative, e.g. `element.prop = 'value';` - // 2. declarative, e.g. `` - // 3. definition, e.g. `properties: { prop: { value: 'value' } }` - if (definition.readOnly) { - return; - } - const attribute = this.camelToDashCase(property); - const initialPropertyValue = target[property]; - if (initialPropertyValue !== undefined) { - return initialPropertyValue; - } else if (target.hasAttribute(attribute)) { - const value = target.getAttribute(attribute); - return this.deserializeAttribute(target, property, definition, value); - } else if (definition.value !== undefined) { - const defaultValue = definition.value; - return defaultValue instanceof Function ? defaultValue() : defaultValue; - } - } - - static initializeProperty(target, property, definition) { - const symbol = Symbol.for(property); - const get = () => target[symbol]; - const set = rawValue => { - if (this.shouldPropertyChange(target, property, definition, rawValue)) { - this.changeProperty(target, property, definition, rawValue); - } - }; - const configurable = false; - Reflect.deleteProperty(target, property); - Reflect.defineProperty(target, property, { get, set, configurable }); - } - - static beforeInitialRender(target) { - super.beforeInitialRender(target); - - // Analysis may dispatchErrors, only do this after element is connected. - this.analyzeProperties(target, this.properties); - - // Only reflect attributes when the element is connected - // See https://dom.spec.whatwg.org/#dom-node-isconnected - const entries = Object.entries(target.propertyDefinitions); - for (const [property, definition] of entries) { - const value = this.getInitialValue(target, property, definition); - this.initializeProperty(target, property, definition); - target[property] = value; - } - - // Allows us to guard against early handling in attributeChangedCallback. - target[PROPERTIES_INITIALIZED] = true; - } - - static rawValuesAreEqual(a, b) { - return a === b || (Number.isNaN(a) && Number.isNaN(b)); - } - - static rawValueChanged(target, property, rawValue) { - const oldRawValue = target[PROPERTY_VALUE_CACHE][property]; - return this.rawValuesAreEqual(oldRawValue, rawValue) === false; - } - - static shouldPropertyChange(target, property, definition, rawValue) { - return ( - !definition.readOnly && this.rawValueChanged(target, property, rawValue) - ); - } - - // eslint-disable-next-line no-unused-vars - static propertyWillChange(target, property, definition, value, oldValue) { - // Provided for symmetry with propertyDidChange. - } - - // eslint-disable-next-line no-unused-vars - static propertyDidChange(target, property, definition, value, oldValue) { - target.invalidate(); - } - - static changeProperty(target, property, definition, rawValue) { - // For internal use. Needed to set readOnly properties. - target[PROPERTY_VALUE_CACHE][property] = rawValue; - const value = this.applyType(rawValue, definition.type); - const symbol = Symbol.for(property); - this.propertyWillChange(target, property, definition, value); - const oldValue = target[property]; - target[symbol] = value; - this.propertyDidChange(target, property, definition, value, oldValue); - } - - attributeChangedCallback(attribute, oldValue, newValue, namespace) { - super.attributeChangedCallback(attribute, oldValue, newValue, namespace); - if (newValue !== oldValue && this[PROPERTIES_INITIALIZED]) { - const ctor = this.constructor; - const property = ctor.dashToCamelCase(attribute); - const definition = this.propertyDefinitions[property]; - this[property] = ctor.deserializeAttribute( - this, - property, - definition, - newValue - ); - } - } - - static deserializeAttribute(target, property, definition, value) { - if (definition.type && definition.type.name === 'Boolean') { - // per HTML spec, every value other than null is considered true - return Object.is(value, null) === false; - } - return value; - } - - static applyType(value, type) { - // null remains null - if (Object.is(value, null)) { - return null; - } - // undefined remains undefined - if (value === undefined) { - return undefined; - } - // don't apply any type if none was provided - if (!type) { - return value; - } - // only valid arrays (no coercion) - if (type.name === 'Array') { - return Array.isArray(value) ? value : null; - } - // only valid objects (no coercion) - if (type.name === 'Object') { - return Object.prototype.toString.call(value) === '[object Object]' - ? value - : null; - } - // otherwise coerce type as needed - return value instanceof type ? value : type(value); - } - - static dashToCamelCase(dash) { - if (caseMap.has(dash) === false) { - const camel = - dash.indexOf('-') < 0 - ? dash - : dash.replace(DASH_TO_CAMEL, m => m[1].toUpperCase()); - caseMap.set(dash, camel); - } - return caseMap.get(dash); - } - - static camelToDashCase(camel) { - if (caseMap.has(camel) === false) { - const dash = camel.replace(CAMEL_TO_DASH, '-$1').toLowerCase(); - caseMap.set(camel, dash); - } - return caseMap.get(camel); - } - }; diff --git a/mixins/property-effects-mixin.js b/mixins/property-effects-mixin.js deleted file mode 100644 index 8907209..0000000 --- a/mixins/property-effects-mixin.js +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Add effects that happen after a property is set: observer, computed, reflect. - */ -import Graph from '../etc/graph.js'; - -const COMPUTED_REGEX = /^function[^(]*\(([^)]*)\)[\s\S]*$/; -const COMPUTED_INFO = Symbol.for('__computedInfo__'); -const COMPUTE_READY = Symbol.for('__computeReady__'); -const OBSERVE_READY = Symbol.for('__observeReady__'); -const REFLECT_READY = Symbol.for('__reflectReady__'); - -export default superclass => - class extends superclass { - static parseComputed(computed) { - // Note, we don't protect against deconstruction, defaults, and comments. - try { - let candidate; - eval(`candidate = (() => function ${computed} {})()`); - if (candidate instanceof Function) { - const dependencies = `${candidate}` - .match(COMPUTED_REGEX)[1] - .split(',') - .map(part => part.trim()) - .filter(part => part); - return { methodName: candidate.name, dependencies }; - } - } catch (err) { - // Bad input. Ignore. - } - } - - static resolveMethodName(target, methodName) { - // Look for method on instance and then on constructor. - if (target[methodName] instanceof Function) { - return target[methodName].bind(target); - } else if (target.constructor[methodName] instanceof Function) { - return target.constructor[methodName].bind(target.constructor); - } else { - const err = new Error(`Cannot resolve methodName "${methodName}".`); - target.dispatchError(err); - } - } - - static createComputedCallback(target, property, methodName, dependencies) { - const method = this.resolveMethodName(target, methodName); - if (method) { - return skipIfUndefined => { - // Get definition at runtime in case things changed during analysis. - const definition = target.propertyDefinitions[property]; - const args = dependencies.map(dependency => target[dependency]); - if (!skipIfUndefined || args.some(arg => arg !== undefined)) { - const rawValue = method(...args); - if (this.rawValueChanged(target, property, rawValue)) { - this.changeProperty(target, property, definition, rawValue); - } - } - }; - } - } - - static analyzeObserverProperty(target, property, definition) { - if (definition.observer) { - const methodName = definition.observer; - const method = this.resolveMethodName(target, methodName); - if (method) { - return Object.assign({}, definition, { observe: method }); - } - } - return definition; - } - - static analyzeComputedProperty(target, property, definition) { - const computedInfo = target[COMPUTED_INFO]; - if (computedInfo) { - const { dependencyToDependents, dependentToCallback } = computedInfo; - if (property in dependencyToDependents && !definition.computed) { - const sorted = Graph.sort( - Graph.createFromNodeMapping(property, dependencyToDependents) - ); - if (sorted) { - const callbacks = sorted - .map(dependent => dependentToCallback[dependent]) - .filter(callback => callback); - if (callbacks.length > 0) { - const compute = () => callbacks.forEach(callback => callback()); - return Object.assign({}, definition, { compute }); - } - } - } else if (definition.computed) { - return Object.assign({}, definition, { readOnly: true }); - } - } - return definition; - } - - static analyzeComputedProperties(target, properties) { - const dependencyToDependents = {}; - const dependentToCallback = {}; - - let hasComputedProperties = false; - for (const [property, definition] of Object.entries(properties)) { - if (definition.computed) { - hasComputedProperties = true; - const { computed } = definition; - const parsedComputed = this.parseComputed(computed); - if (parsedComputed) { - const { methodName, dependencies } = parsedComputed; - for (const dependency of dependencies) { - if (dependency in properties === false) { - const err = new Error(`Missing dependency "${dependency}".`); - target.dispatchError(err); - } - } - const callback = this.createComputedCallback( - target, - property, - methodName, - dependencies - ); - if (callback) { - dependentToCallback[property] = callback; - } - for (const dependency of dependencies) { - if (dependency in dependencyToDependents === false) { - dependencyToDependents[dependency] = []; - } - if (property in dependencyToDependents[dependency] === false) { - dependencyToDependents[dependency].push(property); - } - } - } else { - const err = new Error(`Malformed computed "${computed}".`); - target.dispatchError(err); - } - } - } - - if (hasComputedProperties) { - target[COMPUTED_INFO] = { - dependencyToDependents, - dependentToCallback, - }; - - // We also need to initialize our computed props. We set that up here. - const nodes = Array.from(Object.keys(properties)); - const edges = []; - const entries = Object.entries(dependencyToDependents); - for (const [dependency, dependents] of entries) { - edges.push(...dependents.map(dependent => [dependency, dependent])); - } - const sorted = Graph.sort(new Graph(nodes, edges)); - if (sorted) { - const callbacks = sorted - .map(dependent => dependentToCallback[dependent]) - .filter(callback => callback); - if (callbacks.length > 0) { - // The "true" arg skips callback if dependencies are all undefined. - // TODO: #27: skip initial compute if dependencies are all undefined - // pass "true" arg to each callback to accomplish this. - target[COMPUTED_INFO].initialCompute = () => - callbacks.forEach(callback => callback()); - } - } else { - target.dispatchError(new Error('Computed properties are cyclic.')); - } - } - } - - static analyzeProperties(target, properties) { - // Computed properties need to be analyzed altogether since it's a graph. - this.analyzeComputedProperties(target, properties); - super.analyzeProperties(target, properties); - } - - static analyzeProperty(target, property, definition) { - let next = super.analyzeProperty(target, property, definition); - next = this.analyzeObserverProperty(target, property, next); - return this.analyzeComputedProperty(target, property, next); - } - - static serializeProperty(target, property, definition, value) { - const typeName = definition.type && definition.type.name; - switch (typeName) { - case 'Boolean': - return value ? '' : undefined; - case 'String': - case 'Number': - return value === null || value === undefined - ? undefined - : value.toString(); - default: { - const message = - `Attempted to serialize "${property}" and reflect, but it is not ` + - `a Boolean, String, or Number type (${typeName}).`; - target.dispatchError(new Error(message)); - } - } - } - - static reflectPropertyToAttribute(target, property, definition, value) { - const attribute = this.camelToDashCase(property); - const serialization = this.serializeProperty( - target, - property, - definition, - value - ); - if (serialization === undefined) { - target.removeAttribute(attribute); - } else { - target.setAttribute(attribute, serialization); - } - } - - static beforeInitialRender(target) { - super.beforeInitialRender(target); - target[COMPUTE_READY] = true; - if (target[COMPUTED_INFO] && target[COMPUTED_INFO].initialCompute) { - target[COMPUTED_INFO].initialCompute(); - } - } - - static afterInitialRender(target) { - super.afterInitialRender(target); - target[REFLECT_READY] = true; - target[OBSERVE_READY] = true; - const entries = Object.entries(target.propertyDefinitions); - for (const [property, definition] of entries) { - const value = target[property]; - if (definition.reflect && value !== undefined) { - this.reflectPropertyToAttribute(target, property, definition, value); - } - if (definition.observe && value !== undefined) { - // TODO: #26: switch order of arguments. - definition.observe(undefined, value); - } - } - delete target[COMPUTED_INFO]; - } - - static propertyDidChange(target, property, definition, value, oldValue) { - super.propertyDidChange(target, property, definition, value, oldValue); - if (definition.reflect && target[REFLECT_READY]) { - this.reflectPropertyToAttribute(target, property, definition, value); - } - if (definition.observe && target[OBSERVE_READY]) { - // TODO: #26: switch order of arguments. - definition.observe(oldValue, value); - } - if (definition.compute && target[COMPUTE_READY]) { - definition.compute(); - } - } - }; diff --git a/package.json b/package.json index 77eab66..2ea999e 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,8 @@ "files": [ "LICENSE", "/x-element.js", - "/x-element-basic.js", - "/x-element-properties.js", "/etc", "/demo", - "/mixins", "/test", "/index.html", "/index.js", @@ -34,7 +31,7 @@ }, "devDependencies": { "@netflix/element-server": "^1.0.12", - "@netflix/x-test": "^1.0.0-rc.11", + "@netflix/x-test": "^1.0.0-rc.16", "eslint": "^7.4.0", "puppeteer": "^5.0.0", "tap-parser": "^10.0.1" diff --git a/test.js b/test.js index 8b820c4..a0a779b 100644 --- a/test.js +++ b/test.js @@ -3,26 +3,26 @@ const puppeteer = require('puppeteer'); (async () => { try { + // Open our browser. const browser = await puppeteer.launch({ timeout: 10000 }); const page = await browser.newPage(); + // Starts to gather coverage information for JS and CSS files + await page.coverage.startJSCoverage(); + // Before navigation, start mapping browser logs to stdout. - // eslint-disable-next-line no-console - page.on('console', message => console.log(message.text())); + page.on('console', message => console.log(message.text())); // eslint-disable-line no-console // Visit our test page. - await page.goto('http://0.0.0.0:8080/node_modules/@netflix/x-element/test/?x-test-no-reporter'); + await page.goto('http://0.0.0.0:8080/node_modules/@netflix/x-element/test/?x-test-cover'); // Wait to be signaled about the end of the test. Because the test may have // not started, already started, or already ended, ping for status. await page.evaluate(async () => { - return new Promise(resolve => { + await new Promise(resolve => { const onMessage = evt => { const { type, data } = evt.data; - if ( - type === 'x-test-ended' || - (type === 'x-test-pong' && data.ended) - ) { + if (type === 'x-test-ended' || (type === 'x-test-pong' && data.ended)) { top.removeEventListener('message', onMessage); resolve(); } @@ -32,11 +32,29 @@ const puppeteer = require('puppeteer'); }); }); + // Gather coverage information. + const js = await page.coverage.stopJSCoverage(); + + // Send coverage information to x-test and await test completion. + await page.evaluate(async data => { + await new Promise(resolve => { + const onMessage = evt => { + const { type } = evt.data; + if (type === 'x-test-cover-ended') { + top.removeEventListener('message', onMessage); + resolve(); + } + }; + top.addEventListener('message', onMessage); + top.postMessage({ type: 'x-test-cover-start', data }, '*'); + }); + }, { js }); + + // Close our browser. await browser.close(); } catch (err) { // Ensure we exit with a non-zero code if anything fails (e.g., timeout). - // eslint-disable-next-line no-console - console.error(err); + console.error(err); // eslint-disable-line no-console process.exit(1); } })(); diff --git a/test/fixture-element-attr-binding.js b/test/fixture-element-attr-binding.js deleted file mode 100644 index db5633c..0000000 --- a/test/fixture-element-attr-binding.js +++ /dev/null @@ -1,41 +0,0 @@ -import XElementProperties from '../x-element-properties.js'; - -class TestElement extends XElementProperties { - static template() { - return ({ - camelCaseProperty: ccp, - numericProperty: num, - nullProperty: nul, - }) => ` -
- ${ccp} - ${num} - ${nul} -
- `; - } - - static get properties() { - return { - camelCaseProperty: { - type: String, - value: 'Bactrian', - }, - numericProperty: { - type: Number, - value: 10, - }, - nullProperty: { - type: String, - value: null, - }, - typelessProperty: {}, - }; - } - - connectedCallback() { - super.connectedCallback(); - } -} - -customElements.define('test-element-attr-binding', TestElement); diff --git a/test/fixture-element-attr-reflection.js b/test/fixture-element-attr-reflection.js deleted file mode 100644 index c89401c..0000000 --- a/test/fixture-element-attr-reflection.js +++ /dev/null @@ -1,44 +0,0 @@ -import XElementProperties from '../x-element-properties.js'; - -class TestElement extends XElementProperties { - static template() { - return ({ camelCaseProperty: ccp }) => ` - ${ccp} - `; - } - - static get properties() { - return { - camelCaseProperty: { - type: String, - value: 'reflectedCamel', - reflect: true, - }, - overrideProperty: { - type: String, - value: 'override_me', - reflect: true, - }, - booleanPropertyTrue: { - type: Boolean, - value: true, - reflect: true, - }, - booleanPropertyFalse: { - type: Boolean, - value: false, - reflect: true, - }, - typelessProperty: { - reflect: true, - }, - }; - } - - connectedCallback() { - super.connectedCallback(); - this.overrideProperty = 'overridden'; - } -} - -customElements.define('test-element-attr-reflection', TestElement); diff --git a/test/fixture-element-basic.js b/test/fixture-element-basic.js deleted file mode 100644 index 73c1185..0000000 --- a/test/fixture-element-basic.js +++ /dev/null @@ -1,46 +0,0 @@ -import XElementBasic from '../x-element-basic.js'; - -class TestElement extends XElementBasic { - static template() { - return ({ user }) => ` - Hello ${user}. - `; - } - - get user() { - return 'world'; - } - - set overrideProperty(value) { - if (value) { - this.setAttribute('override-property', value); - } else { - this.removeAttribute('override-property'); - } - this.invalidate(); - } - - get overrideProperty() { - return this.getAttribute('override-property'); - } - - set booleanProperty(value) { - if (value) { - this.setAttribute('boolean-property', ''); - } else { - this.removeAttribute('boolean-property'); - } - this.invalidate(); - } - - get booleanProperty() { - return this.hasAttribute('boolean-property'); - } - - connectedCallback() { - super.connectedCallback(); - this.overrideProperty = 'overridden'; - } -} - -customElements.define('test-element-basic', TestElement); diff --git a/test/fixture-element-computed-properties.js b/test/fixture-element-computed-properties.js deleted file mode 100644 index 5bff1d4..0000000 --- a/test/fixture-element-computed-properties.js +++ /dev/null @@ -1,150 +0,0 @@ -import XElementProperties from '../x-element-properties.js'; - -let count = 0; - -class TestElementComputedProperties extends XElementProperties { - static get properties() { - return { - a: { - type: Number, - }, - b: { - type: Number, - }, - c: { - type: Number, - // Checks that multiline computed strings will work. - computed: ` - computeC( - a, - b, - ) - `, - }, - negative: { - type: Boolean, - computed: 'computeNegative(c)', - reflect: true, - }, - underline: { - type: Boolean, - computed: 'computeUnderline(negative)', - reflect: true, - }, - italic: { - type: Boolean, - reflect: true, - }, - y: { - type: Boolean, - }, - z: { - type: Boolean, - computed: 'computeZ(y)', - }, - today: { - type: Date, - }, - tomorrow: { - type: Date, - computed: 'computeTomorrow(today)', - }, - countTrigger: { - type: String, - }, - count: { - type: Number, - computed: 'computeCount(countTrigger)', - value: count, - }, - }; - } - computeC(a, b) { - return a + b; - } - static computeNegative(c) { - return c < 0; - } - static computeCount() { - // This doesn't use an observer to prevent a coupled test. - return ++count; - } - static computeUnderline(negative) { - return !!negative; - } - static computeZ(y) { - return y; - } - static computeTomorrow(today) { - if (today) { - return today.valueOf() + 1000 * 60 * 60 * 24; - } - } - static template() { - return ({ a, b, c }) => { - return ` - - ${a} + ${b} = ${c} - `; - }; - } -} - -customElements.define( - 'test-element-computed-properties', - TestElementComputedProperties -); - -class TestElementComputedPropertiesErrors extends XElementProperties { - static get properties() { - return { - malformed: { - type: Boolean, - computed: 'malformed(a,,b)', - }, - dne: { - type: Boolean, - computed: 'thisDNE(malformed)', - }, - missing: { - type: String, - computed: 'computeMissing(notDeclared)', - }, - zz: { - type: Boolean, - }, - cyclic: { - type: String, - computed: 'computeCyclic(zz, cyclic)', - }, - }; - } - static computeMissing() { - return `this is just here to get past the unresolved method check`; - } - static computeCyclic() { - return `this is just here to get past the unresolved method check`; - } - static template() { - return () => ``; - } -} - -customElements.define( - 'test-element-computed-properties-errors', - TestElementComputedPropertiesErrors -); diff --git a/test/fixture-element-listeners.js b/test/fixture-element-listeners.js deleted file mode 100644 index 6b3d547..0000000 --- a/test/fixture-element-listeners.js +++ /dev/null @@ -1,51 +0,0 @@ -import ElementMixin from '../mixins/element-mixin.js'; -import ListenersMixin from '../mixins/listeners-mixin.js'; - -class TestElement extends ListenersMixin(ElementMixin(HTMLElement)) { - static get listeners() { - return { click: 'onClick' }; - } - - static get observedAttributes() { - return ['clicks', 'count']; - } - - static template() { - return ({ clicks, count }) => ` - - - clicks: ${clicks} count ${count} - `; - } - - attributeChangedCallback() { - this.invalidate(); - } - - get clicks() { - return Number(this.getAttribute('clicks')); - } - - set clicks(value) { - this.setAttribute('clicks', value); - } - - get count() { - return Number(this.getAttribute('count')); - } - - set count(value) { - this.setAttribute('count', value); - } - - onClick(evt) { - this.clicks++; - if (evt.target.id === 'increment') { - this.count++; - } else if (evt.target.id === 'decrement') { - this.count--; - } - } -} - -customElements.define('test-element-listeners', TestElement); diff --git a/test/fixture-element-observed-properties.js b/test/fixture-element-observed-properties.js deleted file mode 100644 index 962161b..0000000 --- a/test/fixture-element-observed-properties.js +++ /dev/null @@ -1,104 +0,0 @@ -import XElementProperties from '../x-element-properties.js'; - -class TestElementObservedProperties extends XElementProperties { - static get properties() { - return { - a: { - type: String, - observer: 'observeA', - }, - b: { - type: String, - observer: 'observeB', - }, - c: { - type: String, - computed: 'computeC(a, b)', - observer: 'observeC', - }, - changes: { - type: Array, - }, - popped: { - type: Boolean, - reflect: true, - observer: 'observePopped', - }, - }; - } - computeC(a, b) { - return `${a} ${b}`; - } - // TODO: #26: switch order of arguments. - observeA(oldValue, newValue) { - const changes = Object.assign([], this.changes); - changes.push({ property: 'a', newValue, oldValue }); - this.changes = changes; - } - // TODO: #26: switch order of arguments. - observeB(oldValue, newValue) { - const changes = Object.assign([], this.changes); - changes.push({ property: 'b', newValue, oldValue }); - this.changes = changes; - } - // TODO: #26: switch order of arguments. - observeC(oldValue, newValue) { - const changes = Object.assign([], this.changes); - changes.push({ property: 'c', newValue, oldValue }); - this.changes = changes; - } - // TODO: #26: switch order of arguments. - observePopped(oldValue, newValue) { - const changes = Object.assign([], this.changes); - changes.push({ property: 'popped', newValue, oldValue }); - this.changes = changes; - } - static template() { - return ({ changes }) => { - return ` - -
-
Changes:
-
    ${(changes || []) - .map(({ property, oldValue, newValue }) => { - return `
  • ${property}: "${oldValue}" → "${newValue}"
  • `; - }) - .join('')} -
-
- `; - }; - } -} - -customElements.define( - 'test-element-observed-properties', - TestElementObservedProperties -); - -class TestElementObservedPropertiesErrors extends XElementProperties { - static get properties() { - return { - dne: { - type: Boolean, - observer: 'thisDNE', - }, - }; - } -} - -customElements.define( - 'test-element-observed-properties-errors', - TestElementObservedPropertiesErrors -); diff --git a/test/fixture-element-read-only-properties.js b/test/fixture-element-read-only-properties.js deleted file mode 100644 index 2c3fe72..0000000 --- a/test/fixture-element-read-only-properties.js +++ /dev/null @@ -1,31 +0,0 @@ -import XElementProperties from '../x-element-properties.js'; - -class TestElement extends XElementProperties { - static template() { - return ({ readOnlyProperty }) => ` -
- ${readOnlyProperty} -
- `; - } - - static get properties() { - return { - readOnlyProperty: { - type: String, - readOnly: true, - }, - }; - } - - connectedCallback() { - super.connectedCallback(); - // TODO: improve interface for readOnly properties. - const property = 'readOnlyProperty'; - const definition = this.propertyDefinitions[property]; - const value = 'Ferus'; - this.constructor.changeProperty(this, property, definition, value); - } -} - -customElements.define('test-element-read-only-properties', TestElement); diff --git a/test/fixture-element-scratch.js b/test/fixture-element-scratch.js deleted file mode 100644 index 92e531c..0000000 --- a/test/fixture-element-scratch.js +++ /dev/null @@ -1,110 +0,0 @@ -import XElement from '../x-element.js'; - -class TestElement extends XElement { - static template(html) { - return ({ prop1 }) => html` - ${prop1} - `; - } - - static get properties() { - return { - // reflected with no value - prop1: { - type: String, - reflect: true, - }, - // reflected with falsy initial value (null) - prop2: { - type: String, - value: null, - reflect: true, - }, - // reflected with falsy initial value (undefined) - prop3: { - type: String, - value: null, - reflect: true, - }, - // reflected with falsy initial value (false) - prop4: { - type: String, - value: false, - reflect: true, - }, - // reflected with initial value - prop5: { - type: String, - value: 'test', - reflect: true, - }, - // Boolean without initial value - prop6: { - type: Boolean, - reflect: true, - }, - // Boolean with `false` initial value - prop7: { - type: Boolean, - value: false, - reflect: true, - }, - // Boolean with `true` initial value - prop8: { - type: Boolean, - value: true, - reflect: true, - }, - // Boolean with truthy initial value (String) - prop9: { - type: Boolean, - value: 'ok', - reflect: true, - }, - // Boolean with falsy initial value (Number) - prop10: { - type: Boolean, - value: 0, - reflect: true, - }, - arrayProp: { - type: Array, - value: () => ['foo', 'bar'], - }, - objProp: { - type: Object, - value: () => { - return { foo: 'bar' }; - }, - }, - objPropReflect: { - type: Object, - reflect: true, - value: () => { - return { foo: 'bar' }; - }, - }, - objDateProp: { - type: Date, - value: () => { - return new Date(); - }, - }, - objMapProp: { - type: Map, - value: () => { - return new Map(); - }, - }, - computedProp: { - type: String, - computed: 'computeComputedProp(prop1, prop2)', - }, - }; - } - static computeComputedProp(prop1, prop2) { - return `${prop1} ${prop2}`; - } -} - -customElements.define('test-element-scratch', TestElement); diff --git a/test/fixture-element-upgrade.js b/test/fixture-element-upgrade.js deleted file mode 100644 index ebab875..0000000 --- a/test/fixture-element-upgrade.js +++ /dev/null @@ -1,35 +0,0 @@ -import XElementBasic from '../x-element-basic.js'; - -export default class TestElement extends XElementBasic { - constructor() { - super(); - this._readOnlyProperty = 'didelphidae'; - this._readOnlyKey = 'didelphimorphia'; - Reflect.defineProperty(this, 'readOnlyDefinedProperty', { - value: 'phalangeriformes', - configurable: false, - }); - } - - static template() { - return ({ readOnlyProperty }) => `
${readOnlyProperty}
`; - } - - get readOnlyProperty() { - return this._readOnlyProperty; - } - - get [Symbol.for('readOnlyKey')]() { - return this._readOnlyKey; - } - - get reflectedProperty() { - return this.getAttribute('reflected-property'); - } - - set reflectedProperty(value) { - this.setAttribute('reflected-property', value); - } -} - -// NOTE, this is not defined in this file on purpose. diff --git a/test/index.js b/test/index.js index a36423f..a4af403 100644 --- a/test/index.js +++ b/test/index.js @@ -1,12 +1,23 @@ -import { test } from '../../../@netflix/x-test/x-test.js'; +import { test, cover } from '../../../@netflix/x-test/x-test.js'; +// We import this here so we can see code coverage. +import '../x-element.js'; + +// Set a high bar for code coverage! +cover(new URL('../x-element.js', import.meta.url).href, 100); + +test('./test-analysis-errors.html'); +test('./test-connected-errors.html'); +test('./test-attribute-changed-errors.html'); test('./test-upgrade.html'); -test('./test-basic.html'); +test('./test-render-root.html'); test('./test-listeners.html'); test('./test-attr-binding.html'); test('./test-attr-reflection.html'); +test('./test-normal-properties.html'); test('./test-read-only-properties.html'); -test('./test-graph.html'); -test('./test-computed-properties.html'); +test('./test-internal-properties.html'); +test('./test-resolved-properties.html'); test('./test-observed-properties.html'); +test('./test-render.html'); test('./test-scratch.html'); diff --git a/test/test-analysis-errors.html b/test/test-analysis-errors.html new file mode 100644 index 0000000..fd6ac1f --- /dev/null +++ b/test/test-analysis-errors.html @@ -0,0 +1,8 @@ + + + + + +

View Console

+ + diff --git a/test/test-analysis-errors.js b/test/test-analysis-errors.js new file mode 100644 index 0000000..29c5414 --- /dev/null +++ b/test/test-analysis-errors.js @@ -0,0 +1,583 @@ +import XElement from '../x-element.js'; +import { assert, it } from '../../../@netflix/x-test/x-test.js'; + +it('properties should be an object', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return undefined; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'TestElement.properties has an unexpected value (expected Object, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('properties should not shadow XElement prototype interface', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { internal: {} }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected key "TestElement.properties.internal" shadows XElement.prototype interface (constructor, connectedCallback, attributeChangedCallback, adoptedCallback, disconnectedCallback, render, listen, unlisten, dispatchError, internal).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('properties should not shadow prototype chain interface', () => { + let passed = false; + let message = 'no error was thrown'; + const consoleWarn = console.warn; // eslint-disable-line no-console + console.warn = error => { // eslint-disable-line no-console + message = error.message; + if (error.message === 'Unexpected key "TestElement.properties.title" shadows inherited property name, behavior not guaranteed.') { + passed = true; + } + }; + class TestElement extends XElement { + static get properties() { + return { title: {} }; + } + } + customElements.define('test-element-warning', TestElement); + console.warn = consoleWarn; // eslint-disable-line no-console + assert(passed, message); +}); + +it('properties should only be from our known set', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badProperty: { doesNotExist: true } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected key "TestElement.properties.badProperty.doesNotExist".'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('properties should be objects', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badProperty: undefined }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'TestElement.properties.badProperty has an unexpected value (expected Object, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('type should be a function', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badType: { type: undefined } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badType.type" (expected Function, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('resolver should be a function', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badResolver: { resolver: undefined } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badResolver.resolver" (expected Function, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('observer should be a function', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badObserver: { observer: undefined } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badObserver.observer" (expected Function, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('attribute should be a string', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badAttribute: { attribute: undefined } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badAttribute.attribute" (expected String, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('attribute should be a non-empty string', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badAttribute: { attribute: '' } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badAttribute.attribute" (expected non-empty String).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('attributes cannot be duplicated', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { attribute: {}, aliased: { attribute: 'attribute' } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'TestElement.properties.aliased causes a duplicated attribute "attribute".'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('value must be a simple value or a function', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badValue: { value: {} } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badValue.value" (expected Boolean, String, Number, or Function, got Object).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('reflect should be a boolean', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badReflect: { reflect: undefined } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badReflect.reflect" (expected Boolean, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('internal should be a boolean', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badInternal: { internal: undefined } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badInternal.internal" (expected Boolean, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('readOnly should be a boolean', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badReadOnly: { readOnly: undefined } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badReadOnly.readOnly" (expected Boolean, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('dependencies should be an array', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badDependencies: { dependencies: {} } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badDependencies.dependencies" (expected Array, got Object).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('dependencies items should be strings', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { badDependenciesItems: { dependencies: ['foo', 'bar', undefined] } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.badDependenciesItems.dependencies[2]" (expected String, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('dependencies must be declared as other properties', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { + foo: {}, + badDependenciesItems: { + dependencies: ['foo', 'bar'], + resolver: () => {}, + }, + }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'TestElement.properties.badDependenciesItems.dependencies[1] has an unexpected item ("bar" has not been declared).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('dependencies cannot be cyclic (simple)', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { + simpleCycle: { dependencies: ['simpleCycle'], resolver: () => {} }, + }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'TestElement.properties.simpleCycle.dependencies are cyclic.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('dependencies cannot be cyclic (complex)', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { + a: { dependencies: ['c'], resolver: () => {} }, + b: { dependencies: ['a'], resolver: () => {} }, + c: { dependencies: ['b'], resolver: () => {} }, + }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'TestElement.properties.a.dependencies are cyclic.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('dependencies cannot be declared without a resolver', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { missingResolver: { dependencies: ['foo', 'bar', 'baz'] } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Found "TestElement.properties.missingResolver.dependencies" without "TestElement.properties.missingResolver.resolver" (dependencies require a resolver).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('value cannot be declared alongside a resolver', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { unexpectedValue: { resolver: () => {}, value: 5 } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Found "TestElement.properties.unexpectedValue.value" and "TestElement.properties.unexpectedValue.resolver" (resolved properties cannot use value initializer).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('readOnly cannot be declared alongside a resolver', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { unexpectedValue: { resolver: () => {}, readOnly: true } }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Found "TestElement.properties.unexpectedValue.readOnly" and "TestElement.properties.unexpectedValue.resolver" (resolved properties cannot define read-only).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('internal properties cannot also be readOnly', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { + internalReadOnlyProperty: { + type: String, + internal: true, + readOnly: true, + }, + }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Both "TestElement.properties.internalReadOnlyProperty.internal" and "TestElement.properties.internalReadOnlyProperty.readOnly" are true (read-only properties cannot be internal).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('internal properties cannot also be reflected', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { + internalReflectedProperty: { + type: String, + internal: true, + reflect: true, + }, + }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Both "TestElement.properties.internalReflectedProperty.reflect" and "TestElement.properties.internalReflectedProperty.internal" are true (reflected properties cannot be internal).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('internal properties cannot define an attribute', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { + internalAttributeProperty: { + type: String, + internal: true, + attribute: 'custom-attribute', + }, + }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Found "TestElement.properties.internalAttributeProperty.attribute" but "TestElement.properties.internalAttributeProperty.internal" is true (internal properties cannot have attributes).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('reflected properties must have serializable type', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { + nonSerializableProperty: { + type: Object, + reflect: true, + }, + }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Found unserializable "TestElement.properties.nonSerializableProperty.type" (Object) but "TestElement.properties.nonSerializableProperty.reflect" is true.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('reflected properties must have serializable type (2)', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get properties() { + return { + typelessProperty: { + reflect: true, + }, + }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'Found unserializable "TestElement.properties.typelessProperty.type" (Undefined) but "TestElement.properties.typelessProperty.reflect" is true.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('listeners should be an object', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get listeners() { + return undefined; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'TestElement.listeners has unexpected value (expected Object, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('listeners as an object should map to functions', () => { + let passed = false; + let message = 'no error was thrown'; + try { + class TestElement extends XElement { + static get listeners() { + return { foo: undefined }; + } + } + customElements.define('test-element', TestElement); + } catch (error) { + const expected = 'TestElement.listeners.foo has unexpected value (expected Function, got Undefined).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); diff --git a/test/test-attr-binding.js b/test/test-attr-binding.js index cb0a98d..f58cc10 100644 --- a/test/test-attr-binding.js +++ b/test/test-attr-binding.js @@ -1,53 +1,94 @@ -import './fixture-element-attr-binding.js'; +import XElement from '../x-element.js'; import { assert, it } from '../../../@netflix/x-test/x-test.js'; -it('converts dash to camel case and back', () => { - const el = document.createElement('test-element-attr-binding'); - assert(el.constructor.dashToCamelCase('foo-bar') === 'fooBar'); - assert(el.constructor.camelToDashCase('fooBar') === 'foo-bar'); -}); +class TestElement extends XElement { + static get properties() { + return { + camelCaseProperty: { + type: String, + value: 'Bactrian', + }, + numericProperty: { + type: Number, + value: 10, + }, + nullProperty: { + type: String, + value: null, + }, + typelessProperty: {}, + internalProperty: { + internal: true, + }, + }; + } -it('renders an empty string in place of null value', () => { - const el = document.createElement('test-element-attr-binding'); - document.body.appendChild(el); - assert(el.shadowRoot.querySelector('#nul').textContent === ''); -}); + static template(html) { + return ({ camelCaseProperty, numericProperty, nullProperty }) => { + return html` +
+ ${camelCaseProperty} + ${numericProperty} + ${nullProperty} +
+ `; + }; + } +} +customElements.define('test-element', TestElement); it('renders the initial value', () => { - const el = document.createElement('test-element-attr-binding'); - document.body.appendChild(el); - assert(el.shadowRoot.querySelector('#camel').textContent === 'Bactrian'); + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.shadowRoot.getElementById('camel').textContent === 'Bactrian'); +}); + +it('renders an empty string in place of null value', () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.shadowRoot.getElementById('nul').textContent === ''); }); it('property setter updates on next micro tick after connect', async () => { - const el = document.createElement('test-element-attr-binding'); + const el = document.createElement('test-element'); el.camelCaseProperty = 'Nonconforming'; - document.body.appendChild(el); - assert(el.shadowRoot.querySelector('#camel').textContent === 'Nonconforming'); + + // Initial render happens synchronously after initial connection. + document.body.append(el); + assert(el.shadowRoot.getElementById('camel').textContent === 'Nonconforming'); + + // Updates are debounced on a microtask, so they are not immediately seen. el.camelCaseProperty = 'Dromedary'; - assert(el.shadowRoot.querySelector('#camel').textContent === 'Nonconforming'); - await true; - assert(el.shadowRoot.querySelector('#camel').textContent === 'Dromedary'); + assert(el.shadowRoot.getElementById('camel').textContent === 'Nonconforming'); + + // After the microtask runs, the update is handled. + await Promise.resolve(); + assert(el.shadowRoot.getElementById('camel').textContent === 'Dromedary'); }); it('property setter renders blank value', async () => { - const el = document.createElement('test-element-attr-binding'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); el.camelCaseProperty = ''; - await true; - assert(el.shadowRoot.querySelector('#camel').textContent === ''); + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.shadowRoot.getElementById('camel').textContent === ''); el.camelCaseProperty = 'Bactrian'; - await true; - assert(el.shadowRoot.querySelector('#camel').textContent === 'Bactrian'); + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.shadowRoot.getElementById('camel').textContent === 'Bactrian'); }); it('observes all dash-cased versions of declared properties', () => { - const el = document.createElement('test-element-attr-binding'); + const el = document.createElement('test-element'); const expected = [ 'camel-case-property', 'numeric-property', 'null-property', 'typeless-property', + 'internal-property', ]; const actual = el.constructor.observedAttributes; assert(expected.length === actual.length); @@ -55,38 +96,44 @@ it('observes all dash-cased versions of declared properties', () => { }); it('removeAttribute renders blank', async () => { - const el = document.createElement('test-element-attr-binding'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); el.removeAttribute('camel-case-property'); - await true; + + // We must await a microtask for the update to take place. + await Promise.resolve(); // Note, in general, changing non-reflected properties via attributes can // be problematic. For example, attributeChangedCallback is not fired if the // attribute does not change. - assert(el.shadowRoot.querySelector('#camel').textContent !== ''); + assert(el.shadowRoot.getElementById('camel').textContent !== ''); el.setAttribute('camel-case-property', 'foo'); el.removeAttribute('camel-case-property'); - await true; - assert(el.shadowRoot.querySelector('#camel').textContent === ''); + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.shadowRoot.getElementById('camel').textContent === ''); }); it('setAttribute renders the new value', async () => { - const el = document.createElement('test-element-attr-binding'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); el.setAttribute('camel-case-property', 'Racing Camel'); - await true; - assert(el.shadowRoot.querySelector('#camel').textContent === 'Racing Camel'); + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.shadowRoot.getElementById('camel').textContent === 'Racing Camel'); }); -it('coerces attributes to the specified type', async () => { - const el = document.createElement('test-element-attr-binding'); - document.body.appendChild(el); +it('coerces attributes to the specified type', () => { + const el = document.createElement('test-element'); + document.body.append(el); el.setAttribute('numeric-property', '-99'); assert(el.numericProperty === -99); }); it('allows properties without types', () => { - const el = document.createElement('test-element-attr-binding'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); for (const value of [{}, 'foo', '5', [], 2]) { el.typelessProperty = value; assert(el.typelessProperty === value); @@ -95,3 +142,10 @@ it('allows properties without types', () => { el.setAttribute('typeless-property', attributeValue); assert(el.typelessProperty === attributeValue); }); + +it('initializes from attributes on connect', () => { + const el = document.createElement('test-element'); + el.setAttribute('camel-case-property', 'Dromedary'); + document.body.append(el); + assert(el.camelCaseProperty === 'Dromedary'); +}); diff --git a/test/test-attr-reflection.js b/test/test-attr-reflection.js index d463c86..a984bbe 100644 --- a/test/test-attr-reflection.js +++ b/test/test-attr-reflection.js @@ -1,70 +1,106 @@ +import XElement from '../x-element.js'; import { it, assert } from '../../../@netflix/x-test/x-test.js'; -import './fixture-element-attr-reflection.js'; + +class TestElement extends XElement { + static get properties() { + return { + camelCaseProperty: { + type: String, + value: 'reflectedCamel', + reflect: true, + }, + overrideProperty: { + type: String, + value: 'override_me', + reflect: true, + }, + booleanPropertyTrue: { + type: Boolean, + value: true, + reflect: true, + }, + booleanPropertyFalse: { + type: Boolean, + value: false, + reflect: true, + }, + }; + } + static template(html) { + return ({ camelCaseProperty }) => { + return html`${camelCaseProperty}`; + }; + } + connectedCallback() { + super.connectedCallback(); + this.overrideProperty = 'overridden'; + } +} +customElements.define('test-element', TestElement); + it('reflects initial value', () => { - const el = document.createElement('test-element-attr-reflection'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); assert(el.getAttribute('camel-case-property') === 'reflectedCamel'); }); it('renders the template with the initial value', () => { - const el = document.createElement('test-element-attr-reflection'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); assert(el.shadowRoot.querySelector('span').textContent === 'reflectedCamel'); }); it('reflects initial value (Boolean, true)', () => { - const el = document.createElement('test-element-attr-reflection'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); assert(el.hasAttribute('boolean-property-true')); }); it('does not reflect initial value (Boolean, false)', () => { - const el = document.createElement('test-element-attr-reflection'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); assert(el.hasAttribute('boolean-property-false') === false); }); it('reflects next value after a micro tick', async () => { - const el = document.createElement('test-element-attr-reflection'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); el.camelCaseProperty = 'dromedary'; assert( - el.getAttribute('camel-case-property') === 'dromedary' && + el.getAttribute('camel-case-property') === 'reflectedCamel' && el.shadowRoot.querySelector('span').textContent === 'reflectedCamel' ); - await true; + + // We must await a microtask for the update to take place. + await Promise.resolve(); assert(el.shadowRoot.querySelector('span').textContent === 'dromedary'); }); -it('has reflected override value after connected', () => { - const el = document.createElement('test-element-attr-reflection'); - document.body.appendChild(el); +it('has reflected override value after connected', async () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.getAttribute('override-property') === 'override_me'); + + // We must await a microtask for the update to take place. + await Promise.resolve(); assert(el.getAttribute('override-property') === 'overridden'); }); -it('does not reflect next false value (Boolean)', () => { - const el = document.createElement('test-element-attr-reflection'); - document.body.appendChild(el); - el.booleanPropertyTrue = true; +it('does not reflect next false value (Boolean)', async () => { + const el = document.createElement('test-element'); + document.body.append(el); assert(el.hasAttribute('boolean-property-true')); el.booleanPropertyTrue = false; + assert(el.hasAttribute('boolean-property-true')); + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.hasAttribute('boolean-property-true') === false); + el.booleanPropertyTrue = true; assert(el.hasAttribute('boolean-property-true') === false); -}); -it('throws when trying to reflect a typeless property', () => { - const el = document.createElement('test-element-attr-reflection'); - document.body.appendChild(el); - const message = - 'Attempted to serialize "typelessProperty" and reflect, but it is not a Boolean, String, or Number type (undefined).'; - let errored = false; - el.addEventListener('error', evt => { - if (evt.error.message === message) { - errored = true; - evt.stopPropagation(); - } - }, { once: true }); - el.typelessProperty = 'foo'; - assert(el.hasAttribute('typeless-property') === false); - assert(errored === true); + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.hasAttribute('boolean-property-true')); }); diff --git a/test/test-attribute-changed-errors.html b/test/test-attribute-changed-errors.html new file mode 100644 index 0000000..a4dd089 --- /dev/null +++ b/test/test-attribute-changed-errors.html @@ -0,0 +1,8 @@ + + + + + +

View Console

+ + diff --git a/test/test-attribute-changed-errors.js b/test/test-attribute-changed-errors.js new file mode 100644 index 0000000..7104ed6 --- /dev/null +++ b/test/test-attribute-changed-errors.js @@ -0,0 +1,109 @@ +import XElement from '../x-element.js'; +import { assert, it } from '../../../@netflix/x-test/x-test.js'; + +it('errors are thrown in attributeChangedCallback for setting values with bad types', () => { + // We cannot try-catch setAttribute, so we fake the attributeChangedCallback. + class TestElement extends XElement { + static get properties() { + return { + object: { + type: Object, + }, + }; + } + } + customElements.define('test-element-1', TestElement); + const el = new TestElement(); + el.connectedCallback(); + let passed = false; + let message = 'no error was thrown'; + try { + el.attributeChangedCallback('object', null, '{}'); + } catch (error) { + const expected = 'Unexpected deserialization for "TestElement.properties.object" (cannot deserialize into Object).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('errors are thrown in attributeChangedCallback for read-only properties', () => { + // We cannot try-catch setAttribute, so we fake the attributeChangedCallback. + class TestElement extends XElement { + static get properties() { + return { + readOnlyProperty: { + type: String, + readOnly: true, + }, + }; + } + } + customElements.define('test-element-2', TestElement); + const el = new TestElement(); + let passed = false; + let message = 'no error was thrown'; + el.connectedCallback(); + try { + el.attributeChangedCallback('read-only-property', null, 'nope'); + } catch (error) { + const expected = 'Property "TestElement.properties.readOnlyProperty" is read-only (internal.readOnlyProperty).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('errors are thrown in attributeChangedCallback for internal properties', () => { + // We cannot try-catch setAttribute, so we fake the attributeChangedCallback. + class TestElement extends XElement { + static get properties() { + return { + internalProperty: { + type: String, + internal: true, + }, + }; + } + } + customElements.define('test-element-3', TestElement); + const el = new TestElement(); + el.connectedCallback(); + let passed = false; + let message = 'no error was thrown'; + try { + el.attributeChangedCallback('internal-property', null, 'nope'); + } catch (error) { + const expected = 'Property "TestElement.properties.internalProperty" is internal (internal.internalProperty).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('errors are thrown in attributeChangedCallback for resolved properties', () => { + // We cannot try-catch setAttribute, so we fake the attributeChangedCallback. + class TestElement extends XElement { + static get properties() { + return { + resolved: { + type: String, + resolver: () => {}, + }, + }; + } + } + customElements.define('test-element-4', TestElement); + const el = new TestElement(); + el.connectedCallback(); + let passed = false; + let message = 'no error was thrown'; + try { + el.attributeChangedCallback('resolved', null, 'nope'); + } catch (error) { + const expected = 'Property "TestElement.properties.resolved" is resolved (resolved properties are read-only).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); diff --git a/test/test-basic.js b/test/test-basic.js deleted file mode 100644 index 8fa92a6..0000000 --- a/test/test-basic.js +++ /dev/null @@ -1,38 +0,0 @@ -import './fixture-element-basic.js'; -import { it, assert } from '../../../@netflix/x-test/x-test.js'; - -it('upgrades the element with a shadowRoot', () => { - const el = document.createElement('test-element-basic'); - assert(el.shadowRoot instanceof DocumentFragment); -}); - -it('renders the template with variables', () => { - const el = document.createElement('test-element-basic'); - document.body.appendChild(el); - assert(el.shadowRoot.querySelector('span').textContent === 'Hello world.'); -}); - -it('has correct innerHTML', () => { - const el = document.createElement('test-element-basic'); - document.body.appendChild(el); - assert(el.shadowRoot.innerHTML.trim() === 'Hello world.'); -}); - -it('has override value after connected', () => { - const el = document.createElement('test-element-basic'); - document.body.appendChild(el); - assert(el.overrideProperty === 'overridden'); -}); - -it('has expected boolean functionality', () => { - const el = document.createElement('test-element-basic'); - document.body.appendChild(el); - el.booleanProperty = true; - assert(el.booleanProperty === true); - el.booleanProperty = 1; - assert(el.booleanProperty === true); - el.booleanProperty = 'ok'; - assert(el.booleanProperty === true); - el.removeAttribute('boolean-property'); - assert(el.booleanProperty === false); -}); diff --git a/test/test-computed-properties.js b/test/test-computed-properties.js deleted file mode 100644 index 7e4dc61..0000000 --- a/test/test-computed-properties.js +++ /dev/null @@ -1,181 +0,0 @@ -import { it, assert, todo } from '../../../@netflix/x-test/x-test.js'; -import './fixture-element-computed-properties.js'; - -const parsingTestCases = [ - { - label: 'parses simple case', - computed: 'computeC(a, b)', - expected: { methodName: 'computeC', dependencies: ['a', 'b'] }, - }, - { - label: 'parses multiline case', - computed: ` - computeC( - a, - b - ) - `, - expected: { methodName: 'computeC', dependencies: ['a', 'b'] }, - }, - { - label: 'allows trailing commas', - computed: ` - computeC( - a, - b, - ) - `, - expected: { methodName: 'computeC', dependencies: ['a', 'b'] }, - }, - { - label: 'does not allow middle commas', - computed: ` - computeC( - a,, - b, - ) - `, - expected: undefined, - }, - { - label: 'does not allow spaces in tokens', - computed: ` - computeC( - a a, - b, - ) - `, - expected: undefined, - }, - { - label: 'does not allow commas in method name', - computed: 'comp,uteC(a, b)', - expected: undefined, - }, - { - label: 'does not allow spaces in method name', - computed: 'comp uteC(a, b)', - expected: undefined, - }, - { - label: 'does not allow parentheses in tokens (0)', - computed: 'computeC(a), b)', - expected: undefined, - }, - { - label: 'does not allow parentheses in tokens (1)', - computed: 'computeC(a(, b)', - expected: undefined, - }, -]; - -it('should dispatch expected errors', () => { - const malformedMessage = `Malformed computed "malformed(a,,b)".`; - const unresolvedMessage = `Cannot resolve methodName "thisDNE".`; - const missingMessage = `Missing dependency "notDeclared".`; - const cyclicMessage = 'Computed properties are cyclic.'; - let malformed = false; - let unresolved = false; - let missing = false; - let cyclic = false; - const onError = evt => { - evt.stopPropagation(); - if (evt.error.message === malformedMessage) { - malformed = true; - } else if (evt.error.message === unresolvedMessage) { - unresolved = true; - } else if (evt.error.message === missingMessage) { - missing = true; - } else if (evt.error.message === cyclicMessage) { - cyclic = true; - } else { - throw new Error(`unexpected malformedMessage: "${malformedMessage}"`); - } - }; - const el = document.createElement('test-element-computed-properties-errors'); - el.addEventListener('error', onError); - document.body.appendChild(el); - assert(malformed); - assert(unresolved); - assert(missing); - assert(cyclic); -}); - -it('computed property dsl', () => { - const el = document.createElement('test-element-computed-properties-errors'); - for (const { label, computed, expected } of parsingTestCases) { - const actual = el.constructor.parseComputed(computed); - assert(JSON.stringify(actual) === JSON.stringify(expected), label); - } -}); - -it('initializes as expected', () => { - const el = document.createElement('test-element-computed-properties'); - document.body.appendChild(el); - assert(el.a === undefined); - assert(el.b === undefined); - assert(el.y === undefined); - assert(el.z === undefined); - assert(el.countTrigger === undefined); - // TODO: #27. Should revert these. - assert(Number.isNaN(el.c)); - assert(el.negative === false); - assert(el.underline === false); -}); - -todo('Issue #27', `don't initialize until a dependency is defined`, () => { - const el = document.createElement('test-element-computed-properties'); - document.body.appendChild(el); - assert(el.c === undefined); - assert(el.negative === undefined); -}); - -it('properties are recomputed when dependencies change (a, b)', () => { - const el = document.createElement('test-element-computed-properties'); - document.body.appendChild(el); - el.a = 1; - el.b = -2; - assert(el.a === 1); - assert(el.b === -2); - assert(el.c === -1); - assert(el.negative === true); - assert(el.underline === true); -}); - -it('properties are recomputed when dependencies change (y)', () => { - const el = document.createElement('test-element-computed-properties'); - document.body.appendChild(el); - el.y = true; - assert(el.y === true); - assert(el.z === true); - el.y = false; - assert(el.y === false); - assert(el.z === false); -}); - -it('computed properties can be reflected', () => { - const el = document.createElement('test-element-computed-properties'); - document.body.appendChild(el); - el.a = -1; - el.b = 0; - assert(el.c === -1); - assert(el.negative === true); - assert(el.underline === true); - assert(el.hasAttribute('negative')); - assert(el.hasAttribute('underline')); -}); - -it('skips computation when dependencies are the same', () => { - const el = document.createElement('test-element-computed-properties'); - document.body.appendChild(el); - let count = el.count; - el.countTrigger = 'foo'; - assert(el.count === ++count); - el.countTrigger = 'foo'; - el.countTrigger = 'foo'; - el.countTrigger = 'foo'; - el.countTrigger = 'foo'; - assert(el.count === count); - el.countTrigger = 'bar'; - assert(el.count === ++count); -}); diff --git a/test/test-connected-errors.html b/test/test-connected-errors.html new file mode 100644 index 0000000..691c250 --- /dev/null +++ b/test/test-connected-errors.html @@ -0,0 +1,8 @@ + + + + + +

View Console

+ + diff --git a/test/test-connected-errors.js b/test/test-connected-errors.js new file mode 100644 index 0000000..bf8437f --- /dev/null +++ b/test/test-connected-errors.js @@ -0,0 +1,109 @@ +import XElement from '../x-element.js'; +import { assert, it } from '../../../@netflix/x-test/x-test.js'; + +it('errors are thrown in connectedCallback for initializing values with bad types', () => { + // We cannot try-catch append, so we fake the connectedCallback. + class TestElement extends XElement { + static get properties() { + return { + string: { + type: String, + }, + }; + } + } + customElements.define('test-element-0', TestElement); + const el = new TestElement(); + el.string = 5; + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Unexpected value for "TestElement.properties.string" (expected String, got Number).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('errors are thrown in connectedCallback for initializing read-only properties', () => { + // We cannot try-catch append, so we fake the connectedCallback. + class TestElement extends XElement { + static get properties() { + return { + readOnlyProperty: { + type: String, + readOnly: true, + }, + }; + } + } + customElements.define('test-element-1', TestElement); + const el = new TestElement(); + el.readOnlyProperty = 5; + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Property "TestElement.properties.readOnlyProperty" is read-only (internal.readOnlyProperty).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('errors are thrown in connectedCallback for initializing internal properties', () => { + // We cannot try-catch append, so we fake the connectedCallback. + class TestElement extends XElement { + static get properties() { + return { + internalProperty: { + type: String, + internal: true, + }, + }; + } + } + customElements.define('test-element-2', TestElement); + const el = new TestElement(); + el.internalProperty = 5; + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Property "TestElement.properties.internalProperty" is internal (internal.internalProperty).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('errors are thrown in connectedCallback for initializing resolved properties', () => { + // We cannot try-catch append, so we fake the connectedCallback. + class TestElement extends XElement { + static get properties() { + return { + resolved: { + type: String, + resolver: () => {}, + }, + }; + } + } + customElements.define('test-element-3', TestElement); + const el = new TestElement(); + el.resolved = 5; + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Property "TestElement.properties.resolved" is resolved (resolved properties are read-only).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); diff --git a/test/test-graph.js b/test/test-graph.js deleted file mode 100644 index 5dd3fd5..0000000 --- a/test/test-graph.js +++ /dev/null @@ -1,143 +0,0 @@ -import { it, assert } from '../../../@netflix/x-test/x-test.js'; -import Graph from '../etc/graph.js'; - -const graphsAreEqual = (a, b) => { - // Order of nodes and edges should not matter. - return ( - a.nodes.length === b.nodes.length && - a.nodes.every(n => b.nodes.includes(n)) && - a.edges.length === b.edges.length && - a.edges.every(ae => b.edges.find(be => be[0] === ae[0] && be[1] === ae[1])) - ); -}; - -// Note that Directed Acyclic Graphs can have multiple, correct solutions. -const checkSolution = (solution, graph) => { - // We have to have the same nodes in our solution as we do in the graph. - if ( - solution.length !== graph.nodes.length || - graph.nodes.some(node => solution.includes(node) === false) - ) { - return false; - } - // For each edge, "edge[0]" must precede "edge[1]" in "solution". - for (const edge of graph.edges) { - if (solution.indexOf(edge[1]) <= solution.indexOf(edge[0])) { - return false; - } - } - return true; -}; - -const getDependencyToDependents = () => { - // It's easier to think about dependencies this way, so we compute the define - // dependentToDependencies and compute dependencyToDependents. - const dependentToDependencies = { - b: ['a'], - c: ['a', 'b'], - d: ['b', 'c'], - }; - const dependencyToDependents = {}; - const entries = Object.entries(dependentToDependencies); - for (const [dependent, dependencies] of entries) { - for (const dependency of dependencies) { - if (dependency in dependencyToDependents === false) { - dependencyToDependents[dependency] = []; - } - dependencyToDependents[dependency].push(dependent); - } - } - return dependencyToDependents; -}; - -it('createFromNodeMapping can create a graph for "a"', () => { - const dependencyToDependents = getDependencyToDependents(); - const actual = Graph.createFromNodeMapping('a', dependencyToDependents); - const expected = { - edges: [['a', 'b'], ['b', 'c'], ['c', 'd'], ['b', 'd'], ['a', 'c']], - nodes: ['a', 'b', 'c', 'd'], - }; - assert(graphsAreEqual(actual, expected)); -}); - -it('createFromNodeMapping can create a graph for "b"', () => { - const dependencyToDependents = getDependencyToDependents(); - const actual = Graph.createFromNodeMapping('b', dependencyToDependents); - const expected = { - edges: [['b', 'c'], ['c', 'd'], ['b', 'd']], - nodes: ['b', 'c', 'd'], - }; - assert(graphsAreEqual(actual, expected)); -}); - -it('createFromNodeMapping handles simple cycles', () => { - const actual = Graph.createFromNodeMapping('a', { a: ['a'] }); - const expected = { edges: [['a', 'a']], nodes: ['a'] }; - assert(graphsAreEqual(actual, expected)); -}); - -it('createFromNodeMapping handles complex cycles', () => { - const actual = Graph.createFromNodeMapping('a', { - a: ['b'], - b: ['c'], - c: ['a'], - }); - const expected = { - edges: [['a', 'b'], ['b', 'c'], ['c', 'a']], - nodes: ['a', 'b', 'c'], - }; - assert(graphsAreEqual(actual, expected)); -}); - -it('can sort solve a simple graph', () => { - const graph = new Graph(['c', 'b', 'a'], [['a', 'b'], ['b', 'c']]); - const actual = Graph.sort(graph); - const expected = ['a', 'b', 'c']; - assert(checkSolution(expected, graph)); - assert(checkSolution(actual, graph)); -}); - -it('can sort solve a disconnected graph', () => { - const graph = new Graph(['a', 'b', 'c'], [['a', 'b']]); - const actual = Graph.sort(graph); - const expected = ['c', 'a', 'b']; - assert(checkSolution(expected, graph)); - assert(checkSolution(actual, graph)); -}); - -it('can sort solve a complex graph', () => { - const graph = new Graph( - ['a', 'b', 'c', 'd'], - [['a', 'b'], ['a', 'd'], ['b', 'd'], ['c', 'd']] - ); - const actual = Graph.sort(graph); - const expected = ['c', 'a', 'b', 'd']; - assert(checkSolution(expected, graph)); - assert(checkSolution(actual, graph)); -}); - -it('can find simple cycles', () => { - const graph = new Graph(['a'], [['a', 'a']]); - const actual = Graph.sort(graph); - assert(actual === undefined); -}); - -it('can find complex cycles', () => { - const graph = new Graph( - ['a', 'b', 'c', 'd'], - [['a', 'b'], ['b', 'c'], ['c', 'd'], ['d', 'b']] - ); - const actual = Graph.sort(graph); - assert(actual === undefined); -}); - -it('can have anything for node names', () => { - const graph = new Graph( - ['prop1', 'prop2', 'prop3'], - [['prop1', 'prop2'], ['prop1', 'prop3'], ['prop2', 'prop3']] - ); - const actual = Graph.sort(graph); - const expected = ['prop1', 'prop2', 'prop3']; - assert(checkSolution(expected, graph)); - assert(checkSolution(actual, graph)); -}); diff --git a/test/test-computed-properties.html b/test/test-internal-properties.html similarity index 63% rename from test/test-computed-properties.html rename to test/test-internal-properties.html index 03476d2..02410f5 100644 --- a/test/test-computed-properties.html +++ b/test/test-internal-properties.html @@ -2,7 +2,7 @@ - +

View Console

diff --git a/test/test-internal-properties.js b/test/test-internal-properties.js new file mode 100644 index 0000000..9375cc5 --- /dev/null +++ b/test/test-internal-properties.js @@ -0,0 +1,278 @@ +import XElement from '../x-element.js'; +import { assert, it } from '../../../@netflix/x-test/x-test.js'; + +class TestElement extends XElement { + static get properties() { + return { + internalProperty: { + type: String, + internal: true, + value: 'Ferus', + }, + internalResolvedProperty: { + type: String, + internal: true, + resolver: internalProperty => internalProperty, + dependencies: ['internalProperty'], + }, + }; + } + static template(html) { + return ({ internalProperty }) => { + return html`
${internalProperty}
`; + }; + } +} +customElements.define('test-element', TestElement); + +it('initialization', () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.shadowRoot.textContent === 'Ferus', 'initialized as expected'); +}); + +it('can use "has" api or "in" operator.', () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert('internalProperty' in el.internal, 'The "has" trap does not work.'); +}); + +it('can use "ownKeys" api.', () => { + const el = document.createElement('test-element'); + document.body.append(el); + const ownKeys = Reflect.ownKeys(el.internal); + assert( + ownKeys.length === 2 && ownKeys[0] === 'internalProperty' && ownKeys[1] === 'internalResolvedProperty', + 'The "ownKeys" trap does not work.' + ); +}); + +it('cannot be read on host', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + el.internalProperty; + } catch (error) { + const expected = 'Property "TestElement.properties.internalProperty" is internal (internal.internalProperty).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot be written to on host', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + el.internalProperty = `don't do it`; + } catch (error) { + const expected = 'Property "TestElement.properties.internalProperty" is internal (internal.internalProperty).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('can be read from "internal"', () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.internal.internalProperty === 'Ferus'); +}); + +it('can be written to from "internal"', async () => { + const el = document.createElement('test-element'); + document.body.append(el); + el.internal.internalProperty = 'Dromedary'; + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.shadowRoot.textContent === 'Dromedary', 'written to as expected'); +}); + +it('cannot be written to from "internal" if resolved', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + el.internal.internalResolvedProperty = `don't do it`; + } catch (error) { + const expected = 'Property "TestElement.properties.internalResolvedProperty" is resolved (resolved properties are read-only).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot set to known properties', () => { + class BadTestElement extends XElement { + static get properties() { + return { + internalProperty: { + type: String, + internal: true, + }, + }; + } + static template(html) { + return properties => { + properties.internalProperty = 'Dromedary'; + return html`
${properties.internalProperty}
`; + }; + } + } + customElements.define('bad-test-element-1', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Cannot set "BadTestElement.properties.internalProperty" via "properties".'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot get unknown properties', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + el.internal.doesNotExist; + } catch (error) { + const expected = 'Property "TestElement.properties.doesNotExist" does not exist.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot get unknown properties', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + el.internal.doesNotExist = 'nope'; + } catch (error) { + const expected = 'Property "TestElement.properties.doesNotExist" does not exist.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "defineProperty" on internal.', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + Reflect.defineProperty(el.internal, 'foo', {}); + } catch (error) { + const expected = 'Invalid use of internal proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +// This is a funny one, you can set to undefined, but we strictly don't let you +// "delete" since it has a different meaning and you strictly cannot delete our +// accessors. +it('cannot "delete" on internal.', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + Reflect.deleteProperty(el.internal, 'internalProperty'); + } catch (error) { + const expected = 'Invalid use of internal proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "getOwnPropertyDescriptor" on internal.', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + Reflect.getOwnPropertyDescriptor(el.internal, 'internalProperty'); + } catch (error) { + const expected = 'Invalid use of internal proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "getPrototypeOf" on internal.', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + Reflect.getPrototypeOf(el.internal); + } catch (error) { + const expected = 'Invalid use of internal proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "isExtensible" on internal.', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + Reflect.isExtensible(el.internal); + } catch (error) { + const expected = 'Invalid use of internal proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "preventExtensions" on internal.', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + Reflect.preventExtensions(el.internal); + } catch (error) { + const expected = 'Invalid use of internal proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "setPrototypeOf" on internal.', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + Reflect.setPrototypeOf(el.internal, Array); + } catch (error) { + const expected = 'Invalid use of internal proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); diff --git a/test/test-listeners.js b/test/test-listeners.js index 019f7e2..825b556 100644 --- a/test/test-listeners.js +++ b/test/test-listeners.js @@ -1,9 +1,85 @@ +import XElement from '../x-element.js'; import { assert, it } from '../../../@netflix/x-test/x-test.js'; -import './fixture-element-listeners.js'; + +class TestElement extends XElement { + static get properties() { + return { + clicks: { + type: Number, + value: 0, + }, + count: { + type: Number, + value: 0, + }, + customEventStaticCount: { + type: Number, + value: 0, + }, + customEventCount: { + type: Number, + value: 0, + }, + customEventOnceCount: { + type: Number, + value: 0, + }, + }; + } + static get listeners() { + return { click: this.onClick }; + } + static template(html) { + return ({ clicks, count }) => { + return html` + + + clicks: ${clicks} count ${count} +
+ `; + }; + } + static onClick(host, evt) { + host.clicks++; + if (evt.target.id === 'increment') { + host.count++; + } else if (evt.target.id === 'decrement') { + host.count--; + } + } + static onCustomEventStatic(host) { + if (this === TestElement && host.constructor === TestElement) { + host.customEventStaticCount++; + } + } + onCustomEventOnce() { + if (this.constructor === TestElement) { + this.customEventOnceCount++; + } + } + onCustomEvent() { + if (this.constructor === TestElement) { + this.customEventCount++; + } + } + connectedCallback() { + super.connectedCallback(); + this.listen(this.shadowRoot, 'custom-event', this.onCustomEventOnce, { once: true }); + this.listen(this.shadowRoot, 'custom-event', this.onCustomEvent); + this.listen(this.shadowRoot, 'custom-event', this.constructor.onCustomEventStatic); + } + disconnectedCallback() { + super.disconnectedCallback(); + this.unlisten(this.shadowRoot, 'custom-event', this.onCustomEventOnce, { once: true }); + this.unlisten(this.shadowRoot, 'custom-event', this.onCustomEvent); + this.unlisten(this.shadowRoot, 'custom-event', this.constructor.onCustomEventStatic); + } +} +customElements.define('test-element', TestElement); it('test lifecycle', () => { - const el = document.createElement('test-element-listeners'); - document.body.appendChild(el); + const el = document.createElement('test-element'); + document.body.append(el); assert(el.clicks === 0 && el.count === 0, 'initialized as expected'); el.click(); @@ -19,7 +95,40 @@ it('test lifecycle', () => { el.shadowRoot.getElementById('increment').click(); assert(el.clicks === 2 && el.count === 0, 'removes listeners on disconnect'); - document.body.appendChild(el); + document.body.append(el); el.shadowRoot.getElementById('increment').click(); assert(el.clicks === 3 && el.count === 1, 'adds back listeners on reconnect'); }); + +it('test connectedCallback lifecycle', () => { + const el = document.createElement('test-element'); + document.body.append(el); + const eventEmitter = el.shadowRoot.getElementById('custom-event-emitter'); + eventEmitter.dispatchEvent(new CustomEvent('custom-event', { bubbles: true })); + assert(el.customEventOnceCount === 1, 'once method was called'); + assert(el.customEventCount === 1, 'instance method was called'); + assert(el.customEventStaticCount === 1, 'static method was called'); + eventEmitter.dispatchEvent(new CustomEvent('custom-event', { bubbles: true })); + assert(el.customEventOnceCount === 1, 'once method was not called again'); + assert(el.customEventCount === 2, 'instance method was called'); + assert(el.customEventStaticCount === 2, 'static method was called'); + document.body.removeChild(el); + eventEmitter.dispatchEvent(new CustomEvent('custom-event', { bubbles: true })); + assert(el.customEventOnceCount === 1, 'once method was not called again'); + assert(el.customEventCount === 2, 'instance method was not called again'); + assert(el.customEventStaticCount === 2, 'static method was not called again'); +}); + +it('test manual lifecycle', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let count = 0; + const onManualCheck = () => count++; + el.listen(el.shadowRoot, 'manual-check', onManualCheck); + el.shadowRoot.dispatchEvent(new CustomEvent('manual-check')); + el.shadowRoot.dispatchEvent(new CustomEvent('manual-check')); + assert(count === 2, 'listener was added'); + el.unlisten(el.shadowRoot, 'manual-check', onManualCheck); + el.shadowRoot.dispatchEvent(new CustomEvent('manual-check')); + assert(count === 2, 'listener was removed'); +}); diff --git a/test/test-normal-properties.html b/test/test-normal-properties.html new file mode 100644 index 0000000..1f4aad6 --- /dev/null +++ b/test/test-normal-properties.html @@ -0,0 +1,8 @@ + + + + + +

View Console

+ + diff --git a/test/test-normal-properties.js b/test/test-normal-properties.js new file mode 100644 index 0000000..a31c299 --- /dev/null +++ b/test/test-normal-properties.js @@ -0,0 +1,428 @@ +import XElement from '../x-element.js'; +import { assert, it } from '../../../@netflix/x-test/x-test.js'; + +class TestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + value: 'Ferus', + }, + objectProperty: { + type: Object, + }, + }; + } + static template(html) { + return ({ normalProperty }) => { + return html`
${normalProperty}
`; + }; + } +} +customElements.define('test-element', TestElement); + +it('initialization', () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.shadowRoot.textContent === 'Ferus', 'initialized as expected'); +}); + +it('can use "has" api or "in" operator.', () => { + class TempTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + if (!('normalProperty' in properties) || Reflect.has(properties, 'normalProperty') === false) { + throw new Error('The "has" trap does not work.'); + } + return html``; + }; + } + } + customElements.define('temp-test-element-1', TempTestElement); + const el = new TempTestElement(); + let passed = false; + let message; + try { + el.connectedCallback(); + passed = true; + } catch (error) { + message = error.message; + } + assert(passed, message); +}); + +it('can use "ownKeys" api.', () => { + class TempTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + const ownKeys = Reflect.ownKeys(properties); + if (ownKeys.length !== 1 || ownKeys[0] !== 'normalProperty') { + throw new Error('The "ownKeys" trap does not work.'); + } + return html``; + }; + } + } + customElements.define('temp-test-element-2', TempTestElement); + const el = new TempTestElement(); + let passed = false; + let message; + try { + el.connectedCallback(); + passed = true; + } catch (error) { + message = error.message; + } + assert(passed, message); +}); + +it('can be read', async () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.normalProperty === 'Ferus', 'property was read'); + el.normalProperty = 'Dromedary'; + assert(el.normalProperty === 'Dromedary', 'property was written to'); + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert( + el.shadowRoot.textContent === 'Dromedary', + 'text content was updated', + ); +}); + +it('inheritance is considered in type checking', () => { + const el = document.createElement('test-element'); + document.body.append(el); + const array = []; + el.objectProperty = array; + assert(el.objectProperty === array, 'property was set'); +}); + +it('cannot set to known properties', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + properties.normalProperty = 'Dromedary'; + return html`
${properties.normalProperty}
`; + }; + } + } + customElements.define('bad-test-element-1', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Cannot set "BadTestElement.properties.normalProperty" via "properties".'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot set to unknown properties', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + properties.doesNotExist = 'Dromedary'; + return html`
${properties.normalProperty}
`; + }; + } + } + customElements.define('bad-test-element-2', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Property "BadTestElement.properties.doesNotExist" does not exist.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot get unknown properties', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return ({ doesNotExist }) => { + return html`
${doesNotExist}
`; + }; + } + } + customElements.define('bad-test-element-3', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Property "BadTestElement.properties.doesNotExist" does not exist.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot set on "internal"', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + el.internal.normalProperty = 'Dromedary'; + } catch (error) { + const expected = 'Property "TestElement.properties.normalProperty" is publicly available (use normal setter).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "defineProperty" on properties.', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + Reflect.defineProperty(properties, 'foo', {}); + return html``; + }; + } + } + customElements.define('bad-test-element-4', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Invalid use of properties proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "delete" on properties.', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + Reflect.deleteProperty(properties, 'normalProperty'); + return html``; + }; + } + } + customElements.define('bad-test-element-5', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Invalid use of properties proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "getOwnPropertyDescriptor" on properties.', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + Reflect.getOwnPropertyDescriptor(properties, 'normalProperty'); + return html``; + }; + } + } + customElements.define('bad-test-element-6', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Invalid use of properties proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "getPrototypeOf" on properties.', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + Reflect.getPrototypeOf(properties); + return html``; + }; + } + } + customElements.define('bad-test-element-7', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Invalid use of properties proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "isExtensible" on properties.', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + Reflect.isExtensible(properties); + return html``; + }; + } + } + customElements.define('bad-test-element-8', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Invalid use of properties proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "preventExtensions" on properties.', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + Reflect.preventExtensions(properties); + return html``; + }; + } + } + customElements.define('bad-test-element-9', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Invalid use of properties proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot "setPrototypeOf" on properties.', () => { + class BadTestElement extends XElement { + static get properties() { + return { + normalProperty: { + type: String, + }, + }; + } + static template(html) { + return properties => { + Reflect.setPrototypeOf(properties, Array); + return html``; + }; + } + } + customElements.define('bad-test-element-10', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Invalid use of properties proxy.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); diff --git a/test/test-observed-properties.js b/test/test-observed-properties.js index f190b45..235675e 100644 --- a/test/test-observed-properties.js +++ b/test/test-observed-properties.js @@ -1,5 +1,101 @@ +import XElement from '../x-element.js'; import { assert, it } from '../../../@netflix/x-test/x-test.js'; -import './fixture-element-observed-properties.js'; + +class TestElement extends XElement { + static get properties() { + return { + a: { + type: String, + observer: this.observeA, + }, + b: { + type: String, + observer: this.observeB, + }, + c: { + type: String, + resolver: this.resolveC, + dependencies: ['a', 'b'], + observer: this.observeC, + }, + changes: { + type: Array, + observer: () => {}, + }, + popped: { + type: Boolean, + reflect: true, + observer: this.observePopped, + }, + async: { + observer: async () => {}, + }, + }; + } + + static resolveC(a, b) { + return `${a} ${b}`; + } + + static observeA(host, value, oldValue) { + const changes = Object.assign([], host.changes); + changes.push({ property: 'a', value, oldValue }); + host.changes = changes; + } + + static observeB(host, value, oldValue) { + const changes = Object.assign([], host.changes); + changes.push({ property: 'b', value, oldValue }); + host.changes = changes; + } + + static observeC(host, value, oldValue) { + const changes = Object.assign([], host.changes); + changes.push({ property: 'c', value, oldValue }); + host.changes = changes; + } + + static observePopped(host, value, oldValue) { + const changes = Object.assign([], host.changes); + changes.push({ property: 'popped', value, oldValue }); + host.changes = changes; + } + + static template(html) { + return ({ changes }) => { + return html` + +
+
Changes:
+
    + ${(changes || []).map(({ property, oldValue, value }) => { + return html` +
  • + ${property}: "${oldValue}" → "${value}" +
  • + `; + })} +
+
+ `; + }; + } +} + +customElements.define('test-element', TestElement); + const isObject = obj => obj instanceof Object && obj !== null; const deepEqual = (a, b) => { @@ -15,193 +111,79 @@ const deepEqual = (a, b) => { ); }; -it('throws expected errors', () => { - const unresolvedMessage = `Cannot resolve methodName "thisDNE".`; - - let unresolved = false; - - const el = document.createElement('test-element-observed-properties-errors'); - - const onError = evt => { - if (evt.error.message === unresolvedMessage) { - evt.stopPropagation(); - unresolved = true; - el.removeEventListener('error', onError); - } - }; - el.addEventListener('error', onError); - - document.body.appendChild(el); - assert(unresolved, 'should error for unresolved method names'); +it('initialized as expected', () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert( + deepEqual(el.changes, [ + { property: 'c', value: 'undefined undefined', oldValue: undefined }, + ]), + 'initialized as expected' + ); + document.body.removeChild(el); }); -it('x-element observed properties', () => { - const el = document.createElement('test-element-observed-properties'); +it('x-element observed properties', async () => { + const el = document.createElement('test-element'); el.a = '11'; el.b = '22'; - document.body.appendChild(el); + document.body.append(el); assert( deepEqual(el.changes, [ - { - property: 'a', - newValue: '11', - oldValue: undefined, - }, - { - property: 'b', - newValue: '22', - oldValue: undefined, - }, - { - property: 'c', - newValue: '11 22', - oldValue: undefined, - }, + { property: 'a', value: '11', oldValue: undefined }, + { property: 'b', value: '22', oldValue: undefined }, + { property: 'c', value: '11 22', oldValue: undefined }, ]), 'initialized as expected' ); el.b = 'hey'; + + // We must await a microtask for the update to take place. + await Promise.resolve(); assert( deepEqual(el.changes, [ - { - property: 'a', - newValue: '11', - oldValue: undefined, - }, - { - property: 'b', - newValue: '22', - oldValue: undefined, - }, - { - property: 'c', - newValue: '11 22', - oldValue: undefined, - }, - { - property: 'b', - newValue: 'hey', - oldValue: '22', - }, - { - property: 'c', - newValue: '11 hey', - oldValue: '11 22', - }, + { property: 'a', value: '11', oldValue: undefined }, + { property: 'b', value: '22', oldValue: undefined }, + { property: 'c', value: '11 22', oldValue: undefined }, + { property: 'c', value: '11 hey', oldValue: '11 22' }, + { property: 'b', value: 'hey', oldValue: '22' }, ]), 'observers are called when properties change' ); el.b = 'hey'; - assert( - deepEqual(el.changes, [ - { - property: 'a', - newValue: '11', - oldValue: undefined, - }, - { - property: 'b', - newValue: '22', - oldValue: undefined, - }, - { - property: 'c', - newValue: '11 22', - oldValue: undefined, - }, - { - property: 'b', - newValue: 'hey', - oldValue: '22', - }, - { - property: 'c', - newValue: '11 hey', - oldValue: '11 22', - }, - ]), - 'observers are not called when set property is the same' - ); - el.a = 11; + // We must await a microtask for the update to take place. + await Promise.resolve(); assert( deepEqual(el.changes, [ - { - property: 'a', - newValue: '11', - oldValue: undefined, - }, - { - property: 'b', - newValue: '22', - oldValue: undefined, - }, - { - property: 'c', - newValue: '11 22', - oldValue: undefined, - }, - { - property: 'b', - newValue: 'hey', - oldValue: '22', - }, - { - property: 'c', - newValue: '11 hey', - oldValue: '11 22', - }, - { - property: 'a', - newValue: '11', - oldValue: '11', - }, + { property: 'a', value: '11', oldValue: undefined }, + { property: 'b', value: '22', oldValue: undefined }, + { property: 'c', value: '11 22', oldValue: undefined }, + { property: 'c', value: '11 hey', oldValue: '11 22' }, + { property: 'b', value: 'hey', oldValue: '22' }, ]), - 'observers are not called when set property does not cause computed change' + 'observers are not called when set property is the same' ); el.popped = true; + + // We must await a microtask for the update to take place. + await Promise.resolve(); el.setAttribute('popped', 'still technically true'); + + // We must await a microtask for the update to take place. + await Promise.resolve(); assert( deepEqual(el.changes, [ - { - property: 'a', - newValue: '11', - oldValue: undefined, - }, - { - property: 'b', - newValue: '22', - oldValue: undefined, - }, - { - property: 'c', - newValue: '11 22', - oldValue: undefined, - }, - { - property: 'b', - newValue: 'hey', - oldValue: '22', - }, - { - property: 'c', - newValue: '11 hey', - oldValue: '11 22', - }, - { - property: 'a', - newValue: '11', - oldValue: '11', - }, - { - property: 'popped', - newValue: true, - oldValue: undefined, - }, + { property: 'a', value: '11', oldValue: undefined }, + { property: 'b', value: '22', oldValue: undefined }, + { property: 'c', value: '11 22', oldValue: undefined }, + { property: 'c', value: '11 hey', oldValue: '11 22' }, + { property: 'b', value: 'hey', oldValue: '22' }, + { property: 'popped', value: true, oldValue: undefined }, ]), 'no re-entrance for observed, reflected properties' ); diff --git a/test/test-read-only-properties.js b/test/test-read-only-properties.js index 5fb69ec..7ace020 100644 --- a/test/test-read-only-properties.js +++ b/test/test-read-only-properties.js @@ -1,16 +1,103 @@ +import XElement from '../x-element.js'; import { assert, it } from '../../../@netflix/x-test/x-test.js'; -import './fixture-element-read-only-properties.js'; -it('x-element readOnly properties', async () => { - const el = document.createElement('test-element-read-only-properties'); - document.body.appendChild(el); +class TestElement extends XElement { + static get properties() { + return { + readOnlyProperty: { + type: String, + readOnly: true, + value: 'Dromedary', + }, + }; + } + static template(html) { + return ({ readOnlyProperty }) => { + return html`
${readOnlyProperty}
`; + }; + } + connectedCallback() { + super.connectedCallback(); + this.internal.readOnlyProperty = 'Ferus'; + } +} +customElements.define('test-element', TestElement); - await true; - assert(el.readOnlyProperty === 'Ferus', 'initialized as expected'); - el.readOnlyProperty = 'Dromedary'; - assert( - el.readOnlyProperty === 'Ferus', - 'read-only properties cannot be changed' - ); +it('initialization', () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.shadowRoot.textContent === 'Dromedary', 'initialized correctly'); + assert(el.readOnlyProperty === 'Ferus', 'correct value after connection'); +}); + +it('re-render in connectedCallback works', async () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.readOnlyProperty === 'Ferus', 'correct value after connection'); + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.shadowRoot.textContent === 'Ferus', 'correct value after re-render'); +}); + +it('cannot be written to', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + el.readOnlyProperty = `don't do it`; + } catch (error) { + const expected = 'Property "TestElement.properties.readOnlyProperty" is read-only (internal.readOnlyProperty).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot be read from "internal"', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + el.internal.readOnlyProperty; + } catch (error) { + const expected = 'Property "TestElement.properties.readOnlyProperty" is publicly available (use normal getter).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot set to known properties', () => { + class BadTestElement extends XElement { + static get properties() { + return { + readOnlyProperty: { + type: String, + readOnly: true, + }, + }; + } + static template(html) { + return properties => { + properties.readOnlyProperty = 'Dromedary'; + return html`
${properties.readOnlyProperty}
`; + }; + } + } + customElements.define('bad-test-element-1', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Cannot set "BadTestElement.properties.readOnlyProperty" via "properties".'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); }); diff --git a/test/test-graph.html b/test/test-render-root.html similarity index 62% rename from test/test-graph.html rename to test/test-render-root.html index 1222e20..5f9b38a 100644 --- a/test/test-graph.html +++ b/test/test-render-root.html @@ -2,7 +2,7 @@ - +

View Console

diff --git a/test/test-render-root.js b/test/test-render-root.js new file mode 100644 index 0000000..9ee46b7 --- /dev/null +++ b/test/test-render-root.js @@ -0,0 +1,39 @@ +import XElement from '../x-element.js'; +import { assert, it } from '../../../@netflix/x-test/x-test.js'; + +class TestElement extends XElement { + static createRenderRoot(host) { + return host; + } + static template(html) { + return () => { + return html`I'm not in a shadow root.`; + }; + } +} +customElements.define('test-element', TestElement); + + +it('test render root was respected', () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.shadowRoot === null); + assert(el.textContent === `I'm not in a shadow root.`); +}); + +it('errors are thrown in for creating a bad render root', () => { + class BadElement extends XElement { + static createRenderRoot() {} + } + customElements.define('test-element-1', BadElement); + let passed = false; + let message = 'no error was thrown'; + try { + new BadElement(); + } catch (error) { + const expected = 'Unexpected render root returned. Expected "host" or "host.shadowRoot".'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); diff --git a/test/test-basic.html b/test/test-render.html similarity index 64% rename from test/test-basic.html rename to test/test-render.html index 6c75985..cd6d910 100644 --- a/test/test-basic.html +++ b/test/test-render.html @@ -2,7 +2,7 @@ - +

View Console

diff --git a/test/test-render.js b/test/test-render.js new file mode 100644 index 0000000..51b3b22 --- /dev/null +++ b/test/test-render.js @@ -0,0 +1,44 @@ +import XElement from '../x-element.js'; +import { assert, it } from '../../../@netflix/x-test/x-test.js'; + +class TestElement extends XElement { + static get properties() { + return { + property: { + value: 'initial', + }, + }; + } + static template(html) { + return ({ property }) => { + return html`${property}`; + }; + } + constructor() { + super(); + this.count = 0; + } + render() { + this.count++; + if (this.count > 1) { + super.render(); + } + } +} +customElements.define('test-element', TestElement); + + +it('test super.render can be ignored', async () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.count === 1); + assert(el.property === 'initial'); + assert(el.shadowRoot.textContent === ''); + el.property = 'next'; + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.count === 2); + assert(el.property === 'next'); + assert(el.shadowRoot.textContent === 'next'); +}); diff --git a/test/test-resolved-properties.html b/test/test-resolved-properties.html new file mode 100644 index 0000000..71165f2 --- /dev/null +++ b/test/test-resolved-properties.html @@ -0,0 +1,8 @@ + + + + + +

View Console

+ + diff --git a/test/test-resolved-properties.js b/test/test-resolved-properties.js new file mode 100644 index 0000000..43a5238 --- /dev/null +++ b/test/test-resolved-properties.js @@ -0,0 +1,290 @@ +import XElement from '../x-element.js'; +import { it, assert } from '../../../@netflix/x-test/x-test.js'; + +let _count = 0; + +// We call this function "aaa" to test a specific branch in the x-element code. +function aaa(arg) { + return arg && Object.assign(arg, { sideEffect: true }); +} + +class TestElement extends XElement { + static get aaa() { + throw new Error('See top comment.'); + } + static get properties() { + return { + c: { + type: Number, + internal: true, + resolver: this.resolveC, + dependencies: ['a', 'b'], + }, + a: { + type: Number, + }, + b: { + type: Number, + }, + negative: { + type: Boolean, + resolver: c => c < 0, + dependencies: ['c'], + reflect: true, + }, + underline: { + type: Boolean, + resolver: this.resolveUnderline, + dependencies: ['negative'], + reflect: true, + }, + italic: { + type: Boolean, + reflect: true, + }, + y: { + type: Boolean, + }, + z: { + type: Boolean, + resolver: this.resolveZ, + dependencies: ['y'], + }, + today: { + type: Date, + }, + tomorrow: { + type: Date, + resolver: this.resolveTomorrow, + dependencies: ['today'], + }, + countTrigger: {}, + count: { + type: Number, + resolver: this.resolveCount, + dependencies: ['countTrigger'], + }, + sideEffectTrigger: { + type: Object, + internal: true, + }, + sideEffect: { + dependencies: ['sideEffectTrigger'], + // The odd naming is intended. See top comment on this. + resolver: aaa, + }, + }; + } + + static resolveC(a, b) { + return a + b; + } + + static resolveCount() { + // This doesn't use an observer to prevent a coupled test. + return ++_count; + } + + static resolveUnderline(negative) { + return !!negative; + } + + static resolveZ(y) { + return y; + } + + static resolveTomorrow(today) { + if (today) { + return today.valueOf() + 1000 * 60 * 60 * 24; + } + } + + static template(html) { + return ({ a, b, c }) => { + return html` + + ${a} + ${b} = ${c} + `; + }; + } +} +customElements.define('test-element', TestElement); + + +it('initializes as expected', () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.a === undefined); + assert(el.b === undefined); + assert(el.y === undefined); + assert(el.z === undefined); + assert(el.countTrigger === undefined); + assert(Number.isNaN(el.internal.c)); + assert(el.negative === false); + assert(el.underline === false); +}); + +it('properties are re-resolved when dependencies change (a, b)', () => { + const el = document.createElement('test-element'); + document.body.append(el); + el.a = 1; + el.b = -2; + assert(el.a === 1); + assert(el.b === -2); + assert(el.internal.c === -1); + assert(el.negative === true); + assert(el.underline === true); +}); + +it('properties are re-resolved when dependencies change (y)', () => { + const el = document.createElement('test-element'); + document.body.append(el); + el.y = true; + assert(el.y === true); + assert(el.z === true); + el.y = false; + assert(el.y === false); + assert(el.z === false); +}); + +it('resolved properties can be reflected', async () => { + const el = document.createElement('test-element'); + document.body.append(el); + el.a = -1; + el.b = 0; + assert(el.internal.c === -1); + assert(el.negative === true); + assert(el.underline === true); + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.hasAttribute('negative')); + assert(el.hasAttribute('underline')); +}); + +it('skips resolution when dependencies are the same', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let count = el.count; + el.countTrigger = 'foo'; + assert(el.count === ++count); + el.countTrigger = 'foo'; + el.countTrigger = 'foo'; + el.countTrigger = 'foo'; + el.countTrigger = 'foo'; + assert(el.count === count); + el.countTrigger = 'bar'; + assert(el.count === ++count); +}); + +it('lazily resolves', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let count = el.count; + el.countTrigger = 'foo'; + assert(el.count === ++count); + el.countTrigger = 'bar'; + el.countTrigger = 'foo'; + el.countTrigger = 'bar'; + el.countTrigger = 'foo'; + el.countTrigger = 'bar'; + el.countTrigger = 'foo'; + el.countTrigger = 'bar'; + el.countTrigger = 'foo'; + el.countTrigger = 'bar'; + el.countTrigger = 'foo'; + el.countTrigger = 'bar'; + el.countTrigger = 'foo'; + el.countTrigger = 'bar'; + el.countTrigger = 'foo'; + el.countTrigger = 'bar'; + el.countTrigger = 'foo'; + assert(el.count === count); + el.countTrigger = 'bar'; + assert(el.count === ++count); +}); + +it('does correct NaN checking', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let count = el.count; + el.countTrigger = NaN; + assert(el.count === ++count); + el.countTrigger = NaN; + assert(el.count === count); +}); + +it('cannot be written to from host', () => { + const el = document.createElement('test-element'); + document.body.append(el); + let passed = false; + let message = 'no error was thrown'; + try { + el.count = 0; + } catch (error) { + const expected = 'Property "TestElement.properties.count" is resolved (resolved properties are read-only).'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('cannot set to known properties', () => { + class BadTestElement extends XElement { + static get properties() { + return { + resolvedProperty: { + type: String, + resolver: () => {}, + }, + }; + } + static template(html) { + return properties => { + properties.resolvedProperty = 'Dromedary'; + return html`
${properties.resolvedProperty}
`; + }; + } + } + customElements.define('bad-test-element-1', BadTestElement); + const el = new BadTestElement(); + let passed = false; + let message = 'no error was thrown'; + try { + el.connectedCallback(); + } catch (error) { + const expected = 'Cannot set "BadTestElement.properties.resolvedProperty" via "properties".'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('resolvers are called before rendering — even if not queried, reflected, or observed', async () => { + const el = document.createElement('test-element'); + document.body.append(el); + assert(el.internal.sideEffectTrigger === undefined); + el.internal.sideEffectTrigger = { sideEffect: false }; + assert(el.internal.sideEffectTrigger.sideEffect === false); + + // We must await a microtask for the update to take place. + await Promise.resolve(); + assert(el.internal.sideEffectTrigger.sideEffect === true); +}); diff --git a/test/test-scratch.js b/test/test-scratch.js index 78007ee..d4dad83 100644 --- a/test/test-scratch.js +++ b/test/test-scratch.js @@ -1,27 +1,105 @@ -import { assert, it } from '../../../@netflix/x-test/x-test.js'; -import './fixture-element-scratch.js'; +import XElement from '../x-element.js'; +import { assert, it, skip } from '../../../@netflix/x-test/x-test.js'; + +class TestElement extends XElement { + static get properties() { + return { + // reflected with no value + prop1: { + type: String, + reflect: true, + }, + // reflected with falsy initial value (null) + prop2: { + type: String, + value: null, + reflect: true, + }, + // reflected with falsy initial value (undefined) + prop3: { + type: String, + value: null, + reflect: true, + }, + // reflected with initial value + prop5: { + type: String, + value: 'test', + reflect: true, + }, + // Boolean without initial value + prop6: { + type: Boolean, + reflect: true, + }, + // Boolean with `false` initial value + prop7: { + type: Boolean, + value: false, + reflect: true, + }, + // Boolean with `true` initial value + prop8: { + type: Boolean, + value: true, + reflect: true, + }, + arrayProp: { + type: Array, + value: () => ['foo', 'bar'], + }, + objProp: { + type: Object, + value: () => { + return { foo: 'bar' }; + }, + }, + objDateProp: { + type: Date, + value: () => { + return new Date(); + }, + }, + objMapProp: { + type: Map, + value: () => { + return new Map(); + }, + }, + resolvedProp: { + type: String, + resolver: this.resolveResolvedProp, + dependencies: ['prop1', 'prop2'], + }, + adopted: { + type: Boolean, + value: false, + }, + }; + } + + static template(html) { + return ({ prop1 }) => { + return html`${prop1}`; + }; + } + + static resolveResolvedProp(prop1, prop2) { + return `${prop1} ${prop2}`; + } + + adoptedCallback() { + super.adoptedCallback(); + this.adopted = true; + } +} + +customElements.define('test-element', TestElement); -it('scratch', async () => { - let errorsWhenReflectingUnserializableType = false; - const el = document.createElement('test-element-scratch'); - const onError = evt => { - if ( - evt.error.message === - `Attempted to serialize "objPropReflect" and reflect, but it is not a Boolean, String, or Number type (Object).` - ) { - evt.stopPropagation(); - errorsWhenReflectingUnserializableType = true; - el.removeEventListener('error', onError); - } - }; - el.addEventListener('error', onError); - - document.body.appendChild(el); - assert( - errorsWhenReflectingUnserializableType, - 'should error on unserializable reflection' - ); +it('scratch', async () => { + const el = document.createElement('test-element'); + document.body.append(el); // Attribute reflection tests assert( @@ -30,12 +108,18 @@ it('scratch', async () => { ); el.prop1 = 'test'; + + // We must await a microtask for the update to take place. + await Promise.resolve(); assert( el.getAttribute('prop1') === 'test', 'should reflect when value changes from unspecified to a string' ); el.prop1 = null; + + // We must await a microtask for the update to take place. + await Promise.resolve(); assert( el.hasAttribute('prop1') === false, 'should not reflect when value changes from a string to null' @@ -51,11 +135,6 @@ it('scratch', async () => { 'should not reflect when initial value is falsy (undefined)' ); - assert( - el.getAttribute('prop4') === 'false', - 'should reflect when initial value is false' - ); - assert( el.getAttribute('prop5') === 'test', 'should reflect when initial value is a String' @@ -63,8 +142,7 @@ it('scratch', async () => { // Boolean attribute reflection tests assert(el.hasAttribute('prop6') === false, 'reflect boolean'); - el.prop6 = 1; - assert(el.prop6 === true, 'boolean coerced'); + assert( el.hasAttribute('prop7') === false, 'should not reflect when initial value is false' @@ -75,27 +153,19 @@ it('scratch', async () => { 'should reflect when initial value is true' ); - assert( - el.getAttribute('prop9') === '', - 'should reflect when initial value is truthy' - ); - - assert( - el.hasAttribute('prop10') === false, - 'should not reflect when initial value is falsy' - ); - // Async data binding el.prop1 = null; - await el; + + // We must await a microtask for the update to take place. + await Promise.resolve(); assert( el.shadowRoot.querySelector('span').textContent === '', 'should update the DOM bindings' ); - // TODO: https://github.com/eslint/eslint/issues/11899: False positive? - // eslint-disable-next-line require-atomic-updates el.prop1 = 'test2'; - await el; + + // We must await a microtask for the update to take place. + await Promise.resolve(); assert( el.shadowRoot.querySelector('span').textContent === 'test2', 'should update the DOM bindings again' @@ -114,5 +184,42 @@ it('scratch', async () => { // lifecycle document.body.removeChild(el); - document.body.appendChild(el); + document.body.append(el); +}); + +it('test dispatchError', () => { + const el = document.createElement('test-element'); + const error = new Error('Foo'); + let passed = false; + const onError = event => { + if ( + event.error === error && + event.message === error.message && + event.bubbles === true && + event.composed === true + ) { + passed = true; + } + }; + el.addEventListener('error', onError); + el.dispatchError(error); + el.removeEventListener('error', onError); + assert(passed); +}); + +// TODO: Firefox somehow returns an un-upgraded instance after adoption. This +// seems like a bug in the browser, but we should look into it. +(navigator.userAgent.includes('Firefox') ? skip : it)('test adoptedCallback', () => { + const el = document.createElement('test-element'); + el.prop1 = 'adopt me!'; + document.body.append(el); + assert(el.adopted === false); + const iframe = document.createElement('iframe'); + iframe.src = 'about:blank'; + iframe.style.height = '10vh'; + iframe.style.width = '10vw'; + document.body.append(iframe); + iframe.ownerDocument.adoptNode(el); + iframe.contentDocument.body.append(el); + assert(el.adopted); }); diff --git a/test/test-upgrade.js b/test/test-upgrade.js index 809d634..813a709 100644 --- a/test/test-upgrade.js +++ b/test/test-upgrade.js @@ -1,5 +1,44 @@ import { assert, it } from '../../../@netflix/x-test/x-test.js'; -import TestElement from './fixture-element-upgrade.js'; +import XElement from '../x-element.js'; + +export default class TestElement extends XElement { + constructor() { + super(); + this._readOnlyProperty = 'didelphidae'; + this._readOnlyKey = 'didelphimorphia'; + Reflect.defineProperty(this, 'readOnlyDefinedProperty', { + value: 'phalangeriformes', + configurable: false, + }); + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.firstElementChild.textContent = this.readOnlyProperty; + } + + static template(html) { + return () => { + return html`
`; + }; + } + + get readOnlyProperty() { + return this._readOnlyProperty; + } + + get [Symbol.for('readOnlyKey')]() { + return this._readOnlyKey; + } + + get reflectedProperty() { + return this.getAttribute('reflected-property'); + } + + set reflectedProperty(value) { + this.setAttribute('reflected-property', value); + } +} const setupEl = el => { el.className = 'marsupialia'; @@ -36,7 +75,7 @@ const hasUpgraded = el => { }; it('x-element upgrade lifecycle', () => { - const localName = 'test-element-upgrade'; + const localName = 'test-element'; assert( customElements.get(localName) === undefined, 'localName is initially undefined' @@ -45,7 +84,7 @@ it('x-element upgrade lifecycle', () => { const el1 = document.createElement(localName); el1.id = 'el1'; setupEl(el1); - document.body.appendChild(el1); + document.body.append(el1); const el2 = document.createElement(localName); el2.id = 'el2'; @@ -89,13 +128,13 @@ it('x-element upgrade lifecycle', () => { ); assert(hasNotUpgraded(el2), 'element out of document is still not upgraded'); - document.body.appendChild(el2); + document.body.append(el2); assert( el2.shadowRoot.textContent === 'didelphidae', 'element out of document upgrades/renders after being added' ); - document.body.appendChild(el3); + document.body.append(el3); assert( el3.shadowRoot.textContent === 'didelphidae', 'element created after definition upgrades/renders after being added' diff --git a/x-element-basic.js b/x-element-basic.js deleted file mode 100644 index 0b3546b..0000000 --- a/x-element-basic.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Implements a naive template rendering system and a few helpers. - */ -import ElementMixin from './mixins/element-mixin.js'; - -export default ElementMixin(HTMLElement); diff --git a/x-element-properties.js b/x-element-properties.js deleted file mode 100644 index 6f6973f..0000000 --- a/x-element-properties.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Implements properties and property effects (computed, observer, reflect). - */ - -import ElementMixin from './mixins/element-mixin.js'; -import PropertiesMixin from './mixins/properties-mixin.js'; -import PropertyEffectsMixin from './mixins/property-effects-mixin.js'; - -export default PropertyEffectsMixin(PropertiesMixin(ElementMixin(HTMLElement))); diff --git a/x-element.js b/x-element.js index 41d0923..14bb6a9 100644 --- a/x-element.js +++ b/x-element.js @@ -1,15 +1,721 @@ -/** - * Base class for custom elements. - */ - -import ElementMixin from './mixins/element-mixin.js'; -import ListenersMixin from './mixins/listeners-mixin.js'; -import LitHtmlMixin from './mixins/lit-html-mixin.js'; -import PropertiesMixin from './mixins/properties-mixin.js'; -import PropertyEffectsMixin from './mixins/property-effects-mixin.js'; - -export default LitHtmlMixin( - ListenersMixin( - PropertyEffectsMixin(PropertiesMixin(ElementMixin(HTMLElement))) - ) -); +import { asyncAppend } from '../../lit-html/directives/async-append.js'; +import { asyncReplace } from '../../lit-html/directives/async-replace.js'; +import { cache } from '../../lit-html/directives/cache.js'; +import { classMap } from '../../lit-html/directives/class-map.js'; +import { directive, html, render, svg } from '../../lit-html/lit-html.js'; +import { guard } from '../../lit-html/directives/guard.js'; +import { ifDefined } from '../../lit-html/directives/if-defined.js'; +import { live } from '../../lit-html/directives/live.js'; +import { repeat } from '../../lit-html/directives/repeat.js'; +import { styleMap } from '../../lit-html/directives/style-map.js'; +import { templateContent } from '../../lit-html/directives/template-content.js'; +import { unsafeHTML } from '../../lit-html/directives/unsafe-html.js'; +import { unsafeSVG } from '../../lit-html/directives/unsafe-svg.js'; +import { until } from '../../lit-html/directives/until.js'; + +const PROPERTY_KEYS = new Set(['type', 'attribute', 'resolver', 'dependencies', 'observer', 'reflect', 'internal', 'readOnly', 'value']); +const SERIALIZABLE_TYPES = new Set([Boolean, String, Number]); +const CAMEL_MAP = new Map(); + +// TODO: replace "__thing >> #thing" when static private fields are supported. +export default class XElement extends HTMLElement { + // All declared properties are observed via associated attributes. + static get observedAttributes() { + XElement.__analyzeConstructor(this); + const { attributeMap } = XElement.__constructors.get(this); + return [...attributeMap.keys()]; + } + + // This gets converted into "propertyMap" and "attributeMap" internally. + static get properties() { + return {}; + } + + // This gets converted into "listenerMap" internally. + static get listeners() { + return {}; + } + + // Typically, you want a shadow root, but "host" could also be returned. + static createRenderRoot(host) { + return host.attachShadow({ mode: 'open' }); + } + + // Use the current property values to interpolate an html template. + static template(html, engine) { // eslint-disable-line no-unused-vars, no-shadow + return properties => {}; // eslint-disable-line no-unused-vars + } + + // Standard instance constructor. + constructor() { + super(); + XElement.__constructHost(this); + } + + // Standard HTMLElement connectedCallback. + connectedCallback() { + XElement.__initializeHost(this); + XElement.__addListeners(this); + } + + // Standard HTMLElement attributeChangedCallback. + attributeChangedCallback(attribute, oldValue, value) { + const { attributeMap } = XElement.__constructors.get(this.constructor); + attributeMap.get(attribute).sync(this, value, oldValue); + } + + // Standard HTMLElement adoptedCallback. + adoptedCallback() {} + + // Standard HTMLElement disconnectedCallback. + disconnectedCallback() { + XElement.__removeListeners(this); + } + + // Bind template result to render root. + render() { + const { template, properties, renderRoot } = XElement.__hosts.get(this); + render(template(properties), renderRoot); + } + + // Convenience wrapper around addEventListener to get the "this" right. + listen(element, type, callback, options) { + XElement.__addListener(this, element, type, callback, options); + } + + // Inverse of listen. Use this to make sure function pointer is the same. + unlisten(element, type, callback, options) { + XElement.__removeListener(this, element, type, callback, options); + } + + // Dispatch a standard "ErrorEvent" on the element. + dispatchError(error) { + const { message } = error; + const eventData = { error, message, bubbles: true, composed: true }; + this.dispatchEvent(new ErrorEvent('error', eventData)); + } + + // For element authors. Set and get "internal" properties. + get internal() { + return XElement.__hosts.get(this).internal; + } + + static __analyzeConstructor(constructor) { + if (XElement.__constructors.has(constructor) === false) { + const { properties, listeners } = constructor; + XElement.__validateListeners(constructor, listeners); + XElement.__validateProperties(constructor, properties); + const propertyMap = new Map(Object.entries(properties)); + const internalPropertyMap = new Map(); + const attributeMap = new Map(); + for (const [key, property] of propertyMap) { + // We mutate (vs copy) to allow cross-referencing property objects. + XElement.__mutateProperty(constructor, propertyMap, key, property); + if (property.internal || property.readOnly) { + internalPropertyMap.set(key, property); + } + attributeMap.set(property.attribute, property); + } + const listenerMap = new Map(Object.entries(listeners)); + XElement.__constructors.set(constructor, { propertyMap, internalPropertyMap, attributeMap, listenerMap }); + } + } + + static __validateListeners(constructor, listeners) { + const path = `${constructor.name}.listeners`; + if (XElement.__typeIsWrong(Object, listeners)) { + const typeName = XElement.__getTypeName(listeners); + throw new Error(`${path} has unexpected value (expected Object, got ${typeName}).`); + } + const entries = Object.entries(listeners); + for (const [type, listener] of entries) { + if (XElement.__typeIsWrong(Function, listener)) { + const typeName = XElement.__getTypeName(listener); + throw new Error(`${path}.${type} has unexpected value (expected Function, got ${typeName}).`); + } + } + } + + static __validateProperties(constructor, properties) { + const path = `${constructor.name}.properties`; + if (XElement.__typeIsWrong(Object, properties)) { + const typeName = XElement.__getTypeName(properties); + throw new Error(`${path} has an unexpected value (expected Object, got ${typeName}).`); + } + const entries = Object.entries(properties); + for (const [key, property] of entries) { + if (XElement.__typeIsWrong(Object, property)) { + const typeName = XElement.__getTypeName(property); + throw new Error(`${path}.${key} has an unexpected value (expected Object, got ${typeName}).`); + } + } + for (const [key, property] of entries) { + XElement.__validateProperty(constructor, key, property); + } + const attributes = new Set(); + const dependencyMap = new Map(); + for (const [key, property] of entries) { + const attribute = property.attribute || XElement.__camelToKebab(key); + if (attributes.has(attribute)) { + throw new Error(`${path}.${key} causes a duplicated attribute "${attribute}".`); + } + attributes.add(attribute); + const { dependencies } = property; + if (dependencies) { + dependencyMap.set(property, dependencies.map(dependencyKey => properties[dependencyKey])); + for (const [index, dependencyKey] of Object.entries(dependencies)) { + if (XElement.__typeIsWrong(Object, properties[dependencyKey])) { + throw new Error(`${path}.${key}.dependencies[${index}] has an unexpected item ("${dependencyKey}" has not been declared).`); + } + } + } + } + for (const [key, property] of entries) { + if (XElement.__propertyIsCyclic(property, dependencyMap)) { + throw new Error(`${path}.${key}.dependencies are cyclic.`); + } + } + } + + static __validateProperty(constructor, key, property) { + const path = `${constructor.name}.properties.${key}`; + if (X_ELEMENT_INTERFACE.has(key)) { + throw new Error(`Unexpected key "${path}" shadows XElement.prototype interface (${[...X_ELEMENT_INTERFACE].join(', ')}).`); + } + if (INHERITED_INTERFACE.has(key)) { + const error = new Error(`Unexpected key "${path}" shadows inherited property name, behavior not guaranteed.`); + console.warn(error); // eslint-disable-line no-console + } + for (const propertyKey of Object.keys(property)) { + if (PROPERTY_KEYS.has(propertyKey) === false) { + throw new Error(`Unexpected key "${path}.${propertyKey}".`); + } + } + const { attribute, resolver, dependencies, reflect, internal, readOnly, value } = property; + for (const subKey of ['type', 'resolver', 'observer']) { + if (Reflect.has(property, subKey) && XElement.__typeIsWrong(Function, property[subKey])) { + const typeName = XElement.__getTypeName(property[subKey]); + throw new Error(`Unexpected value for "${path}.${subKey}" (expected Function, got ${typeName}).`); + } + } + for (const subKey of ['reflect', 'internal', 'readOnly']) { + if (Reflect.has(property, subKey) && XElement.__typeIsWrong(Boolean, property[subKey])) { + const typeName = XElement.__getTypeName(property[subKey]); + throw new Error(`Unexpected value for "${path}.${subKey}" (expected Boolean, got ${typeName}).`); + } + } + if (Reflect.has(property, 'attribute') && XElement.__typeIsWrong(String, attribute)) { + const typeName = XElement.__getTypeName(attribute); + throw new Error(`Unexpected value for "${path}.attribute" (expected String, got ${typeName}).`); + } + if (Reflect.has(property, 'attribute') && attribute === '') { + throw new Error(`Unexpected value for "${path}.attribute" (expected non-empty String).`); + } + if ( + Reflect.has(property, 'value') && + value !== undefined && + value !== null && + XElement.__typeIsWrong(Boolean, value) && + XElement.__typeIsWrong(String, value) && + XElement.__typeIsWrong(Number, value) && + XElement.__typeIsWrong(Function, value) + ) { + const typeName = XElement.__getTypeName(value); + throw new Error(`Unexpected value for "${path}.value" (expected Boolean, String, Number, or Function, got ${typeName}).`); + } + if (Reflect.has(property, 'dependencies') && XElement.__typeIsWrong(Array, dependencies)) { + const typeName = XElement.__getTypeName(dependencies); + throw new Error(`Unexpected value for "${path}.dependencies" (expected Array, got ${typeName}).`); + } + if (Reflect.has(property, 'dependencies')) { + for (const [index, dependencyKey] of Object.entries(dependencies)) { + if (XElement.__typeIsWrong(String, dependencyKey)) { + const typeName = XElement.__getTypeName(dependencyKey); + throw new Error(`Unexpected value for "${path}.dependencies[${index}]" (expected String, got ${typeName}).`); + } + } + } + if (reflect && (Reflect.has(property, 'type') === false || SERIALIZABLE_TYPES.has(property.type) === false)) { + const typeName = property.type && property.type.prototype && property.type.name ? property.type.name : XElement.__getTypeName(property.type); + throw new Error(`Found unserializable "${path}.type" (${typeName}) but "${path}.reflect" is true.`); + } + if (dependencies && !resolver) { + throw new Error(`Found "${path}.dependencies" without "${path}.resolver" (dependencies require a resolver).`); + } + if (Reflect.has(property, 'value') && resolver) { + throw new Error(`Found "${path}.value" and "${path}.resolver" (resolved properties cannot use value initializer).`); + } + if (Reflect.has(property, 'readOnly') && resolver) { + throw new Error(`Found "${path}.readOnly" and "${path}.resolver" (resolved properties cannot define read-only).`); + } + if (reflect && internal) { + throw new Error(`Both "${path}.reflect" and "${path}.internal" are true (reflected properties cannot be internal).`); + } + if (internal && readOnly) { + throw new Error(`Both "${path}.internal" and "${path}.readOnly" are true (read-only properties cannot be internal).`); + } + if (internal && attribute) { + throw new Error(`Found "${path}.attribute" but "${path}.internal" is true (internal properties cannot have attributes).`); + } + } + + static __propertyIsCyclic(property, dependencyMap, seen = new Set()) { + if (dependencyMap.has(property)) { + for (const dependency of dependencyMap.get(property)) { + const nextSeen = new Set([...seen, property]); + if ( + dependency === property || + seen.has(dependency) || + XElement.__propertyIsCyclic(dependency, dependencyMap, nextSeen) + ) { + return true; + } + } + } + } + + static __mutateProperty(constructor, propertyMap, key, property) { + property.key = key; + property.attribute = property.attribute ?? XElement.__camelToKebab(key); + property.dependencies = new Set((property.dependencies ?? []) + .map(dependencyKey => propertyMap.get(dependencyKey))); + property.dependants = property.dependants ?? new Set(); + for (const dependency of property.dependencies) { + dependency.dependants = dependency.dependants ?? new Set(); + dependency.dependants.add(property); + } + XElement.__addPropertySync(constructor, property); + XElement.__addPropertyResolver(constructor, property); + XElement.__addPropertyReflect(constructor, property); + XElement.__addPropertyObserver(constructor, property); + } + + // Wrapper to improve ergonomics of syncing attributes back to properties. + static __addPropertySync(constructor, property) { + if (Reflect.has(property, 'type') && SERIALIZABLE_TYPES.has(property.type) === false) { + property.sync = () => { + const path = `${constructor.name}.properties.${property.key}`; + throw new Error(`Unexpected deserialization for "${path}" (cannot deserialize into ${property.type.name}).`); + }; + } else { + property.sync = (host, value, oldValue) => { + const { initialized, reflecting } = XElement.__hosts.get(host); + if (reflecting === false && initialized && value !== oldValue) { + const deserialization = XElement.__deserializeProperty(host, property, value); + host[property.key] = deserialization; + } + }; + } + } + + // Wrapper to centralize logic needed to perform reflection. + static __addPropertyReflect(constructor, property) { + if (property.reflect) { + property.reflect = host => { + const value = XElement.__getPropertyValue(host, property); + const serialization = XElement.__serializeProperty(host, property, value); + XElement.__hosts.get(host).reflecting = true; + serialization === undefined + ? host.removeAttribute(property.attribute) + : host.setAttribute(property.attribute, serialization); + XElement.__hosts.get(host).reflecting = false; + }; + } + } + + // Wrapper to prevent repeated resolver callbacks. + static __addPropertyResolver(constructor, property) { + if (property.resolver) { + const { resolver, dependencies } = property; + const callback = XElement.__isMethod(constructor, resolver) + ? resolver.bind(constructor) + : resolver; + property.resolver = host => { + const saved = XElement.__hosts.get(host).resolverMap.get(property); + if (saved.resolved === false) { + const next = []; + for (const dependency of dependencies) { + next.push(XElement.__getPropertyValue(host, dependency)); + } + if (saved.last === undefined || next.some((arg, index) => arg !== saved.last[index])) { + Object.assign(saved, { value: callback(...next), last: next }); + } + saved.resolved = true; + } + return saved.value; + }; + } + } + + // Wrapper to provide last value to observer callbacks. + static __addPropertyObserver(constructor, property) { + if (property.observer) { + const { observer } = property; + const callback = XElement.__isMethod(constructor, observer) + ? observer.bind(constructor) + : observer; + property.observer = host => { + const saved = XElement.__hosts.get(host).observerMap.get(property); + const value = XElement.__getPropertyValue(host, property); + if (Object.is(value, saved.value) === false) { + callback(host, value, saved.value); + } + saved.value = value; + }; + } + } + + static __constructHost(host) { + if (XElement.__hosts.has(host) === false) { + const invalidProperties = new Set(); + // The weak map prevents memory leaks. E.g., adding anonymous listeners. + const listenerWeakMap = new WeakMap(); + const valueMap = new Map(); + const renderRoot = host.constructor.createRenderRoot(host); + if (!renderRoot || renderRoot !== host && renderRoot !== host.shadowRoot) { + throw new Error('Unexpected render root returned. Expected "host" or "host.shadowRoot".'); + } + const template = host.constructor.template(html, { + asyncAppend, asyncReplace, cache, classMap, directive, guard, html, + ifDefined, live, repeat, styleMap, svg, templateContent, unsafeHTML, + unsafeSVG, until, + }); + const properties = XElement.__createProperties(host); + const internal = XElement.__createInternal(host); + const resolverMap = new Map(); + const observerMap = new Map(); + const { propertyMap } = XElement.__constructors.get(host.constructor); + for (const property of propertyMap.values()) { + if (property.resolver) { + resolverMap.set(property, { resolved: false, value: undefined, last: undefined }); + } + if (property.observer) { + observerMap.set(property, { value: undefined }); + } + } + XElement.__hosts.set(host, { + initialized: false, reflecting: false, invalidProperties, + listenerWeakMap, renderRoot, template, properties, internal, + resolverMap, observerMap, valueMap, + }); + } + } + + static __createInternal(host) { + const { propertyMap, internalPropertyMap } = XElement.__constructors.get(host.constructor); + // Everything but "get", "set", "has", and "ownKeys" are considered invalid. + // Note that impossible traps like "apply" or "construct" are not guarded. + const invalid = () => { throw new Error('Invalid use of internal proxy.'); }; + const get = (target, key) => { + const internalProperty = internalPropertyMap.get(key); + if (internalProperty && internalProperty.internal) { + return XElement.__getPropertyValue(host, internalProperty); + } else { + const path = `${host.constructor.name}.properties.${key}`; + const property = propertyMap.get(key); + if (property === undefined) { + throw new Error(`Property "${path}" does not exist.`); + } else { + throw new Error(`Property "${path}" is publicly available (use normal getter).`); + } + } + }; + const set = (target, key, value) => { + const internalProperty = internalPropertyMap.get(key); + if (internalProperty && internalProperty.resolver === undefined) { + XElement.__setPropertyValue(host, internalProperty, value); + return true; + } else { + const path = `${host.constructor.name}.properties.${key}`; + const property = propertyMap.get(key); + if (property === undefined) { + throw new Error(`Property "${path}" does not exist.`); + } else if (property.resolver) { + throw new Error(`Property "${path}" is resolved (resolved properties are read-only).`); + } else { + throw new Error(`Property "${path}" is publicly available (use normal setter).`); + } + } + }; + const has = (target, key) => internalPropertyMap.has(key); + const ownKeys = () => [...internalPropertyMap.keys()]; + const handler = { + defineProperty: invalid, deleteProperty: invalid, get, + getOwnPropertyDescriptor: invalid, getPrototypeOf: invalid, has, + isExtensible: invalid, ownKeys, preventExtensions: invalid, + set, setPrototypeOf: invalid, + }; + // Use a normal object for better autocomplete when debugging in console. + const target = Object.fromEntries(internalPropertyMap.entries()); + return new Proxy(target, handler); + } + + // Only available in template callback. Provides getter for all properties. + static __createProperties(host) { + const { propertyMap } = XElement.__constructors.get(host.constructor); + // Everything but "get", "set", "has", and "ownKeys" are considered invalid. + const invalid = () => { throw new Error('Invalid use of properties proxy.'); }; + const get = (target, key) => { + if (propertyMap.has(key)) { + return XElement.__getPropertyValue(host, propertyMap.get(key)); + } else { + const path = `${host.constructor.name}.properties.${key}`; + throw new Error(`Property "${path}" does not exist.`); + } + }; + const set = (target, key) => { + const path = `${host.constructor.name}.properties.${key}`; + if (propertyMap.has(key)) { + throw new Error(`Cannot set "${path}" via "properties".`); + } else { + throw new Error(`Property "${path}" does not exist.`); + } + }; + const has = (target, key) => propertyMap.has(key); + const ownKeys = () => [...propertyMap.keys()]; + const handler = { + defineProperty: invalid, deleteProperty: invalid, get, + getOwnPropertyDescriptor: invalid, getPrototypeOf: invalid, has, + isExtensible: invalid, ownKeys, preventExtensions: invalid, set, + setPrototypeOf: invalid, + }; + // Use a normal object for better autocomplete when debugging in console. + const target = Object.fromEntries(propertyMap.entries()); + return new Proxy(target, handler); + } + + static __initializeHost(host) { + const { initialized, invalidProperties } = XElement.__hosts.get(host); + if (initialized === false) { + XElement.__upgradeOwnProperties(host); + // Only reflect attributes when the element is connected. + const { propertyMap } = XElement.__constructors.get(host.constructor); + for (const property of propertyMap.values()) { + const { initialValue, initializer } = XElement.__getInitialPropertyValue(host, property); + XElement.__initializeProperty(host, property); + if (initializer === 'value') { + XElement.__setPropertyValue(host, property, initialValue); + } else if (initializer === 'property' || initializer === 'attribute') { + host[property.key] = initialValue; + } + invalidProperties.add(property); + } + XElement.__hosts.get(host).initialized = true; + XElement.__updateHost(host); + } + } + + // Prevent shadowing from properties added to element instance pre-upgrade. + static __upgradeOwnProperties(host) { + for (const key of Reflect.ownKeys(host)) { + const value = Reflect.get(host, key); + Reflect.deleteProperty(host, key); + Reflect.set(host, key, value); + } + } + + static __getInitialPropertyValue(host, property) { + // Process possible sources of initial state, with this priority: + // 1. imperative, e.g. `element.prop = 'value';` + // 2. declarative, e.g. `` + // 3. definition, e.g. `properties: { prop: { value: 'value' } }` + const { key, attribute, value } = property; + let initialValue, initializer; + if (Reflect.has(host, key)) { + initialValue = host[key]; + initializer = 'property'; + } else if (host.hasAttribute(attribute)) { + const attributeValue = host.getAttribute(attribute); + initialValue = XElement.__deserializeProperty(host, property, attributeValue); + initializer = 'attribute'; + } else if (Reflect.has(property, 'value')) { + initialValue = value instanceof Function ? value() : value; + initializer = 'value'; + } + return { initialValue, initializer }; + } + + static __initializeProperty(host, property) { + const { key, resolver, readOnly, internal } = property; + const path = `${host.constructor.name}.properties.${key}`; + const get = internal + ? () => { throw new Error(`Property "${path}" is internal (internal.${key}).`); } + : () => XElement.__getPropertyValue(host, property); + const set = resolver || readOnly || internal + ? () => { + if (resolver && !internal) { + throw new Error(`Property "${path}" is resolved (resolved properties are read-only).`); + } else if (readOnly) { + throw new Error(`Property "${path}" is read-only (internal.${key}).`); + } else { + throw new Error(`Property "${path}" is internal (internal.${key}).`); + } + } + : value => XElement.__setPropertyValue(host, property, value); + const enumerable = !internal; + Reflect.deleteProperty(host, key); + Reflect.defineProperty(host, key, { get, set, enumerable }); + } + + static __addListener(host, element, type, callback, options) { + callback = XElement.__getListener(host, callback); + element.addEventListener(type, callback, options); + } + + static __addListeners(host) { + const { listenerMap } = XElement.__constructors.get(host.constructor); + const { renderRoot } = XElement.__hosts.get(host); + for (const [type, listener] of listenerMap) { + XElement.__addListener(host, renderRoot, type, listener); + } + } + + static __removeListener(host, element, type, callback, options) { + callback = XElement.__getListener(host, callback); + element.removeEventListener(type, callback, options); + } + + static __removeListeners(host) { + const { listenerMap } = XElement.__constructors.get(host.constructor); + const { renderRoot } = XElement.__hosts.get(host); + for (const [type, listener] of listenerMap) { + XElement.__removeListener(host, renderRoot, type, listener); + } + } + + static __getListener(host, listener) { + const { listenerWeakMap } = XElement.__hosts.get(host); + const { constructor } = host; + const callback = XElement.__isMethod(constructor, listener) + ? listener.bind(host.constructor, host) + : XElement.__isMethod(host, listener) + ? listener.bind(host) + : listener.bind(null, host); + if (listenerWeakMap.has(listener) === false) { + listenerWeakMap.set(listener, callback); + } + return listenerWeakMap.get(listener); + } + + static __updateHost(host) { + // Order of operations: resolve, reflect, render, then observe. + const { invalidProperties } = XElement.__hosts.get(host); + const copy = new Set(invalidProperties); + invalidProperties.clear(); + for (const { resolver, reflect } of copy) { + resolver && resolver(host); + reflect && reflect(host); + } + host.render(); + for (const { observer } of copy) { + observer && observer(host); + } + } + + static async __invalidateProperty(host, property) { + const { initialized, invalidProperties, resolverMap } = XElement.__hosts.get(host); + if (initialized) { + for (const dependant of property.dependants) { + XElement.__invalidateProperty(host, dependant); + } + const queueUpdate = invalidProperties.size === 0; + invalidProperties.add(property); + if (property.resolver) { + resolverMap.get(property).resolved = false; + } + if (queueUpdate) { + // Queue a microtask. Allows multiple, synchronous changes. + await Promise.resolve(); + XElement.__updateHost(host); + } + } + } + + static __getPropertyValue(host, property) { + const { valueMap } = XElement.__hosts.get(host); + return property.resolver ? property.resolver(host) : valueMap.get(property); + } + + static __setPropertyValue(host, property, value) { + const { valueMap } = XElement.__hosts.get(host); + if (Object.is(value, valueMap.get(property)) === false) { + if (property.type && value !== undefined && value !== null) { + if (XElement.__typeIsWrong(property.type, value)) { + const path = `${host.constructor.name}.properties.${property.key}`; + const typeName = XElement.__getTypeName(value); + throw new Error(`Unexpected value for "${path}" (expected ${property.type.name}, got ${typeName}).`); + } + } + valueMap.set(property, value); + XElement.__invalidateProperty(host, property); + } + } + + static __serializeProperty(host, property, value) { + if (value !== undefined && value !== null) { + if (property.type === Boolean) { + return value ? '' : undefined; + } + return value.toString(); + } + } + + static __deserializeProperty(host, property, value) { + if (property.type) { + if (property.type === Boolean) { + // Per HTML spec, every value other than null is considered true. + return value !== null; + } else if (value === null) { + // Null as an attribute is really "undefined" as a property. + return undefined; + } else { + // Coerce type as needed. + return property.type(value); + } + } else { + return value; + } + } + + // Used to tell if we should bind the constructor to the method for later use. + static __isMethod(object, method) { + // Try-catch since an accessor could throw here. + try { + return object[method.name] === method; + } catch { + return false; + } + } + + static __getTypeName(value) { + return Object.prototype.toString.call(value).slice(8, -1); + } + + static __typeIsWrong(type, value) { + // Because `instanceof` fails on primitives (`'' instanceof String === false`) + // and `Object.prototype.toString` cannot handle inheritance, we use both. + return ( + (value === undefined || value === null) || + (!(value instanceof type) && XElement.__getTypeName(value) !== type.name) + ); + } + + static __camelToKebab(camel) { + if (CAMEL_MAP.has(camel) === false) { + CAMEL_MAP.set(camel, camel.replace(/([A-Z])/g, '-$1').toLowerCase()); + } + return CAMEL_MAP.get(camel); + } +} + +// TODO: define as private class fields inside the constructor when supported. +XElement.__constructors = new WeakMap(); +XElement.__hosts = new WeakMap(); + +const X_ELEMENT_INTERFACE = new Set(Object.getOwnPropertyNames(XElement.prototype)); +const INHERITED_INTERFACE = new Set(); +let prototype = HTMLElement.prototype; +while (prototype) { + Object.getOwnPropertyNames(prototype).forEach(name => INHERITED_INTERFACE.add(name)); + prototype = Object.getPrototypeOf(prototype); +} diff --git a/yarn.lock b/yarn.lock index 2e4b1c7..5189b0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32,10 +32,10 @@ dependencies: node-static "^0.7.10" -"@netflix/x-test@^1.0.0-rc.11": - version "1.0.0-rc.11" - resolved "https://registry.yarnpkg.com/@netflix/x-test/-/x-test-1.0.0-rc.11.tgz#b3b3bb9ea841afcbdd6186e145e2c5adc7410f50" - integrity sha512-wOTH5OCXXiLnEO6d4QAB7JGbO1POyvXuXCA+UHcMRyMrNUCgeBc/3Mb9Hr3JzSSdPGytfmYT8Coozakq5p0C6Q== +"@netflix/x-test@^1.0.0-rc.16": + version "1.0.0-rc.16" + resolved "https://registry.yarnpkg.com/@netflix/x-test/-/x-test-1.0.0-rc.16.tgz#4568e599ce9602cb803e15e9cb92a7ac29d15216" + integrity sha512-uhR5HUx282Jed3//ZdEkla/XTPk4zDulU+qBqDoDDQV3zn5ikQKjkj625EJxvUjZnMg2dusHz9S5P18sLgJoLA== "@types/color-name@^1.1.1": version "1.1.1"