Skip to content

Commit

Permalink
feat(define): change detection refactor
Browse files Browse the repository at this point in the history
BREAKING CHANGE: Change detection mechanism no longer dispatch `@invalidate` DOM event. Use introduced `observe` method of the property descriptor. Read more in descriptors section of the documentation.
  • Loading branch information
smalluban committed May 29, 2019
1 parent 0a3e279 commit d88e040
Show file tree
Hide file tree
Showing 23 changed files with 718 additions and 625 deletions.
17 changes: 13 additions & 4 deletions docs/built-in-factories/property.md
Expand Up @@ -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

Expand All @@ -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`.

Expand Down
48 changes: 24 additions & 24 deletions docs/built-in-factories/render.md
Expand Up @@ -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';
Expand All @@ -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) (`<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';
Expand All @@ -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 `<div>` 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 `<div>` element):

```javascript
import { html } from 'hybrids';
Expand Down
4 changes: 4 additions & 0 deletions docs/core-concepts/definition.md
Expand Up @@ -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);
```
Expand Down
81 changes: 50 additions & 31 deletions 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

Expand All @@ -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 = {
Expand All @@ -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;
}
```

Expand All @@ -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 = {
Expand All @@ -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) => {
Expand All @@ -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 = {
Expand All @@ -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) => {
Expand All @@ -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';
Expand All @@ -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`.

0 comments on commit d88e040

Please sign in to comment.