diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1877ad..b1d7ca8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,42 +8,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- You can now bind attributes with `??foo="${bar}"` syntax. This is functionally
- equivalent to the `nullish` updater and will replace that functionality later.
-- A new `unsafe` updater was added to replace `unsafeHTML` and `unsafeSVG`. You
- use it like `unsafe(value, 'html')` and `unsafe(value, 'svg')`.
+- You can now bind attributes with `??foo="${bar}"` syntax in the default
+ template engine. This is functionally equivalent to the `nullish` updater from
+ the default template engine and will replace that functionality later (#204).
### Changed
- Template errors now include approximate line numbers from the offending
- template. They also print the registered custom element tag name (#201).
+ template in the default template engine. They also print the registered custom
+ element tag name (#201).
- The `ifDefined` updater now deletes the attribute on `null` in addition to
- `undefined`. This makes it behave identically to `nullish`. However, both
- updaters are deprecated and the `??attr` binding should be used instead.
-- Interpolation of `textarea` is stricter. This used to be handled with some
- leniency — ``. Now, you have to fit the
- interpolation exactly — ``.
+ `undefined` in the default template engine. This makes it behave identically
+ to `nullish` in the default template engine. However, both updaters are
+ deprecated — the `??attr` binding should be used instead when using the
+ default template engine (#204).
+- Interpolation of `textarea` is more strict in the default template engine.
+ This used to be handled with some leniency for newlines in templates —
+ ``. Now, you have to interpolate exactly —
+ `` (#219).
+- You may now bind values of type `DocumentFragment` within the template engine.
+ In particular, this was added to enable advanced flows without needing to
+ bloat the default template engine interface (#207, #216).
### Deprecated
- The `ifDefined` and `nullish` updaters are deprecated, update templates to use
- syntax like `??foo="${bar}"`.
-- The `repeat` updater is deprecated, use `map` instead.
-- The `unsafeHTML` and `unsafeSVG` updaters are deprecated, use `unsafe`.
+ syntax like `??foo="${bar}"` (#204).
+- The `repeat` updater is deprecated, use `map` instead (#204).
+- The `unsafeHTML` and `unsafeSVG` updaters are deprecated, bind a
+ `DocumentFragment` value instead (#207, #216).
- The `plaintext` tag is no longer handled. This is a deprecated html tag which
- required special handling… but it’s unlikely that anyone is using that.
+ required special handling… but it’s unlikely that anyone is using that (#220).
+- The `live` updater is deprecated. Use a delegated event listener for the
+ `change` event if you need tight control over DOM state in forms (#208).
### Fixed
-- Transitions from different content values should all now work. For example,
- you previously could not change from a text value to an array. Additionally,
- state is properly cleared when going from one value type to another — e.g.,
- when going from `unsafe` back to `null`.
-- The `map` updater throws immediately when given non-array input. Previously,
- it only threw _just before_ it was bound as content.
-- Dummy content cursor is no longer appended to end of template. This was an
- innocuous off-by-one error when creating instrumented html from the tagged
- template strings.
+- Transitions from different content values should all now work for the default
+ template engine. For example, you previously could not change from a text
+ value to an array. Additionally, state is properly cleared when going from one
+ value type to another — e.g., when going from `unsafe` back to `null` (#223).
+- The `map` updater throws immediately when given non-array input for the
+ default template engine. Previously, it only threw when it was bound (#222).
+- The `map` updater throws if the return value from the provided `identify`
+ callback returns a duplicate value (#218).
+- Dummy content cursor is no longer appended to end of template for the default
+ template engine. This was an innocuous off-by-one error when creating
+ instrumented html from the tagged template strings (#221).
## [1.1.1] - 2024-11-09
diff --git a/doc/RECIPES.md b/doc/RECIPES.md
new file mode 100644
index 0000000..739651a
--- /dev/null
+++ b/doc/RECIPES.md
@@ -0,0 +1,103 @@
+# Recipes
+
+Part of the [philosophy](../README.md#project-philosophy) for `x-element` is to
+implement only a minimal set of functionality. Rather than build a bespoke
+feature to cover each-and-every use case — we simply document how to achieve
+some desired outcomes via “recipes” for less common situations.
+
+## How do I instantiate trusted markup?
+
+In certain, _rare_ occasions, it’s acceptable to instantiate a pre-defined
+markup string as DOM using `innerHTML`. Rather than supply some sort of special
+function (e.g., `carefulWhatYouAreDoingIsUnsafe`), we trust that authors will
+understand the hazards of `innerHTML` and will use with care. The basic pattern
+here is to instantiate your markup with a `` and then pass its inner
+`.content` (a `DocumentFragment`) into the template engine.
+
+```js
+class MyElement extends XElement {
+ static get properties() {
+ return {
+ // …
+ markup: {
+ type: String,
+ input: [/* … */],
+ compute: (/* … */) => {/* sanitize / purify / careful out there! */},
+ },
+ fragment: {
+ type: DocumentFragment,
+ input: ['markup'],
+ compute: (markup) => {
+ if (markup) {
+ const template = document.createElement('template');
+ template.innerHTML = markup;
+ return template.content;
+ }
+ },
+ },
+ };
+ }
+ static template(html) {
+ return ({ fragment }) => {
+ return html`
+
+
The following is injected…
+ ${fragment}
+
+ `;
+ };
+ }
+}
+```
+
+## How do I force application state to flow the way I want in forms?
+
+A common pain point when building forms is managing the _flow of data_. Does the
+model act as the source of truth? Or, does the DOM? Well, that’s up to you! If
+you _are_ trying to control forms strictly from some application state, you will
+need to make sure that (1) your change events propagate the right information,
+(2) your state is guaranteed to flow back to your view, and (3) your DOM state
+is correct by the time a potential form submission occurs (e.g., a submit event
+can follow _directly_ behind a change event in certain situations). It’s not
+possible to predict how authors wish to manage such cases — so it’s not possible
+to encode this at a library level. Here’s one way you might go about managing
+this though!
+
+```js
+class MyElement extends XElement {
+ static get properties() {
+ return {
+ // …
+ foo: {
+ type: String, // You probably want this to be a string for proper comparisons.
+ },
+ };
+ }
+ static get listeners() {
+ return {
+ change: (host, event) => this.onChange(host, event);
+ };
+ }
+ static template(html, { connected }) {
+ return ({ foo }) => {
+ return html`
+
+ `;
+ };
+ }
+ static onChange(host, event) {
+ if (event.target.id === 'foo') {
+ // The user has updated the input value. Wait for the next animation
+ // frame and re-bind our value. Note that even in this case, if a submit
+ // follows directly behind a change event — the DOM would still contain
+ // possibly-stale state.
+ requestAnimationFrame(() => {
+ const foo = host.shadowRoot.getElementById('foo');
+ foo.value = host.foo;
+ });
+ }
+ }
+}
+```
diff --git a/doc/TEMPLATES.md b/doc/TEMPLATES.md
index 67fab91..273c39c 100644
--- a/doc/TEMPLATES.md
+++ b/doc/TEMPLATES.md
@@ -8,62 +8,33 @@ Add a static template function in your `x-element` definition in order to
leverage automagical DOM generation and data binding:
```javascript
-static template(html, { map }) {
+static template(html) {
return ({ options, selectedId }) => {
return html`
`;
};
}
```
-The following binding types are supported:
+The following bindings are supported:
-| Type | Example |
-| :------------------ | :----------------------------------------- |
-| attribute | `` |
-| attribute (boolean) | `` |
-| attribute (defined) | `` |
-| property | `` |
-| content | `${foo}` |
-
-Emulates:
-
-```javascript
-const el = document.createElement('div');
-el.attachShadow({ mode: 'open' });
-el.innerHTML = '';
-const target = el.shadowRoot.getElementById('target');
-
-// attribute value bindings set the attribute value
-target.setAttribute('foo', bar);
-
-// attribute boolean bindings set the attribute to an empty string or remove
-target.setAttribute('foo', ''); // when bar is truthy
-target.removeAttribute('foo'); // when bar is falsy
-
-// attribute defined bindings set the attribute if the value is non-nullish
-target.setAttribute('foo', bar); // when bar is non-nullish
-target.removeAttribute('foo'); // when bar is nullish
-
-// property bindings assign the value to the property of the node
-target.foo = bar;
-
-// content bindings create text nodes for basic content
-const text = document.createTextNode('');
-text.textContent = foo;
-target.append(text);
-
-// content bindings append a child for singular, nested content
-target.append(foo);
-
-// content binding maps and appends children for arrays of nested content
-target.append(...foo);
-```
+| Binding | Template | Emulates |
+| :------------------ | :--------------------------- | :------------------------------------------------------------ |
+| -- | -- | `const el = document.createElement('div');` |
+| attribute | `` | `el.setAttribute('foo', bar);` |
+| attribute (boolean) | `` | `el.setAttribute('foo', ''); // if “bar” is truthy` |
+| -- | -- | `el.removeAttribute('foo'); // if “bar” is falsy` |
+| attribute (defined) | `` | `el.setAttribute('foo', bar); // if “bar” is non-nullish` |
+| -- | -- | `el.removeAttribute('foo'); // if “bar” is nullish` |
+| property | `` | `el.foo = bar;` |
+| content | `
${foo}
` | `el.append(document.createTextNode(foo)) // if “bar” is text` |
+| -- | -- | (see [content binding](#content-binding) for composition) |
**Important note on serialization during data binding:**
@@ -78,12 +49,6 @@ The following template languages are supported:
* `html`
* `svg`
-The following value updaters are supported:
-
-* `map` (can be used with content bindings)
-* `unsafe` (can be used with content bindings)
-* `live` (can be used with property bindings)
-
**A note on non-primitive data:**
Because DOM manipulation is *slow* — template engines do their best to avoid it
@@ -216,23 +181,6 @@ html``;
// el.foo = bar;
```
-#### The `live` property binding
-
-You can wrap the property being bound in the `live` updater to ensure that each
-`render` call will sync the template‘s value into the DOM. This is primarily
-used to control form inputs.
-
-```js
-const bar = 'something';
-html``;
-//
-// el.value = bar;
-```
-
-The key difference to note is that the basic property binding will not attempt
-to perform an update if `value === lastValue`. The `live` binding will instead
-check if `value === el.value` whenever a `render` is kicked off.
-
### Content binding
The content binding does different things based on the value type passed in.
@@ -283,7 +231,7 @@ html`
${bar}
`;
#### Array content binding
-When the content being bound is an array of template results, you get a mapping.
+When the content being bound is an array of template results, you get a list.
```js
const bar = [
@@ -300,14 +248,16 @@ html`
${bar}
`;
//
onetwo
```
-#### The `map` content binding
+#### Map content binding
-The `map` content binding adds some special behavior on top of the basic array
-content binding. In particular, it _keeps track_ of each child node based on
-an `identify` function declared by the caller. This enables the template engine
-to _move_ child nodes under certain circumstances (versus having to constantly
-destroy and recreate). And that shuffling behavior enables authors to animate
-DOM nodes across such transitions.
+When the content being bound is an array of key-value map entries (where the
+`key` is a unique string within the list and the `value` is a template result),
+you get also list. But, this value will come with some special behavior on top
+of the basic array content binding. In particular, it _keeps track_ of each
+child node based on the given `key` you declare. This enables the template
+engine to _move_ child nodes under certain circumstances (versus having to
+constantly destroy and recreate). And that shuffling behavior enables authors to
+animate DOM nodes across such transitions.
```js
// Note that you can shuffle the deck without destroying / creating DOM.
@@ -318,41 +268,12 @@ const deck = [
];
const items = deck;
const identify = item => item.id;
-const callback = item => html`${item.text}`;
-const bar = map(items, identify, callback);
+const template = item => html`${item.text}`;
+const bar = items.map(item => [identify(item), template(item)]);
html`
${bar}
`;
//
♥1…♣A
```
-#### The `unsafe` content binding
-
-The `unsafe` content binding allows you to parse / instantiate text from a
-trusted source. This should _only_ be used to inject trusted content — never
-user content.
-
-```js
-const bar = '';
-html`
${unsafe(bar, 'html')}
`;
-//
-// console.prompt('can you hear me now?');
-
-const bar = '';
-html`
-
-`;
-//
-//
-//
-```
-
## Customizing your base class
Following is a working example using [lit-html](https://lit.dev):
diff --git a/test/test-initialization-errors.js b/test/test-initialization-errors.js
index 194402c..cf2d3d7 100644
--- a/test/test-initialization-errors.js
+++ b/test/test-initialization-errors.js
@@ -89,13 +89,13 @@ it('errors are thrown in connectedCallback when template result fails to render'
class TestElement extends XElement {
static get properties() {
return {
- strings: { default: () => [] },
+ strings: { default: () => ['one', 'two', 'three'] },
};
}
- static template(html, { map }) {
+ static template(html) {
return ({ strings }) => {
- // In this case, "map" will fail when bound to an attribute.
- return html``;
+ // In this case, the array will fail if items are not template results.
+ return html`
${strings}
`;
};
}
}
@@ -120,13 +120,13 @@ it('errors are thrown in connectedCallback when template result fails to render
class TestElement extends XElement {
static get properties() {
return {
- strings: { default: () => [] },
+ strings: { default: () => ['one', 'two', 'three'] },
};
}
- static template(html, { map }) {
+ static template(html) {
return ({ strings }) => {
- // In this case, "map" will fail when bound to an attribute.
- return html``;
+ // In this case, the array will fail if items are not template results.
+ return html`
`;
@@ -889,14 +933,7 @@ describe('html updaters', () => {
// The template engine needs to clear content between cursors if the updater
// changes — it‘d be far too complex to try and allow one updater try and
// take over from a different one.
- const resolve = (type, value) => {
- switch(type) {
- case 'map': return map(value, item => item.id, item => html``);
- case 'html': return unsafe(value, 'html');
- default: return value; // E.g., an array, some text, null, undefined, etc.
- }
- };
- const getTemplate = ({ type, value }) => html`
${resolve(type, value)}
`;
+ const getTemplate = ({ value }) => html`
${value}
`;
const run = (...transitions) => {
const container = document.createElement('div');
document.body.append(container);
@@ -906,26 +943,34 @@ describe('html updaters', () => {
container.remove();
};
const toUndefinedContent = container => {
- render(container, getTemplate({ type: undefined, value: undefined }));
+ render(container, getTemplate({ value: undefined }));
assert(!!container.querySelector('#target'));
assert(container.querySelector('#target').childElementCount === 0);
};
const toNullContent = container => {
- render(container, getTemplate({ type: undefined, value: null }));
+ render(container, getTemplate({ value: null }));
assert(!!container.querySelector('#target'));
assert(container.querySelector('#target').childElementCount === 0);
};
const toTextContent = container => {
- render(container, getTemplate({ type: undefined, value: 'hi there' }));
+ render(container, getTemplate({ value: 'hi there' }));
assert(!!container.querySelector('#target'));
assert(container.querySelector('#target').childElementCount === 0);
assert(container.querySelector('#target').textContent === 'hi there');
};
+ const toFragmentContent = container => {
+ const fragment = new DocumentFragment();
+ fragment.append(document.createElement('p'), document.createElement('p'));
+ render(container, getTemplate({ value: fragment }));
+ assert(!!container.querySelector('#target'));
+ assert(container.querySelector('#target').childElementCount === 2);
+ assert(container.querySelector('#target').children[0].localName === 'p');
+ assert(container.querySelector('#target').children[1].localName === 'p');
+ };
const toArrayContent = container => {
- const getArrayTemplate = ({ id }) => html``;
+ const items = [{ id: 'moo' }, { id: 'mar' }, { id: 'maz' }];
render(container, getTemplate({
- type: undefined,
- value: [{ id: 'moo' }, { id: 'mar' }, { id: 'maz' }].map(item => getArrayTemplate(item)),
+ value: items.map(item => html``),
}));
assert(!!container.querySelector('#target'));
assert(!!container.querySelector('#moo'));
@@ -934,14 +979,11 @@ describe('html updaters', () => {
assert(container.querySelector('#target').childElementCount === 3);
assert(container.querySelector('#target').textContent === '', container.querySelector('#target').textContent);
};
- const toUnsafeHtml = container => {
- render(container, getTemplate({ type: 'html', value: '' }));
- assert(!!container.querySelector('#target'));
- assert(!!container.querySelector('#unsafe-html'));
- assert(container.querySelector('#target').textContent === '');
- };
- const toMap = container => {
- render(container, getTemplate({ type: 'map', value: [{ id: 'foo' }, { id: 'bar' }] }));
+ const toMapContent = container => {
+ const items = [{ id: 'foo' }, { id: 'bar' }];
+ render(container, getTemplate({
+ value: items.map(item => [item.id, html``]),
+ }));
assert(!!container.querySelector('#target'));
assert(!!container.querySelector('#foo'));
assert(!!container.querySelector('#bar'));
@@ -951,39 +993,39 @@ describe('html updaters', () => {
it('can change from undefined content to null content', () => run(toUndefinedContent, toNullContent));
it('can change from undefined content to text content', () => run(toUndefinedContent, toTextContent));
+ it('can change from undefined content to fragment content', () => run(toUndefinedContent, toFragmentContent));
it('can change from undefined content to array content', () => run(toUndefinedContent, toArrayContent));
- it('can change from undefined content to map', () => run(toUndefinedContent, toMap));
- it('can change from undefined content to unsafe html', () => run(toUndefinedContent, toUnsafeHtml));
+ it('can change from undefined content to map content', () => run(toUndefinedContent, toMapContent));
it('can change from null content to undefined content', () => run(toNullContent, toUndefinedContent));
it('can change from null content to text content', () => run(toNullContent, toTextContent));
+ it('can change from null content to fragment content', () => run(toNullContent, toFragmentContent));
it('can change from null content to array content', () => run(toNullContent, toArrayContent));
- it('can change from null content to map', () => run(toNullContent, toMap));
- it('can change from null content to unsafe html', () => run(toNullContent, toUnsafeHtml));
+ it('can change from null content to map content', () => run(toNullContent, toMapContent));
it('can change from text content to undefined content', () => run(toTextContent, toUndefinedContent));
it('can change from text content to null content', () => run(toTextContent, toNullContent));
+ it('can change from text content to fragment content', () => run(toTextContent, toFragmentContent));
it('can change from text content to array content', () => run(toTextContent, toArrayContent));
- it('can change from text content to map', () => run(toTextContent, toMap));
- it('can change from text content to unsafe html', () => run(toTextContent, toUnsafeHtml));
+ it('can change from text content to map content', () => run(toTextContent, toMapContent));
+
+ it('can change from fragment content to undefined content', () => run(toFragmentContent, toUndefinedContent));
+ it('can change from fragment content to null content', () => run(toFragmentContent, toNullContent));
+ it('can change from fragment content to text content', () => run(toFragmentContent, toTextContent));
+ it('can change from fragment content to array content', () => run(toFragmentContent, toArrayContent));
+ it('can change from fragment content to map content', () => run(toFragmentContent, toMapContent));
it('can change from array content to undefined content', () => run(toArrayContent, toUndefinedContent));
it('can change from array content to null content', () => run(toArrayContent, toNullContent));
it('can change from array content to text content', () => run(toArrayContent, toTextContent));
- it('can change from array content to map', () => run(toArrayContent, toMap));
- it('can change from array content to unsafe html', () => run(toArrayContent, toUnsafeHtml));
-
- it('can change from map to undefined content', () => run(toMap, toUndefinedContent));
- it('can change from map to null content', () => run(toMap, toNullContent));
- it('can change from map to text content', () => run(toMap, toTextContent));
- it('can change from map to array content', () => run(toMap, toArrayContent));
- it('can change from map to unsafe html', () => run(toMap, toUnsafeHtml));
-
- it('can change from unsafeHtml to undefined content', () => run(toUnsafeHtml, toUndefinedContent));
- it('can change from unsafeHtml to null content', () => run(toUnsafeHtml, toNullContent));
- it('can change from unsafeHtml to text content', () => run(toUnsafeHtml, toTextContent));
- it('can change from unsafeHtml to array content', () => run(toUnsafeHtml, toArrayContent));
- it('can change from unsafeHtml to map', () => run(toUnsafeHtml, toMap));
+ it('can change from array content to fragment content', () => run(toArrayContent, toFragmentContent));
+ it('can change from array content to map content', () => run(toArrayContent, toMapContent));
+
+ it('can change from map content to undefined content', () => run(toMapContent, toUndefinedContent));
+ it('can change from map content to null content', () => run(toMapContent, toNullContent));
+ it('can change from map content to text content', () => run(toMapContent, toTextContent));
+ it('can change from map content to fragment content', () => run(toMapContent, toFragmentContent));
+ it('can change from map content to array content', () => run(toMapContent, toArrayContent));
});
});
@@ -1032,31 +1074,6 @@ describe('svg rendering', () => {
});
describe('svg updaters', () => {
- it('unsafe svg', () => {
- const getTemplate = ({ content }) => {
- return html`
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- render(container, getTemplate({ content: '' }));
- assert(!!container.querySelector('#injected'));
- assert(container.querySelector('#injected').getBoundingClientRect().height = 20);
- assert(container.querySelector('#injected').getBoundingClientRect().width = 20);
- render(container, getTemplate({ content: '' }));
- assert(!!container.querySelector('#injected'));
- assert(container.querySelector('#injected').getBoundingClientRect().height = 10);
- assert(container.querySelector('#injected').getBoundingClientRect().width = 10);
- container.remove();
- });
-
it('unsafeSVG', () => {
const getTemplate = ({ content }) => {
return html`
@@ -1085,6 +1102,16 @@ describe('svg updaters', () => {
describe('rendering errors', () => {
describe('templating', () => {
+ it('throws when given container is not a node', () => {
+ let error;
+ try {
+ render({}, html``);
+ } catch (e) {
+ error = e;
+ }
+ assert(error?.message === 'Unexpected non-node render container "[object Object]".', error.message);
+ });
+
it('throws when attempting to interpolate within a style tag', () => {
const getTemplate = ({ color }) => {
return html`
@@ -1160,12 +1187,12 @@ describe('rendering errors', () => {
});
it('throws for unquoted attributes', () => {
- const templateResultReference = html`
- `;
- };
+ it('throws for list with non-string key in a map entry', () => {
const container = document.createElement('div');
document.body.append(container);
- render(container, getTemplate({ array: [{ id: 'foo', value: 'oh hai' }] }));
let error;
try {
- render(container, getTemplate({ array: [{ id: 'foo', value: null }] }));
+ render(container, html`
${[[1, html``]]}
`);
} catch (e) {
error = e;
}
- assert(error?.message === 'Unexpected repeat value "null" provided by callback.', error?.message);
+ assert(error?.message === 'Unexpected non-string key found in map entry at 0 "1".', error?.message);
container.remove();
});
- });
- describe('native array', () => {
- it('throws for non-template value', () => {
- const getTemplate = ({ items }) => {
- return html`
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let error;
- try {
- render(container, getTemplate({ items: [null] }));
- } catch (e) {
- error = e;
- }
- assert(error?.message === 'Unexpected array value "null" provided by callback.', error?.message);
- container.remove();
- });
+ it('throws for list with duplicated key in a map entry', () => {
+ const container = document.createElement('div');
+ document.body.append(container);
+ let error;
+ try {
+ render(container, html`
${[['1', html``], ['2', html``], ['1', html``]]}
`);
+ } catch (e) {
+ error = e;
+ }
+ assert(error?.message === 'Unexpected duplicate key found in map entry at 2 "1".', error?.message);
+ container.remove();
+ });
+
+ it('throws for list with non-template values in a map entry', () => {
+ const container = document.createElement('div');
+ document.body.append(container);
+ let error;
+ try {
+ render(container, html`
${[['1', null]]}
`);
+ } catch (e) {
+ error = e;
+ }
+ assert(error?.message === 'Unexpected non-template value found in map entry at 0 "null".', error?.message);
+ container.remove();
+ });
- it('throws for non-template value on re-render', () => {
- const getTemplate = ({ items }) => {
- return html`
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- render(container, getTemplate({ items: ['foo'] }));
- let error;
- try {
- render(container, getTemplate({ items: [null] }));
- } catch (e) {
- error = e;
- }
- assert(error?.message === 'Unexpected array value "null" provided by callback.', error?.message);
- container.remove();
- });
});
});
@@ -2252,3 +2080,9 @@ describe('interface migration errors', () => {
});
}
});
+
+it('confirm that deprecation warnings are still necessary', () => {
+ for (const message of localMessages) {
+ assert(seen.has(message), `Unused deprecation warning: ${message}`);
+ }
+});
diff --git a/x-element.js b/x-element.js
index bc5ff8a..ab6ea94 100644
--- a/x-element.js
+++ b/x-element.js
@@ -1013,12 +1013,16 @@ export default class XElement extends HTMLElement {
/** Internal implementation details for template engine. */
class TemplateEngine {
// Special markers added to markup enabling discovery post-instantiation.
- static #ATTRIBUTE_PREFIX = 'x-element-attribute';
- static #BOOLEAN_PREFIX = 'x-element-boolean';
- static #DEFINED_PREFIX = 'x-element-defined';
- static #PROPERTY_PREFIX = 'x-element-property';
- static #CONTENT_PREFIX = 'x-element-content';
- static #ATTRIBUTE_PADDING = 6;
+ static #NEXT_MARKER = 'x-element-next:'; // The ":" helps for debugging.
+ static #CONTENT_MARKER = 'x-element-content';
+
+ // Types of bindings that we can have.
+ static #ATTRIBUTE = 'attribute';
+ static #BOOLEAN = 'boolean';
+ static #DEFINED = 'defined';
+ static #PROPERTY = 'property';
+ static #CONTENT = 'content';
+ static #TEXT = 'text';
// Patterns to find special edges in original html strings.
static #OPEN_REGEX = /<[a-z][a-z0-9-]*(?=\s)/g;
@@ -1026,21 +1030,22 @@ class TemplateEngine {
static #ATTRIBUTE_OR_PROPERTY_REGEX = /\s+(?:(?\?{0,2})?(?([a-z][a-zA-Z0-9-]*))|\.(?[a-z][a-zA-Z0-9_]*))="$/y;
static #CLOSE_REGEX = />/g;
+ // Sentinel to hold raw result language. Also leveraged to determine whether a
+ // value is a raw result or not. Template engine supports html and svg.
+ static #LANGUAGE = Symbol();
+ static #HTML = 'html';
+ static #SVG = 'svg';
+
// Sentinel to initialize the “last values” array.
static #UNSET = Symbol();
- // Mapping of container nodes to internal state.
- static #nodeToState = new WeakMap();
-
- // Mapping of starting comment cursors to internal array state.
- static #nodeToArrayState = new WeakMap();
+ // Sentinels to manage internal state on nodes.
+ static #STATE = Symbol();
+ static #ARRAY_STATE = Symbol();
// Mapping of tagged template function “strings” to caches computations.
static #stringsToAnalysis = new WeakMap();
- // Mapping of opaque references to internal result objects.
- static #symbolToResult = new WeakMap();
-
// Mapping of opaque references to internal update objects.
static #symbolToUpdate = new WeakMap();
@@ -1053,18 +1058,15 @@ class TemplateEngine {
render: TemplateEngine.render,
html: TemplateEngine.html,
svg: TemplateEngine.svg,
- map: TemplateEngine.map,
- unsafe: TemplateEngine.unsafe,
-
- // Tentative interface.
- live: TemplateEngine.live,
// Deprecated interface.
- unsafeHTML: TemplateEngine.unsafeHTML,
- unsafeSVG: TemplateEngine.unsafeSVG,
- ifDefined: TemplateEngine.ifDefined,
- nullish: TemplateEngine.nullish,
- repeat: TemplateEngine.repeat,
+ map: TemplateEngine.#interfaceDeprecated('map', TemplateEngine.map),
+ live: TemplateEngine.#interfaceDeprecated('live', TemplateEngine.live),
+ unsafeHTML: TemplateEngine.#interfaceDeprecated('unsafeHTML', TemplateEngine.unsafeHTML),
+ unsafeSVG: TemplateEngine.#interfaceDeprecated('unsafeSVG', TemplateEngine.unsafeSVG),
+ ifDefined: TemplateEngine.#interfaceDeprecated('ifDefined', TemplateEngine.ifDefined),
+ nullish: TemplateEngine.#interfaceDeprecated('nullish', TemplateEngine.nullish),
+ repeat: TemplateEngine.#interfaceDeprecated('repeat', TemplateEngine.repeat),
// Removed interface.
asyncAppend: TemplateEngine.#interfaceRemoved('asyncAppend'),
@@ -1088,10 +1090,7 @@ class TemplateEngine {
* @returns {any}
*/
static html(strings, ...values) {
- const symbol = Object.create(null);
- const result = { type: 'html', strings, values };
- TemplateEngine.#symbolToResult.set(symbol, result);
- return symbol;
+ return TemplateEngine.#createRawResult(TemplateEngine.#HTML, strings, values);
}
/**
@@ -1104,31 +1103,28 @@ class TemplateEngine {
* @returns {any}
*/
static svg(strings, ...values) {
- const symbol = Object.create(null);
- const result = { type: 'svg', strings, values };
- TemplateEngine.#symbolToResult.set(symbol, result);
- return symbol;
+ return TemplateEngine.#createRawResult(TemplateEngine.#SVG, strings, values);
}
/**
* Core rendering entry point for x-element template engine.
- * Accepts a "container" element and renders the given "result" into it.
+ * Accepts a "container" element and renders the given "raw result" into it.
* @param {HTMLElement} container
- * @param {any} resultReference
+ * @param {any} rawResult
*/
- static render(container, resultReference) {
- const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToState, container, () => ({}));
- if (resultReference) {
- const result = TemplateEngine.#symbolToResult.get(resultReference);
- if (TemplateEngine.#cannotReuseResult(state.result, result)) {
+ static render(container, rawResult) {
+ if (!(container instanceof Node)) {
+ throw new Error(`Unexpected non-node render container "${container}".`);
+ }
+ rawResult = TemplateEngine.#isRawResult(rawResult) ? rawResult : null;
+ const state = TemplateEngine.#getState(container, TemplateEngine.#STATE);
+ if (rawResult) {
+ if (!TemplateEngine.#canReuseDom(state.preparedResult, rawResult)) {
TemplateEngine.#removeWithin(container);
- TemplateEngine.#ready(result);
- TemplateEngine.#commit(result);
- TemplateEngine.#inject(result, container);
- state.result = result;
+ const preparedResult = TemplateEngine.#inject(rawResult, container);
+ state.preparedResult = preparedResult;
} else {
- TemplateEngine.#assign(state.result, result);
- TemplateEngine.#commit(state.result);
+ TemplateEngine.#update(state.preparedResult, rawResult);
}
} else {
TemplateEngine.#clearObject(state);
@@ -1182,6 +1178,7 @@ class TemplateEngine {
* ```js
* html``;
* ```
+ * @deprecated
* @param {any} value
* @returns {any}
*/
@@ -1193,28 +1190,6 @@ class TemplateEngine {
return symbol;
}
- /**
- * Updater to inject trusted “html” or “svg” into the DOM.
- * Use with caution. The "unsafe" updater allows arbitrary input to be
- * parsed and injected into the DOM.
- * ```js
- * html`
${unsafe(obj.trustedMarkup, 'html')}
`;
- * ```
- * @param {any} value
- * @param {'html'|'svg'} language
- * @returns {any}
- */
- static unsafe(value, language) {
- if (language !== 'html' && language !== 'svg') {
- throw new Error(`Unexpected unsafe language "${language}". Expected "html" or "svg".`);
- }
- const symbol = Object.create(null);
- const updater = TemplateEngine.#unsafe;
- const update = { updater, value, language };
- TemplateEngine.#symbolToUpdate.set(symbol, update);
- return symbol;
- }
-
/**
* Updater to inject trusted HTML into the DOM.
* Use with caution. The "unsafeHTML" updater allows arbitrary input to be
@@ -1281,12 +1256,7 @@ class TemplateEngine {
if (typeof callback !== 'function') {
throw new Error(`Unexpected map callback "${callback}" provided, expected a function.`);
}
- const symbol = Object.create(null);
- const value = items;
- const updater = TemplateEngine.#map;
- const update = { updater, value, identify, callback };
- TemplateEngine.#symbolToUpdate.set(symbol, update);
- return symbol;
+ return items.map(item => [identify(item), callback(item)]);
}
/**
@@ -1310,12 +1280,9 @@ class TemplateEngine {
} else if (typeof callback !== 'function') {
throw new Error(`Unexpected repeat callback "${callback}" provided, expected a function.`);
}
- const symbol = Object.create(null);
- const value = items;
- const updater = TemplateEngine.#repeat;
- const update = { updater, value, identify, callback };
- TemplateEngine.#symbolToUpdate.set(symbol, update);
- return symbol;
+ return identify
+ ? items.map(item => [identify(item), callback(item)])
+ : items.map(item => callback(item)); // Just a basic array.
}
// Deprecated. Will remove in future release.
@@ -1336,31 +1303,13 @@ class TemplateEngine {
}
}
+ // Deprecated. Will remove in future release.
static #live(node, name, value) {
if (node[name] !== value) {
node[name] = value;
}
}
- static #unsafe(node, startNode, value, lastValue, language) {
- if (value !== lastValue) {
- if (typeof value === 'string') {
- const template = document.createElement('template');
- if (language === 'html') {
- template.innerHTML = value;
- TemplateEngine.#removeBetween(startNode, node);
- TemplateEngine.#insertAllBefore(node.parentNode, node, template.content.childNodes);
- } else {
- template.innerHTML = ``;
- TemplateEngine.#removeBetween(startNode, node);
- TemplateEngine.#insertAllBefore(node.parentNode, node, template.content.firstChild.childNodes);
- }
- } else {
- throw new Error(`Unexpected unsafe value "${value}".`);
- }
- }
- }
-
// Deprecated. Will remove in future release.
static #unsafeHTML(node, startNode, value, lastValue) {
if (value !== lastValue) {
@@ -1389,97 +1338,10 @@ class TemplateEngine {
}
}
- static #mapInner(node, startNode, identify, callback, inputs, name) {
- const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({}));
- if (!state.map) {
- TemplateEngine.#clearObject(state);
- state.map = new Map();
- let index = 0;
- for (const input of inputs) {
- const reference = callback ? callback(input, index) : input;
- const result = TemplateEngine.#symbolToResult.get(reference);
- if (result) {
- const id = identify ? identify(input, index) : String(index);
- const cursors = TemplateEngine.#createCursors(node);
- TemplateEngine.#ready(result);
- TemplateEngine.#commit(result);
- TemplateEngine.#inject(result, cursors.node, { before: true });
- state.map.set(id, { id, result, ...cursors });
- } else {
- throw new Error(`Unexpected ${name} value "${reference}" provided by callback.`);
- }
- index++;
- }
- } else {
- let lastItem;
- const ids = new Set();
- let index = 0;
- for (const input of inputs) {
- const reference = callback ? callback(input, index) : input;
- const result = TemplateEngine.#symbolToResult.get(reference);
- if (result) {
- const id = identify ? identify(input, index) : String(index);
- if (state.map.has(id)) {
- const item = state.map.get(id);
- if (TemplateEngine.#cannotReuseResult(item.result, result)) {
- // Add new comment cursors before removing old comment cursors.
- const cursors = TemplateEngine.#createCursors(item.startNode);
- TemplateEngine.#removeThrough(item.startNode, item.node);
- TemplateEngine.#ready(result);
- TemplateEngine.#commit(result);
- TemplateEngine.#inject(result, cursors.node, { before: true });
- Object.assign(item, { result, ...cursors });
- } else {
- TemplateEngine.#assign(item.result, result);
- TemplateEngine.#commit(item.result);
- }
- } else {
- const cursors = TemplateEngine.#createCursors(node);
- TemplateEngine.#ready(result);
- TemplateEngine.#commit(result);
- TemplateEngine.#inject(result, cursors.node, { before: true });
- const item = { id, result, ...cursors };
- state.map.set(id, item);
- }
- const item = state.map.get(id);
- const referenceNode = lastItem ? lastItem.node.nextSibling : startNode.nextSibling;
- if (referenceNode !== item.startNode) {
- const nodesToMove = [item.startNode];
- while (nodesToMove[nodesToMove.length - 1] !== item.node) {
- nodesToMove.push(nodesToMove[nodesToMove.length - 1].nextSibling);
- }
- TemplateEngine.#insertAllBefore(referenceNode.parentNode, referenceNode, nodesToMove);
- }
- TemplateEngine.#commit(item.result);
- ids.add(item.id);
- lastItem = item;
- } else {
- throw new Error(`Unexpected ${name} value "${reference}" provided by callback.`);
- }
- index++;
- }
- for (const [id, item] of state.map.entries()) {
- if (!ids.has(id)) {
- TemplateEngine.#removeThrough(item.startNode, item.node);
- state.map.delete(id);
- }
- }
- }
- }
-
- static #map(node, startNode, value, identify, callback) {
- TemplateEngine.#mapInner(node, startNode, identify, callback, value, 'map');
- }
-
- // Deprecated. Will remove in future release.
- static #repeat(node, startNode, value, identify, callback) {
- TemplateEngine.#mapInner(node, startNode, identify, callback, value, 'repeat');
- }
-
// Walk through each string from our tagged template function “strings” array
// in a stateful way so that we know what kind of bindings are implied at
// each interpolated value.
- static #exhaustString(string, state) {
+ static #exhaustString(string, state, context) {
if (!state.inside) {
// We're outside the opening tag.
TemplateEngine.#OPEN_REGEX.lastIndex = state.index;
@@ -1487,21 +1349,21 @@ class TemplateEngine {
if (openMatch) {
state.inside = true;
state.index = TemplateEngine.#OPEN_REGEX.lastIndex;
- TemplateEngine.#exhaustString(string, state);
+ state.lastOpenContext = context;
+ state.lastOpenIndex = openMatch.index;
+ TemplateEngine.#exhaustString(string, state, context);
}
} else {
// We're inside the opening tag.
TemplateEngine.#STEP_REGEX.lastIndex = state.index;
- const stepMatch = TemplateEngine.#STEP_REGEX.exec(string);
- if (stepMatch) {
+ if (TemplateEngine.#STEP_REGEX.test(string)) {
state.index = TemplateEngine.#STEP_REGEX.lastIndex;
}
TemplateEngine.#CLOSE_REGEX.lastIndex = state.index;
- const closeMatch = TemplateEngine.#CLOSE_REGEX.exec(string);
- if (closeMatch) {
+ if (TemplateEngine.#CLOSE_REGEX.test(string)) {
state.inside = false;
state.index = TemplateEngine.#CLOSE_REGEX.lastIndex;
- TemplateEngine.#exhaustString(string, state);
+ TemplateEngine.#exhaustString(string, state, context);
}
}
}
@@ -1511,33 +1373,34 @@ class TemplateEngine {
//
// E.g., the user might have passed this interpolation:
//
- //
+ //
// ${content}
//
//
// … and we would instrument it as follows:
//
- //
+ //
//
//
//
- static #createHtml(type, strings) {
+ static #createHtml(language, strings) {
+ const keyToKeyState = new Map();
const htmlStrings = [];
- const state = { inside: false, index: 0 };
+ const state = { inside: false, index: 0, lastOpenContext: 0, lastOpenIndex: 0 };
// We don’t have to test the last string since it is already on the other
// side of the last interpolation, by definition. Hence the “- 1” below.
// Note that this final string is added just after the loop completes.
for (let iii = 0; iii < strings.length - 1; iii++) {
+ // The index may be set to “1” here, which indicates we are slicing off a
+ // trailing quote character from a attribute-or-property match. After
+ // slicing, we reset the index to zero so regular expressions know to
+ // match from the start in “exhaustString”.
let string = strings[iii];
- TemplateEngine.#exhaustString(string, state);
+ if (state.index !== 0) {
+ string = string.slice(state.index);
+ state.index = 0;
+ }
+ TemplateEngine.#exhaustString(string, state, iii);
if (state.inside) {
TemplateEngine.#ATTRIBUTE_OR_PROPERTY_REGEX.lastIndex = state.index;
const match = TemplateEngine.#ATTRIBUTE_OR_PROPERTY_REGEX.exec(string);
@@ -1547,22 +1410,26 @@ class TemplateEngine {
// We found a match like this: html``.
// … or this: html``.
// … or this: html``.
- // The syntax takes up 2-4 characters: `${questions}${attribute}="`.
- let syntax = 2;
- let prefix = TemplateEngine.#ATTRIBUTE_PREFIX;
+ // Syntax is 3-5 characters: `${questions}${attribute}="` + `"`.
+ let syntax = 3;
+ let kind = TemplateEngine.#ATTRIBUTE;
switch (questions) {
- case '??': prefix = TemplateEngine.#DEFINED_PREFIX; syntax = 4; break;
- case '?': prefix = TemplateEngine.#BOOLEAN_PREFIX; syntax = 3; break;
+ case '??': kind = TemplateEngine.#DEFINED; syntax = 5; break;
+ case '?': kind = TemplateEngine.#BOOLEAN; syntax = 4; break;
}
- const index = String(iii).padStart(TemplateEngine.#ATTRIBUTE_PADDING, '0');
- string = string.slice(0, -syntax - attribute.length) + `${prefix}-${index}="${attribute}`;
+ string = string.slice(0, -syntax - attribute.length);
+ const key = state.lastOpenContext;
+ const keyState = TemplateEngine.#setIfMissing(keyToKeyState, key, () => ({ index: state.lastOpenIndex, items: [] }));
+ keyState.items.push(`${kind}=${attribute}`);
} else {
// We found a match like this: html``.
- // The syntax takes up 3 characters: `.${property}="`.
- const syntax = 3;
- const prefix = TemplateEngine.#PROPERTY_PREFIX;
- const index = String(iii).padStart(TemplateEngine.#ATTRIBUTE_PADDING, '0');
- string = string.slice(0, -syntax - property.length) + `${prefix}-${index}="${property}`;
+ // Syntax is 4 characters: `.${property}="` + `"`.
+ const syntax = 4;
+ const kind = TemplateEngine.#PROPERTY;
+ string = string.slice(0, -syntax - property.length);
+ const key = state.lastOpenContext;
+ const keyState = TemplateEngine.#setIfMissing(keyToKeyState, key, () => ({ index: state.lastOpenIndex, items: [] }));
+ keyState.items.push(`${kind}=${property}`);
}
state.index = 1; // Accounts for an expected quote character next.
} else {
@@ -1572,22 +1439,32 @@ class TemplateEngine {
throw new Error(`Found invalid template on or after line ${lineCount} in substring \`${string}\`. Failed to parse \`${string.slice(state.index)}\`.`);
}
} else {
- // Assume it's a match like this: html`
${value}
`.
- string += ``;
+ // Assume it’s a match like this: html`
${value}
`.
+ string += ``;
state.index = 0; // No characters to account for. Reset to zero.
}
htmlStrings[iii] = string;
}
- htmlStrings.push(strings.at(-1));
+ // Again, there might be a quote we need to slice off here still.
+ let lastString = strings.at(-1);
+ if (state.index > 0) {
+ lastString = lastString.slice(state.index);
+ }
+ htmlStrings.push(lastString);
+ for (const [iii, { index, items }] of keyToKeyState.entries()) {
+ const comment = ``;
+ const htmlString = htmlStrings[iii];
+ htmlStrings[iii] = `${htmlString.slice(0, index)}${comment}${htmlString.slice(index)}`;
+ }
const html = htmlStrings.join('');
- return type === 'svg'
+ return language === TemplateEngine.#SVG
? ``
: html;
}
- static #createFragment(type, strings) {
+ static #createFragment(language, strings) {
const template = document.createElement('template');
- const html = TemplateEngine.#createHtml(type, strings);
+ const html = TemplateEngine.#createHtml(language, strings);
template.innerHTML = html;
return template.content;
}
@@ -1598,18 +1475,11 @@ class TemplateEngine {
// while we go through, clean up our bespoke markers.
// Note that we are always walking the interpolated strings and the resulting,
// instantiated DOM _in the same depth-first manner_. This means that the
- // ordering is fairly reliable. The only special handling we need to do is to
- // ensure that we don’t rely on the ordering of NamedNodeMap objects since
- // the spec doesn’t guarantee anything there (though in practice, it would
- // probably work…).
+ // ordering is fairly reliable.
//
// For example, we walk this structure:
//
- //
+ //
//
//
//
@@ -1623,63 +1493,51 @@ class TemplateEngine {
static #findLookups(node, nodeType = Node.DOCUMENT_FRAGMENT_NODE, lookups = [], path = []) {
// @ts-ignore — TypeScript doesn’t seem to understand the nodeType param.
if (nodeType === Node.ELEMENT_NODE) {
- // Copy the live NamedNodeMap since we need to mutate it during iteration.
- for (const attribute of [...node.attributes]) {
- const name = attribute.name;
- // Order checks in expected order of usage frequency.
- const type = name.startsWith(TemplateEngine.#PROPERTY_PREFIX)
- ? 'property'
- : name.startsWith(TemplateEngine.#ATTRIBUTE_PREFIX)
- ? 'attribute'
- : name.startsWith(TemplateEngine.#BOOLEAN_PREFIX)
- ? 'boolean'
- : name.startsWith(TemplateEngine.#DEFINED_PREFIX)
- ? 'defined'
- : null;
- if (type) {
- const index = Number(name.slice(-TemplateEngine.#ATTRIBUTE_PADDING));
- const value = attribute.value;
- lookups[index] = { path, type, name: value };
- node.removeAttribute(name);
- }
- }
// Special case to handle elements which only allow text content (no comments).
const { localName } = node;
if (
(localName === 'style' || localName === 'script') &&
- node.textContent.includes(TemplateEngine.#CONTENT_PREFIX)
+ node.textContent.includes(TemplateEngine.#CONTENT_MARKER)
) {
throw new Error(`Interpolation of "${localName}" tags is not allowed.`);
} else if (localName === 'textarea' || localName === 'title') {
- if (node.textContent.includes(TemplateEngine.#CONTENT_PREFIX)) {
- if (node.textContent === ``) {
+ if (node.textContent.includes(TemplateEngine.#CONTENT_MARKER)) {
+ if (node.textContent === ``) {
node.textContent = '';
- lookups.push({ path, type: 'text' });
+ lookups.push({ path, binding: TemplateEngine.#TEXT });
} else {
throw new Error(`Only basic interpolation of "${localName}" tags is allowed.`);
}
}
}
- } else if (
- // @ts-ignore — TypeScript doesn’t seem to understand the nodeType param.
- nodeType === Node.COMMENT_NODE &&
- node.textContent.startsWith(TemplateEngine.#CONTENT_PREFIX)
- ) {
- node.textContent = '';
- const startNode = document.createComment('');
- node.parentNode.insertBefore(startNode, node);
- path[path.length - 1] = path[path.length - 1] + 1;
- lookups.push({ path, type: 'content' });
}
- let iii = 0;
- if (
- nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
- nodeType === Node.ELEMENT_NODE
- ) {
- for (const childNode of node.childNodes) {
+ if (nodeType === Node.DOCUMENT_FRAGMENT_NODE || nodeType === Node.ELEMENT_NODE) {
+ // It’s expensive to make a copy of “childNodes”. Instead, we carefully
+ // manage our index as we iterate over the live collection.
+ const childNodes = node.childNodes;
+ for (let iii = 0; iii < childNodes.length; iii++) {
+ const childNode = childNodes[iii];
const childNodeType = childNode.nodeType;
- if (childNodeType === Node.ELEMENT_NODE || Node.COMMENT_NODE) {
- TemplateEngine.#findLookups(childNode, childNodeType, lookups, [...path, iii++]);
+ if (childNodeType === Node.COMMENT_NODE) {
+ const textContent = childNode.textContent;
+ if (textContent.startsWith(TemplateEngine.#CONTENT_MARKER)) {
+ childNode.textContent = '';
+ const startNode = document.createComment('');
+ node.insertBefore(startNode, childNode);
+ iii++;
+ lookups.push({ path: [...path, iii], binding: TemplateEngine.#CONTENT });
+ } else if (textContent.startsWith(TemplateEngine.#NEXT_MARKER)) {
+ const data = textContent.slice(TemplateEngine.#NEXT_MARKER.length);
+ const items = data.split(',');
+ for (const item of items) {
+ const [binding, name] = item.split('=');
+ lookups.push({ path: [...path, iii], binding, name });
+ }
+ iii--;
+ node.removeChild(childNode);
+ }
+ } else if (childNodeType === Node.ELEMENT_NODE) {
+ TemplateEngine.#findLookups(childNode, childNodeType, lookups, [...path, iii]);
}
}
}
@@ -1691,60 +1549,137 @@ class TemplateEngine {
// After cloning our common fragment, we use the “lookups” to cache live
// references to DOM nodes so that we can surgically perform updates later in
// an efficient manner. Lookups are like directions to find our real targets.
+ // As a performance boost, we pre-bind references so that the interface is
+ // just a simple function call when we need to bind new values.
static #findTargets(fragment, lookups) {
const targets = [];
const cache = new Map();
const find = path => {
let node = fragment;
for (const index of path) {
- const ref = node;
- node = TemplateEngine.#setIfMissing(cache, node, () => ref.childNodes)[index];
+ // eslint-disable-next-line no-loop-func
+ node = TemplateEngine.#setIfMissing(cache, node, () => node.childNodes)[index];
}
return node;
};
- for (const { path, type, name } of lookups) {
+ for (const { path, binding, name } of lookups) {
const node = find(path);
- switch (type) {
- case 'attribute':
- case 'boolean':
- case 'defined':
- case 'property':
- targets.push({ type, name, node });
+ switch (binding) {
+ case TemplateEngine.#ATTRIBUTE:
+ targets.push(TemplateEngine.#commitAttribute.bind(null, node, name));
+ break;
+ case TemplateEngine.#BOOLEAN:
+ targets.push(TemplateEngine.#commitBoolean.bind(null, node, name));
+ break;
+ case TemplateEngine.#DEFINED:
+ targets.push(TemplateEngine.#commitDefined.bind(null, node, name));
break;
- case 'content':
- targets.push({ type, node, startNode: node.previousSibling });
+ case TemplateEngine.#PROPERTY:
+ targets.push(TemplateEngine.#commitProperty.bind(null, node, name));
break;
- case 'text':
- targets.push({ type, node });
+ case TemplateEngine.#CONTENT:
+ targets.push(TemplateEngine.#commitContent.bind(null, node, node.previousSibling));
+ break;
+ case TemplateEngine.#TEXT:
+ targets.push(TemplateEngine.#commitText.bind(null, node));
break;
}
}
return targets;
}
- // Create and prepare a document fragment to be injected into some container.
- static #ready(result) {
- if (result.readied) {
- throw new Error(`Unexpected re-injection of template result.`);
- }
- result.readied = true;
- const { type, strings } = result;
- const analysis = TemplateEngine.#setIfMissing(TemplateEngine.#stringsToAnalysis, strings, () => ({}));
- if (!analysis.done) {
- analysis.done = true;
- const fragment = TemplateEngine.#createFragment(type, strings);
- const lookups = TemplateEngine.#findLookups(fragment);
- Object.assign(analysis, { fragment, lookups });
+ // Validates array item or map entry and returns an “id” and a “rawResult”.
+ static #parseListValue(value, index, category, ids) {
+ if (category === 'array') {
+ // Values should look like "".
+ const id = String(index);
+ const rawResult = value;
+ ids.add(id);
+ if (!TemplateEngine.#isRawResult(rawResult)) {
+ throw new Error(`Unexpected non-template value found in array item at ${index} "${rawResult}".`);
+ }
+ return [id, rawResult];
+ } else {
+ // Values should look like "[, ]".
+ if (value.length !== 2) {
+ throw new Error(`Unexpected entry length found in map entry at ${index} with length "${value.length}".`);
+ }
+ const [id, rawResult] = value;
+ if (typeof id !== 'string') {
+ throw new Error(`Unexpected non-string key found in map entry at ${index} "${id}".`);
+ }
+ if (ids.has(id)) {
+ throw new Error(`Unexpected duplicate key found in map entry at ${index} "${id}".`);
+ }
+ ids.add(id);
+ if (!TemplateEngine.#isRawResult(rawResult)) {
+ throw new Error(`Unexpected non-template value found in map entry at ${index} "${rawResult}".`);
+ }
+ return [id, rawResult];
}
- const fragment = analysis.fragment.cloneNode(true);
- const targets = TemplateEngine.#findTargets(fragment, analysis.lookups);
- const entries = Object.entries(targets);
- Object.assign(result, { fragment, entries });
}
- static #assign(result, newResult) {
- result.lastValues = result.values;
- result.values = newResult.values;
+ // Loops over given value array to either create-or-update a list of nodes.
+ static #list(node, startNode, values, category) {
+ const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE);
+ if (!arrayState.map) {
+ // There is no mapping in our state — we have a clean slate to work with.
+ TemplateEngine.#clearObject(arrayState);
+ arrayState.map = new Map();
+ const ids = new Set(); // Populated in “parseListValue”.
+ let index = 0;
+ for (const value of values) {
+ const [id, rawResult] = TemplateEngine.#parseListValue(value, index, category, ids);
+ const cursors = TemplateEngine.#createCursors(node);
+ const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true);
+ arrayState.map.set(id, { id, preparedResult, ...cursors });
+ index++;
+ }
+ } else {
+ // A mapping has already been created — we need to update the items.
+ let lastItem;
+ const ids = new Set(); // Populated in “parseListValue”.
+ let index = 0;
+ for (const value of values) {
+ const [id, rawResult] = TemplateEngine.#parseListValue(value, index, category, ids);
+ if (arrayState.map.has(id)) {
+ const item = arrayState.map.get(id);
+ if (!TemplateEngine.#canReuseDom(item.preparedResult, rawResult)) {
+ // Add new comment cursors before removing old comment cursors.
+ const cursors = TemplateEngine.#createCursors(item.startNode);
+ TemplateEngine.#removeThrough(item.startNode, item.node);
+ const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true);
+ item.preparedResult = preparedResult;
+ item.startNode = cursors.startNode;
+ item.node = cursors.node;
+ } else {
+ TemplateEngine.#update(item.preparedResult, rawResult);
+ }
+ } else {
+ const cursors = TemplateEngine.#createCursors(node);
+ const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true);
+ const item = { id, preparedResult, ...cursors };
+ arrayState.map.set(id, item);
+ }
+ const item = arrayState.map.get(id);
+ const referenceNode = lastItem ? lastItem.node.nextSibling : startNode.nextSibling;
+ if (referenceNode !== item.startNode) {
+ const nodesToMove = [item.startNode];
+ while (nodesToMove[nodesToMove.length - 1] !== item.node) {
+ nodesToMove.push(nodesToMove[nodesToMove.length - 1].nextSibling);
+ }
+ TemplateEngine.#insertAllBefore(referenceNode.parentNode, referenceNode, nodesToMove);
+ }
+ lastItem = item;
+ index++;
+ }
+ for (const [id, item] of arrayState.map.entries()) {
+ if (!ids.has(id)) {
+ TemplateEngine.#removeThrough(item.startNode, item.node);
+ arrayState.map.delete(id);
+ }
+ }
+ }
}
static #commitAttribute(node, name, value, lastValue) {
@@ -1759,7 +1694,7 @@ class TemplateEngine {
TemplateEngine.#nullish(node, name, update.value, lastUpdate?.value);
break;
default:
- TemplateEngine.#throwUpdaterError(update.updater, 'attribute');
+ TemplateEngine.#throwUpdaterError(update.updater, TemplateEngine.#ATTRIBUTE);
break;
}
} else {
@@ -1772,7 +1707,7 @@ class TemplateEngine {
static #commitBoolean(node, name, value, lastValue) {
const update = TemplateEngine.#symbolToUpdate.get(value);
if (update) {
- TemplateEngine.#throwUpdaterError(update.updater, 'boolean');
+ TemplateEngine.#throwUpdaterError(update.updater, TemplateEngine.#BOOLEAN);
} else {
if (value !== lastValue) {
value ? node.setAttribute(name, '') : node.removeAttribute(name);
@@ -1783,7 +1718,7 @@ class TemplateEngine {
static #commitDefined(node, name, value, lastValue) {
const update = TemplateEngine.#symbolToUpdate.get(value);
if (update) {
- TemplateEngine.#throwUpdaterError(update.updater, 'defined');
+ TemplateEngine.#throwUpdaterError(update.updater, TemplateEngine.#DEFINED);
} else {
if (value !== lastValue) {
value === undefined || value === null
@@ -1801,7 +1736,7 @@ class TemplateEngine {
TemplateEngine.#live(node, name, update.value);
break;
default:
- TemplateEngine.#throwUpdaterError(update.updater, 'property');
+ TemplateEngine.#throwUpdaterError(update.updater, TemplateEngine.#PROPERTY);
break;
}
} else {
@@ -1812,31 +1747,25 @@ class TemplateEngine {
}
static #commitContent(node, startNode, value, lastValue) {
- const update = TemplateEngine.#symbolToUpdate.get(value);
- const lastUpdate = TemplateEngine.#symbolToUpdate.get(lastValue);
+ const introspection = TemplateEngine.#getValueIntrospection(value);
+ const lastIntrospection = TemplateEngine.#getValueIntrospection(lastValue);
if (
lastValue !== TemplateEngine.#UNSET && (
- !!Array.isArray(value) !== !!Array.isArray(lastValue) ||
- !!update !== !!lastUpdate ||
- update?.updater !== lastUpdate?.updater
+ introspection?.category !== lastIntrospection?.category ||
+ introspection?.update?.updater !== lastIntrospection?.update?.updater
)
) {
// Reset content under certain conditions. E.g., `map(…)` >> `null`.
+ const state = TemplateEngine.#getState(node, TemplateEngine.#STATE);
+ const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE);
TemplateEngine.#removeBetween(startNode, node);
- const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({}));
TemplateEngine.#clearObject(state);
+ TemplateEngine.#clearObject(arrayState);
}
- if (update) {
+ if (introspection?.category === 'update') {
+ const { update } = introspection;
+ const lastUpdate = lastIntrospection?.update;
switch (update.updater) {
- case TemplateEngine.#map:
- TemplateEngine.#map(node, startNode, update.value, update.identify, update.callback);
- break;
- case TemplateEngine.#repeat:
- TemplateEngine.#repeat(node, startNode, update.value, update.identify, update.callback);
- break;
- case TemplateEngine.#unsafe:
- TemplateEngine.#unsafe(node, startNode, update.value, lastUpdate?.value, update.language);
- break;
case TemplateEngine.#unsafeHTML:
TemplateEngine.#unsafeHTML(node, startNode, update.value, lastUpdate?.value);
break;
@@ -1844,33 +1773,37 @@ class TemplateEngine {
TemplateEngine.#unsafeSVG(node, startNode, update.value, lastUpdate?.value);
break;
default:
- TemplateEngine.#throwUpdaterError(update.updater, 'content');
+ TemplateEngine.#throwUpdaterError(update.updater, TemplateEngine.#CONTENT);
break;
}
} else {
+ // Note that we always want to re-render results / lists, but because the
+ // way they are created, a new outer reference should always have been
+ // generated, so it’s ok to leave inside this value check.
if (value !== lastValue) {
- if (TemplateEngine.#symbolToResult.has(value)) {
- const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({}));
- const result = TemplateEngine.#symbolToResult.get(value);
- if (TemplateEngine.#cannotReuseResult(state.result, result)) {
+ if (introspection?.category === 'result') {
+ const state = TemplateEngine.#getState(node, TemplateEngine.#STATE);
+ const rawResult = value;
+ if (!TemplateEngine.#canReuseDom(state.preparedResult, rawResult)) {
TemplateEngine.#removeBetween(startNode, node);
TemplateEngine.#clearObject(state);
- TemplateEngine.#ready(result);
- TemplateEngine.#commit(result);
- TemplateEngine.#inject(result, node, { before: true });
- state.result = result;
+ const preparedResult = TemplateEngine.#inject(rawResult, node, true);
+ state.preparedResult = preparedResult;
} else {
- TemplateEngine.#assign(state.result, result);
- TemplateEngine.#commit(state.result);
+ TemplateEngine.#update(state.preparedResult, rawResult);
}
- } else if (Array.isArray(value)) {
- TemplateEngine.#mapInner(node, startNode, null, null, value, 'array');
- } else {
- const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({}));
- if (state.result) {
+ } else if (introspection?.category === 'array' || introspection?.category === 'map') {
+ TemplateEngine.#list(node, startNode, value, introspection.category);
+ } else if (introspection?.category === 'fragment') {
+ if (value.childElementCount === 0) {
+ throw new Error(`Unexpected child element count of zero for given DocumentFragment.`);
+ }
+ const previousSibling = node.previousSibling;
+ if (previousSibling !== startNode) {
TemplateEngine.#removeBetween(startNode, node);
- TemplateEngine.#clearObject(state);
}
+ node.parentNode.insertBefore(value, node);
+ } else {
const previousSibling = node.previousSibling;
if (previousSibling === startNode) {
// The `?? ''` is a shortcut for creating a text node and then
@@ -1891,7 +1824,7 @@ class TemplateEngine {
static #commitText(node, value, lastValue) {
const update = TemplateEngine.#symbolToUpdate.get(value);
if (update) {
- TemplateEngine.#throwUpdaterError(update.updater, 'text');
+ TemplateEngine.#throwUpdaterError(update.updater, TemplateEngine.#TEXT);
} else {
if (value !== lastValue) {
node.textContent = value;
@@ -1901,61 +1834,120 @@ class TemplateEngine {
// Bind the current values from a result by walking through each target and
// updating the DOM if things have changed.
- static #commit(result) {
- result.lastValues ??= result.values.map(() => TemplateEngine.#UNSET);
- const { entries, values, lastValues } = result;
- for (const [key, target] of entries) {
- const value = values[key];
- const lastValue = lastValues[key];
- switch (target.type) {
- case 'attribute': TemplateEngine.#commitAttribute(target.node, target.name, value, lastValue); break;
- case 'boolean': TemplateEngine.#commitBoolean(target.node, target.name, value, lastValue); break;
- case 'defined': TemplateEngine.#commitDefined(target.node, target.name, value, lastValue); break;
- case 'property':TemplateEngine.#commitProperty(target.node, target.name, value, lastValue); break;
- case 'content': TemplateEngine.#commitContent(target.node, target.startNode, value, lastValue); break;
- case 'text': TemplateEngine.#commitText(target.node, value, lastValue); break;
- }
+ static #commit(preparedResult) {
+ preparedResult.values ??= preparedResult.rawResult.values;
+ preparedResult.lastValues ??= preparedResult.values.map(() => TemplateEngine.#UNSET);
+ const { targets, values, lastValues } = preparedResult;
+ for (let iii = 0; iii < targets.length; iii++) {
+ const target = targets[iii];
+ const value = values[iii];
+ const lastValue = lastValues[iii];
+ target(value, lastValue);
+ }
+ }
+
+ // Inject a given result into a node for the first time. If we’ve never seen
+ // the template “strings” before, we also have to generate html, parse it,
+ // and find out binding targets. Then, we commit the values by iterating over
+ // our targets. Finally, we actually attach our new DOM into our node.
+ static #inject(rawResult, node, before) {
+ // Create and prepare a document fragment to be injected.
+ const { [TemplateEngine.#LANGUAGE]: language, strings } = rawResult;
+ const analysis = TemplateEngine.#setIfMissing(TemplateEngine.#stringsToAnalysis, strings, () => ({}));
+ if (!analysis.done) {
+ analysis.done = true;
+ const fragment = TemplateEngine.#createFragment(language, strings);
+ const lookups = TemplateEngine.#findLookups(fragment);
+ analysis.fragment = fragment;
+ analysis.lookups = lookups;
}
- }
+ const fragment = analysis.fragment.cloneNode(true);
+ const targets = TemplateEngine.#findTargets(fragment, analysis.lookups);
+ const preparedResult = { rawResult, fragment, targets };
+
+ // Bind values via our live targets into our disconnected DOM.
+ TemplateEngine.#commit(preparedResult);
- // Attach a document fragment into some container. Note that all the DOM in
- // the fragment will already have values correctly bound.
- static #inject(result, node, options) {
- const nodes = result.type === 'svg'
- ? result.fragment.firstChild.childNodes
- : result.fragment.childNodes;
- options?.before
+ // Attach a document fragment into the node. Note that all the DOM in the
+ // fragment will already have values correctly committed on the line above.
+ const nodes = language === TemplateEngine.#SVG
+ ? fragment.firstChild.childNodes
+ : fragment.childNodes;
+ before
? TemplateEngine.#insertAllBefore(node.parentNode, node, nodes)
: TemplateEngine.#insertAllBefore(node, null, nodes);
- result.fragment = null;
+
+ return preparedResult;
}
- static #throwUpdaterError(updater, type) {
- switch (updater) {
- case TemplateEngine.#live:
- throw new Error(`The live update must be used on ${TemplateEngine.#getTypeText('property')}, not on ${TemplateEngine.#getTypeText(type)}.`);
- case TemplateEngine.#map:
- throw new Error(`The map update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`);
- case TemplateEngine.#unsafe:
- throw new Error(`The unsafe update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`);
+ static #update(preparedResult, rawResult) {
+ preparedResult.lastValues = preparedResult.values;
+ preparedResult.values = rawResult.values;
+ TemplateEngine.#commit(preparedResult);
+ }
+
+ static #createRawResult(language, strings, values) {
+ return { [TemplateEngine.#LANGUAGE]: language, strings, values };
+ }
+
+ static #isRawResult(value) {
+ return !!value?.[TemplateEngine.#LANGUAGE];
+ }
+
+ // TODO: Revisit this concept when we delete deprecated interfaces. Once that
+ // happens, the _only_ updater available for content is `map`, and we may be
+ // able to make this more performant.
+ // We can probably change this to something like the following eventually:
+ //
+ // static #getCategory(value) {
+ // if (typeof value === 'object') {
+ // if (TemplateEngine.#isRawResult(value)) {
+ // return 'result';
+ // } else if (Array.isArray(value)) {
+ // return Array.isArray(value[0]) ? 'map' : 'array';
+ // } else if (value instanceof DocumentFragment) {
+ // return 'fragment';
+ // }
+ // }
+ // }
+ //
+ static #getValueIntrospection(value) {
+ if (Array.isArray(value)) {
+ return Array.isArray(value[0]) ? { category: 'map' } : { category: 'array' };
+ } else if (value instanceof DocumentFragment) {
+ return { category: 'fragment' };
+ } else if (value !== null && typeof value === 'object') {
+ if (TemplateEngine.#isRawResult(value)) {
+ return { category: 'result' };
+ } else {
+ const update = TemplateEngine.#symbolToUpdate.get(value);
+ if (update) {
+ return { category: 'update', update };
+ }
+ }
+ }
+ }
+ static #throwUpdaterError(updater, binding) {
+ switch (updater) {
// We’ll delete these updaters later.
+ case TemplateEngine.#live:
+ throw new Error(`The live update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#PROPERTY)}, not on ${TemplateEngine.#getBindingText(binding)}.`);
case TemplateEngine.#unsafeHTML:
- throw new Error(`The unsafeHTML update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`);
+ throw new Error(`The unsafeHTML update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#CONTENT)}, not on ${TemplateEngine.#getBindingText(binding)}.`);
case TemplateEngine.#unsafeSVG:
- throw new Error(`The unsafeSVG update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`);
+ throw new Error(`The unsafeSVG update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#CONTENT)}, not on ${TemplateEngine.#getBindingText(binding)}.`);
case TemplateEngine.#ifDefined:
- throw new Error(`The ifDefined update must be used on ${TemplateEngine.#getTypeText('attribute')}, not on ${TemplateEngine.#getTypeText(type)}.`);
+ throw new Error(`The ifDefined update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#ATTRIBUTE)}, not on ${TemplateEngine.#getBindingText(binding)}.`);
case TemplateEngine.#nullish:
- throw new Error(`The nullish update must be used on ${TemplateEngine.#getTypeText('attribute')}, not on ${TemplateEngine.#getTypeText(type)}.`);
- case TemplateEngine.#repeat:
- throw new Error(`The repeat update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`);
+ throw new Error(`The nullish update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#ATTRIBUTE)}, not on ${TemplateEngine.#getBindingText(binding)}.`);
}
}
- static #cannotReuseResult(result, newResult) {
+ static #canReuseDom(preparedResult, rawResult) {
return (
- result?.type !== newResult.type || result?.strings !== newResult.strings
+ preparedResult?.rawResult[TemplateEngine.#LANGUAGE] === rawResult?.[TemplateEngine.#LANGUAGE] &&
+ preparedResult?.rawResult.strings === rawResult?.strings
);
}
@@ -1968,17 +1960,20 @@ class TemplateEngine {
}
static #insertAllBefore(parentNode, referenceNode, nodes) {
- // Make a copy of the array, else the live NodeList will be mutated as you
- // iterate — which would cause us to miss nodes.
+ // Iterate backwards over the live node collection since we’re mutating it.
// Note that passing “null” as the reference node appends nodes to the end.
- for (const node of [...nodes]) {
+ for (let iii = nodes.length - 1; iii >= 0; iii--) {
+ const node = nodes[iii];
parentNode.insertBefore(node, referenceNode);
+ referenceNode = node;
}
}
static #removeWithin(node) {
- while(node.firstChild) {
- node.firstChild.remove();
+ // Iterate backwards over the live node collection since we’re mutating it.
+ const childNodes = node.childNodes;
+ for (let iii = childNodes.length - 1; iii >= 0; iii--) {
+ node.removeChild(childNodes[iii]);
}
}
@@ -2012,17 +2007,40 @@ class TemplateEngine {
return value;
}
- static #getTypeText(type) {
- switch (type) {
- case 'attribute': return 'an attribute';
- case 'boolean': return 'a boolean attribute';
- case 'defined': return 'a defined attribute';
- case 'property': return 'a property';
- case 'content': return 'content';
- case 'text': return 'text content';
+ static #getState(object, key) {
+ // Values set in this file are ALL truthy.
+ let value = object[key];
+ if (!value) {
+ value = {};
+ object[key] = value;
+ }
+ return value;
+ }
+
+ static #getBindingText(binding) {
+ switch (binding) {
+ case TemplateEngine.#ATTRIBUTE: return 'an attribute';
+ case TemplateEngine.#BOOLEAN: return 'a boolean attribute';
+ case TemplateEngine.#DEFINED: return 'a defined attribute';
+ case TemplateEngine.#PROPERTY: return 'a property';
+ case TemplateEngine.#CONTENT: return 'content';
+ case TemplateEngine.#TEXT: return 'text content';
}
}
+ static #interfaceDeprecatedStacks = new Set();
+ static #interfaceDeprecated(name, callback) {
+ return (...args) => {
+ const error = new Error(`Deprecated "${name}" from default templating engine interface.`);
+ const stack = error.stack;
+ if (!this.#interfaceDeprecatedStacks.has(stack)) {
+ this.#interfaceDeprecatedStacks.add(stack);
+ console.warn(error); // eslint-disable-line no-console
+ }
+ return callback(...args);
+ };
+ }
+
static #interfaceRemoved(name) {
return () => {
throw new Error(`Removed "${name}" from default templating engine interface. Import and plug-in "lit-html" as your element's templating engine if you want this functionality.`);