` 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..60d97f7 100644
--- a/doc/TEMPLATES.md
+++ b/doc/TEMPLATES.md
@@ -21,49 +21,19 @@ static template(html, { map }) {
}
```
-The following binding types 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);
-```
+The following bindings are supported:
+
+| 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:**
@@ -81,8 +51,6 @@ The following template languages are supported:
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:**
@@ -216,23 +184,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.
@@ -324,35 +275,6 @@ 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`
-
- ${unsafe(bar, 'svg')}
-
-`;
-//
-//
-//
-//
-//
-```
-
## Customizing your base class
Following is a working example using [lit-html](https://lit.dev):
diff --git a/test/test-template-engine.js b/test/test-template-engine.js
index 2d89ff4..c298b9c 100644
--- a/test/test-template-engine.js
+++ b/test/test-template-engine.js
@@ -1,14 +1,7 @@
import XElement from '../x-element.js';
import { assert, describe, it } from './x-test.js';
-// Long-term interface.
-const { render, html, svg, map, unsafe } = XElement.templateEngine;
-
-// Tentative interface. We may or may not keep these.
-const { live } = XElement.templateEngine;
-
-// Deprecated interface. We will eventually delete these.
-const { ifDefined, nullish, repeat, unsafeHTML, unsafeSVG } = XElement.templateEngine;
+const { render, html, svg, map } = XElement.templateEngine;
describe('html rendering', () => {
it('renders basic string', () => {
@@ -505,218 +498,27 @@ describe('html rendering', () => {
assert(container.textContent === '[object HTMLInputElement]');
container.remove();
});
-});
-
-describe('html updaters', () => {
- // This is mainly for backwards compat, "nullish" is likely a better match.
- it('ifDefined', () => {
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- render(container, getTemplate({ maybe: 'yes' }));
- assert(container.querySelector('#target').getAttribute('maybe') === 'yes');
- render(container, getTemplate({ maybe: undefined }));
- assert(container.querySelector('#target').getAttribute('maybe') === null);
- render(container, getTemplate({ maybe: false }));
- assert(container.querySelector('#target').getAttribute('maybe') === 'false');
- render(container, getTemplate({ maybe: null }));
- assert(container.querySelector('#target').getAttribute('maybe') === null);
- container.remove();
- });
-
- it('nullish', () => {
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- render(container, getTemplate({ maybe: 'yes' }));
- assert(container.querySelector('#target').getAttribute('maybe') === 'yes');
- render(container, getTemplate({ maybe: undefined }));
- assert(container.querySelector('#target').getAttribute('maybe') === null);
- render(container, getTemplate({ maybe: false }));
- assert(container.querySelector('#target').getAttribute('maybe') === 'false');
- render(container, getTemplate({ maybe: null }));
- assert(container.querySelector('#target').getAttribute('maybe') === null);
- container.remove();
- });
-
- it('live', () => {
- const getTemplate = ({ alive, dead }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- render(container, getTemplate({ alive: 'lively', dead: 'deadly' }));
- assert(container.querySelector('#target').alive === 'lively');
- assert(container.querySelector('#target').dead === 'deadly');
- container.querySelector('#target').alive = 'changed';
- container.querySelector('#target').dead = 'changed';
- assert(container.querySelector('#target').alive === 'changed');
- assert(container.querySelector('#target').dead === 'changed');
- render(container, getTemplate({ alive: 'lively', dead: 'deadly' }));
- assert(container.querySelector('#target').alive === 'lively');
- assert(container.querySelector('#target').dead === 'changed');
- container.remove();
- });
-
- it('unsafe html', () => {
- const getTemplate = ({ content }) => {
- return html`${unsafe(content, 'html')}
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- render(container, getTemplate({ content: 'oh hai
' }));
- assert(!!container.querySelector('#injected'));
- render(container, getTemplate({ content: 'oh hai, again
' }));
- assert(!!container.querySelector('#booster'));
- container.remove();
- });
-
- it('unsafeHTML', () => {
- const getTemplate = ({ content }) => {
- return html`${unsafeHTML(content)}
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- render(container, getTemplate({ content: 'oh hai
' }));
- assert(!!container.querySelector('#injected'));
- render(container, getTemplate({ content: 'oh hai, again
' }));
- assert(!!container.querySelector('#booster'));
- container.remove();
- });
-
- // This is mainly for backwards compat, TBD if we deprecate or not.
- it('repeat works when called with all arguments', () => {
- const getTemplate = ({ items }) => {
- return html`
-
- ${repeat(items, item => item.id, item => {
- return html`
${item.id}
`;
- })}
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar'}, { id: 'baz' }] }));
- const foo = container.querySelector('#foo');
- const bar = container.querySelector('#bar');
- const baz = container.querySelector('#baz');
- assert(container.querySelector('#target').childElementCount === 3);
- assert(!!foo);
- assert(!!bar);
- assert(!!baz);
- assert(container.querySelector('#target').children[0] === foo);
- assert(container.querySelector('#target').children[1] === bar);
- assert(container.querySelector('#target').children[2] === baz);
- render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar'}, { id: 'baz' }] }));
- assert(container.querySelector('#target').childElementCount === 3);
- assert(container.querySelector('#target').children[0] === foo);
- assert(container.querySelector('#target').children[1] === bar);
- assert(container.querySelector('#target').children[2] === baz);
- render(container, getTemplate({ items: [{ id: 'baz' }, { id: 'foo' }, { id: 'bar'}] }));
- assert(container.querySelector('#target').childElementCount === 3);
- assert(container.querySelector('#target').children[0] === baz);
- assert(container.querySelector('#target').children[1] === foo);
- assert(container.querySelector('#target').children[2] === bar);
- render(container, getTemplate({ items: [{ id: 'bar'}, { id: 'baz' }, { id: 'foo' }] }));
- assert(container.querySelector('#target').childElementCount === 3);
- assert(container.querySelector('#target').children[0] === bar);
- assert(container.querySelector('#target').children[1] === baz);
- assert(container.querySelector('#target').children[2] === foo);
- render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar'}, { id: 'baz' }] }));
- assert(container.querySelector('#target').childElementCount === 3);
- assert(container.querySelector('#target').children[0] === foo);
- assert(container.querySelector('#target').children[1] === bar);
- assert(container.querySelector('#target').children[2] === baz);
- render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar'}] }));
- assert(container.querySelector('#target').childElementCount === 2);
- assert(container.querySelector('#target').children[0] === foo);
- assert(container.querySelector('#target').children[1] === bar);
- render(container, getTemplate({ items: [{ id: 'foo' }] }));
- assert(container.querySelector('#target').childElementCount === 1);
- assert(container.querySelector('#target').children[0] === foo);
- render(container, getTemplate({ items: [] }));
- assert(container.querySelector('#target').childElementCount === 0);
- render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar'}, { id: 'baz' }] }));
- assert(container.querySelector('#target').childElementCount === 3);
- assert(container.querySelector('#target').children[0] !== foo);
- assert(container.querySelector('#target').children[1] !== bar);
- assert(container.querySelector('#target').children[2] !== baz);
- container.remove();
- });
-
- it('repeat works when called with omitted lookup', () => {
- const getTemplate = ({ items }) => {
- return html`
-
- ${repeat(items, item => {
- return html`
${item.id}
`;
- })}
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar'}, { id: 'baz' }] }));
- const foo = container.querySelector('#foo');
- const bar = container.querySelector('#bar');
- const baz = container.querySelector('#baz');
- assert(container.querySelector('#target').childElementCount === 3);
- assert(!!foo);
- assert(!!bar);
- assert(!!baz);
- assert(container.querySelector('#target').children[0] === foo);
- assert(container.querySelector('#target').children[1] === bar);
- assert(container.querySelector('#target').children[2] === baz);
- render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar'}, { id: 'baz' }] }));
- assert(container.querySelector('#target').childElementCount === 3);
- assert(container.querySelector('#target').children[0] === foo);
- assert(container.querySelector('#target').children[1] === bar);
- assert(container.querySelector('#target').children[2] === baz);
-
- // Because "lookup" is omitted, we don't expect DOM nodes to remain after a shift.
- render(container, getTemplate({ items: [{ id: 'baz' }, { id: 'foo' }, { id: 'bar'}] }));
- assert(container.querySelector('#target').childElementCount === 3);
- assert(container.querySelector('#target').children[0] !== baz);
- assert(container.querySelector('#target').children[1] !== foo);
- assert(container.querySelector('#target').children[2] !== bar);
- container.remove();
- });
- it('repeat re-runs each time', () => {
- const getTemplate = ({ items, lookup }) => {
- return html`
-
-
- ${repeat(items, item => item.id, item => {
- return html`${lookup?.[item.id]} `;
- })}
-
-
- `;
+ it('renders DocumentFragment nodes with simple append action', () => {
+ const getTemplate = ({ fragment }) => {
+ return html`${fragment}`;
};
const container = document.createElement('div');
document.body.append(container);
- const items = [{ id: 'a' }, { id: 'b'}, { id: 'c' }];
- let lookup = { a: 'foo', b: 'bar', c: 'baz' };
- render(container, getTemplate({ items, lookup }));
- assert(container.querySelector('#target').childElementCount === 3);
- assert(container.querySelector('#a').textContent === 'foo');
- assert(container.querySelector('#b').textContent === 'bar');
- assert(container.querySelector('#c').textContent === 'baz');
- lookup = { a: 'fizzle', b: 'bop', c: 'fuzz' };
- render(container, getTemplate({ items, lookup }));
- assert(container.querySelector('#target').childElementCount === 3);
- assert(container.querySelector('#a').textContent === 'fizzle');
- assert(container.querySelector('#b').textContent === 'bop');
- assert(container.querySelector('#c').textContent === 'fuzz');
+ const template = document.createElement('template');
+ template.innerHTML = ' ';
+ render(container, getTemplate({ fragment: template.content.cloneNode(true) }));
+ assert(container.childElementCount === 1);
+ assert(container.children[0].localName === 'input');
+ template.innerHTML = '';
+ render(container, getTemplate({ fragment: template.content.cloneNode(true) }));
+ assert(container.childElementCount === 1);
+ assert(container.children[0].localName === 'textarea');
container.remove();
});
+});
+describe('html updaters', () => {
it('map', () => {
const getTemplate = ({ items }) => {
return html`
@@ -892,8 +694,7 @@ describe('html updaters', () => {
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.
+ default: return value; // E.g., an array, some text, null, undefined, fragment, etc.
}
};
const getTemplate = ({ type, value }) => html`${resolve(type, value)}
`;
@@ -921,6 +722,15 @@ describe('html updaters', () => {
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({ type: undefined, 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`
`;
render(container, getTemplate({
@@ -934,12 +744,6 @@ 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' }] }));
assert(!!container.querySelector('#target'));
@@ -951,39 +755,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 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 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 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', () => run(toFragmentContent, toMap));
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 fragment content', () => run(toArrayContent, toFragmentContent));
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 fragment content', () => run(toMap, toFragmentContent));
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));
});
});
@@ -1031,58 +835,6 @@ describe('svg rendering', () => {
});
});
-describe('svg updaters', () => {
- it('unsafe svg', () => {
- const getTemplate = ({ content }) => {
- return html`
-
- ${unsafe(content, 'svg')}
-
- `;
- };
- 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`
-
- ${unsafeSVG(content)}
-
- `;
- };
- 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();
- });
-});
-
describe('rendering errors', () => {
describe('templating', () => {
it('throws when attempting to interpolate within a style tag', () => {
@@ -1215,6 +967,24 @@ describe('rendering errors', () => {
container.remove();
});
+ it('throws for empty DocumentFragment value binding', () => {
+ const expected = 'Unexpected child element count of zero for given DocumentFragment.';
+ const getTemplate = ({ fragment }) => {
+ return html`${fragment}
`;
+ };
+ const container = document.createElement('div');
+ document.body.append(container);
+ let actual;
+ try {
+ render(container, getTemplate({ fragment: new DocumentFragment() }));
+ } catch (error) {
+ actual = error.message;
+ }
+ assert(!!actual, 'No error was thrown.');
+ assert(actual === expected, actual);
+ container.remove();
+ });
+
it('throws for re-injection of template result', () => {
const templateResultReference = html`
`;
const container = document.createElement('div');
@@ -1234,17 +1004,17 @@ describe('rendering errors', () => {
});
});
- describe('ifDefined', () => {
- it('throws if used on a "boolean"', () => {
- const expected = 'The ifDefined update must be used on an attribute, not on a boolean attribute.';
+ describe('map', () => {
+ it('throws if identify is not a function', () => {
+ const expected = 'Unexpected map identify "undefined" provided, expected a function.';
const getTemplate = ({ maybe }) => {
- return html`
`;
+ return html`
`;
};
const container = document.createElement('div');
document.body.append(container);
let actual;
try {
- render(container, getTemplate({ maybe: 'yes' }));
+ render(container, getTemplate({ maybe: ['yes'] }));
} catch (error) {
actual = error.message;
}
@@ -1253,16 +1023,16 @@ describe('rendering errors', () => {
container.remove();
});
- it('throws if used on a "property"', () => {
- const expected = 'The ifDefined update must be used on an attribute, not on a property.';
+ it('throws if callback is not a function', () => {
+ const expected = 'Unexpected map callback "undefined" provided, expected a function.';
const getTemplate = ({ maybe }) => {
- return html`
`;
+ return html`
`;
};
const container = document.createElement('div');
document.body.append(container);
let actual;
try {
- render(container, getTemplate({ maybe: 'yes' }));
+ render(container, getTemplate({ maybe: ['yes'] }));
} catch (error) {
actual = error.message;
}
@@ -1271,718 +1041,74 @@ describe('rendering errors', () => {
container.remove();
});
- it('throws if used with "content"', () => {
- const expected = 'The ifDefined update must be used on an attribute, not on content.';
- const getTemplate = ({ maybe }) => {
- return html`${ifDefined(maybe)}
`;
+ it('throws for duplicate identify responses on initial render', () => {
+ const getTemplate = ({ array }) => {
+ return html`
+
+ ${map(array, () => 'foo', () => html``)}
+
+ `;
};
const container = document.createElement('div');
document.body.append(container);
- let actual;
+ let error;
try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
+ render(container, getTemplate({ array: [1, 2, 3] }));
+ } catch (e) {
+ error = e;
}
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
+ assert(error?.message === 'Unexpected duplicate value returned from identify callback "foo".', error?.message);
container.remove();
});
- it('throws if used with "text"', () => {
- const expected = 'The ifDefined update must be used on an attribute, not on text content.';
- const getTemplate = ({ maybe }) => {
- return html``;
+ it('throws for duplicate identify responses on subsequent render', () => {
+ const getTemplate = ({ array }) => {
+ return html`
+
+ ${map(array, item => item, () => html``)}
+
+ `;
};
const container = document.createElement('div');
document.body.append(container);
- let actual;
+ let error;
+ render(container, getTemplate({ array: [1, 2, 3] }));
try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
+ render(container, getTemplate({ array: [1, 2, 3, 4, 4] }));
+ } catch (e) {
+ error = e;
}
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
+ assert(error?.message === 'Unexpected duplicate value returned from identify callback "4".', error?.message);
container.remove();
});
- });
- describe('nullish', () => {
- it('throws if used on a "boolean"', () => {
- const expected = 'The nullish update must be used on an attribute, not on a boolean attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
+ it('throws for non-array value', () => {
+ const getTemplate = ({ array }) => {
+ return html`
+
+ ${map(array, () => {}, () => html``)}
+
+ `;
};
const container = document.createElement('div');
document.body.append(container);
- let actual;
+ let error;
try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
+ render(container, getTemplate({ array: 5 }));
+ } catch (e) {
+ error = e;
}
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
+ assert(error?.message === 'Unexpected map items "5" provided, expected an array.', error?.message);
container.remove();
});
- it('throws if used on a "property"', () => {
- const expected = 'The nullish update must be used on an attribute, not on a property.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with "content"', () => {
- const expected = 'The nullish update must be used on an attribute, not on content.';
- const getTemplate = ({ maybe }) => {
- return html`${nullish(maybe)}
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with "text"', () => {
- const expected = 'The nullish update must be used on an attribute, not on text content.';
- const getTemplate = ({ maybe }) => {
- return html``;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
- });
-
- describe('live', () => {
- it('throws if used on an "attribute"', () => {
- const expected = 'The live update must be used on a property, not on an attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "boolean"', () => {
- const expected = 'The live update must be used on a property, not on a boolean attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "defined"', () => {
- const expected = 'The live update must be used on a property, not on a defined attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with "content"', () => {
- const expected = 'The live update must be used on a property, not on content.';
- const getTemplate = ({ maybe }) => {
- return html`${live(maybe)}
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with "text"', () => {
- const expected = 'The live update must be used on a property, not on text content.';
- const getTemplate = ({ maybe }) => {
- return html``;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
- });
-
-
- describe('unsafe', () => {
- it('throws if used on an unexpected language', () => {
- const expected = 'Unexpected unsafe language "css". Expected "html" or "svg".';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on an "attribute"', () => {
- const expected = 'The unsafe update must be used on content, not on an attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "boolean"', () => {
- const expected = 'The unsafe update must be used on content, not on a boolean attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "defined"', () => {
- const expected = 'The unsafe update must be used on content, not on a defined attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with a "property"', () => {
- const expected = 'The unsafe update must be used on content, not on a property.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with "text"', () => {
- const expected = 'The unsafe update must be used on content, not on text content.';
- const getTemplate = ({ maybe }) => {
- return html``;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws for non-string value', () => {
- const getTemplate = ({ content }) => {
- return html`
-
- ${unsafe(content, 'html')}
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let error;
- try {
- render(container, getTemplate({ content: null }));
- } catch (e) {
- error = e;
- }
- assert(error?.message === 'Unexpected unsafe value "null".', error?.message);
- container.remove();
- });
- });
-
- describe('unsafeHTML', () => {
- it('throws if used on an "attribute"', () => {
- const expected = 'The unsafeHTML update must be used on content, not on an attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "boolean"', () => {
- const expected = 'The unsafeHTML update must be used on content, not on a boolean attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "defined"', () => {
- const expected = 'The unsafeHTML update must be used on content, not on a defined attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with a "property"', () => {
- const expected = 'The unsafeHTML update must be used on content, not on a property.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with "text"', () => {
- const expected = 'The unsafeHTML update must be used on content, not on text content.';
- const getTemplate = ({ maybe }) => {
- return html``;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws for non-string value', () => {
- const getTemplate = ({ content }) => {
- return html`
-
- ${unsafeHTML(content)}
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let error;
- try {
- render(container, getTemplate({ content: null }));
- } catch (e) {
- error = e;
- }
- assert(error?.message === 'Unexpected unsafeHTML value "null".', error?.message);
- container.remove();
- });
- });
-
- describe('unsafeSVG', () => {
- it('throws if used on an "attribute"', () => {
- const expected = 'The unsafeSVG update must be used on content, not on an attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "boolean"', () => {
- const expected = 'The unsafeSVG update must be used on content, not on a boolean attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "defined"', () => {
- const expected = 'The unsafeSVG update must be used on content, not on a defined attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with a "property"', () => {
- const expected = 'The unsafeSVG update must be used on content, not on a property.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with "text"', () => {
- const expected = 'The unsafeSVG update must be used on content, not on text content.';
- const getTemplate = ({ maybe }) => {
- return html``;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: 'yes' }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws for non-string value', () => {
- const getTemplate = ({ content }) => {
- return html`
-
- ${unsafeSVG(content)}
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let error;
- try {
- render(container, getTemplate({ content: null }));
- } catch (e) {
- error = e;
- }
- assert(error?.message === 'Unexpected unsafeSVG value "null".', error?.message);
- container.remove();
- });
- });
-
- describe('map', () => {
- it('throws if identify is not a function', () => {
- const expected = 'Unexpected map identify "undefined" provided, expected a function.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if callback is not a function', () => {
- const expected = 'Unexpected map callback "undefined" provided, expected a function.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on an "attribute"', () => {
- const expected = 'The map update must be used on content, not on an attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "boolean"', () => {
- const expected = 'The map update must be used on content, not on a boolean attribute.';
- const getTemplate = ({ maybe }) => {
- return html` {}, () => {})}">
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "defined"', () => {
- const expected = 'The map update must be used on content, not on a defined attribute.';
- const getTemplate = ({ maybe }) => {
- return html` {}, () => {})}">
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with a "property"', () => {
- const expected = 'The map update must be used on content, not on a property.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with "text"', () => {
- const expected = 'The map update must be used on content, not on text content.';
- const getTemplate = ({ maybe }) => {
- return html``;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws for non-array value', () => {
- const getTemplate = ({ array }) => {
- return html`
-
- ${map(array, () => {}, () => html``)}
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let error;
- try {
- render(container, getTemplate({ array: 5 }));
- } catch (e) {
- error = e;
- }
- assert(error?.message === 'Unexpected map items "5" provided, expected an array.', error?.message);
- container.remove();
- });
-
- it('throws for non-template callback value', () => {
- const getTemplate = ({ array }) => {
- return html`
-
- ${map(array, item => item.id, item => item.value ? html`
${item.value}
` : null)}
-
- `;
+ it('throws for non-template callback value', () => {
+ const getTemplate = ({ array }) => {
+ return html`
+
+ ${map(array, item => item.id, item => item.value ? html`
${item.value}
` : null)}
+
+ `;
};
const container = document.createElement('div');
document.body.append(container);
@@ -2018,177 +1144,6 @@ describe('rendering errors', () => {
});
});
- describe('repeat', () => {
- it('throws if callback is not a function (1)', () => {
- const expected = 'Unexpected repeat identify "undefined" provided, expected a function.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if callback is not a function (2)', () => {
- const expected = 'Unexpected repeat callback "5" provided, expected a function.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on an "attribute"', () => {
- const expected = 'The repeat update must be used on content, not on an attribute.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used on a "boolean"', () => {
- const expected = 'The repeat update must be used on content, not on a boolean attribute.';
- const getTemplate = ({ maybe }) => {
- return html` {})}">
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with a "property"', () => {
- const expected = 'The repeat update must be used on content, not on a property.';
- const getTemplate = ({ maybe }) => {
- return html`
`;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws if used with "text"', () => {
- const expected = 'The repeat update must be used on content, not on text content.';
- const getTemplate = ({ maybe }) => {
- return html``;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let actual;
- try {
- render(container, getTemplate({ maybe: ['yes'] }));
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- container.remove();
- });
-
- it('throws for non-array value', () => {
- const getTemplate = ({ array }) => {
- return html`
-
- ${repeat(array, () => {}, () => html``)}
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let error;
- try {
- render(container, getTemplate({ array: 5 }));
- } catch (e) {
- error = e;
- }
- assert(error?.message === 'Unexpected repeat items "5" provided, expected an array.', error?.message);
- container.remove();
- });
-
- it('throws for non-template callback value', () => {
- const getTemplate = ({ array }) => {
- return html`
-
- ${repeat(array, item => item.id, item => item.value ? html`
${item.value}
` : null)}
-
- `;
- };
- const container = document.createElement('div');
- document.body.append(container);
- let error;
- try {
- render(container, getTemplate({ array: [{ id: 'foo', value: null }] }));
- } catch (e) {
- error = e;
- }
- assert(error?.message === 'Unexpected repeat value "null" provided by callback.', error?.message);
- container.remove();
- });
-
- it('throws for non-template callback value (on re-render)', () => {
- const getTemplate = ({ array }) => {
- return html`
-
- ${repeat(array, item => item.id, item => item.value ? html`
${item.value}
` : null)}
-
- `;
- };
- 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 }] }));
- } catch (e) {
- error = e;
- }
- assert(error?.message === 'Unexpected repeat value "null" provided by callback.', error?.message);
- container.remove();
- });
- });
-
describe('native array', () => {
it('throws for non-template value', () => {
const getTemplate = ({ items }) => {
@@ -2232,23 +1187,3 @@ describe('rendering errors', () => {
});
});
});
-
-describe('interface migration errors', () => {
- const removedInterfaceNames = [
- 'asyncAppend', 'asyncReplace', 'cache', 'classMap', 'directive', 'guard',
- 'styleMap', 'templateContent', 'until',
- ];
- for (const name of removedInterfaceNames) {
- it(`warns that "${name}" no longer exists.`, () => {
- const expected = `Removed "${name}" from default templating engine interface. Import and plug-in "lit-html" as your element's templating engine if you want this functionality.`;
- let actual;
- try {
- XElement.templateEngine[name]();
- } catch (error) {
- actual = error.message;
- }
- assert(!!actual, 'No error was thrown.');
- assert(actual === expected, actual);
- });
- }
-});
diff --git a/x-element.js b/x-element.js
index bc5ff8a..9efc17f 100644
--- a/x-element.js
+++ b/x-element.js
@@ -1041,41 +1041,18 @@ class TemplateEngine {
// Mapping of opaque references to internal result objects.
static #symbolToResult = new WeakMap();
- // Mapping of opaque references to internal update objects.
- static #symbolToUpdate = new WeakMap();
+ // Mapping of opaque references to internal mapping objects.
+ static #symbolToMapping = new WeakMap();
/**
* Default template engine interface — what you get inside “template”.
* @type {{[key: string]: Function}}
*/
static interface = Object.freeze({
- // Long-term interface.
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,
-
- // Removed interface.
- asyncAppend: TemplateEngine.#interfaceRemoved('asyncAppend'),
- asyncReplace: TemplateEngine.#interfaceRemoved('asyncReplace'),
- cache: TemplateEngine.#interfaceRemoved('cache'),
- classMap: TemplateEngine.#interfaceRemoved('classMap'),
- directive: TemplateEngine.#interfaceRemoved('directive'),
- guard: TemplateEngine.#interfaceRemoved('guard'),
- styleMap: TemplateEngine.#interfaceRemoved('styleMap'),
- templateContent: TemplateEngine.#interfaceRemoved('templateContent'),
- until: TemplateEngine.#interfaceRemoved('until'),
});
/**
@@ -1122,13 +1099,10 @@ class TemplateEngine {
const result = TemplateEngine.#symbolToResult.get(resultReference);
if (TemplateEngine.#cannotReuseResult(state.result, result)) {
TemplateEngine.#removeWithin(container);
- TemplateEngine.#ready(result);
- TemplateEngine.#commit(result);
TemplateEngine.#inject(result, container);
state.result = result;
} else {
- TemplateEngine.#assign(state.result, result);
- TemplateEngine.#commit(state.result);
+ TemplateEngine.#update(state.result, result);
}
} else {
TemplateEngine.#clearObject(state);
@@ -1136,127 +1110,6 @@ class TemplateEngine {
}
}
- /**
- * Updater to manage an attribute which may be undefined.
- * In the following example, the "ifDefined" updater will remove the
- * attribute if it's undefined. Else, it sets the key-value pair.
- * ```js
- * html``;
- * ```
- * @deprecated
- * @param {any} value
- * @returns {any}
- */
- static ifDefined(value) {
- const symbol = Object.create(null);
- const updater = TemplateEngine.#ifDefined;
- TemplateEngine.#symbolToUpdate.set(symbol, { updater, value });
- return symbol;
- }
-
- /**
- * Updater to manage an attribute which may not exist.
- * In the following example, the "nullish" updater will remove the
- * attribute if it's nullish. Else, it sets the key-value pair.
- * ```js
- * html` `;
- * ```
- * @deprecated
- * @param {any} value
- * @returns {any}
- */
- static nullish(value) {
- const symbol = Object.create(null);
- const updater = TemplateEngine.#nullish;
- const update = { updater, value };
- TemplateEngine.#symbolToUpdate.set(symbol, update);
- return symbol;
- }
-
- /**
- * Updater to manage a property which may change outside the template engine.
- * Typically, properties are declaratively managed from state and efficient
- * value checking is used (i.e., "value !== lastValue"). However, if DOM state
- * is expected to change, the "live" updater can be used to essentially change
- * this check to "value !== node[property]".
- * ```js
- * html` `;
- * ```
- * @param {any} value
- * @returns {any}
- */
- static live(value) {
- const symbol = Object.create(null);
- const updater = TemplateEngine.#live;
- const update = { updater, value };
- TemplateEngine.#symbolToUpdate.set(symbol, update);
- 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
- * parsed as HTML and injected into the DOM.
- * ```js
- * html`${unsafeHTML(obj.trustedMarkup)}
`;
- * ```
- * @deprecated
- * @param {any} value
- * @returns {any}
- */
- static unsafeHTML(value) {
- const symbol = Object.create(null);
- const updater = TemplateEngine.#unsafeHTML;
- const update = { updater, value };
- TemplateEngine.#symbolToUpdate.set(symbol, update);
- return symbol;
- }
-
- /**
- * Updater to inject trusted SVG into the DOM.
- * Use with caution. The "unsafeSVG" updater allows arbitrary input to be
- * parsed as SVG and injected into the DOM.
- * ```js
- * html`
- *
- * ${unsafeSVG(obj.trustedMarkup)}
- *
- * `;
- * ```
- * @deprecated
- * @param {any} value
- * @returns {any}
- */
- static unsafeSVG(value) {
- const symbol = Object.create(null);
- const updater = TemplateEngine.#unsafeSVG;
- const update = { updater, value };
- TemplateEngine.#symbolToUpdate.set(symbol, update);
- return symbol;
- }
-
/**
* Updater to manage a keyed array of templates (allows for DOM reuse).
* ```js
@@ -1283,197 +1136,13 @@ class TemplateEngine {
}
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;
- }
-
- /**
- * Shim for prior "repeat" function. Use "map".
- * @deprecated
- * @param {any[]} items
- * @param {Function} identify
- * @param {Function} [callback]
- * @returns {any}
- */
- static repeat(items, identify, callback) {
- if (arguments.length === 2) {
- callback = identify;
- identify = null;
- }
- if (!Array.isArray(items)) {
- throw new Error(`Unexpected repeat items "${items}" provided, expected an array.`);
- }
- if (arguments.length !== 2 && typeof identify !== 'function') {
- throw new Error(`Unexpected repeat identify "${identify}" provided, expected a function.`);
- } 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);
+ const mapping = { value, identify, callback };
+ TemplateEngine.#symbolToMapping.set(symbol, mapping);
return symbol;
}
- // Deprecated. Will remove in future release.
- static #ifDefined(node, name, value, lastValue) {
- if (value !== lastValue) {
- value === undefined || value === null
- ? node.removeAttribute(name)
- : node.setAttribute(name, value);
- }
- }
-
- // Deprecated. Will remove in future release.
- static #nullish(node, name, value, lastValue) {
- if (value !== lastValue) {
- value === undefined || value === null
- ? node.removeAttribute(name)
- : node.setAttribute(name, value);
- }
- }
-
- 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 = `${value} `;
- 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) {
- if (typeof value === 'string') {
- const template = document.createElement('template');
- template.innerHTML = value;
- TemplateEngine.#removeBetween(startNode, node);
- TemplateEngine.#insertAllBefore(node.parentNode, node, template.content.childNodes);
- } else {
- throw new Error(`Unexpected unsafeHTML value "${value}".`);
- }
- }
- }
-
- // Deprecated. Will remove in future release.
- static #unsafeSVG(node, startNode, value, lastValue) {
- if (value !== lastValue) {
- if (typeof value === 'string') {
- const template = document.createElement('template');
- template.innerHTML = `${value} `;
- TemplateEngine.#removeBetween(startNode, node);
- TemplateEngine.#insertAllBefore(node.parentNode, node, template.content.firstChild.childNodes);
- } else {
- throw new Error(`Unexpected unsafeSVG value "${value}".`);
- }
- }
- }
-
- 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');
+ TemplateEngine.#mapInputs(node, startNode, identify, callback, value, 'map');
}
// Walk through each string from our tagged template function “strings” array
@@ -1722,155 +1391,155 @@ class TemplateEngine {
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 });
+ // Loops over given inputs to either create-or-update a list of nodes.
+ static #mapInputs(node, startNode, identify, callback, inputs, name) {
+ const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({}));
+ if (!state.map) {
+ // There is no mapping in our state — we have a clean slate to work with.
+ TemplateEngine.#clearObject(state);
+ state.map = new Map();
+ 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 (ids.has(id)) {
+ throw new Error(`Unexpected duplicate value returned from identify callback "${id}".`);
+ }
+ ids.add(id);
+ const cursors = TemplateEngine.#createCursors(node);
+ TemplateEngine.#inject(result, cursors.node, true);
+ state.map.set(id, { id, result, ...cursors });
+ } else {
+ throw new Error(`Unexpected ${name} value "${reference}" provided by callback.`);
+ }
+ index++;
+ }
+ } else {
+ // A mapping has already been created — we need to update the items.
+ 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 (ids.has(id)) {
+ throw new Error(`Unexpected duplicate value returned from identify callback "${id}".`);
+ }
+ ids.add(id);
+ 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.#inject(result, cursors.node, true);
+ Object.assign(item, { result, ...cursors });
+ } else {
+ TemplateEngine.#update(item.result, result);
+ }
+ } else {
+ const cursors = TemplateEngine.#createCursors(node);
+ TemplateEngine.#inject(result, cursors.node, 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);
+ }
+ 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);
+ }
+ }
}
- 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;
}
static #commitAttribute(node, name, value, lastValue) {
- const update = TemplateEngine.#symbolToUpdate.get(value);
- const lastUpdate = TemplateEngine.#symbolToUpdate.get(lastValue);
- if (update) {
- switch (update.updater) {
- case TemplateEngine.#ifDefined:
- TemplateEngine.#ifDefined(node, name, update.value, lastUpdate?.value);
- break;
- case TemplateEngine.#nullish:
- TemplateEngine.#nullish(node, name, update.value, lastUpdate?.value);
- break;
- default:
- TemplateEngine.#throwUpdaterError(update.updater, 'attribute');
- break;
- }
- } else {
- if (value !== lastValue) {
- node.setAttribute(name, value);
- }
+ if (value !== lastValue) {
+ node.setAttribute(name, value);
}
}
static #commitBoolean(node, name, value, lastValue) {
- const update = TemplateEngine.#symbolToUpdate.get(value);
- if (update) {
- TemplateEngine.#throwUpdaterError(update.updater, 'boolean');
- } else {
- if (value !== lastValue) {
- value ? node.setAttribute(name, '') : node.removeAttribute(name);
- }
+ if (value !== lastValue) {
+ value ? node.setAttribute(name, '') : node.removeAttribute(name);
}
}
static #commitDefined(node, name, value, lastValue) {
- const update = TemplateEngine.#symbolToUpdate.get(value);
- if (update) {
- TemplateEngine.#throwUpdaterError(update.updater, 'defined');
- } else {
- if (value !== lastValue) {
- value === undefined || value === null
- ? node.removeAttribute(name)
- : node.setAttribute(name, value);
- }
+ if (value !== lastValue) {
+ value === undefined || value === null
+ ? node.removeAttribute(name)
+ : node.setAttribute(name, value);
}
}
static #commitProperty(node, name, value, lastValue) {
- const update = TemplateEngine.#symbolToUpdate.get(value);
- if (update) {
- switch (update.updater) {
- case TemplateEngine.#live:
- TemplateEngine.#live(node, name, update.value);
- break;
- default:
- TemplateEngine.#throwUpdaterError(update.updater, 'property');
- break;
- }
- } else {
- if (value !== lastValue) {
- node[name] = value;
- }
+ if (value !== lastValue) {
+ node[name] = value;
}
}
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
- )
+ lastValue !== TemplateEngine.#UNSET &&
+ introspection?.category !== lastIntrospection?.category
) {
// Reset content under certain conditions. E.g., `map(…)` >> `null`.
+ const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToState, node, () => ({}));
+ const arrayState = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({}));
TemplateEngine.#removeBetween(startNode, node);
- const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({}));
TemplateEngine.#clearObject(state);
+ TemplateEngine.#clearObject(arrayState);
}
- if (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;
- case TemplateEngine.#unsafeSVG:
- TemplateEngine.#unsafeSVG(node, startNode, update.value, lastUpdate?.value);
- break;
- default:
- TemplateEngine.#throwUpdaterError(update.updater, 'content');
- break;
- }
+ if (introspection?.category === 'mapping') {
+ const { mapping } = introspection;
+ TemplateEngine.#map(node, startNode, mapping.value, mapping.identify, mapping.callback);
} else {
if (value !== lastValue) {
- if (TemplateEngine.#symbolToResult.has(value)) {
- const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({}));
- const result = TemplateEngine.#symbolToResult.get(value);
+ if (introspection?.category === 'result') {
+ const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToState, node, () => ({}));
+ const { result } = introspection;
if (TemplateEngine.#cannotReuseResult(state.result, result)) {
TemplateEngine.#removeBetween(startNode, node);
TemplateEngine.#clearObject(state);
- TemplateEngine.#ready(result);
- TemplateEngine.#commit(result);
- TemplateEngine.#inject(result, node, { before: true });
+ TemplateEngine.#inject(result, node, true);
state.result = result;
} else {
- TemplateEngine.#assign(state.result, result);
- TemplateEngine.#commit(state.result);
+ TemplateEngine.#update(state.result, result);
}
- } 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') {
+ TemplateEngine.#mapInputs(node, startNode, null, null, value, 'array');
+ } 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
@@ -1889,13 +1558,8 @@ class TemplateEngine {
}
static #commitText(node, value, lastValue) {
- const update = TemplateEngine.#symbolToUpdate.get(value);
- if (update) {
- TemplateEngine.#throwUpdaterError(update.updater, 'text');
- } else {
- if (value !== lastValue) {
- node.textContent = value;
- }
+ if (value !== lastValue) {
+ node.textContent = value;
}
}
@@ -1918,38 +1582,66 @@ class TemplateEngine {
}
}
- // 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) {
+ // 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(result, node, before) {
+ // If we see the _exact_ same result again… that’s an error. We don’t allow
+ // integrators to reuse template results.
+ if (result.readied) {
+ throw new Error(`Unexpected re-injection of template result.`);
+ }
+
+ // Create and prepare a document fragment to be injected.
+ 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 });
+ }
+ const fragment = analysis.fragment.cloneNode(true);
+ const targets = TemplateEngine.#findTargets(fragment, analysis.lookups);
+ const entries = Object.entries(targets);
+ Object.assign(result, { fragment, entries });
+
+ // Bind values via our live targets into our disconnected DOM.
+ TemplateEngine.#commit(result);
+
+ // 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 = result.type === 'svg'
? result.fragment.firstChild.childNodes
: result.fragment.childNodes;
- options?.before
+ before
? TemplateEngine.#insertAllBefore(node.parentNode, node, nodes)
: TemplateEngine.#insertAllBefore(node, null, nodes);
result.fragment = null;
}
- 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(result, newResult) {
+ Object.assign(result, { lastValues: result.values, values: newResult.values });
+ TemplateEngine.#commit(result);
+ }
- // We’ll delete these updaters later.
- case TemplateEngine.#unsafeHTML:
- throw new Error(`The unsafeHTML update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`);
- case TemplateEngine.#unsafeSVG:
- throw new Error(`The unsafeSVG update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`);
- case TemplateEngine.#ifDefined:
- throw new Error(`The ifDefined update must be used on ${TemplateEngine.#getTypeText('attribute')}, not on ${TemplateEngine.#getTypeText(type)}.`);
- 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)}.`);
+ static #getValueIntrospection(value) {
+ if (Array.isArray(value)) {
+ return { category: 'array' };
+ } else if (value instanceof DocumentFragment) {
+ return { category: 'fragment' };
+ } else if (value !== null && typeof value === 'object') {
+ const result = TemplateEngine.#symbolToResult.get(value);
+ if (result) {
+ return { category: 'result', result };
+ } else {
+ const mapping = TemplateEngine.#symbolToMapping.get(value);
+ if (mapping) {
+ return { category: 'mapping', mapping };
+ }
+ }
}
}
@@ -2011,21 +1703,4 @@ 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 #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.`);
- };
- }
}