From d8f736526013cb87742233996e5064d495304d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 29 May 2019 19:26:24 +0200 Subject: [PATCH] feat(define): change detection refactor (#51) BREAKING CHANGE: Change detection mechanism no longer dispatches `@invalidate` DOM event. Use the `observe` method of the property descriptor for side effects. Read more in descriptors section of the documentation. --- docs/built-in-factories/property.md | 17 +- docs/built-in-factories/render.md | 48 +++--- docs/core-concepts/definition.md | 4 + docs/core-concepts/descriptors.md | 81 ++++++---- src/cache.js | 52 ++++-- src/children.js | 26 +-- src/define.js | 94 ++++++----- src/emitter.js | 42 +++++ src/parent.js | 12 +- src/render.js | 110 ++----------- src/template/core.js | 29 +++- src/template/index.js | 13 +- src/template/resolvers/array.js | 5 +- src/utils.js | 1 - test/helpers.js | 21 ++- test/spec/cache.js | 108 ++++++++++++- test/spec/children.js | 41 ++--- test/spec/define.js | 81 +++++++--- test/spec/emitter.js | 78 +++++++++ test/spec/html.js | 134 ++++++++++++--- test/spec/parent.js | 41 ++--- test/spec/property.js | 62 ++++--- test/spec/render.js | 243 ++-------------------------- 23 files changed, 718 insertions(+), 625 deletions(-) create mode 100644 src/emitter.js create mode 100644 test/spec/emitter.js diff --git a/docs/built-in-factories/property.md b/docs/built-in-factories/property.md index ff9773d4..e94ca65e 100644 --- a/docs/built-in-factories/property.md +++ b/docs/built-in-factories/property.md @@ -20,13 +20,22 @@ const MyElement = { Property factory holds a property value with the type transform with fallback to the corresponding attribute value. -The [translation](../core-concepts/translation.md) has two rules, which use property factory. You can set property value as primitive or an object without `get` and `set` methods to define it using property factory. +### Translation + +The [translation](../core-concepts/translation.md) allows using property factory implicitly. You can set a property as a primitive or an array to create descriptor by the property factory under the hood: + +```javascript +const MyElement = { + value: 0, + items: [], +}; +``` ## Transform -`property` uses a transform function, which ensures the strict type of the value set by a property or an attribute. +`property` factory uses a transform function, which ensures the strict type of the value set by property or an attribute. -The type of the `defaultValue` is used to detect transform function. For example, when `defaultValue` is set to `"text"`, `String` function is used. If the `defaultValue` is a function, it is called when a property value is set. +The type of `defaultValue` is used to detect the transform function. For example, when `defaultValue` is set to `"text"`, `String` function is used. If the `defaultValue` is a function, it is called when a property value is set. ### Transform Types @@ -37,7 +46,7 @@ The type of the `defaultValue` is used to detect transform function. For example * `object` -> `Object.freeze(value)` * `undefined` -> `value` -Object values are frozen to prevent mutation of the own properties, which does not invalidate cached value. Moreover, `defaultValue` is shared between custom element instances, so any of them should not change it. +Object values are frozen to prevent mutation of their properties, which does not invalidate cached value. Moreover, `defaultValue` is shared between custom element instances, so any of them should not change it. To omit transform, `defaultValue` has to be set to `undefined`. diff --git a/docs/built-in-factories/render.md b/docs/built-in-factories/render.md index 9c93ad88..035393b2 100644 --- a/docs/built-in-factories/render.md +++ b/docs/built-in-factories/render.md @@ -25,13 +25,19 @@ export const MyElement = { }; ``` +Render factory creates and updates the DOM structure of your custom element. It works out of the box with built-in [template engine](../template-engine/introduction.md), but the passed `fn` function may use any external UI library, that renders DOM. + 👆 [Click and play with `render` factory using `React` library on ⚡StackBlitz](https://stackblitz.com/edit/hybrids-react-counter?file=react-counter.js) -Render factory creates and updates the DOM structure of your custom element. It works out of the box with built-in [template engine](../template-engine/introduction.md), but the passed `fn` function may use any external UI library, that renders DOM. +Render factory trigger update of the DOM by the `observe` method of the descriptor. It means that an update is scheduled with the internal queue and executed in the next animation frame. The passed `fn` is always called for the first time and when related properties change. + +If you use render factory for wrapping other UI libraries remember to access required properties from the `host` synchronously in the body of `fn` function (only then cache mechanism can save dependencies for the update). Otherwise, your function might be called only once. 👆 [Click and play with `render` factory using `lit-html` library on ⚡StackBlitz](https://stackblitz.com/edit/hybrids-lit-html-counter?file=lit-counter.js) -The `render` key of the property is not mandatory. However, the first rule of the [translation](../core-concepts/translation.md) makes possible to pass `fn` function as a `render` property value to use render factory: +### Translation + +The `render` key of the property is not mandatory. However, the first rule of the [translation](../core-concepts/translation.md) makes possible to pass `fn` function as a `render` property to use render factory: ```javascript import { html } from 'hybrids'; @@ -42,13 +48,24 @@ const MyElement = { }; ``` -### Shadow DOM +## Manual Update + +It is possible to trigger an update by calling property manually on the element instance: + +```javascript +const myElement = document.getElementsByTagName('my-element')[0]; +myElement.render(); +``` + +Property defined with `render` factory uses the same cache mechanism like other properties. The update process calls `fn` only if related properties have changed. + +## Shadow DOM -The factory by default uses [Shadow DOM](https://developer.mozilla.org/docs/Web/Web_Components/Using_shadow_DOM) as a `target`, which is created synchronously in `connect` callback. It is expected behavior, so usually you can omit `options` object and use [translation](../core-concepts/translation.md) rule for the render factory. +The factory by default uses [Shadow DOM](https://developer.mozilla.org/docs/Web/Web_Components/Using_shadow_DOM) as a `target`, which is created synchronously in `connect` callback. It is expected behavior, so usually you can omit `options` object and use [translation](../core-concepts/translation.md) rule for the render factory. Although, If your element does not require [style encapsulation](https://developers.google.com/web/fundamentals/web-components/shadowdom#styling) and [children distribution](https://developers.google.com/web/fundamentals/web-components/shadowdom#composition_slot) (`` element can be used only inside of the `shadowRoot`) you can disable Shadow DOM in the `options` object. Then, `target` argument of the update function become a `host`. In the result, your template will replace children content of the custom element (in Light DOM). -Keep in mind, that the `options` can be passed only with `render(fn, options)` factory function called explicitly: +Keep in mind that the `options` can be passed only with `render(fn, options)` factory function called explicitly: ```javascript import { html, render } from 'hybrids'; @@ -62,28 +79,11 @@ const MyElement = { }; ``` -## Update Mechanism - -Render factory updates an element using global render scheduler. It listens to `@invalidate` event triggered by the change detection. It schedules update with `requestAnimationFrame()` API and adds an element to the queue. The DOM is updated when one of the properties used in `fn` changes. - -However, if execution of the update passes ~16ms threshold (it counts from the beginning of the schedule), the following elements in the queue are updated within the next `requestAnimationFrame()`. - -### Manual Update - -It is possible to trigger an update by calling property manually on the element instance: - -```javascript -const myElement = document.getElementsByTagName('my-element')[0]; -myElement.render(); -``` - -Property defined with `render` factory uses the same cache mechanism like other properties. The update process calls `fn` only if related properties have changed. - ## Unit Testing -Because of the asynchronous update mechanism with threshold, it might be tricky to test if custom element instance renders correctly. However, you can create your unit tests on the basis of the definition itself. +Because of the asynchronous update mechanism with threshold, it might be tricky to test if the custom element instance renders correctly. However, you can create your unit tests based on the definition itself. -The render key is usually a function, which returns update function. It can be called synchronously with mocked host and arbitrary target element (for example `
` element): +The render key is usually a function, which returns the update function. It can be called synchronously with mocked host and arbitrary target element (for example `
` element): ```javascript import { html } from 'hybrids'; diff --git a/docs/core-concepts/definition.md b/docs/core-concepts/definition.md index dc628926..002682b1 100644 --- a/docs/core-concepts/definition.md +++ b/docs/core-concepts/definition.md @@ -9,6 +9,10 @@ To simplify using external custom elements with those created by the library, yo ```javascript import { define } from 'hybrids'; +const MyElement = { + ... +}; + // Define one element with explicit tag name define('my-element', MyElement); ``` diff --git a/docs/core-concepts/descriptors.md b/docs/core-concepts/descriptors.md index ad6a6bf3..be22bd7c 100644 --- a/docs/core-concepts/descriptors.md +++ b/docs/core-concepts/descriptors.md @@ -1,8 +1,8 @@ # Descriptors -The library provides own `define` function, which under the hood calls Custom Element API (read more in [Definition](./definition.md) section). Because of that, the library has all control over the parameters of the custom element definition. It creates class wrapper constructor dynamically, applies properties on its prototype, and finally defines custom element using `customElements.define()` method. +The library provides `define` function, which under the hood calls Custom Element API (read more in [Definition](./definition.md) section). Because of that, the library has all control over the parameters of the custom element definition. It creates class wrapper constructor dynamically, applies properties on its prototype, and finally defines custom element using `customElements.define()` method. -Property definitions are known as a *property descriptor*. The name came from the third argument of the `Object.defineProperty(obj, prop, descriptor)` method, which is used to set those properties on the `prototype` of the custom element constructor. +The property definition is known as a *property descriptor*. The name came from the third argument of the `Object.defineProperty(obj, prop, descriptor)` method, which is used to set those properties on the `prototype` of the custom element constructor. ## Structure @@ -15,19 +15,23 @@ const MyElement = { set: (host, value, lastValue) => { ... }, connect: (host, key, invalidate) => { ... - return () => { ... }; // disconnect + // disconnect + return () => { ... }; }, + observe: (host, value, lastValue) => { ... }, }, }; ``` However, there are a few differences. Instead of using function context (`this` keyword), the first argument of all methods is the instance of an element. It allows using arrow functions and destructuring function arguments. -The second most change is the cache mechanism, which controls and holds current property value. By the specs, getter/setter property requires external variable for keeping the value. In the hybrids, cache covers that for you. +The second most change is the cache mechanism, which controls and holds current property value. By the specs, getter/setter property requires an external variable for keeping the value. In the hybrids, cache covers that for you. Additionally, the library provides a mechanism for change detection and calls `observe` method, when the value of the property has changed (directly or when one of the dependency changes). -**Despite the [factories](factories.md) and [translation](translation.md) concepts, you can always define properties using descriptors**. The only requirement is that your definition has to include at least one of the `get`, `set` or `connect` methods. +**Despite the [factories](factories.md) and [translation](translation.md) concepts, you can always define properties using descriptors**. The only requirement is that your definition has to be an object instance (instead of a function reference, an array instance or primitive value). -The library uses default `get` or `set` method if they are not defined. The fallback method returns last saved value for `get`, and saves passed value for `set`. If `get` method is defined, `set` method does not fallback to default. It allows creating read-only property. +## Defaults + +The library provides a default method for `get` or `set` if they are omitted in the definition. The fallback method returns last saved value for `get`, and saves passed value for `set`. If the `get` method is defined, the `set` method does not support fallback to default (it allows creating read-only property). ```javascript const MyElement = { @@ -42,25 +46,30 @@ const MyElement = { // get: (host, value) => value, set: () => {...}, }, - defaultGetAndSet: { + defaultsWithConnect: { // get: (host, value) => value, // set: (host, value) => value, connect: () => {...}, }, + defaultsWithObserve: { + // get: (host, value) => value, + // set: (host, value) => value, + observe: () => {...}, + }, } ``` -In the above example `readonly` and `defaultGet` properties might have `connect` method but is not required. `defaultGetAndSet` applies only when `connect` method is defined. +## Methods -### Get +### get ```typescript get: (host: Element, lastValue: any) => { - // calculate next value - const nextValue = ...; + // calculate current value + const value = ...; // return it - return nextValue; + return value; } ``` @@ -70,9 +79,11 @@ get: (host: Element, lastValue: any) => { * **returns (required)**: * `nextValue` - a value of the current state of the property -`get` method calculates current property value. The returned value is cached by default. This method is called again only if other properties defined by the library used in the body of the function have changed. Cache mechanism uses equality check to compare values (`nextValue !== lastValue`), so it enforces using immutable data, which is one of the ground rules of the library. +It calculates the current property value. The returned value is cached by default. The cache mechanism works between properties defined by the library (even between different elements). If your `get` method does not use other properties, it won't be called again (the only way to update the value then is to assert new value or call `invalidate` from `connect` method). -In the following example `get` method of the `name` property is called again if `firstName` or `lastName` has changed: +Cache mechanism uses equality check to compare values (`nextValue !== lastValue`), so it enforces using immutable data, which is one of the ground rules of the library. + +In the following example, the `get` method of the `name` property is called again if `firstName` or `lastName` has changed: ```javascript const MyElement = { @@ -87,7 +98,7 @@ console.log(myElement.name); // calls 'get' and returns 'John Smith' console.log(myElement.name); // Cache returns 'John Smith' ``` -### Set +### set ```typescript set: (host: Element, value: any, lastValue: any) => { @@ -106,11 +117,9 @@ set: (host: Element, value: any, lastValue: any) => { * **returns (required)**: * `nextValue` - a value of the property, which replaces cached value -Every assertion of the property calls `set` method (like `myElement.property = 'new value'`). Cache value is invalidated if returned `nextValue` is not equal to `lastValue`. Only if cache invalidates `get` method is called. - -However, `set` method doesn't call `get` immediately. The next access to the property calls `get` method, although `set` returned a new value. Then `get` method takes this value as the `lastValue` argument, calculates `nextValue` and returns new value. +Every assertion of the property calls `set` method (like `myElement.property = 'new value'`). If returned `nextValue` is not equal to `lastValue`, cache of the property invalidates. However, `set` method does not trigger `get` method automatically. Only the next access to the property (like `const value = myElement.property`) calls `get` method. Then `get` takes `nextValue` from `set` as the `lastValue` argument, calculates `value` and returns it. -The following example shows `power` property using the default `get`, and defined `set` method, which calculates the power of the number passed to the property: +The following example shows the `power` property, which uses the default `get`, defines the `set` method, and calculates the power of the number passed to the property: ```javascript const MyElement = { @@ -123,9 +132,9 @@ myElement.power = 10; // calls 'set' method and set cache to 100 console.log(myElement.power); // Cache returns 100 ``` -If your property value only depends on other properties from the component, you can omit `set` method and use cache mechanism for holding property value (use only `get` method). +If your property value only depends on other properties from the component, you can omit the `set` method and use the cache mechanism for holding property value (use only the `get` method). -### Connect & Disconnect +### connect ```typescript connect: (host: Element, key: string, invalidate: Function) => { @@ -150,17 +159,9 @@ connect: (host: Element, key: string, invalidate: Function) => { When you insert, remove or relocate an element in the DOM tree, `connect` or `disconnect` is called synchronously (in the `connectedCallback` and `disconnectedCallback` callbacks of the Custom Elements API). -You can use `connect` to attach event listeners, initialize property value (using `key` argument) and many more. To clean up subscriptions return a `disconnect` function, where you can remove attached listeners and other things. +You can use `connect` to attach event listeners, initialize property value (using `key` argument) and many more. To clean up subscriptions, return a `disconnect` function, where you can remove attached listeners and other things. -## Change detection - -```javascript -myElement.addEventListener('@invalidate', () => { ...}); -``` - -When property cache value invalidates, change detection dispatches `@invalidate` custom event (bubbling). You can listen to this event and observe changes in the element properties. It is dispatched implicit when you set new value by the assertion or explicit by calling `invalidate` in `connect` callback. The event type was chosen to avoid name collision with those created by the custom elements authors. - -If the third party code is responsible for the property value, you can use `invalidate` callback to update it and trigger event dispatch. For example, it can be used to connect to async web APIs or external libraries: +If the third party code is responsible for the property value, you can use `invalidate` callback to notify that value should be recalculated (within next access). For example, it can be used to connect to async web APIs or external libraries: ```javascript import reduxStore from './store'; @@ -176,3 +177,21 @@ const MyElement = { 👆 [Click and play with `redux` integration on ⚡StackBlitz](https://stackblitz.com/edit/hybrids-redux-counter?file=redux-counter.js) In the above example, a cached value of `name` property invalidates if `reduxStore` changes. However, the `get` method is called if you access the property. + +### observe + +```typescript +observe: (host: Element, value: any, lastValue: any) => { + // Do side-effects related to value change + ... +} +``` + +* **arguments**: + * `host` - an element instance + * `value` - current value of the property + * `lastValue` - last cached value of the property + +When property cache invalidates (directly by the assertion or when one of the dependency invalidates) and `observe` method is set, the change detection mechanism adds the property to the internal queue. Within the next animation frame (using `requestAnimationFrame`) properties from the queue are checked if they have changed, and if they did, `observe` method of the property is called. It means, that `observe` method is asynchronous by default, and it is only called for properties, which value is different in the time of execution of the queue (in the `requestAnimationFrame` call). + +The property is added to the queue (if `observe` is set) for the first time when an element instance is created (in the `constructor()` of the element). Property value defaults to `undefined`. The `observe` method will be called at the start only if your `get` method returns other value than `undefined`. diff --git a/src/cache.js b/src/cache.js index b4d796b8..0e11321c 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,4 +1,5 @@ import { stringifyElement } from './utils'; +import * as emitter from './emitter'; const entries = new WeakMap(); export function getEntry(target, key) { @@ -15,9 +16,11 @@ export function getEntry(target, key) { target, key, value: undefined, - deps: new Set(), + contexts: undefined, + deps: undefined, state: 1, checksum: 0, + observed: false, }; targetMap.set(key, entry); } @@ -25,17 +28,24 @@ export function getEntry(target, key) { return entry; } -function calculateChecksum({ state, deps }) { - let checksum = state; - deps.forEach((entry) => { - // eslint-disable-next-line no-unused-expressions - entry.target[entry.key]; - checksum += entry.state; - }); +function calculateChecksum(entry) { + let checksum = entry.state; + if (entry.deps) { + entry.deps.forEach((depEntry) => { + // eslint-disable-next-line no-unused-expressions + depEntry.target[depEntry.key]; + checksum += depEntry.state; + }); + } return checksum; } +function dispatchDeep(entry) { + if (entry.observed) emitter.dispatch(entry); + if (entry.contexts) entry.contexts.forEach(dispatchDeep); +} + let context = null; export function get(target, key, getter) { const entry = getEntry(target, key); @@ -46,9 +56,15 @@ export function get(target, key, getter) { } if (context) { + context.deps = context.deps || new Set(); context.deps.add(entry); } + if (context && (context.observed || (context.contexts && context.contexts.size))) { + entry.contexts = entry.contexts || new Set(); + entry.contexts.add(context); + } + const parentContext = context; context = entry; @@ -57,8 +73,11 @@ export function get(target, key, getter) { return entry.value; } - if (entry.deps.size) { - entry.deps.clear(); + if (entry.deps && entry.deps.size) { + entry.deps.forEach((depEntry) => { + if (depEntry.contexts) depEntry.contexts.delete(entry); + }); + entry.deps = undefined; } try { @@ -67,6 +86,8 @@ export function get(target, key, getter) { if (nextValue !== entry.value) { entry.state += 1; entry.value = nextValue; + + dispatchDeep(entry); } entry.checksum = calculateChecksum(entry); @@ -79,7 +100,7 @@ export function get(target, key, getter) { return entry.value; } -export function set(target, key, setter, value, callback) { +export function set(target, key, setter, value) { if (context) { context = null; throw Error(`Try to set '${key}' of '${stringifyElement(target)}' in get call`); @@ -92,7 +113,7 @@ export function set(target, key, setter, value, callback) { entry.state += 1; entry.value = newValue; - callback(); + dispatchDeep(entry); } } @@ -105,8 +126,15 @@ export function invalidate(target, key, clearValue) { const entry = getEntry(target, key); entry.checksum = 0; + dispatchDeep(entry); if (clearValue) { entry.value = undefined; } } + +export function observe(target, key, fn) { + const entry = getEntry(target, key); + entry.observed = true; + emitter.subscribe(entry, fn); +} diff --git a/src/children.js b/src/children.js index de0a2979..1f9e85d2 100644 --- a/src/children.js +++ b/src/children.js @@ -1,5 +1,3 @@ -import { deferred } from './utils'; - function walk(node, fn, options, items = []) { Array.from(node.children).forEach((child) => { const hybrids = child.constructor.hybrids; @@ -22,34 +20,12 @@ export default function children(hybridsOrFn, options = { deep: false, nested: f get(host) { return walk(host, fn, options); }, connect(host, key, invalidate) { const observer = new MutationObserver(invalidate); - const set = new Set(); - - const childEventListener = ({ target }) => { - if (!set.size) { - deferred.then(() => { - const list = host[key]; - for (let i = 0; i < list.length; i += 1) { - if (set.has(list[i])) { - invalidate(false); - break; - } - } - set.clear(); - }); - } - set.add(target); - }; observer.observe(host, { childList: true, subtree: !!options.deep, }); - host.addEventListener('@invalidate', childEventListener); - - return () => { - observer.disconnect(); - host.removeEventListener('@invalidate', childEventListener); - }; + return () => { observer.disconnect(); }; }, }; } diff --git a/src/define.js b/src/define.js index 4ad1bb99..7562975f 100644 --- a/src/define.js +++ b/src/define.js @@ -2,45 +2,34 @@ import property from './property'; import render from './render'; import * as cache from './cache'; -import { dispatch, pascalToDash, deferred } from './utils'; +import { pascalToDash, deferred } from './utils'; /* istanbul ignore next */ try { process.env.NODE_ENV } catch(e) { var process = { env: { NODE_ENV: 'production' } }; } // eslint-disable-line -const dispatchSet = new Set(); - -function dispatchInvalidate(host) { - if (!dispatchSet.size) { - deferred.then(() => { - dispatchSet.forEach(target => dispatch(target, '@invalidate', { bubbles: true })); - dispatchSet.clear(); - }); - } - - dispatchSet.add(host); -} - const defaultMethod = (host, value) => value; -function compile(Hybrid, hybrids) { - Hybrid.hybrids = hybrids; +function compile(Hybrid, descriptors) { + Hybrid.hybrids = descriptors; Hybrid.connects = []; + Hybrid.observers = []; - Object.keys(hybrids).forEach((key) => { - const value = hybrids[key]; - const type = typeof value; + Object.keys(descriptors).forEach((key) => { + const desc = descriptors[key]; + const type = typeof desc; let config; if (type === 'function') { - config = key === 'render' ? render(value) : { get: value }; - } else if (value === null || type !== 'object' || (Array.isArray(value))) { - config = property(value); + config = key === 'render' ? render(desc) : { get: desc }; + } else if (type !== 'object' || desc === null || (Array.isArray(desc))) { + config = property(desc); } else { config = { - get: value.get || defaultMethod, - set: value.set || (!value.get && defaultMethod) || undefined, - connect: value.connect, + get: desc.get || defaultMethod, + set: desc.set || (!desc.get && defaultMethod) || undefined, + connect: desc.connect, + observe: desc.observe, }; } @@ -49,18 +38,30 @@ function compile(Hybrid, hybrids) { return cache.get(this, key, config.get); }, set: config.set && function set(newValue) { - cache.set(this, key, config.set, newValue, () => dispatchInvalidate(this)); + cache.set(this, key, config.set, newValue); }, enumerable: true, configurable: process.env.NODE_ENV !== 'production', }); if (config.connect) { - Hybrid.connects.push(host => config.connect(host, key, (clearCache = true) => { - if (clearCache) cache.invalidate(host, key); - dispatchInvalidate(host); + Hybrid.connects.push(host => config.connect(host, key, () => { + cache.invalidate(host, key); })); } + + if (config.observe) { + Hybrid.observers.push((host) => { + let lastValue; + cache.observe(host, key, () => { + const value = host[key]; + if (value !== lastValue) { + config.observe(host, value, lastValue); + } + lastValue = value; + }); + }); + } }); } @@ -93,7 +94,6 @@ if (process.env.NODE_ENV !== 'production') { }); node.connectedCallback(); - dispatchInvalidate(node); } }); updateQueue.clear(); @@ -103,7 +103,7 @@ if (process.env.NODE_ENV !== 'production') { }; } -const connects = new WeakMap(); +const disconnects = new WeakMap(); function defineElement(tagName, hybridsOrConstructor) { const type = typeof hybridsOrConstructor; @@ -143,20 +143,32 @@ function defineElement(tagName, hybridsOrConstructor) { class Hybrid extends HTMLElement { static get name() { return tagName; } + constructor() { + super(); + const { observers } = this.constructor; + + for (let index = 0; index < observers.length; index += 1) { + observers[index](this); + } + } + connectedCallback() { - const list = this.constructor.connects.reduce((acc, fn) => { - const result = fn(this); - if (result) acc.add(result); - return acc; - }, new Set()); - - connects.set(this, list); - dispatchInvalidate(this); + const { connects } = this.constructor; + const list = []; + + for (let index = 0; index < connects.length; index += 1) { + const disconnect = connects[index](this); + if (disconnect) list.push(disconnect); + } + + disconnects.set(this, list); } disconnectedCallback() { - const list = connects.get(this); - list.forEach(fn => fn()); + const list = disconnects.get(this); + for (let index = 0; index < list.length; index += 1) { + list[index](); + } } } diff --git a/src/emitter.js b/src/emitter.js new file mode 100644 index 00000000..26d4410d --- /dev/null +++ b/src/emitter.js @@ -0,0 +1,42 @@ +const targets = new WeakMap(); + +function getListeners(target) { + let listeners = targets.get(target); + if (!listeners) { + listeners = new Set(); + targets.set(target, listeners); + } + return listeners; +} + +const queue = new Set(); +const run = fn => fn(); + +function execute() { + try { + queue.forEach((target) => { + try { + getListeners(target).forEach(run); + queue.delete(target); + } catch (e) { + queue.delete(target); + throw e; + } + }); + } catch (e) { + if (queue.size) execute(); + throw e; + } +} + +export function dispatch(target) { + if (!queue.size) { + requestAnimationFrame(execute); + } + queue.add(target); +} + +export function subscribe(target, cb) { + getListeners(target).add(cb); + dispatch(target); +} diff --git a/src/parent.js b/src/parent.js index 0b280264..7c392cb2 100644 --- a/src/parent.js +++ b/src/parent.js @@ -21,19 +21,9 @@ export default function parent(hybridsOrFn) { get: host => walk(host, fn), connect(host, key, invalidate) { const target = host[key]; - const cb = (event) => { - if (event.target === target) invalidate(false); - }; - if (target) { - target.addEventListener('@invalidate', cb); - - return () => { - target.removeEventListener('@invalidate', cb); - invalidate(); - }; + return invalidate; } - return false; }, }; diff --git a/src/render.js b/src/render.js index a963c7f7..3e920fac 100644 --- a/src/render.js +++ b/src/render.js @@ -1,113 +1,29 @@ -import { deferred, shadyCSS } from './utils'; - -const map = new WeakMap(); -const cache = new WeakMap(); - -let queue = []; -let index = 0; -let startTime = 0; - -const FPS_THRESHOLD = 1000 / 60; // 60 FPS ~ 16,67ms time window - -export function update() { - try { - let offset = 1; - - startTime = performance.now(); - - for (; index < queue.length; index += 1) { - const target = queue[index]; - - if (map.has(target)) { - const key = map.get(target); - const prevUpdate = cache.get(target); - const nextUpdate = target[key]; - - if (nextUpdate !== prevUpdate) { - cache.set(target, nextUpdate); - nextUpdate(); - if (!prevUpdate) { - shadyCSS(shady => shady.styleElement(target)); - } else { - shadyCSS(shady => shady.styleSubtree(target)); - } - } - - if (index % offset === 0) { - if (index + 1 < queue.length && (performance.now() - startTime) > FPS_THRESHOLD) { - throw queue; - } else { - offset *= 2; - } - } - } - } - - queue = []; - index = 0; - deferred.then(() => { startTime = 0; }); - } catch (e) { - index += 1; - requestAnimationFrame(update); - deferred.then(() => { startTime = 0; }); - - if (e !== queue) throw e; - } -} - -function addToQueue(event) { - if (event.target === event.currentTarget && map.has(event.target)) { - if (!startTime) { - if (!queue.length) { - requestAnimationFrame(update); - } else { - queue.splice(index, 0, event.target); - return; - } - } else if (!queue.length) { - if ((performance.now() - startTime) > FPS_THRESHOLD) { - requestAnimationFrame(update); - } else { - deferred.then(update); - } - } - - queue.push(event.target); - } -} - export default function render(get, customOptions = {}) { if (typeof get !== 'function') { throw TypeError(`The first argument must be a function: ${typeof get}`); } const options = { shadowRoot: true, ...customOptions }; + const shadowRootInit = { mode: 'open' }; + + if (typeof options.shadowRoot === 'object') { + Object.assign(shadowRootInit, options.shadowRoot); + } return { - get: (host) => { + get(host) { const fn = get(host); - return () => fn(host, options.shadowRoot ? host.shadowRoot : host); + return function flush() { + fn(host, options.shadowRoot ? host.shadowRoot : host); + }; }, - connect(host, key) { - if (map.has(host)) { - throw Error(`Render factory already used in '${map.get(host)}' key`); - } - + connect(host) { if (options.shadowRoot && !host.shadowRoot) { - const shadowRootInit = { mode: 'open' }; - if (typeof options.shadowRoot === 'object') { - Object.assign(shadowRootInit, options.shadowRoot); - } host.attachShadow(shadowRootInit); } - - host.addEventListener('@invalidate', addToQueue); - map.set(host, key); - - return () => { - host.removeEventListener('@invalidate', addToQueue); - map.delete(host); - }; + }, + observe(host, fn) { + fn(); }, }; } diff --git a/src/template/core.js b/src/template/core.js index beddc7da..43fdd82e 100644 --- a/src/template/core.js +++ b/src/template/core.js @@ -132,7 +132,7 @@ function createExternalWalker(context) { const createWalker = typeof window.ShadyDOM === 'object' && window.ShadyDOM.inUse ? createInternalWalker : createExternalWalker; const container = document.createElement('div'); -export function compile(rawParts, isSVG, styles) { +export function compileTemplate(rawParts, isSVG, styles) { const template = document.createElement('template'); const parts = []; @@ -239,7 +239,7 @@ export function compile(rawParts, isSVG, styles) { compileIndex += 1; } - return (host, target, args) => { + return function updateTemplateInstance(host, target, args) { const data = dataMap.get(target, { type: 'function' }); if (template !== data.template) { @@ -255,7 +255,8 @@ export function compile(rawParts, isSVG, styles) { const markers = []; - Object.assign(data, { template, markers }); + data.template = template; + data.markers = markers; while (renderWalker.nextNode()) { const node = renderWalker.currentNode; @@ -298,10 +299,24 @@ export function compile(rawParts, isSVG, styles) { } } - data.markers.forEach(([node, fn], index) => { - if (data.lastArgs && data.lastArgs[index] === args[index]) return; - fn(host, node, args[index], data.lastArgs ? data.lastArgs[index] : undefined); - }); + for (let index = 0; index < data.markers.length; index += 1) { + const [node, marker] = data.markers[index]; + if (!data.lastArgs || data.lastArgs[index] !== args[index]) { + marker(host, node, args[index], data.lastArgs ? data.lastArgs[index] : undefined); + } + } + + if (target.nodeType !== Node.TEXT_NODE) { + shadyCSS((shady) => { + if (host.shadowRoot) { + if (data.lastArgs) { + shady.styleSubtree(host); + } else { + shady.styleElement(host); + } + } + }); + } data.lastArgs = args; }; diff --git a/src/template/index.js b/src/template/index.js index 9aafd954..d4a20674 100644 --- a/src/template/index.js +++ b/src/template/index.js @@ -1,9 +1,10 @@ import defineElements from '../define'; -import { compile, getPlaceholder } from './core'; +import { compileTemplate, getPlaceholder } from './core'; import * as helpers from './helpers'; const PLACEHOLDER = getPlaceholder(); +const SVG_PLACEHOLDER = getPlaceholder('svg'); const templatesMap = new Map(); const stylesMap = new WeakMap(); @@ -24,22 +25,22 @@ const methods = { }; function create(parts, args, isSVG) { - const fn = (host, target = host) => { - const styles = stylesMap.get(fn); + const createTemplate = (host, target = host) => { + const styles = stylesMap.get(createTemplate); let id = parts.join(PLACEHOLDER); if (styles) id += styles.join(PLACEHOLDER); - if (isSVG) id += getPlaceholder('svg'); + if (isSVG) id += SVG_PLACEHOLDER; let render = templatesMap.get(id); if (!render) { - render = compile(parts, isSVG, styles); + render = compileTemplate(parts, isSVG, styles); templatesMap.set(id, render); } render(host, target, args); }; - return Object.assign(fn, methods); + return Object.assign(createTemplate, methods); } export function html(parts, ...args) { diff --git a/src/template/resolvers/array.js b/src/template/resolvers/array.js index 87110b9f..6867091d 100644 --- a/src/template/resolvers/array.js +++ b/src/template/resolvers/array.js @@ -54,7 +54,8 @@ export default function resolveArray(host, target, value) { const lastIndex = value.length - 1; const data = dataMap.get(target); - entries.forEach((entry, index) => { + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; let matchedEntry; if (lastEntries) { for (let i = 0; i < lastEntries.length; i += 1) { @@ -88,7 +89,7 @@ export default function resolveArray(host, target, value) { if (index === lastIndex) data.endNode = previousSibling; entry.placeholder = placeholder; - }); + } if (lastEntries) { lastEntries.forEach((entry) => { diff --git a/src/utils.js b/src/utils.js index 403d65fb..ef24f74c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -33,5 +33,4 @@ export function stringifyElement(element) { } export const IS_IE = 'ActiveXObject' in window; - export const deferred = Promise.resolve(); diff --git a/test/helpers.js b/test/helpers.js index 6caae17b..60f1b414 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -2,16 +2,25 @@ export function test(html) { const template = document.createElement('template'); template.innerHTML = html; - return (spec) => { + return spec => (done) => { const wrapper = document.createElement('div'); document.body.appendChild(wrapper); - wrapper.appendChild(template.content.cloneNode(true)); - const promise = spec(wrapper.children[0]); + const result = spec(wrapper.children[0]); - Promise.resolve(promise).then(() => { - document.body.removeChild(wrapper); - }); + if (result) { + Promise.resolve(result).then(() => { + requestAnimationFrame(() => { + document.body.removeChild(wrapper); + done(); + }); + }); + } else { + requestAnimationFrame(() => { + document.body.removeChild(wrapper); + done(); + }); + } }; } diff --git a/test/spec/cache.js b/test/spec/cache.js index f63ddf13..5dc7192d 100644 --- a/test/spec/cache.js +++ b/test/spec/cache.js @@ -1,13 +1,13 @@ -import { get, set, invalidate } from '../../src/cache'; +import { + get, set, invalidate, observe, +} from '../../src/cache'; describe('cache:', () => { let target; let getSpy; - let setSpy; beforeEach(() => { getSpy = jasmine.createSpy('getter'); - setSpy = jasmine.createSpy('setter'); target = { value: 1, @@ -21,7 +21,7 @@ describe('cache:', () => { set(target, 'one', () => { this.value = value; return value; - }, value, setSpy); + }, value); }, get two() { return get(target, 'two', () => this.one); @@ -38,7 +38,6 @@ describe('cache:', () => { it('set value', () => { target.one = 'value'; expect(target.one).toBe('value'); - expect(setSpy).toHaveBeenCalledTimes(1); }); it('does not call get when set not changes value', () => { @@ -155,4 +154,103 @@ describe('cache:', () => { expect(spy).toHaveBeenCalledTimes(2); }); }); + + describe('observe', () => { + let obj; + let contextObj; + let spy; + + const getContext = () => get(obj, 'obj', (t, v) => v); + + beforeEach(() => { + obj = {}; + contextObj = {}; + spy = jasmine.createSpy('start spy'); + set(obj, 'obj', (t, v) => v, 'value'); + }); + + it('- should call callback', (done) => { + observe(contextObj, 'context', spy); + expect(get(contextObj, 'context', getContext)).toBe('value'); + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + done(); + }); + }); + + it('- should call callback when dependency value changes', (done) => { + observe(contextObj, 'context', spy); + expect(get(contextObj, 'context', getContext)).toBe('value'); + + requestAnimationFrame(() => { + set(obj, 'obj', (t, v) => v, 'new value'); + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(2); + done(); + }); + }); + }); + + it('- should call callback when deep dependency value changes', (done) => { + observe(contextObj, 'context', spy); + expect(get(contextObj, 'context', getContext)).toBe('value'); + + const deepObj = {}; + set(deepObj, 'deep', (t, v) => v, 'value'); + + invalidate(obj, 'obj'); + get(obj, 'obj', () => { + const result = get(deepObj, 'deep', (t, v) => v); + return result; + }); + + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + spy = jasmine.createSpy(); + observe(contextObj, 'context', spy); + + requestAnimationFrame(() => { + set(deepObj, 'deep', (t, v) => v, 'new value'); + + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(2); + done(); + }); + }); + }); + }); + + it('- removes contexts from deps when context changes dependencies', (done) => { + observe(contextObj, 'context', spy); + + expect(get(contextObj, 'context', getContext)).toBe('value'); + set(contextObj, 'context', (t, v) => v, 'another value'); + expect(get(contextObj, 'context', (t, v) => v)).toBe('another value'); + + + requestAnimationFrame(() => { + set(obj, 'obj', (t, v) => v, 'better value'); + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + + it('- skips contexts from deps when context changes dependencies', (done) => { + expect(get(contextObj, 'context', getContext)).toBe('value'); + set(contextObj, 'context', (t, v) => v, 'another value'); + expect(get(contextObj, 'context', (t, v) => v)).toBe('another value'); + + observe(contextObj, 'context', spy); + + requestAnimationFrame(() => { + set(obj, 'obj', (t, v) => v, 'better value'); + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + }); }); diff --git a/test/spec/children.js b/test/spec/children.js index 32ee915b..6e7e8cf8 100644 --- a/test/spec/children.js +++ b/test/spec/children.js @@ -14,6 +14,7 @@ describe('children:', () => { define('test-children-direct', { direct: children(child), customName: ({ direct }) => direct && direct[0] && direct[0].customName, + render: ({ customName }) => html`${customName}`, }); const tree = test(` @@ -28,25 +29,24 @@ describe('children:', () => { `); - it('returns list', () => tree((el) => { + it('returns list', tree((el) => { expect(el.direct).toEqual([ el.children[0], el.children[1], ]); })); - it('removes item from list', done => tree((el) => { + it('removes item from list', tree((el) => { el.removeChild(el.children[1]); return resolveRaf(() => { expect(el.direct).toEqual([ jasmine.objectContaining({ customName: 'one' }), ]); - done(); }); })); - it('adds item to list', done => tree((el) => { + it('adds item to list', tree((el) => { const newItem = document.createElement('test-children-child'); newItem.customName = 'four'; @@ -58,11 +58,10 @@ describe('children:', () => { jasmine.objectContaining({ customName: 'two' }), jasmine.objectContaining({ customName: 'four' }), ]); - done(); }); })); - it('reorder list items', done => tree((el) => { + it('reorder list items', tree((el) => { el.insertBefore(el.children[1], el.children[0]); return resolveRaf(() => { @@ -70,23 +69,15 @@ describe('children:', () => { jasmine.objectContaining({ customName: 'two' }), jasmine.objectContaining({ customName: 'one' }), ]); - done(); }); })); - it('updates parent computed property', done => tree(el => resolveTimeout(() => { + it('updates parent computed property', tree(el => resolveTimeout(() => { expect(el.customName).toBe('one'); el.children[0].customName = 'four'; - let called = false; - - el.addEventListener('@invalidate', ({ target }) => { - if (target === el) called = true; - }); return resolveRaf(() => { - expect(called).toBe(true); - expect(el.customName).toBe('four'); - done(); + expect(el.shadowRoot.innerHTML).toBe('four'); }); }))); }); @@ -102,7 +93,7 @@ describe('children:', () => { `); - it('returns item list', () => tree((el) => { + it('returns item list', tree((el) => { expect(el.direct.length).toBe(1); expect(el.direct[0]).toBe(el.children[0]); })); @@ -125,7 +116,7 @@ describe('children:', () => { `); - it('returns item list', () => tree((el) => { + it('returns item list', tree((el) => { expect(el.deep).toEqual([ jasmine.objectContaining({ customName: 'one' }), jasmine.objectContaining({ customName: 'two' }), @@ -133,7 +124,7 @@ describe('children:', () => { ]); })); - it('removes item from list', done => tree((el) => { + it('removes item from list', tree((el) => { el.children[2].innerHTML = ''; return resolveRaf(() => { @@ -141,11 +132,10 @@ describe('children:', () => { jasmine.objectContaining({ customName: 'one' }), jasmine.objectContaining({ customName: 'two' }), ]); - done(); }); })); - it('does not update if other children element is invalidated', done => tree(el => resolveRaf(() => { + it('does not update if other children element is invalidated', tree(el => resolveRaf(() => { el.children[0].children[0].customName = 'test'; return resolveRaf(() => { expect(el.deep).toEqual([ @@ -153,7 +143,6 @@ describe('children:', () => { jasmine.objectContaining({ customName: 'two' }), jasmine.objectContaining({ customName: 'three' }), ]); - done(); }); }))); }); @@ -175,7 +164,7 @@ describe('children:', () => { `); - it('returns item list', () => tree((el) => { + it('returns item list', tree((el) => { expect(el.nested).toEqual([ jasmine.objectContaining({ customName: 'one' }), jasmine.objectContaining({ customName: 'five' }), @@ -184,7 +173,7 @@ describe('children:', () => { ]); })); - it('removes item from list', done => tree((el) => { + it('removes item from list', tree((el) => { el.children[0].innerHTML = ''; return resolveRaf(() => { @@ -193,7 +182,6 @@ describe('children:', () => { jasmine.objectContaining({ customName: 'two' }), jasmine.objectContaining({ customName: 'three' }), ]); - done(); }); })); }); @@ -229,11 +217,10 @@ describe('children:', () => { `); - it('adds dynamic item', done => tree(el => resolveTimeout(() => { + it('adds dynamic item', tree(el => resolveTimeout(() => { el.items = ['two']; return resolveTimeout(() => { expect(el.shadowRoot.children[0].shadowRoot.children[0].children.length).toBe(2); - done(); }); }))); }); diff --git a/test/spec/define.js b/test/spec/define.js index 41f4cbd4..4b08b108 100644 --- a/test/spec/define.js +++ b/test/spec/define.js @@ -1,4 +1,4 @@ -import { test } from '../helpers'; +import { test, resolveRaf } from '../helpers'; import define from '../../src/define'; import { invalidate } from '../../src/cache'; @@ -52,13 +52,18 @@ describe('define:', () => { }); describe('for object descriptor', () => { - let spy; + let connectSpy; + let observeSpy; + let disconnectSpy; define('test-define-object', { one: { get: (host, v) => v + 1, set: (host, newVal) => newVal, - connect: (...args) => spy(...args), + connect: (...args) => { + connectSpy(...args); + return disconnectSpy; + }, }, two: { set: (host, value) => value * value, @@ -72,20 +77,25 @@ describe('define:', () => { host[key] = 'four'; }, }, + five: { + observe: (...args) => observeSpy(...args), + }, }); const tree = test(''); beforeEach(() => { - spy = jasmine.createSpy(); + connectSpy = jasmine.createSpy(); + observeSpy = jasmine.createSpy(); + disconnectSpy = jasmine.createSpy(); }); - it('sets getter and setter', () => tree((el) => { + it('sets getter and setter', tree((el) => { el.one = 10; expect(el.one).toBe(11); })); - it('sets setter and uses default getter', () => tree((el) => { + it('sets setter and uses default getter', tree((el) => { el.two = 10; expect(el.two).toBe(100); })); @@ -94,21 +104,48 @@ describe('define:', () => { expect(el.three).toEqual(undefined); })); - it('uses default get and set methods when both omitted', () => tree((el) => { + it('uses default get and set methods when both omitted', tree((el) => { expect(el.four).toEqual('four'); })); - it('calls connect method', () => tree((el) => { - expect(spy.calls.first().args[0]).toBe(el); - expect(spy.calls.first().args[1]).toBe('one'); + it('calls connect method', tree((el) => { + expect(connectSpy.calls.first().args[0]).toBe(el); + expect(connectSpy.calls.first().args[1]).toBe('one'); + })); + + it('calls disconnect method', tree((el) => { + el.parentElement.removeChild(el); + expect(disconnectSpy).toHaveBeenCalledTimes(1); })); - it('returns previous value when invalidate', () => tree((el) => { + it('returns previous value when invalidate', tree((el) => { el.one = 10; expect(el.one).toBe(11); invalidate(el, 'one'); expect(el.one).toBe(12); })); + + it('calls observe method', tree((el) => { + expect(observeSpy).toHaveBeenCalledTimes(0); + el.five = 1; + return resolveRaf(() => { + expect(observeSpy).toHaveBeenCalledTimes(1); + expect(observeSpy).toHaveBeenCalledWith(el, 1, undefined); + }); + })); + + it('does not call observe method if value did not change', tree((el) => { + el.five = 1; + return resolveRaf(() => { + expect(observeSpy).toHaveBeenCalledTimes(1); + el.one = 1; + el.five = 2; + el.five = 1; + return resolveRaf(() => { + expect(observeSpy).toHaveBeenCalledTimes(1); + }); + }); + })); }); describe('for primitive value', () => { @@ -120,7 +157,7 @@ describe('define:', () => { `); - it('applies property module with passed argument', () => tree((el) => { + it('applies property module with passed argument', tree((el) => { expect(el.testProperty).toBe('value'); })); }); @@ -134,25 +171,25 @@ describe('define:', () => { `); - it('sets it as getter of the element property', () => tree((el) => { + it('sets it as getter of the element property', tree((el) => { expect(el.getter).toBe('some value'); })); }); describe('for render key', () => { - it('uses render factory if value is a function', () => { + it('uses render factory if value is a function', (done) => { define('test-define-render', { - render: () => {}, + render: () => () => {}, }); const tree = test(''); tree((el) => { expect(typeof el.render).toBe('function'); - }); + })(done); }); - it('does not use render factory if value is not a function', () => { + it('does not use render factory if value is not a function', (done) => { define('test-define-render-other', { render: [], }); @@ -161,7 +198,7 @@ describe('define:', () => { tree((el) => { expect(typeof el.render).toBe('object'); - }); + })(done); }); }); @@ -176,7 +213,7 @@ describe('define:', () => { `); - it('sets object as a property', () => tree((el) => { + it('sets object as a property', tree((el) => { expect(el.one).toBe(one); })); }); @@ -224,9 +261,8 @@ describe('define:', () => { expect(el.children[0].one).toBe('text'); expect(spy).toHaveBeenCalledTimes(2); - done(); }); - }); + })(done); }); it('updates elements in shadowRoot', (done) => { @@ -242,9 +278,8 @@ describe('define:', () => { return Promise.resolve().then(() => { expect(connect).toHaveBeenCalledTimes(1); expect(child.one).toBe('text'); - done(); }); - }); + })(done); }); }); diff --git a/test/spec/emitter.js b/test/spec/emitter.js new file mode 100644 index 00000000..3e3741ba --- /dev/null +++ b/test/spec/emitter.js @@ -0,0 +1,78 @@ +import { dispatch, subscribe } from '../../src/emitter'; + +describe('emitter:', () => { + let spy; + let target; + + beforeEach(() => { + spy = jasmine.createSpy(); + target = {}; + }); + + it('subscribe saves fn and dispatch target in next animation frame', (done) => { + subscribe(target, spy); + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + done(); + }); + }); + + describe('dispatch', () => { + let origRAF; + let catchSpy; + + beforeEach(() => { + catchSpy = jasmine.createSpy(); + }); + + beforeAll(() => { + origRAF = window.requestAnimationFrame; + window.requestAnimationFrame = function requestAnimationFrame(fn) { + origRAF.call(this, () => { + try { + fn(); + } catch (e) { + catchSpy(fn); + } + }); + }; + }); + + afterAll(() => { + window.requestAnimationFrame = origRAF; + }); + + it('calls fn for target', (done) => { + subscribe(target, spy); + requestAnimationFrame(() => { + dispatch(target); + requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(2); + done(); + }); + }); + }); + + it('catches error', (done) => { + subscribe(target, () => { throw new Error('asd'); }); + + requestAnimationFrame(() => { + expect(catchSpy).toHaveBeenCalledTimes(1); + done(); + }); + }); + + it('catches error and calls next target', (done) => { + const target2 = {}; + + subscribe(target, () => { throw new Error('asd'); }); + subscribe(target2, spy); + + requestAnimationFrame(() => { + expect(catchSpy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); +}); diff --git a/test/spec/html.js b/test/spec/html.js index 7b992b28..b3298257 100644 --- a/test/spec/html.js +++ b/test/spec/html.js @@ -2,12 +2,13 @@ import { html } from '../../src/template'; import { createInternalWalker } from '../../src/template/core'; import define from '../../src/define'; import { dispatch } from '../../src/utils'; +import { test, resolveTimeout } from '../helpers'; describe('html:', () => { let fragment; beforeEach(() => { - fragment = document.createElement('div'); + fragment = document.createElement('custom-element'); document.body.appendChild(fragment); }); @@ -668,7 +669,70 @@ describe('html:', () => { }); }); - describe('shadyCSS polyfill', () => { + describe('ShadyDOM polyfill', () => { + it('uses internal TreeWalker', () => { + const el = document.createElement('div'); + el.innerHTML = '
text
text
text
'; + + const walker = createInternalWalker(el); + let index = 0; + while (walker.nextNode()) { + expect(walker.currentNode).not.toBeNull(); + index += 1; + } + expect(index).toBe(7); + }); + }); + + describe('ShadyCSS custom property scope', () => { + const TestShadyChild = { + value: 0, + render: ({ value }) => html` + ${value} + + `, + }; + + const TestShadyParent = { + active: false, + render: ({ active }) => html` + + + `.define({ TestShadyChild }), + }; + + define('test-shady-parent', TestShadyParent); + + const shadyTree = test(` + + `); + + it('should set custom property', shadyTree(el => resolveTimeout(() => { + const { color } = window.getComputedStyle(el.shadowRoot.children[0].shadowRoot.children[0]); + expect(color).toBe('rgb(255, 0, 0)'); + }))); + + it('should update custom property', shadyTree(el => resolveTimeout(() => { + el.active = true; + return resolveTimeout(() => { + const { color } = window.getComputedStyle(el.shadowRoot.children[0].shadowRoot.children[0]); + expect(color).toBe('rgb(0, 0, 255)'); + }); + }))); + }); + + describe('ShadyCSS encapsulation', () => { const render = text => html`
${text}
+ `; + + beforeEach(() => { + if (!shadyCSSApplied) { + window.ShadyCSS = { + prepareTemplate: template => template, + styleElement: jasmine.createSpy(), + styleSubtree: jasmine.createSpy(), + }; + } else { + spyOn(window.ShadyCSS, 'styleElement'); + spyOn(window.ShadyCSS, 'styleSubtree'); + } + }); + + afterEach(() => { + if (!shadyCSSApplied) { + delete window.ShadyCSS; + } + }); + + it('uses styleElement on first paint', () => { + fragment.attachShadow({ mode: 'open' }); + render(fragment, fragment.shadowRoot); + expect(window.ShadyCSS.styleElement).toHaveBeenCalled(); + expect(window.ShadyCSS.styleSubtree).not.toHaveBeenCalled(); + }); + + it('uses styleSubtree on sequential paint', () => { + fragment.attachShadow({ mode: 'open' }); + render(fragment, fragment.shadowRoot); + render(fragment, fragment.shadowRoot); + expect(window.ShadyCSS.styleSubtree).toHaveBeenCalled(); + }); + + it('does not use ShadyCSS when shadowRoot is not used', () => { + render(fragment, fragment); + expect(window.ShadyCSS.styleElement).not.toHaveBeenCalled(); + expect(window.ShadyCSS.styleSubtree).not.toHaveBeenCalled(); + }); + }); + describe('use external element with shadowRoot', () => { class TestExternalElement extends HTMLElement { constructor() { @@ -741,21 +854,6 @@ describe('html:', () => { }); }); - describe('ShadyDOM polyfill', () => { - it('uses internal TreeWalker', () => { - const el = document.createElement('div'); - el.innerHTML = '
text
text
text
'; - - const walker = createInternalWalker(el); - let index = 0; - while (walker.nextNode()) { - expect(walker.currentNode).not.toBeNull(); - index += 1; - } - expect(index).toBe(7); - }); - }); - describe('svg element', () => { it('sets attribute from an expression', () => { const render = html``; diff --git a/test/spec/parent.js b/test/spec/parent.js index 2cc929e7..acc06858 100644 --- a/test/spec/parent.js +++ b/test/spec/parent.js @@ -44,12 +44,12 @@ describe('parent:', () => { `); - it('connects with direct parent element', () => directParentTree((el) => { + it('connects with direct parent element', directParentTree((el) => { const child = el.children[0]; expect(child.parent).toBe(el); })); - it('disconnects from parent element', done => directParentTree((el) => { + it('disconnects from parent element', directParentTree((el) => { const child = el.children[0]; expect(child.parent).toBe(el); @@ -58,16 +58,15 @@ describe('parent:', () => { return Promise.resolve().then(() => { expect(child.parent).toBe(null); - done(); }); })); - it('connects to indirect parent element', () => indirectParentTree((el) => { + it('connects to indirect parent element', indirectParentTree((el) => { const child = el.children[0].children[0]; expect(child.parent).toBe(el); })); - it('connects to out of the shadow parent element', () => shadowParentTree((el) => { + it('connects to out of the shadow parent element', shadowParentTree((el) => { const shadowRoot = el.attachShadow({ mode: 'open' }); const child = document.createElement('test-parent-child'); const wrapper = document.createElement('div'); @@ -77,35 +76,25 @@ describe('parent:', () => { expect(child.parent).toBe(el); })); - it('connects to parent by a function argument', () => fnParentTree((el) => { + it('connects to parent by a function argument', fnParentTree((el) => { const child = el.children[0]; expect(child.parent).toBe(el); })); - it('returns null for no parent', () => noParentTree((el) => { + it('returns null for no parent', noParentTree((el) => { expect(el.parent).toBe(null); })); - it('updates child computed property', done => directParentTree((el) => { - Promise.resolve().then(() => { - const spy = jasmine.createSpy('event callback'); - const child = el.children[0]; - - expect(el.customProperty).toBe('value'); - expect(child.computed).toBe('value other value'); - - child.addEventListener('@invalidate', spy); + it('updates child computed property', directParentTree(el => Promise.resolve().then(() => { + const child = el.children[0]; - el.customProperty = 'new value'; + expect(el.customProperty).toBe('value'); + expect(child.computed).toBe('value other value'); - Promise.resolve().then(() => { - Promise.resolve().then(() => { - expect(spy).toHaveBeenCalledTimes(1); - expect(child.computed).toBe('new value other value'); + el.customProperty = 'new value'; - done(); - }); - }); - }); - })); + return Promise.resolve().then(() => Promise.resolve().then(() => { + expect(child.computed).toBe('new value other value'); + })); + }))); }); diff --git a/test/spec/property.js b/test/spec/property.js index c9789de7..864b28d4 100644 --- a/test/spec/property.js +++ b/test/spec/property.js @@ -26,15 +26,15 @@ describe('property:', () => { `); - it('uses value from configuration', () => empty((el) => { + it('uses value from configuration', empty((el) => { expect(el.stringProp).toBe('value'); })); - it('uses host attribute', () => tree((el) => { + it('uses host attribute', tree((el) => { expect(el.stringProp).toBe('default value'); })); - it('uses host attribute value only once', done => tree((el) => { + it('uses host attribute value only once', tree((el) => { const parent = el.parentElement; el.stringProp = 'new value'; @@ -45,7 +45,6 @@ describe('property:', () => { Promise.resolve().then(() => { expect(el.stringProp).toBe('new value'); - done(); resolve(); }); }); @@ -69,11 +68,11 @@ describe('property:', () => { `); - it('transforms attribute to number', () => tree((el) => { + it('transforms attribute to number', tree((el) => { expect(el.numberProp).toBe(321); })); - it('transforms property to number', () => empty((el) => { + it('transforms property to number', empty((el) => { el.numberProp = '321'; expect(el.numberProp).toBe(321); })); @@ -84,17 +83,19 @@ describe('property:', () => { `); - it('transforms attribute to boolean', () => { + it('transforms unset attribute to boolean', (done) => { empty((el) => { expect(el.boolProp).toBe(false); - }); + })(done); + }); + it('transforms set attribute to boolean', (done) => { tree((el) => { expect(el.boolProp).toBe(true); - }); + })(done); }); - it('transforms property to number', () => empty((el) => { + it('transforms property to number', empty((el) => { el.boolProp = 'value'; expect(el.boolProp).toBe(true); @@ -122,7 +123,7 @@ describe('property:', () => { }); }); - it('transforms property with function', () => empty((el) => { + it('transforms property with function', empty((el) => { el.funcProp = false; expect(el.funcProp).toEqual({ value: false, @@ -135,13 +136,11 @@ describe('property:', () => { `); - it('does not transform attribute', () => { - tree((el) => { - expect(el.objProp).toBe(objProp); - }); - }); + it('does not transform attribute', tree((el) => { + expect(el.objProp).toBe(objProp); + })); - it('set object value', () => empty((el) => { + it('set object value', empty((el) => { const value = {}; el.objProp = value; expect(el.objProp).toBe(value); @@ -150,7 +149,7 @@ describe('property:', () => { expect(el.objProp).toBe(null); })); - it('throws when set with other type than object', () => empty((el) => { + it('throws when set with other type than object', empty((el) => { expect(() => { el.objProp = false; }).toThrow(); })); }); @@ -160,25 +159,22 @@ describe('property:', () => { `); - it('does not transform attribute', () => { - empty((el) => { - expect(el.nullProp).toBe(null); - expect(el.undefinedProp).toBe(undefined); - }); - - tree((el) => { - expect(el.nullProp).toBe(null); - expect(el.undefinedProp).toBe(undefined); - }); - }); + it('does not transform attribute', empty((el) => { + expect(el.nullProp).toBe(null); + expect(el.undefinedProp).toBe(undefined); + })); + it('does not transform attribute', tree((el) => { + expect(el.nullProp).toBe(null); + expect(el.undefinedProp).toBe(undefined); + })); - it('passes null property without transform', () => empty((el) => { + it('passes null property without transform', empty((el) => { const obj = {}; el.nullProp = obj; expect(el.nullProp).toBe(obj); })); - it('passes undefined property without transform', () => empty((el) => { + it('passes undefined property without transform', empty((el) => { el.undefinedProp = false; expect(el.undefinedProp).toBe(false); @@ -189,13 +185,13 @@ describe('property:', () => { }); describe('connect option', () => { - it('is called', () => { + it('is called', (done) => { const spy = jasmine.createSpy('connect'); define('test-property-connect', { prop: property(0, spy), }); - test('')(() => expect(spy).toHaveBeenCalledTimes(1)); + test('')(() => expect(spy).toHaveBeenCalledTimes(1))(done); }); }); }); diff --git a/test/spec/render.js b/test/spec/render.js index 44403407..c50bc67f 100644 --- a/test/spec/render.js +++ b/test/spec/render.js @@ -1,6 +1,6 @@ -import { test, resolveRaf, resolveTimeout } from '../helpers'; +import { test, resolveRaf } from '../helpers'; import { define, html } from '../../src'; -import render, { update } from '../../src/render'; +import render from '../../src/render'; describe('render:', () => { define('test-render', { @@ -23,43 +23,23 @@ describe('render:', () => { }).toThrow(); }); - it('renders content', done => tree(el => resolveRaf(() => { + it('renders content', tree(el => resolveRaf(() => { expect(el.shadowRoot.children[0].textContent).toBe('0'); - done(); }))); - it('updates content', done => tree(el => resolveRaf(() => { + it('updates content', tree(el => resolveRaf(() => { el.value = 1; return resolveRaf(() => { expect(el.shadowRoot.children[0].textContent).toBe('1'); - done(); }); }))); - it('does not render when element is out of the document', done => tree((el) => { - const fragment = document.createDocumentFragment(); - fragment.appendChild(el); - - return resolveRaf(() => { - expect(el.shadowRoot.innerHTML).toBe(''); - done(); - }); - })); - - it('does not render if getter does not change', done => tree(el => resolveRaf(() => { - el.property = 'new value'; - return resolveRaf(() => { - expect(el.shadowRoot.children[0].textContent).toBe('0'); - done(); - }); - }))); - - it('renders content on direct call', () => tree((el) => { + it('renders content on direct call', tree((el) => { el.render(); expect(el.shadowRoot.children[0].textContent).toBe('0'); })); - it('does not re-create shadow DOM', done => tree((el) => { + it('does not re-create shadow DOM', tree((el) => { const shadowRoot = el.shadowRoot; const parent = el.parentElement; parent.removeChild(el); @@ -67,152 +47,9 @@ describe('render:', () => { return resolveRaf(() => { parent.appendChild(el); expect(el.shadowRoot).toBe(shadowRoot); - done(); }); })); - describe('defer next update tasks after threshold', () => { - define('test-render-long', { - nested: false, - delay: false, - value: '', - render: ({ nested, delay, value }) => (target, shadowRoot) => { - let template = `
${value}
`; - - if (nested) { - template += ` - - - `; - } - if (delay) { - const now = performance.now(); - while (performance.now() - now < 20); - } - - shadowRoot.innerHTML = template; - }, - }); - - it('renders nested elements', done => test(` -
- -
- `)(el => new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const one = el.children[0]; - expect(one.shadowRoot.children[0].textContent).toBe('one'); - resolve(); - done(); - }); - }); - }); - }))); - - it('renders nested elements with delay', done => test(` -
- -
- `)(el => new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const one = el.children[0]; - expect(one.shadowRoot.children[0].textContent).toBe('one'); - resolve(); - done(); - }); - }); - }); - }))); - }); - - it('update function catches error in render function', (done) => { - let fn = () => { throw Error(); }; - define('test-render-throws-in-render', { - render: () => fn(), - }); - - test('')(() => { - Promise.resolve().then(() => { - expect(() => { - update(); - }).toThrow(); - fn = () => {}; - done(); - }); - }); - }); - - it('update function catches error in result of render function', (done) => { - let fn = () => { throw Error('example error'); }; - define('test-render-throws-in-callback', { - render: () => fn, - }); - - test('')(() => { - Promise.resolve().then(() => { - expect(() => { - update(); - }).toThrow(); - fn = () => {}; - done(); - }); - }); - }); - - describe('shady css custom property scope', () => { - const TestShadyChild = { - value: 0, - render: ({ value }) => html` - ${value} - - `, - }; - - const TestShadyParent = { - active: false, - render: ({ active }) => html` - - - `.define({ TestShadyChild }), - }; - - define('test-shady-parent', TestShadyParent); - - const shadyTree = test(` - - `); - - it('should set custom property', done => shadyTree(el => resolveTimeout(() => { - const { color } = window.getComputedStyle(el.shadowRoot.children[0].shadowRoot.children[0]); - expect(color).toBe('rgb(255, 0, 0)'); - done(); - }))); - - it('should update custom property', done => shadyTree(el => resolveTimeout(() => { - el.active = true; - return resolveTimeout(() => { - const { color } = window.getComputedStyle(el.shadowRoot.children[0].shadowRoot.children[0]); - expect(color).toBe('rgb(0, 0, 255)'); - done(); - }); - }))); - }); - it('renders elements in parent slot', (done) => { const TestRenderParentSlot = { render: () => html``, @@ -228,58 +65,7 @@ describe('render:', () => { slotTree(el => resolveRaf(() => { expect(el.children[0].shadowRoot.children[0].textContent).toBe('0'); - done(); - })); - }); - - it('throws error for duplicate render', () => { - const hybrids = { - renderOne: render(() => () => {}), - renderTwo: render(() => () => {}), - }; - - const el = document.createElement('div'); - - expect(() => { - hybrids.renderOne.connect(el, 'renderOne'); - hybrids.renderTwo.connect(el, 'renderTwo'); - }).toThrow(); - }); - - describe('shadyCSS polyfill', () => { - const shadyCSSApplied = window.ShadyCSS && !window.ShadyCSS.nativeShadow; - - beforeAll(() => { - if (!shadyCSSApplied) { - window.ShadyCSS = { - prepareTemplate: template => template, - styleElement: jasmine.createSpy(), - styleSubtree: jasmine.createSpy(), - }; - } else { - spyOn(window.ShadyCSS, 'styleElement'); - spyOn(window.ShadyCSS, 'styleSubtree'); - } - }); - - afterAll(() => { - if (!shadyCSSApplied) { - delete window.ShadyCSS; - } - }); - - it('uses styleElement on first paint', done => tree(() => resolveTimeout(() => { - expect(window.ShadyCSS.styleElement).toHaveBeenCalled(); - done(); - }))); - - it('uses styleSubtree on sequential paint', done => tree(el => resolveRaf(() => { - el.value = 1; - return resolveTimeout(() => { - expect(window.ShadyCSS.styleSubtree).toHaveBeenCalled(); - done(); - }); - }))); + }))(done); }); describe('options object with shadowRoot option', () => { @@ -310,24 +96,29 @@ describe('render:', () => { expect(el.children.length).toBe(2); expect(el.children[0].innerHTML).toBe('other content'); expect(el.children[1].innerHTML).toBe('false'); - done(); }); - })); + }))(done); }); - it('for object creates shadowRoot with "delegatesFocus" option', () => { + it('for object creates shadowRoot with "delegatesFocus" option', (done) => { const TestRenderCustomShadow = define('test-render-custom-shadow', { render: render(() => html` `, { shadowRoot: { delegatesFocus: true } }), }); - const spy = spyOn(TestRenderCustomShadow.prototype, 'attachShadow'); + const origAttachShadow = TestRenderCustomShadow.prototype.attachShadow; + const spy = jasmine.createSpy('attachShadow'); + + TestRenderCustomShadow.prototype.attachShadow = function attachShadow(...args) { + spy(...args); + return origAttachShadow.call(this, ...args); + }; test(` `)(() => { expect(spy).toHaveBeenCalledWith({ mode: 'open', delegatesFocus: true }); - }); + })(done); }); }); });