From b69502017b2e5c96f586b2a7509fc4e02de3d9dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Mon, 13 May 2024 11:07:02 +0200 Subject: [PATCH] Updates & Fixes --- docs/component-model/structure.md | 155 +++++++------ src/cache.js | 6 +- src/define.js | 10 +- src/render.js | 2 +- src/store.js | 374 +++++++++++++++--------------- test/spec/cache.js | 8 +- test/spec/store.js | 95 ++++---- types/index.d.ts | 35 ++- 8 files changed, 346 insertions(+), 339 deletions(-) diff --git a/docs/component-model/structure.md b/docs/component-model/structure.md index 1d9f87e3..a00d6f7c 100644 --- a/docs/component-model/structure.md +++ b/docs/component-model/structure.md @@ -1,8 +1,8 @@ # Structure -The component is based on a plain object with a number of properties. The library checks the type of the property value to generate object descriptors, which then are used in the custom element class definition. It means, that the values can be primitives, functions, or if you need a full control - object descriptors. +The component definition is based on a plain object with a number of properties. The library checks the type of the property value to generate descriptors, which then are used in the custom element class definition. The values can be primitives, functions, or if you need a full control - object descriptors. -## Cache +## Cache & Change Detection The core idea of the hybrid properties is its unique cache and change detection mechanism. It tracks dependencies between the properties (even between different custom elements) and notify about changes. Still, the value of the property is only recalculated when it is accessed and the value of its dependencies has changed. If the property does not use other properties, it won’t be recalculated, and the first cached value is always returned. @@ -12,14 +12,14 @@ The cache mechanism uses equality check to compare values (`nextValue` !== `last ## Reserved Keys -There are three reserved properties in the definition: +There are three reserved property names in the definition: * `tag` - a string which sets the custom element tag name -* `render` and `content`, which expect a function, and have additional options available +* `render` and `content`, which expect the value as a function, and have additional options available -## Descriptor +## Property Descriptor -The descriptor structure is an plain object with a `value` and number of options: +The property descriptor structure is a plain object with the `value` and number of options: ```typescript { @@ -31,19 +31,21 @@ The descriptor structure is an plain object with a `value` and number of options | (host, value, lastValue) => { ... }; connect?: (host, key, invalidate) => { ... }; observe?: (host, value, lastValue) => { ... }; - reflect?: boolean; + reflect?: boolean | (value) => string; } ..., } ``` -For the property definition, which is not an object instance, the library translates the value to the object descriptor with the `value` option: +### Translation + +If the property value is not an object instance, the library translates it to the object descriptor with the `value` option: ```javascript property: "something" -> property: { value: "something" } ``` -In the result, the following definitions are equivalent: +The following definitions are equal: **Shorthand version** @@ -61,23 +63,19 @@ define({ ```javascript define({ tag: "my-element", - name: { - value: "John", - }, - lastName: { - value: "Doe", - }, + name: { value: "John" }, + lastName: { value: "Doe" }, name: { value: ({ firstName, lastName }) => `${firstName} ${lastName}` }, }); ``` -Usually, the first definition is more readable and less verbose, but the second one gives more control over the property behavior, as it allows to pass to the object descriptor additional options. +Usually, the shorthand definition is more readable and less verbose, but the second one gives more control over the property behavior, as it allows to pass to the object descriptor additional options. -## Values +### Value -### Primitives & Objects +#### Primitives & Objects ```ts value: string | boolean | number | object | undefined | null @@ -94,7 +92,7 @@ define({ }); ``` -A default value as object instance can only be set using the object descriptor `value` option: +A default value as an object instance can only be set using full object descriptor with `value` option: ```javascript define({ @@ -103,26 +101,25 @@ define({ }); ``` -As the cache mechanism utilizes strong equality check, the object instances for default values are frozen during the compilation step of the component definition. Keep in mind, that it might be not compatible with some external libraries, which require mutable objects. +The cache mechanism utilizes strong equality check, so the object instances for default values must be frozen during the compilation step of the component definition. Keep in mind, that it might be not compatible with some external libraries, which require mutable objects (You can use computed property with a function, which returns a new object instance). -### Functions +#### Function ```ts -value: (host) => { ... } | (host, value, lastValue) => { ... } +value: (host) => { ... } | (host, value) => { ... } ``` * **arguments**: * `host` - an element instance * `value` - a value passed to assertion (ex., el.myProperty = 'new value') - * `lastValue` - last cached value of the property * **returns**: * a value of the property -If the descriptor `value` option is a function, the library creates a property with the function as a getter, and optionally with a setter (if the function has more than one argument). +If the descriptor `value` option is a function, the library creates a property with a getter, and optionally with a setter (if the function has more than one argument). #### Readonly -If the function has only one argument, the property is read-only, and the function is called with the element instance. Usually, the first argument is sufficient, which also can be destructured: +If the function has only one argument, the property is read-only, and the function is called with the element instance: ```javascript define({ @@ -135,7 +132,7 @@ define({ #### Writable -If the function has more than one argument, the property is writable, and the function is shared between the getter and setter. However, the function is called only if the value of the property is accessed - the assert value is kept in the cache until the next access. +If the function has two arguments, the property is writable. However, the function is called only if the value of the property is accessed (getter) - the asserted value is kept in the cache until the next access. ```javascript define({ @@ -144,7 +141,7 @@ define({ }); ``` -It is very important that the library uses `fn.length` to detect number of arguments, so the [default parameters](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Functions/Default_parameters) and the [rest parameters](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Functions/rest_parameters) syntax cannot be used for the function arguments: +The library uses `fn.length` to detect number of arguments, so the [default parameters](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Functions/Default_parameters) and the [rest parameters](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Functions/rest_parameters) syntax cannot be used for the function arguments: ```javascript // won't work (fn.length === 1) @@ -160,11 +157,9 @@ data: (host, value) => { }, ``` -## Attributes - -Properties use the corresponding dashed-cased attribute for the initial value when the custom element is connected for the very first time (expect read-only computed properties). +#### Attributes -Use the attributes only to define static values in the templates or the document, as the attribute changes are not being watched, and setting the attribute does not update the property. +Writable properties use the corresponding dashed-cased attribute for the initial value when the custom element is being created. Use the attributes only to define static values in the templates or the document, as the attribute changes are not being watched, and setting the attribute does not update the property. Use static values in the templates: @@ -182,7 +177,7 @@ el.firstName = "George"; el.getAttribute("first-name"); ``` -#### Booleans +##### Booleans The library follows the HTML standard when transforming attributes to the boolean type. An empty value of an existing attribute is interpreted as `true`. For setting `false` by the attribute, you must not set the attribute at all. It means, that if you want to support the boolean attribute, it is best to set the default value of the property to `false`. @@ -204,27 +199,7 @@ html` ` ``` -### Reflect - -```ts -reflect: boolean | (value) => string -``` - -Only if the `reflect` option is set to `true` or a transform function, the property value is reflected back to the attribute when the value changes. The attribute is also set when the element is connected, and the property value is set. - -You can use this feature to create CSS selectors in Shadow DOM: - -```javascript -define({ - tag: "my-element", - isAdmin: { value: false, reflect: true }, - render: () => html``.css` - :host([is-admin]) { background: yellow; } - `, -}); -``` - -## Connect +### Connect ```ts connect: (host, key, invalidate) => () => { ... } @@ -235,9 +210,9 @@ connect: (host, key, invalidate) => () => { ... } * `key` - the property name * `invalidate` - a callback to notify that the property value should be recalculated -The descriptor `connect` method should be used for setup the property when the element is connected. To clean up things, return a `disconnect` function, where you can remove attached listeners and others. +Use the `connect` method to setup the property when the element is connected. To clean up the setup, return a `disconnect` function, where you can remove attached listeners etc. -When you insert, remove, or relocate an element in the DOM tree, `connect` method and `disconnect` callback are called (by the `connectedCallback` and `disconnectedCallback` callbacks of the Custom Elements API). +> When you insert, remove, or relocate an element in the DOM tree, `connect` method and `disconnect` callback are called (by the `connectedCallback` and `disconnectedCallback` callbacks of the Custom Elements API). ```javascript define({ @@ -264,7 +239,7 @@ define({ If the third-party code is responsible for the property value, you can use the `invalidate` callback to notify that value should be recalculated. For example, it can be used to connect to async web APIs or external libraries. -## Observe +### Observe ```ts observe: (host, value, lastValue) => { ... } @@ -275,7 +250,7 @@ observe: (host, value, lastValue) => { ... } * `value` - current value of the property * `lastValue` - last cached value of the property -Use the `observe` method for calling side effects, when the property value changes. The method is called asynchronously for the first time when the element is connected, and then every time the property value changes. +Use the `observe` method for calling side effects, when the property value changes. The method is called asynchronously for the first time when the element is connected, and every time the property value changes. > If the synchronous updates compensate, and the result value is the same as before, the function won't be called. @@ -291,9 +266,27 @@ define({ }); ``` +### Reflect + +```ts +reflect: boolean | (value) => string +``` + +Use the `reflect` option to reflect back property value to the corresponding dashed-cased attribute. Set it to `true` or to a transform function. For example, you can use this feature to create CSS selectors in the Shadow DOM: + +```javascript +define({ + tag: "my-element", + isAdmin: { value: false, reflect: true }, + render: () => html``.css` + :host([is-admin]) { background: yellow; } + `, +}); +``` + ## `render` & `content` -The `render` and `content` properties are reserved for the internal structure of the custom element. The `value` option must be a function, which returns a result of the call to the built-in template engine or a custom update function. +The `render` and `content` properties are reserved for the rendering structure of the custom element. The `value` option must be a function, which returns a result of the call to the built-in template engine or a custom update function. The library uses internally the `observe` pattern to called function automatically when dependencies change. As the property returns an update function, it can also be called manually, by `el.render()` or `el.content()`. @@ -318,26 +311,28 @@ define({ }); ``` -For more granular control, use object descriptor with additional options: +The `render` property allows passing additional options to `host.attachShadow()` method. Use full descriptor with `options` key: ```ts render: { value: (host) => { ... }, - connect: ..., - observe: ..., - options: + options: { + mode: "open" | "closed", + delegatesFocus: boolean, + }, + ... } - -```javascript - -The Shadow DOM supports setting the `delegatesFocus` option. You can use it by assigning a boolean value to the `delegatesFocus` property of the function: +``` ```javascript import { define, html } from "hybrids"; define({ tag: "my-element", - render: Object.assign(() => html`
...
`, { delegatesFocus: true }), + render: { + value: html`
...
`, + options: { delegatesFocus: true }, + }, }); ``` @@ -345,7 +340,7 @@ define({ Use the `content` property for rendering templates in the content of the custom element. By the design, it does not support isolated styles, slot elements, etc. -However, it might be the way to build an app-like views structure, which can be rendered as a document content in light DOM, so it is easily accessible in developer tools and search engines. For example, form elements (like ``) have to be in the same subtree with the `
` element. +However, it is the way to build an app-like views structure, which can be rendered as a document content in light DOM. It is easily accessible in developer tools and search engines. For example, form elements (like ``) have to be in the same subtree with the `` element. ```javascript import { define, html } from "hybrids"; @@ -405,9 +400,27 @@ import { define, html } from "hybrids"; define({ tag: "my-element", input: ({ render }) => render().querySelector("#input"), - render: () => html` - - `, + render: () => html``, +}); +``` + +### Connect & Observe + +You can setup property or call side effects when the element re-renders by using the `connect` and `observe` methods. Use full object descriptor with `value` option to define the function: + +```javascript +define({ + tag: "my-element", + render: { + value: () => html`

Hello!

`, + connect(host, key, invalidate) { + console.log("connected"); + return () => console.log("disconnected"); + }, + observe(host, value, lastValue) { + console.log(`${value} -> ${lastValue}`); + }, + }, }); ``` diff --git a/src/cache.js b/src/cache.js index da20dd65..47d6f96f 100644 --- a/src/cache.js +++ b/src/cache.js @@ -59,8 +59,8 @@ export function getEntries(target) { } let context = null; -export function getCurrentEntry() { - return context; +export function getCurrentValue() { + return context?.value; } export function get(target, key, fn) { @@ -93,7 +93,7 @@ export function get(target, key, fn) { context = entry; stack.add(entry); - entry.value = fn(target, entry.assertValue, entry.value); + entry.value = fn(target, entry.assertValue); entry.resolved = true; context = lastContext; diff --git a/src/define.js b/src/define.js index c9141f45..29c97a41 100644 --- a/src/define.js +++ b/src/define.js @@ -94,13 +94,9 @@ function compile(hybrids, HybridsElement) { } Object.defineProperty(HybridsElement.prototype, key, { - get: desc.writable - ? function get() { - return cache.get(this, key, desc.value); - } - : function get() { - return cache.get(this, key, (host) => desc.value(host)); - }, + get: function get() { + return cache.get(this, key, desc.value); + }, set: desc.writable ? function assert(newValue) { cache.assert(this, key, newValue); diff --git a/src/render.js b/src/render.js index 55aa31b2..8e146639 100644 --- a/src/render.js +++ b/src/render.js @@ -25,7 +25,7 @@ export default function render(key, desc) { if (key === "render") { const options = desc.options || {}; - const shadowOptions = key === "render" && { + const shadowOptions = { mode: options.mode || "open", delegatesFocus: options.delegatesFocus, }; diff --git a/src/store.js b/src/store.js index e9e561bd..03c1df1a 100644 --- a/src/store.js +++ b/src/store.js @@ -323,21 +323,15 @@ function getTypeConstructor(type, key) { } } -function setModelState(model, state, value = model) { - cache.set( - model, - "state", - (_, value, lastValue) => { - if (value.state === "error") { - return { state: "error", error: value.value }; - } +function setModelState(model, state, value) { + const lastConfig = cache.getEntry(model, "state").value; - value.error = !!lastValue && lastValue.error; + cache.assert(model, "state", { + state, + value, + error: (state === "error" ? value : lastConfig?.error) || false, + }); - return value; - }, - { state, value }, - ); return model; } @@ -345,7 +339,7 @@ function getModelState(model) { return cache.get( model, "state", - (model, _, v = { state: "ready", value: model, error: false }) => v, + (model, config = { state: "ready", error: false }) => config, ); } @@ -932,10 +926,6 @@ function setupListModel(Model, nested) { return config; } -function resolveTimestamp(h, v) { - return v || getCurrentTimestamp(); -} - function normalizeId(id) { if (typeof id !== "object") return id !== undefined ? String(id) : id; @@ -1015,14 +1005,16 @@ function get(Model, id) { id = normalizeId(id); - return cache.get(config, stringId, (h, assertModel, cachedModel) => { + return cache.get(config, stringId, () => { + const cachedModel = cache.getEntry(config, stringId).value; + if (cachedModel && pending(cachedModel)) return cachedModel; let validContexts = true; if (config.contexts) { for (const context of config.contexts) { if ( - cache.get(context, context, resolveTimestamp) === + cache.get(context, context, () => getCurrentTimestamp()) === getCurrentTimestamp() ) { validContexts = false; @@ -1039,59 +1031,74 @@ function get(Model, id) { (offline && config.create(offline.get(stringId))) || config.placeholder(id); + let result; try { - let result = config.storage.get(id); + result = config.storage.get(id); + } catch (e) { + return setTimestamp(mapError(fallback(), e)); + } - if ( - !(result instanceof Promise) && - (result === undefined || typeof result !== "object") - ) { - throw TypeError( - stringifyModel( - Model, - `Storage 'get' method must return a Promise, an instance, or null: ${result}`, - ), - ); - } + if ( + !(result instanceof Promise) && + result !== undefined && + typeof result !== "object" + ) { + throw TypeError( + stringifyModel( + Model, + `Storage 'get' method must return a Promise, an instance, or null: ${result}`, + ), + ); + } + try { if (typeof result !== "object" || result === null) { if (offline) offline.set(stringId, null); throw notFoundError(Model, stringId); } + } catch (e) { + return setTimestamp(mapError(fallback(), e)); + } - if (result instanceof Promise) { - result = result - .then((data) => { - if (typeof data !== "object" || data === null) { - if (offline) offline.set(stringId, null); - throw notFoundError(Model, stringId); - } + if (result instanceof Promise) { + result = result + .then((data) => { + if (data !== undefined && typeof data !== "object") { + throw TypeError( + stringifyModel( + Model, + `Storage 'get' method must resolve to an instance, or null: ${data}`, + ), + ); + } - if (data.id !== id) data.id = id; - const model = config.create(data); + if (typeof data !== "object" || data === null) { + if (offline) offline.set(stringId, null); + throw notFoundError(Model, stringId); + } - if (offline) offline.set(stringId, model); + if (data.id !== id) data.id = id; + const model = config.create(data); - return syncCache(config, stringId, setTimestamp(model)); - }) - .catch((e) => syncCache(config, stringId, mapError(fallback(), e))); + if (offline) offline.set(stringId, model); - return setModelState(fallback(), "pending", result); - } + return syncCache(config, stringId, setTimestamp(model)); + }) + .catch((e) => syncCache(config, stringId, mapError(fallback(), e))); - if (result.id !== id) result.id = id; - const model = config.create(result); + return setModelState(fallback(), "pending", result); + } - if (offline) { - Promise.resolve().then(() => { - offline.set(stringId, model); - }); - } + if (result.id !== id) result.id = id; + const model = config.create(result); - return resolve(config, setTimestamp(model), cachedModel); - } catch (e) { - return setTimestamp(mapError(fallback(), e)); + if (offline) { + Promise.resolve().then(() => { + offline.set(stringId, model); + }); } + + return resolve(config, setTimestamp(model), cachedModel); }); } @@ -1164,143 +1171,141 @@ function set(model, values = {}) { const isDraft = draftMap.get(config); let id; - try { - if ( - config.enumerable && - !isInstance && - (!values || typeof values !== "object") - ) { - throw TypeError(`Values must be an object instance: ${values}`); - } + if ( + config.enumerable && + !isInstance && + (!values || typeof values !== "object") + ) { + throw TypeError(`Values must be an object instance: ${values}`); + } - if (!isDraft && values && hasOwnProperty.call(values, "id")) { - throw TypeError(`Values must not contain 'id' property: ${values.id}`); - } + if (!isDraft && values && hasOwnProperty.call(values, "id")) { + throw TypeError(`Values must not contain 'id' property: ${values.id}`); + } - const localModel = config.create(values, isInstance ? model : undefined); - const keys = values ? Object.keys(values) : []; + const localModel = config.create(values, isInstance ? model : undefined); + const keys = values ? Object.keys(values) : []; - const errors = {}; - const lastError = isInstance && isDraft && error(model); + const errors = {}; + const lastError = isInstance && isDraft && error(model); - let hasErrors = false; + let hasErrors = false; - if (localModel) { - for (const [key, fn] of config.checks.entries()) { - if (keys.indexOf(key) === -1) { - if (lastError && lastError.errors && lastError.errors[key]) { - hasErrors = true; - errors[key] = lastError.errors[key]; - } - - if (isDraft && localModel[key] == config.model[key]) { - continue; - } + if (localModel) { + for (const [key, fn] of config.checks.entries()) { + if (keys.indexOf(key) === -1) { + if (lastError && lastError.errors && lastError.errors[key]) { + hasErrors = true; + errors[key] = lastError.errors[key]; } - let checkResult; - try { - checkResult = fn(localModel[key], key, localModel); - } catch (e) { - checkResult = e; + if (isDraft && localModel[key] == config.model[key]) { + continue; } + } - if (checkResult !== true && checkResult !== undefined) { - hasErrors = true; - errors[key] = checkResult || true; - } + let checkResult; + try { + checkResult = fn(localModel[key], key, localModel); + } catch (e) { + checkResult = e; } - if (hasErrors && !isDraft) { - throw getValidationError(errors); + if (checkResult !== true && checkResult !== undefined) { + hasErrors = true; + errors[key] = checkResult || true; } } + } + + let result; + try { + if (hasErrors && !isDraft) { + throw getValidationError(errors); + } id = localModel ? localModel.id : model.id; - let result = config.storage.set( - isInstance ? id : undefined, - localModel, - keys, - ); + result = config.storage.set(isInstance ? id : undefined, localModel, keys); + } catch (e) { + if (isInstance) setModelState(model, "error", e); + return Promise.reject(e); + } - if ( - !(result instanceof Promise) && - (result === undefined || typeof result !== "object") - ) { - throw TypeError( - stringifyModel( - config.model, - `Storage 'set' method must return a Promise, an instance, or null: ${result}`, - ), - ); - } + if ( + !(result instanceof Promise) && + result !== undefined && + typeof result !== "object" + ) { + throw TypeError( + stringifyModel( + config.model, + `Storage 'set' method must return a Promise, an instance, or null: ${result}`, + ), + ); + } - result = Promise.resolve(result) - .then((data) => { - if (data === undefined || typeof data !== "object") { - throw TypeError( - stringifyModel( - config.model, - `Storage 'set' method must resolve to an instance, or null: ${data}`, - ), - ); - } + result = Promise.resolve(result) + .then((data) => { + if (data !== undefined && typeof data !== "object") { + throw TypeError( + stringifyModel( + config.model, + `Storage 'set' method must resolve to an instance or null: ${data}`, + ), + ); + } - const resultModel = - data === localModel ? localModel : config.create(data); + const resultModel = + data === localModel ? localModel : config.create(data); - if (isInstance && resultModel && id !== resultModel.id) { - throw TypeError( - stringifyModel( - config.model, - `Local and storage data must have the same id: '${id}', '${resultModel.id}'`, - ), - ); - } + if (isInstance && resultModel && id !== resultModel.id) { + throw TypeError( + stringifyModel( + config.model, + `Local and storage data must have the same id: '${id}', '${resultModel.id}'`, + ), + ); + } - let resultId = resultModel ? resultModel.id : id; + let resultId = resultModel ? resultModel.id : id; - if (hasErrors && isDraft) { - setModelState(resultModel, "error", getValidationError(errors)); - } + if (hasErrors && isDraft) { + setModelState(resultModel, "error", getValidationError(errors)); + } - if ( - isDraft && - isInstance && - hasOwnProperty.call(data, "id") && - (!localModel || localModel.id !== model.id) - ) { - resultId = model.id; - } else if (config.storage.offline) { - config.storage.offline.set(resultId, resultModel); - } + if ( + isDraft && + isInstance && + hasOwnProperty.call(data, "id") && + (!localModel || localModel.id !== model.id) + ) { + resultId = model.id; + } else if (config.storage.offline) { + config.storage.offline.set(resultId, resultModel); + } - return syncCache( - config, - resultId, - resultModel || - mapError( - config.placeholder(resultId), - notFoundError(config.model, id), - false, - ), - true, - ); - }) - .catch((err) => { - err = err !== undefined ? err : Error("Undefined error"); - if (isInstance) setModelState(model, "error", err); - throw err; - }); + return syncCache( + config, + resultId, + resultModel || + mapError( + config.placeholder(resultId), + notFoundError(config.model, id), + false, + ), + true, + ); + }) + .catch((err) => { + err = err !== undefined ? err : Error("Undefined error"); + if (isInstance) setModelState(model, "error", err); + throw err; + }); - if (isInstance) setModelState(model, "pending", result); + if (isInstance) setModelState(model, "pending", result); - return result; - } catch (e) { - if (isInstance) setModelState(model, "error", e); - return Promise.reject(e); - } + return result; } function sync(model, values) { @@ -1530,13 +1535,14 @@ function resolveId(value) { return typeof value === "object" ? value?.id : value ?? undefined; } -function resolveModel(Model, config, id, lastModel) { +function resolveModel(Model, config, id) { id = resolveId(id); if (!config.enumerable && !config.list) { return get(Model, id); } + const lastModel = cache.getCurrentValue(); const nextModel = id !== undefined || config.list ? get(Model, id) : undefined; @@ -1551,7 +1557,7 @@ function resolveModel(Model, config, id, lastModel) { const clone = Object.freeze(Object.create(lastModel)); definitions.set(clone, config); - cache.set(clone, "state", () => getModelState(nextModel)); + cache.assert(clone, "state", getModelState(nextModel)); return clone; } @@ -1559,8 +1565,9 @@ function resolveModel(Model, config, id, lastModel) { return nextModel; } -function resolveDraft(Model, config, id, value, lastValue) { - id = resolveId(id); +function resolveDraft(Model, config, id, value) { + const lastValue = cache.getCurrentValue(); + id = resolveId(id ?? lastValue?.id); if ( id === undefined && @@ -1620,16 +1627,8 @@ function store(Model, options = {}) { return { value: options.id - ? (host, value, lastValue) => - resolveDraft(Model, draft, options.id(host), value, lastValue) - : (host, value, lastValue) => - resolveDraft( - Model, - draft, - value ?? lastValue?.id, - value, - lastValue, - ), + ? (host, value) => resolveDraft(Model, draft, options.id(host), value) + : (host, value) => resolveDraft(Model, draft, value, value), connect: config.enumerable ? (host, key) => () => { clear(host[key], true); @@ -1640,15 +1639,8 @@ function store(Model, options = {}) { return { value: options.id - ? (host) => - resolveModel( - Model, - config, - options.id(host), - cache.getCurrentEntry().value, - ) - : (host, value, lastValue) => - resolveModel(Model, config, value, lastValue), + ? (host) => resolveModel(Model, config, options.id(host)) + : (host, value) => resolveModel(Model, config, value), }; } diff --git a/test/spec/cache.js b/test/spec/cache.js index 9e3504a6..d00472f6 100644 --- a/test/spec/cache.js +++ b/test/spec/cache.js @@ -102,11 +102,11 @@ describe("cache:", () => { expect(spy).toHaveBeenCalledTimes(0); - set(target, "key", () => "new value"); + assert(target, "key", "new value"); get(target, "key", spy); expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(target, undefined, "new value"); + expect(spy).toHaveBeenCalledWith(target, "new value"); }); it("does not invalidates state for next get call", () => { @@ -128,7 +128,7 @@ describe("cache:", () => { get(target, "key", spy); expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(target, undefined, "value"); + expect(spy).toHaveBeenCalledWith(target, undefined); }); }); @@ -154,7 +154,7 @@ describe("cache:", () => { invalidate(target, "key", { clearValue: true }); get(target, "key", spy); - expect(spy).toHaveBeenCalledWith(target, undefined, undefined); + expect(spy).toHaveBeenCalledWith(target, undefined); }); it("clears dependencies", () => { diff --git a/test/spec/store.js b/test/spec/store.js index 211137a0..8df3fd67 100644 --- a/test/spec/store.js +++ b/test/spec/store.js @@ -352,64 +352,59 @@ describe("store:", () => { }).toThrow(); })); - it("rejects an error when values are not an object or null", () => - store.set(Model, false).catch((e) => expect(e).toBeInstanceOf(Error))); + it("throws an error when values are not an object or null", () => + expect(() => store.set(Model, false)).toThrow()); - it("rejects an error when model definition is used with null", () => - store.set(Model, null).catch((e) => expect(e).toBeInstanceOf(Error))); + it("throws an error when model definition is used with null", () => + expect(() => store.set(Model, null)).toThrow()); - it("rejects an error when model instance is used with not an object", () => - promise - .then((model) => store.set(model, false)) - .catch((e) => expect(e).toBeInstanceOf(Error))); + it("throws an error when model instance is used with not an object", async () => { + const model = await promise; + expect(() => store.set(model, false)).toThrow(); + }); - it("rejects an error when values contain 'id' property", () => - promise - .then((model) => store.set(model, model)) - .catch((e) => expect(e).toBeInstanceOf(Error))); + it("throws an error when values contain 'id' property", async () => { + const model = await promise; + expect(() => store.set(model, model)).toThrow(); + }); - it("rejects an error when array with primitives is set with wrong type", () => { - promise - .then((model) => { - return store - .set(model, { - nestedArrayOfPrimitives: "test", - }) - .catch(() => {}); - }) - .catch((e) => { - expect(e).toBeInstanceOf(Error); - }); + it("throws an error when array with primitives is set with wrong type", async () => { + const model = await promise; + expect(() => + store.set(model, { + nestedArrayOfPrimitives: "test", + }), + ).toThrow(); }); - it("rejects an error when array with objects is set with wrong type", () => - promise - .then((model) => - store.set(model, { - nestedArrayOfObjects: "test", - }), - ) - .catch((e) => expect(e).toBeInstanceOf(Error))); + it("throws an error when array with objects is set with wrong type", async () => { + const model = await promise; + expect(() => + store.set(model, { + nestedArrayOfObjects: "test", + }), + ).toThrow(); + }); - it("rejects an error when array with external objects is set with wrong type", () => - promise - .then((model) => - store.set(model, { - nestedArrayOfExternalObjects: "test", - }), - ) - .catch((e) => expect(e).toBeInstanceOf(Error))); + it("rejects an error when array with external objects is set with wrong type", async () => { + const model = await promise; + expect(() => + store.set(model, { + nestedArrayOfExternalObjects: "test", + }), + ).toThrow(); + }); - it("rejects an error when array with nested objects are set with wrong type", () => - promise - .then((model) => - store.set(model, { - nestedArrayOfObjects: [{}, "test"], - }), - ) - .catch((e) => expect(e).toBeInstanceOf(Error))); + it("rejects an error when array with nested objects are set with wrong type", async () => { + const model = await promise; + expect(() => + store.set(model, { + nestedArrayOfObjects: [{}, "test"], + }), + ).toThrow(); + }); - it("rejects an error when set method returning undefined", () => { + it("rejects an error when set method returns undefined", () => { Model = { value: "test", [store.connect]: { @@ -2022,7 +2017,7 @@ describe("store:", () => { one: "one", two: "two", [store.connect]: { - get: () => {}, + get: () => null, set: (id, values, keys) => { spy(keys); return values; diff --git a/types/index.d.ts b/types/index.d.ts index ed1343e3..cf785a5e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,19 +1,22 @@ export type Property = - | (V extends string | number | boolean | undefined ? V : never) - | ((host: E & HTMLElement, lastValue: V) => V) - | Descriptor - | undefined; + | (V extends string | number | boolean | null | undefined ? V : never) + | ((host: E & HTMLElement) => V) + | ((host: E & HTMLElement, value: any) => V) + | Descriptor; export interface Descriptor { - value?: V; - get?: (host: E & HTMLElement, lastValue: any) => V; - set?: (host: E & HTMLElement, value: any, lastValue: V) => any; + value?: + | V + | ((host: E & HTMLElement) => V) + | ((host: E & HTMLElement, value: any) => V); + connect?( host: E & HTMLElement & { __property_key__: V }, key: "__property_key__", - invalidate: (options?: { force?: boolean }) => void, + invalidate: () => void, ): Function | void; observe?(host: E & HTMLElement, value: V, lastValue: V): void; + reflect?: boolean | ((value: V) => string); } export interface UpdateFunction { @@ -24,6 +27,11 @@ export interface RenderFunction { (host: E & HTMLElement): UpdateFunction; } +export interface RenderDescriptor extends Descriptor> { + value: RenderFunction; + reflect?: never; +} + export type ComponentBase = { tag: string; __router__connect__?: ViewOptions; @@ -35,12 +43,12 @@ export type Component = ComponentBase & { string >]: property extends "render" | "content" ? E[property] extends () => HTMLElement - ? RenderFunction + ? RenderFunction | RenderDescriptor : Property : Property; } & { - render?: RenderFunction; - content?: RenderFunction; + render?: RenderFunction | RenderDescriptor; + content?: RenderFunction | RenderDescriptor; }; export interface HybridElement { @@ -168,7 +176,10 @@ export type StorageValues = { : M[property]; }; -export type StorageResult = StorageValues | null; +export type StorageResult = + | StorageValues + | null + | undefined; export type Storage = { get?: (id: ModelIdentifier) => StorageResult | Promise>;