diff --git a/CHANGELOG.md b/CHANGELOG.md index 855f04e..7780ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- You can now bind attributes with `??foo="${bar}"` syntax. This is functionally + equivalent to the `nullish` updater and will replace that functionality later. + ### Changed - Template errors now include approximate line numbers from the offending template. 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. + +### Deprecated + +- The `ifDefined` and `nullish` updaters are deprecated, update templates to use + syntax like `??foo="${bar}"`. +- The `repeat` updater is deprecated, use `map` instead. + +### 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 `unsafeHTML` back to `null`. ## [1.1.1] - 2024-11-09 @@ -27,13 +48,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New support for static `styles` getter for `adoptedStyleSheets` ergonomics (#52). -### Fixed - -- The `map` function now works with properties / attributes bound across - template contexts (#179). -- The `x-element.d.ts` file now reflects the actual interface. Previously, it - has some issues (e.g., improper module export). - ### Changed - The `x-element.js` file is now “typed” via JSDoc. The validity of the JSDoc @@ -41,6 +55,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 are exported into a generated `x-element.d.ts` file. Previously, that file was hand-curated. +### Fixed + +- The `map` function now works with properties / attributes bound across + template contexts (#179). +- The `x-element.d.ts` file now reflects the actual interface. Previously, it + has some issues (e.g., improper module export). + ## [1.0.0] - 2024-02-29 ### Added diff --git a/doc/TEMPLATES.md b/doc/TEMPLATES.md index c792e05..4294ef1 100644 --- a/doc/TEMPLATES.md +++ b/doc/TEMPLATES.md @@ -20,57 +20,330 @@ static template(html, { map }) { The following binding types are supported: -| Type | Example | -| :------------------ | :--------------------- | -| attribute | `
` | -| attribute (boolean) | `
` | -| property | `
` | -| content | `
${foo}
` | -| content | `${foo}` | +| Type | Example | +| :------------------ | :--------------------------------- | +| attribute | `
` | +| attribute (boolean) | `
` | +| attribute (defined) | `
` | +| property | `
` | +| content | `
${foo}
` | Emulates: ```javascript const el = document.createElement('my-custom-element'); +el.attachShadow({ mode: 'open' }); +el.innerHTML = `
`; +const target = el.shadowRoot.getElementById('target'); -// attribute value bindings add or modify the attribute value -el.setAttribute('foo', bar); +// attribute value bindings set the attribute value +target.setAttribute('foo', bar); -// attribute boolean bindings add or remove the attribute -el.setAttribute('foo', ''); +// 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 -el.foo = bar; +target.foo = bar; + +// content bindings create text nodes for text content +target.append(document.createTextNode(foo)); -// text bindings assign the value to the content of the node -el.textContent = foo; +// content bindings append a child for singular, nested content +target.append(foo); -// content bindings create and append text to the node -el.appendChild(document.createTextNode(foo)) +// content binding maps and appends children for arrays of nested content +target.append(...foo); ``` -### Important note on serialization during data binding: +**Important note on serialization during data binding:** + Browsers must serialize values assigned to a DOM node's attributes using `toString()`. Properties with scalar types `String`, `Number`, and `Boolean` are bound to attributes using this native serialization. To help you avoid `[object Object]` surprises, properties defined using `x-element` allow you to specify their anticipated type. Attempting to bind non-scalar types to _attributes_ will result in an `x-element` error message. -The following directives are supported: +The following value updaters are supported: -* ifDefined -* live -* map -* nullish -* repeat -* unsafeHTML -* unsafeSVG +* live (can be used with property bindings) +* map (can be used with content bindings) +* unsafeHTML (can be used with content bindings) +* unsafeSVG (can be used with content bindings) The following template languages are supported: * html * svg -### A note on non-primitive data: +**A note on non-primitive data:** Because DOM manipulation is *slow* — template engines do their best to avoid it (if a value hasn’t changed, DOM manipulation is skipped). To know if a non-primitive property has changed, a change-by-reference check is performed (versus a change-by-value check). Therefore, treat non-primitives as if they’re immutable. I.e., do this: `element.property = { ...element.property, foo: 'bar' }`, and don’t do this: `element.property.foo = 'bar'`. +## Value binding (in detail) + +There are only a handful of _types_ of bindings. And for each of those types of +bindings, there are only a handful of _values_ that are appropriate for them. +This section will cover everything by type, and then by value. + +### Attribute binding + +The basic attribute binding simply calls `setAttribute` verbatim. + +```js +const bar = 'something'; +html`
`; +//
+ +const bar = 99; +html`
`; +//
+ +const bar = undefined; +html`
`; +//
+ +const bar = null; +html`
`; +//
+ +const bar = true; +html`
`; +//
+ +const bar = false; +html`
`; +//
+ +const bar = {}; +html`
`; +//
+``` + +### Boolean attribute binding + +The boolean attribute binding will either set the attribute to the empty string +or remove the attribute. + +```js +const bar = 'something'; +html`
`; +//
+ +const bar = 99; +html`
`; +//
+ +const bar = undefined; +html`
`; +//
+ +const bar = null; +html`
`; +//
+ +const bar = true; +html`
`; +//
+ +const bar = false; +html`
`; +//
+ +const bar = {}; +html`
`; +//
+``` + +### Defined attribute binding + +The defined attribute binding will set the attribute via `setAttribute` verbatim +_if_ the value is non-nullish. Otherwise, it will remove the attribute. + +```js +const bar = 'something'; +html`
`; +//
+ +const bar = 99; +html`
`; +//
+ +const bar = undefined; +html`
`; +//
+ +const bar = null; +html`
`; +//
+ +const bar = true; +html`
`; +//
+ +const bar = false; +html`
`; +//
+ +const bar = {}; +html`
`; +//
+``` + +### Property binding + +#### Basic property binding + +The basic property binding just binds the typed value to the target element. + +```js +const bar = 'something'; +html`
`; +//
+// el.foo = bar; +``` + +#### The `live` property binding + +You can wrap the value 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 always +set `el.value = value` whenever a `render` is kicked off. + +### Content binding + +The content binding does different things based on the value passed in. + +#### Basic content binding + +The most basic content binding sets the value as text content. + +```js +const bar = 'something'; +html`
${bar}
`; +//
something
+ +const bar = 99; +html`
${bar}
`; +//
99
+ +const bar = undefined; +html`
${bar}
`; +//
+ +const bar = null; +html`
${bar}
`; +//
+ +const bar = true; +html`
${bar}
`; +//
true
+ +const bar = false; +html`
${bar}
`; +//
false
+ +const bar = {}; +html`
${bar}
`; +//
[object Object]
+``` + +#### Composed content binding + +When the content being bound is itself a template, you get composition. + +```js +const bar = html`something`; +html`
${bar}
`; +//
something
+``` + +#### Array content binding + +When the content being bound is an array of templates, you get a mapping. + +```js +const bar = [ + html`one`, + html`two`, +]; +html`
${bar}
`; +//
onetwo
+ +// … but, you typically don’t have a static array, you map it idiomatically. +const terms = ['one', 'two']; +const bar = terms.map(term => html`${item}`); +html`
${bar}
`; +//
onetwo
+``` + +#### The `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 +a `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. + +```js +// Note that you can shuffle the deck without destroying / creating DOM. +const deck = [ + { id: 'hearts-one', text: '\u26651' }, + // … + { id: 'clubs-ace', text: '\u2663A' }, +]; +const bar = map(deck, card => card.id, card => html`${item.text}`); +html`
${bar}
`; +//
♥1♣A
+``` + +#### The `unsafeHTML` content binding + +The `unsafeHTML` content binding allows you to leverage html from a trusted +source. This should _only_ be used to inject trusted content — never user +content. + +```js +const bar = ''; +html`
${unsafeHTML(bar)}
`; +//
+// console.prompt('can you hear me now?'); +``` + +#### The `unsafeSVG` content binding + +The `unsafeSVG` binding should be used within some svg context. It will take +trusted svg strings and instantiate them (similar to `unsafeHTML`); + +```js +const bar = ''; +html` + + ${unsafeSVG(bar)} + +`; +// +// +// +// +// +``` + ## 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 0d20954..194402c 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: {}, + strings: { default: () => [] }, }; } static template(html, { map }) { return ({ strings }) => { - // In this case, "map" will fail if "strings" is not an array. - return html`${map(strings, () => {}, string => html`${string}`)}`; + // In this case, "map" will fail when bound to an attribute. + return html`
`; }; } } @@ -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: {}, + strings: { default: () => [] }, }; } static template(html, { map }) { return ({ strings }) => { - // In this case, "map" will fail if "strings" is not an array. - return html`${map(strings, () => {}, string => html`${string}`)}`; + // In this case, "map" will fail when bound to an attribute. + return html`
`; }; } } diff --git a/test/test-template-engine.js b/test/test-template-engine.js index e3f39b1..b33f27e 100644 --- a/test/test-template-engine.js +++ b/test/test-template-engine.js @@ -2,10 +2,13 @@ import XElement from '../x-element.js'; import { assert, describe, it } from './x-test.js'; // Long-term interface. -const { render, html, svg, map, nullish } = XElement.templateEngine; +const { render, html, svg, map } = XElement.templateEngine; -// Migration-related interface. We may or may not keep these. -const { live, unsafeHTML, unsafeSVG, ifDefined, repeat } = XElement.templateEngine; +// Tentative interface. We may or may not keep these. +const { live, unsafeHTML, unsafeSVG } = XElement.templateEngine; + +// Deprecated interface. We will eventually delete these. +const { ifDefined, nullish, repeat } = XElement.templateEngine; describe('html rendering', () => { it('renders basic string', () => { @@ -131,6 +134,31 @@ describe('html rendering', () => { container.remove(); }); + it('renders defined attributes', () => { + const getTemplate = ({ attr }) => { + return html`
`; + }; + const container = document.createElement('div'); + document.body.append(container); + render(container, getTemplate({ attr: 'foo' })); + assert(container.querySelector('#target').getAttribute('attr') === 'foo'); + render(container, getTemplate({ attr: '' })); + assert(container.querySelector('#target').getAttribute('attr') === ''); + render(container, getTemplate({ attr: 'bar' })); + assert(container.querySelector('#target').getAttribute('attr') === 'bar'); + render(container, getTemplate({ attr: undefined })); + assert(container.querySelector('#target').getAttribute('attr') === null); + render(container, getTemplate({ attr: 'baz' })); + assert(container.querySelector('#target').getAttribute('attr') === 'baz'); + render(container, getTemplate({ attr: null })); + assert(container.querySelector('#target').getAttribute('attr') === null); + render(container, getTemplate({ attr: false })); + assert(container.querySelector('#target').getAttribute('attr') === 'false'); + render(container, getTemplate({ attr: true })); + assert(container.querySelector('#target').getAttribute('attr') === 'true'); + container.remove(); + }); + it('renders properties', () => { const getTemplate = ({ prop }) => { return html`\ @@ -291,8 +319,8 @@ describe('html rendering', () => { id="svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" - width="${ifDefined(width)}" - height="${ifDefined(height)}"> + ??width="${width}" + ??height="${height}"> `; }; @@ -319,8 +347,8 @@ describe('html rendering', () => { class="<><>" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" - width="${ifDefined(width)}" - height="${ifDefined(height)}"> + ??width="${width}" + ??height="${height}"> `; }; @@ -340,7 +368,7 @@ describe('html rendering', () => { it('self-closing tags work', () => { const getTemplate = ({ type }) => { - return html``; + return html``; }; const container = document.createElement('div'); document.body.append(container); @@ -377,10 +405,8 @@ describe('html updaters', () => { assert(container.querySelector('#target').getAttribute('maybe') === null); render(container, getTemplate({ maybe: false })); assert(container.querySelector('#target').getAttribute('maybe') === 'false'); - - // This is correct, but perhaps unexpected. render(container, getTemplate({ maybe: null })); - assert(container.querySelector('#target').getAttribute('maybe') === 'null'); + assert(container.querySelector('#target').getAttribute('maybe') === null); container.remove(); }); @@ -729,6 +755,107 @@ describe('html updaters', () => { assert(container.querySelector('#target').children[0] !== foo); container.remove(); }); + + describe('changing content updaters', () => { + // The template engine needs to clear content between cursors if the updater + // changes — it‘d be far to 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 'unsafeHTML': return unsafeHTML(value); + default: return value; // E.g., an array, some text, null, undefined, etc. + } + }; + const getTemplate = ({ type, value }) => html`
${resolve(type, value)}
`; + const run = (...transitions) => { + const container = document.createElement('div'); + document.body.append(container); + for (const transition of transitions) { + transition(container); + } + container.remove(); + }; + const toUndefinedContent = container => { + render(container, getTemplate({ type: undefined, value: undefined })); + assert(!!container.querySelector('#target')); + assert(container.querySelector('#target').childElementCount === 0); + }; + const toNullContent = container => { + render(container, getTemplate({ type: undefined, value: null })); + assert(!!container.querySelector('#target')); + assert(container.querySelector('#target').childElementCount === 0); + }; + const toTextContent = container => { + render(container, getTemplate({ type: undefined, value: 'hi there' })); + assert(!!container.querySelector('#target')); + assert(container.querySelector('#target').childElementCount === 0); + assert(container.querySelector('#target').textContent === 'hi there'); + }; + const toArrayContent = container => { + const getArrayTemplate = ({ id }) => html`
`; + render(container, getTemplate({ + type: undefined, + value: [{ id: 'moo' }, { id: 'mar' }, { id: 'maz' }].map(item => getArrayTemplate(item)), + })); + assert(!!container.querySelector('#target')); + assert(!!container.querySelector('#moo')); + assert(!!container.querySelector('#mar')); + assert(!!container.querySelector('#maz')); + assert(container.querySelector('#target').childElementCount === 3); + assert(container.querySelector('#target').textContent === '', container.querySelector('#target').textContent); + }; + const toUnsafeHTML = container => { + render(container, getTemplate({ type: 'unsafeHTML', 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')); + assert(!!container.querySelector('#foo')); + assert(!!container.querySelector('#bar')); + assert(container.querySelector('#target').childElementCount === 2); + assert(container.querySelector('#target').textContent === ''); + }; + + 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 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 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 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 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)); + }); }); describe('svg rendering', () => { @@ -1106,6 +1233,24 @@ describe('rendering errors', () => { 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 }) => { @@ -1180,6 +1325,24 @@ describe('rendering errors', () => { 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 }) => { @@ -1274,6 +1437,24 @@ describe('rendering errors', () => { 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 }) => { @@ -1341,7 +1522,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1359,7 +1540,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1377,7 +1558,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1395,7 +1576,25 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + 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; } @@ -1413,7 +1612,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1431,7 +1630,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1456,7 +1655,7 @@ describe('rendering errors', () => { } catch (e) { error = e; } - assert(error?.message === 'Unexpected map value "5".', error?.message); + assert(error?.message === 'Unexpected map items "5" provided, expected an array.', error?.message); container.remove(); }); @@ -1512,7 +1711,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1530,7 +1729,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1548,7 +1747,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1566,7 +1765,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1584,7 +1783,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1602,7 +1801,7 @@ describe('rendering errors', () => { document.body.append(container); let actual; try { - render(container, getTemplate({ maybe: 'yes' })); + render(container, getTemplate({ maybe: ['yes'] })); } catch (error) { actual = error.message; } @@ -1627,7 +1826,7 @@ describe('rendering errors', () => { } catch (e) { error = e; } - assert(error?.message === 'Unexpected repeat value "5".', error?.message); + assert(error?.message === 'Unexpected repeat items "5" provided, expected an array.', error?.message); container.remove(); }); diff --git a/x-element.js b/x-element.js index 10627e7..a375fbd 100644 --- a/x-element.js +++ b/x-element.js @@ -1012,25 +1012,73 @@ export default class XElement extends HTMLElement { /** Internal implementation details for template engine. */ class TemplateEngine { - static #UNSET = Symbol(); // Ensures a unique, initial comparison. + // Special markers added to markup enabling discovery post-instantiation. static #ATTRIBUTE_PREFIXES = { attribute: 'x-element-attribute-', boolean: 'x-element-boolean-', + defined: 'x-element-defined-', property: 'x-element-property-', }; static #CONTENT_PREFIX = 'x-element-content-'; static #CONTENT_REGEX = new RegExp(`${TemplateEngine.#CONTENT_PREFIX}(\\d+)`); + + // Patterns to find special edges in original html strings. static #OPEN = /<[a-z][a-z0-9-]*(?=\s)/g; static #STEP = /\s+[a-z][a-z0-9-]*(?=[\s>])|\s+[a-z][a-zA-Z0-9-]*="[^"]*"/y; - static #ATTRIBUTE = /\s+(\??([a-z][a-zA-Z0-9-]*))="$/y; + static #ATTRIBUTE = /\s+(\?{0,2}([a-z][a-zA-Z0-9-]*))="$/y; static #PROPERTY = /\s+\.([a-z][a-zA-Z0-9_]*)="$/y; static #CLOSE = />/g; - static #interface = null; - static #stateMap = new WeakMap(); // Maps nodes to internal state. - static #analysisMap = new WeakMap(); // Maps strings to cached computations. - static #resultMap = new WeakMap(); // Maps symbols to results. - static #updaterMap = new WeakMap(); // Maps symbols to updaters. + // 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(); + + // 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(); + + /** + * 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, + + // Tentative interface. + live: TemplateEngine.live, + unsafeHTML: TemplateEngine.unsafeHTML, + unsafeSVG: TemplateEngine.unsafeSVG, + + // Deprecated interface. + 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'), + }); /** * Declare HTML markup to be interpolated. @@ -1044,7 +1092,7 @@ class TemplateEngine { static html(strings, ...values) { const symbol = Symbol(); const result = TemplateEngine.#createResult('html', strings, values); - TemplateEngine.#resultMap.set(symbol, result); + TemplateEngine.#symbolToResult.set(symbol, result); return symbol; } @@ -1060,7 +1108,7 @@ class TemplateEngine { static svg(strings, ...values) { const symbol = Symbol(); const result = TemplateEngine.#createResult('svg', strings, values); - TemplateEngine.#resultMap.set(symbol, result); + TemplateEngine.#symbolToResult.set(symbol, result); return symbol; } @@ -1071,9 +1119,9 @@ class TemplateEngine { * @param {any} resultReference */ static render(container, resultReference) { - const state = TemplateEngine.#setIfMissing(TemplateEngine.#stateMap, container, () => ({})); + const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToState, container, () => ({})); if (resultReference) { - const result = TemplateEngine.#resultMap.get(resultReference); + const result = TemplateEngine.#symbolToResult.get(resultReference); if (TemplateEngine.#cannotReuseResult(state.result, result)) { TemplateEngine.#removeWithin(container); TemplateEngine.#ready(result); @@ -1097,14 +1145,14 @@ class TemplateEngine { * ```js * html`
`; * ``` + * @deprecated * @param {any} value * @returns {any} */ static ifDefined(value) { const symbol = Symbol(); - const updater = (type, lastValue, details) => TemplateEngine.#ifDefined(type, value, lastValue, details); - updater.value = value; - TemplateEngine.#updaterMap.set(symbol, updater); + const updater = TemplateEngine.#ifDefined; + TemplateEngine.#symbolToUpdate.set(symbol, { updater, value }); return symbol; } @@ -1115,14 +1163,15 @@ class TemplateEngine { * ```js * html`
`; * ``` + * @deprecated * @param {any} value * @returns {any} */ static nullish(value) { const symbol = Symbol(); - const updater = (type, lastValue, details) => TemplateEngine.#nullish(type, value, lastValue, details); - updater.value = value; - TemplateEngine.#updaterMap.set(symbol, updater); + const updater = TemplateEngine.#nullish; + const update = { updater, value }; + TemplateEngine.#symbolToUpdate.set(symbol, update); return symbol; } @@ -1140,9 +1189,9 @@ class TemplateEngine { */ static live(value) { const symbol = Symbol(); - const updater = (type, lastValue, details) => TemplateEngine.#live(type, value, lastValue, details); - updater.value = value; - TemplateEngine.#updaterMap.set(symbol, updater); + const updater = TemplateEngine.#live; + const update = { updater, value }; + TemplateEngine.#symbolToUpdate.set(symbol, update); return symbol; } @@ -1158,9 +1207,9 @@ class TemplateEngine { */ static unsafeHTML(value) { const symbol = Symbol(); - const updater = (type, lastValue, details) => TemplateEngine.#unsafeHTML(type, value, lastValue, details); - updater.value = value; - TemplateEngine.#updaterMap.set(symbol, updater); + const updater = TemplateEngine.#unsafeHTML; + const update = { updater, value }; + TemplateEngine.#symbolToUpdate.set(symbol, update); return symbol; } @@ -1180,9 +1229,9 @@ class TemplateEngine { */ static unsafeSVG(value) { const symbol = Symbol(); - const updater = (type, lastValue, details) => TemplateEngine.#unsafeSVG(type, value, lastValue, details); - updater.value = value; - TemplateEngine.#updaterMap.set(symbol, updater); + const updater = TemplateEngine.#unsafeSVG; + const update = { updater, value }; + TemplateEngine.#symbolToUpdate.set(symbol, update); return symbol; } @@ -1201,17 +1250,26 @@ class TemplateEngine { * @returns {any} */ static map(items, identify, callback) { + if (!Array.isArray(items)) { + throw new Error(`Unexpected map items "${items}" provided, expected an array.`); + } if (typeof identify !== 'function') { throw new Error(`Unexpected map identify "${identify}" provided, expected a function.`); } if (typeof callback !== 'function') { throw new Error(`Unexpected map callback "${callback}" provided, expected a function.`); } - return TemplateEngine.#mapOrRepeat(items, identify, callback, 'map'); + const symbol = Symbol(); + 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] @@ -1222,53 +1280,19 @@ class TemplateEngine { 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.`); } - return TemplateEngine.#mapOrRepeat(items, identify, callback, 'repeat'); - } - - /** - * Default template engine interface — what you get inside “template”. - * @returns {{[key: string]: Function}} - */ - static get interface() { - if (!TemplateEngine.#interface) { - TemplateEngine.#interface = Object.freeze({ - render: TemplateEngine.render, - html: TemplateEngine.html, - svg: TemplateEngine.svg, - map: TemplateEngine.map, - nullish: TemplateEngine.nullish, - - // Help folks migrate from prior interface or plug it back in. - live: TemplateEngine.live, // Kept as-is for now. - unsafeHTML: TemplateEngine.unsafeHTML, // Kept as-is for now. - unsafeSVG: TemplateEngine.unsafeSVG, // Kept as-is for now. - ifDefined: TemplateEngine.ifDefined, // Kept as-is for now. - repeat: TemplateEngine.repeat, // Wrapper around "map". We may deprecate these soon. - asyncAppend: TemplateEngine.#interfaceRemoved('asyncAppend'), // Removed. - asyncReplace: TemplateEngine.#interfaceRemoved('asyncReplace'), // Removed. - cache: TemplateEngine.#interfaceRemoved('cache'), // Removed. - classMap: TemplateEngine.#interfaceRemoved('classMap'), // Removed. - directive: TemplateEngine.#interfaceRemoved('directive'), // Removed. - guard: TemplateEngine.#interfaceRemoved('guard'), // Removed. - styleMap: TemplateEngine.#interfaceRemoved('styleMap'), // Removed. - templateContent: TemplateEngine.#interfaceRemoved('templateContent'), // Removed. - until: TemplateEngine.#interfaceRemoved('until'), // Removed. - }); - } - return TemplateEngine.#interface; - } - - static #mapOrRepeat(value, identify, callback, name) { const symbol = Symbol(); - const context = { identify, callback }; - const updater = (type, lastValue, details) => TemplateEngine.#map(type, value, lastValue, details, context, name); - updater.value = value; - TemplateEngine.#updaterMap.set(symbol, updater); + const value = items; + const updater = TemplateEngine.#repeat; + const update = { updater, value, identify, callback }; + TemplateEngine.#symbolToUpdate.set(symbol, update); return symbol; } @@ -1311,9 +1335,11 @@ class TemplateEngine { ? 'attribute' : name.startsWith(TemplateEngine.#ATTRIBUTE_PREFIXES.boolean) ? 'boolean' - : name.startsWith(TemplateEngine.#ATTRIBUTE_PREFIXES.property) - ? 'property' - : null; + : name.startsWith(TemplateEngine.#ATTRIBUTE_PREFIXES.defined) + ? 'defined' + : name.startsWith(TemplateEngine.#ATTRIBUTE_PREFIXES.property) + ? 'property' + : null; if (type) { const prefix = TemplateEngine.#ATTRIBUTE_PREFIXES[type]; const key = name.slice(prefix.length); @@ -1370,7 +1396,10 @@ class TemplateEngine { const attributeMatch = TemplateEngine.#ATTRIBUTE.exec(string); if (attributeMatch) { const name = attributeMatch[2]; - if (attributeMatch[1].startsWith('?')) { + if (attributeMatch[1].startsWith('??')) { + // We found a match like this: html`
`. + string = string.slice(0, -4 - name.length) + `${TemplateEngine.#ATTRIBUTE_PREFIXES.defined}${iii}="${name}`; + } else if (attributeMatch[1].startsWith('?')) { // We found a match like this: html`
`. string = string.slice(0, -3 - name.length) + `${TemplateEngine.#ATTRIBUTE_PREFIXES.boolean}${iii}="${name}`; } else { @@ -1421,53 +1450,58 @@ class TemplateEngine { switch (direction.type) { case 'attribute': case 'boolean': - case 'property': { + case 'defined': + case 'property': finalDirections.push({ key: direction.key, type: direction.type, name: direction.name, node }); break; - } - case 'content': { - const startNode = node.previousSibling; - finalDirections.push({ key: direction.key, type: direction.type, node, startNode }); + case 'content': + finalDirections.push({ key: direction.key, type: direction.type, node, startNode: node.previousSibling }); break; - } - case 'text': { + case 'text': finalDirections.push({ key: direction.key, type: direction.type, node }); break; - } } } return finalDirections; } - static #attribute(type, value, lastValue, { node, name }) { + static #attribute(node, name, value, lastValue) { if (value !== lastValue) { node.setAttribute(name, value); } } - static #boolean(type, value, lastValue, { node, name }) { + static #boolean(node, name, value, lastValue) { if (value !== lastValue) { value ? node.setAttribute(name, '') : node.removeAttribute(name); } } - static #property(type, value, lastValue, { node, name }) { + static #defined(node, name, value, lastValue) { + if (value !== lastValue) { + value === undefined || value === null + ? node.removeAttribute(name) + : node.setAttribute(name, value); + } + } + + static #property(node, name, value, lastValue) { if (value !== lastValue) { node[name] = value; } } - static #text(type, value, lastValue, { node }) { + static #text(node, value, lastValue) { if (value !== lastValue) { node.textContent = value; } } - static #content(type, value, lastValue, { node, startNode }) { + static #content(node, startNode, value, lastValue) { if (value !== lastValue) { - const state = TemplateEngine.#setIfMissing(TemplateEngine.#stateMap, startNode, () => ({})); - if (TemplateEngine.#resultMap.has(value)) { - const result = TemplateEngine.#resultMap.get(value); + if (TemplateEngine.#symbolToResult.has(value)) { + const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({})); + const result = TemplateEngine.#symbolToResult.get(value); if (TemplateEngine.#cannotReuseResult(state.result, result)) { TemplateEngine.#removeBetween(startNode, node); TemplateEngine.#clearObject(state); @@ -1480,8 +1514,9 @@ class TemplateEngine { TemplateEngine.#commit(state.result); } } else if (Array.isArray(value)) { - TemplateEngine.#mapInner(state, node, startNode, null, null, value, 'array'); + TemplateEngine.#mapInner(node, startNode, null, null, value, 'array'); } else { + const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({})); if (state.result) { TemplateEngine.#removeBetween(startNode, node); TemplateEngine.#clearObject(state); @@ -1498,82 +1533,55 @@ class TemplateEngine { } } - static #ifDefined(type, value, lastValue, { node, name }) { - if (type === 'attribute') { - if (value !== lastValue) { - value !== undefined - ? node.setAttribute(name, value) - : node.removeAttribute(name); - } - } else { - throw new Error(`The ifDefined update must be used on ${TemplateEngine.#getTypeText('attribute')}, not on ${TemplateEngine.#getTypeText(type)}.`); - } + static #ifDefined(node, name, value, lastValue) { + TemplateEngine.#defined(node, name, value, lastValue); } - static #nullish(type, value, lastValue, { node, name }) { - if (type === 'attribute') { - if (value !== lastValue) { - value !== undefined && value !== null - ? node.setAttribute(name, value) - : node.removeAttribute(name); - } - } else { - throw new Error(`The nullish update must be used on ${TemplateEngine.#getTypeText('attribute')}, not on ${TemplateEngine.#getTypeText(type)}.`); - } + static #nullish(node, name, value, lastValue) { + TemplateEngine.#defined(node, name, value, lastValue); } - static #live(type, value, lastValue, { node, name }) { - if (type === 'property') { - if (node[name] !== value) { - node[name] = value; - } - } else { - throw new Error(`The live update must be used on ${TemplateEngine.#getTypeText('property')}, not on ${TemplateEngine.#getTypeText(type)}.`); + static #live(node, name, value) { + if (node[name] !== value) { + node[name] = value; } } - static #unsafeHTML(type, value, lastValue, { node, startNode }) { - if (type === 'content') { - if (value !== lastValue) { - if (typeof value === 'string') { - const template = document.createElement('template'); - template.innerHTML = value; - TemplateEngine.#removeBetween(startNode, node); - TemplateEngine.#insertAllBefore(template.content.childNodes, node); - } else { - throw new Error(`Unexpected unsafeHTML value "${value}".`); - } + 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(template.content.childNodes, node); + } else { + throw new Error(`Unexpected unsafeHTML value "${value}".`); } - } else { - throw new Error(`The unsafeHTML update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`); } } - static #unsafeSVG(type, value, lastValue, { node, startNode }) { - if (type === 'content') { - if (value !== lastValue) { - if (typeof value === 'string') { - const template = document.createElement('template'); - template.innerHTML = `${value}`; - TemplateEngine.#removeBetween(startNode, node); - TemplateEngine.#insertAllBefore(template.content.firstChild.childNodes, node); - } else { - throw new Error(`Unexpected unsafeSVG value "${value}".`); - } + 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(template.content.firstChild.childNodes, node); + } else { + throw new Error(`Unexpected unsafeSVG value "${value}".`); } - } else { - throw new Error(`The unsafeSVG update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`); } } - static #mapInner(state, node, startNode, identify, callback, inputs, name) { + static #mapInner(node, startNode, identify, callback, inputs, name) { + const state = TemplateEngine.#nodeToArrayState.get(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.#resultMap.get(reference); + const result = TemplateEngine.#symbolToResult.get(reference); if (result) { const id = identify ? identify(input, index) : String(index); const cursors = TemplateEngine.#createCursors(node); @@ -1592,7 +1600,7 @@ class TemplateEngine { let index = 0; for (const input of inputs) { const reference = callback ? callback(input, index) : input; - const result = TemplateEngine.#resultMap.get(reference); + const result = TemplateEngine.#symbolToResult.get(reference); if (result) { const id = identify ? identify(input, index) : String(index); if (state.map.has(id)) { @@ -1643,17 +1651,12 @@ class TemplateEngine { } } - static #map(type, value, lastValue, { node, startNode }, { identify, callback }, name) { - if (type === 'content') { - if (Array.isArray(value)) { - const state = TemplateEngine.#setIfMissing(TemplateEngine.#stateMap, startNode, () => ({})); - TemplateEngine.#mapInner(state, node, startNode, identify, callback, value, name); - } else { - throw new Error(`Unexpected ${name} value "${value}".`); - } - } else { - throw new Error(`The ${name} update must be used on ${TemplateEngine.#getTypeText('content')}, not on ${TemplateEngine.#getTypeText(type)}.`); - } + static #map(node, startNode, value, identify, callback) { + TemplateEngine.#mapInner(node, startNode, identify, callback, value, 'map'); + } + + static #repeat(node, startNode, value, identify, callback) { + TemplateEngine.#mapInner(node, startNode, identify, callback, value, 'repeat'); } static #createResult(type, strings, values) { @@ -1672,7 +1675,7 @@ class TemplateEngine { static #getAnalysis(result) { const { type, strings } = result; - const analysis = TemplateEngine.#setIfMissing(TemplateEngine.#analysisMap, strings, () => ({})); + const analysis = TemplateEngine.#setIfMissing(TemplateEngine.#stringsToAnalysis, strings, () => ({})); if (!analysis.done) { analysis.done = true; const initialElement = document.createElement('template'); @@ -1710,39 +1713,146 @@ class TemplateEngine { result.values = newResult.values; } - static #commit(result) { - const { directions, values, lastValues } = result; - for (const { key, type, node, startNode, name } of directions) { - const lastUpdater = TemplateEngine.#updaterMap.get(lastValues[key]); - const lastValue = lastUpdater ? lastUpdater.value : lastValues[key]; - const updater = TemplateEngine.#updaterMap.get(values[key]); - switch (type) { - case 'attribute': - updater - ? updater(type, lastValue, { node, name }) - : TemplateEngine.#attribute(type, values[key], lastValue, { node, name }); + 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 'boolean': - updater - ? updater(type, lastValue, { node, name }) - : TemplateEngine.#boolean(type, values[key], lastValue, { node, name }); + case TemplateEngine.#nullish: + TemplateEngine.#nullish(node, name, update.value, lastUpdate?.value); break; - case 'property': - updater - ? updater(type, lastValue, { node, name }) - : TemplateEngine.#property(type, values[key], lastValue, { node, name }); + default: + TemplateEngine.#throwUpdaterError(update.updater, 'attribute'); break; - case 'content': - updater - ? updater(type, lastValue, { node, startNode }) - : TemplateEngine.#content(type, values[key], lastValue, { node, startNode }); + } + } else { + TemplateEngine.#attribute(node, name, value, lastValue); + } + } + + static #commitBoolean(node, name, value, lastValue) { + const update = TemplateEngine.#symbolToUpdate.get(value); + if (update) { + TemplateEngine.#throwUpdaterError(update.updater, 'boolean'); + } else { + TemplateEngine.#boolean(node, name, value, lastValue); + } + } + + static #commitDefined(node, name, value, lastValue) { + const update = TemplateEngine.#symbolToUpdate.get(value); + if (update) { + TemplateEngine.#throwUpdaterError(update.updater, 'defined'); + } else { + TemplateEngine.#defined(node, name, value, lastValue); + } + } + + 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; - case 'text': - updater - ? updater(type, lastValue, { node }) - : TemplateEngine.#text(type, values[key], lastValue, { node }); + default: + TemplateEngine.#throwUpdaterError(update.updater, 'property'); + break; + } + } else { + TemplateEngine.#property(node, name, value, lastValue); + } + } + + static #commitContent(node, startNode, value, lastValue) { + const update = TemplateEngine.#symbolToUpdate.get(value); + const lastUpdate = TemplateEngine.#symbolToUpdate.get(lastValue); + if (update) { + switch (update.updater) { + case TemplateEngine.#map: + TemplateEngine.#resetIfRequired(node, startNode, value, lastValue, update, lastUpdate); + TemplateEngine.#map(node, startNode, update.value, update.identify, update.callback); + break; + case TemplateEngine.#repeat: + TemplateEngine.#resetIfRequired(node, startNode, value, lastValue, update, lastUpdate); + TemplateEngine.#repeat(node, startNode, update.value, update.identify, update.callback); + break; + case TemplateEngine.#unsafeHTML: + TemplateEngine.#resetIfRequired(node, startNode, value, lastValue, update, lastUpdate); + TemplateEngine.#unsafeHTML(node, startNode, update.value, lastUpdate?.value); + break; + case TemplateEngine.#unsafeSVG: + TemplateEngine.#resetIfRequired(node, startNode, value, lastValue, update, lastUpdate); + TemplateEngine.#unsafeSVG(node, startNode, update.value, lastUpdate?.value); + break; + default: + TemplateEngine.#throwUpdaterError(update.updater, 'content'); break; } + } else { + TemplateEngine.#resetIfRequired(node, startNode, value, lastValue, update, lastUpdate); + TemplateEngine.#content(node, startNode , value, lastValue); + } + } + + static #commitText(node, value, lastValue) { + const update = TemplateEngine.#symbolToUpdate.get(value); + if (update) { + TemplateEngine.#throwUpdaterError(update.updater, 'text'); + } else { + TemplateEngine.#text(node, value, lastValue); + } + } + + static #commit(result) { + const { directions, values, lastValues } = result; + for (const { key, type, node, startNode, name } of directions) { + const value = values[key]; + const lastValue = lastValues[key]; + switch (type) { + case 'attribute': TemplateEngine.#commitAttribute(node, name, value, lastValue); break; + case 'boolean': TemplateEngine.#commitBoolean(node, name, value, lastValue); break; + case 'defined': TemplateEngine.#commitDefined(node, name, value, lastValue); break; + case 'property':TemplateEngine.#commitProperty(node, name, value, lastValue); break; + case 'content': TemplateEngine.#commitContent(node, startNode, value, lastValue); break; + case 'text': TemplateEngine.#commitText(node, value, lastValue); break; + } + } + } + + 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.#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)}.`); + + // We’ll delete these updaters later. + 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 #resetIfRequired(node, startNode, value, lastValue, update, lastUpdate) { + if ( + !!Array.isArray(value) !== !!Array.isArray(lastValue) || + !!update !== !!lastUpdate || + update?.updater !== lastUpdate?.updater + ) { + TemplateEngine.#removeBetween(startNode, node); + const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({})); + TemplateEngine.#clearObject(state); } } @@ -1794,16 +1904,12 @@ class TemplateEngine { static #getTypeText(type) { switch (type) { - case 'attribute': - return 'an attribute'; - case 'boolean': - return 'a boolean attribute'; - case 'property': - return 'a property'; - case 'content': - return 'content'; - case 'text': - return 'text content'; + 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'; } }