From 7348633acd77cecaf7e4bcb08e9065f41b8100c9 Mon Sep 17 00:00:00 2001 From: Andrew Seier Date: Wed, 4 Dec 2024 21:42:27 -0800 Subject: [PATCH] =?UTF-8?q?Restructure=20=E2=80=9Ctest-template-engine.js?= =?UTF-8?q?=E2=80=9D=20tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As we investigate whether we ought to do some custom parsing to enhance our ability to provide accurate and clear developer feedback — we want to start back-porting future tests into the current ecosystem so that we don’t miss any interesting cases if / when we update our underlying parsing mechanism. --- test/test-template-engine.js | 1723 ++++++++++++++++++++-------------- 1 file changed, 1028 insertions(+), 695 deletions(-) diff --git a/test/test-template-engine.js b/test/test-template-engine.js index ba35b2c..88556ee 100644 --- a/test/test-template-engine.js +++ b/test/test-template-engine.js @@ -28,59 +28,246 @@ console.warn = (...args) => { // eslint-disable-line no-console } }; +// Simple helper for asserting thrown messages. +const assertThrows = (callback, expectedMessage) => { + let thrown = false; + try { + callback(); + } catch (error) { + thrown = true; + assert(error.message === expectedMessage, error.message); + } + assert(thrown, 'no error was thrown'); +}; + describe('html rendering', () => { + it('empty template works', () => { + const container = document.createElement('div'); + render(container, html``); + assert(container.childNodes.length === 0); + }); + it('renders basic string', () => { - const getTemplate = () => { - return html`
No interpolation.
`; - }; const container = document.createElement('div'); - document.body.append(container); - render(container, getTemplate()); + render(container, html`
No interpolation.
`); assert(container.childNodes.length === 1); assert(container.querySelector('#target').textContent === 'No interpolation.'); - container.remove(); + }); + + it('renders named html entities in text content', () => { + const container = document.createElement('div'); + render(container, html`
${'--'}{<&>'"}${'--'}
`); + assert(container.childNodes.length === 1); + assert(container.childNodes[0].textContent === `--{<&>'"}--`); + }); + + it('renders named character references for special HTML characters', () => { + const container = document.createElement('div'); + render(container, html`&<>"'`); + assert(container.textContent === `&<>"'`); + }); + + it('renders named character references for commonly-used characters', () => { + const container = document.createElement('div'); + render(container, html` ‘’“”–—…•·†`); + assert(container.textContent === '\u00A0\u2018\u2019\u201C\u201D\u2013\u2014\u2026\u2022\u00B7\u2020'); + }); + + it('renders named html entities in attribute values', () => { + const container = document.createElement('div'); + render(container, html`
`); + assert(container.childElementCount === 1); + assert(container.children[0].getAttribute('foo') === `--{<&>'"}--`); + }); + + it('renders surprisingly-accepted characters in text', () => { + const container = document.createElement('div'); + render(container, html`>'"&& & &
&`); + assert(container.childElementCount === 1); + assert(container.children[0].textContent === ``); }); it('renders interpolated content without parsing', () => { const userContent = 'Click Me!'; - const getTemplate = () => { - return html`
${userContent}
`; - }; const container = document.createElement('div'); - document.body.append(container); - render(container, getTemplate()); + render(container, html`
${userContent}
`); assert(container.querySelector('#target').textContent === userContent); - container.remove(); + }); + + it('renders custom elements', () => { + const container = document.createElement('div'); + render(container, html`${'content'}`); + assert(container.childElementCount === 1); + assert(container.children[0].textContent === 'content'); + }); + + it('renders svg elements', () => { + const container = document.createElement('div'); + const width = 24; + const height = 24; + render(container, html`\ + + + `); + // This would be “HTMLUnknownElement” if we didn’t get the namespacing right. + assert(container.querySelector('svg').constructor.name === 'SVGSVGElement'); + assert(container.querySelector('circle').constructor.name === 'SVGCircleElement'); + }); + + it('renders math elements', () => { + const container = document.createElement('div'); + render(container, html`\ + + + + + + n + = + 1 + + + + + + + + + 1 + + n + 2 + + + + `); + // This would be “HTMLUnknownElement” if we didn’t get the namespacing right. + assert(container.querySelector('math').constructor.name === 'MathMLElement'); + assert(container.querySelector('mo').constructor.name === 'MathMLElement'); + assert(container.querySelector('.target').textContent === '+'); + }); + + it('renders template elements', () => { + // It’s important that the _content_ is populated here. Not the template. + const container = document.createElement('div'); + render(container, html``); + assert(!!container.querySelector('template')); + assert(container.querySelector('template').content.childElementCount === 1); + assert(container.querySelector('template').content.children[0].childElementCount === 1); + assert(container.querySelector('template').content.children[0].children[0].localName === 'p'); }); it('renders nullish templates', () => { - const getTemplate = () => { - return html`
`; - }; const container = document.createElement('div'); - document.body.append(container); - render(container, getTemplate()); + render(container, html`
`); assert(!!container.childNodes.length); render(container, null); assert(!container.childNodes.length); - render(container, getTemplate()); + render(container, html`
`); assert(!!container.childNodes.length); render(container, undefined); assert(!container.childNodes.length); + }); + + it('renders non-interpolated style', () => { + const container = document.createElement('div'); + document.body.append(container); // Need to connect for computed styles. + render(container, html` + +
hi
+ `); + assert(container.childElementCount === 2); + assert(getComputedStyle(container.querySelector('#target')).color === 'rgb(255, 0, 0)'); + assert(container.querySelector('#target').textContent === 'hi'); container.remove(); }); + it('renders comments', () => { + const container = document.createElement('div'); + render(container, html``); + assert(container.childNodes.length === 1); + assert(container.childNodes[0].nodeType === Node.COMMENT_NODE); + assert(container.childNodes[0].data === 'hi'); + }); + + it('renders multi-line comments', () => { + const container = document.createElement('div'); + render(container, html``); + assert(container.childNodes.length === 1); + assert(container.childNodes[0].nodeType === Node.COMMENT_NODE); + assert(container.childNodes[0].data.trim() === 'hi'); + }); + + it('renders multi-line unbound content', () => { + const container = document.createElement('div'); + render(container, html` + one + two + three + `); + assert(container.childNodes.length === 1); + assert(container.childNodes[0].nodeType === Node.TEXT_NODE); + assert(container.childNodes[0].textContent.trim() === 'one\n two\n three'); + }); + + it('renders solo interpolation', () => { + const container = document.createElement('div'); + render(container, html`${''}`); + assert(container.childNodes.length === 3); + assert(container.childNodes[0].nodeType === Node.COMMENT_NODE); + assert(container.childNodes[0].data === ''); + assert(container.childNodes[1].nodeType === Node.TEXT_NODE); + assert(container.childNodes[1].textContent === ''); + assert(container.childNodes[2].nodeType === Node.COMMENT_NODE); + assert(container.childNodes[2].data === ''); + }); + + it('renders adjacent interpolations', () => { + const container = document.createElement('div'); + render(container, html`${'hi'}${'there'}`); + assert(container.childNodes.length === 6); + assert(container.childNodes[0].nodeType === Node.COMMENT_NODE); + assert(container.childNodes[0].data === ''); + assert(container.childNodes[1].nodeType === Node.TEXT_NODE); + assert(container.childNodes[1].textContent === 'hi'); + assert(container.childNodes[2].nodeType === Node.COMMENT_NODE); + assert(container.childNodes[2].data === ''); + assert(container.childNodes[3].nodeType === Node.COMMENT_NODE); + assert(container.childNodes[3].data === ''); + assert(container.childNodes[4].nodeType === Node.TEXT_NODE); + assert(container.childNodes[4].textContent === 'there'); + assert(container.childNodes[5].nodeType === Node.COMMENT_NODE); + assert(container.childNodes[5].data === ''); + }); + + it('renders multiple opening and closing tags', () => { + const container = document.createElement('div'); + render(container, html`
`); + assert(container.childElementCount === 1); + assert(container.children[0].childElementCount === 1); + }); + it('renders interpolated content', () => { const getTemplate = ({ content }) => { return html`
a b ${content}
`; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ content: 'Interpolated.' })); assert(container.querySelector('#target').textContent === 'a b Interpolated.'); render(container, getTemplate({ content: 'Updated.' })); assert(container.querySelector('#target').textContent === 'a b Updated.'); - container.remove(); }); // Unlike a NodeList, a NamedNodeMap (i.e., “.attributes”) is not technically @@ -121,7 +308,6 @@ describe('html rendering', () => { `; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ property1: null, z99: true, @@ -149,20 +335,14 @@ describe('html rendering', () => { assert(target.getAttribute('boolean') === ''); assert(target.getAttribute('aria-label') === 'this is what it does'); assert(target.textContent.trim() === 'influencing'); - container.remove(); }); it('renders multiple, interpolated content', () => { - const getTemplate = ({ one, two }) => { - return html` -
one: ${one} / two: ${two}
- `; - }; const container = document.createElement('div'); - document.body.append(container); - render(container, getTemplate({ one: 'ONE', two: 'TWO' })); + render(container, html` +
one: ${'ONE'} / two: ${'TWO'}
+ `); assert(container.querySelector('#target').textContent === 'one: ONE / two: TWO'); - container.remove(); }); it('renders nested content', () => { @@ -174,7 +354,6 @@ describe('html rendering', () => { `; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ show: true, nestedContent: 'oh hai' })); assert(!!container.querySelector('#conditional')); render(container, getTemplate({ show: false, nestedContent: 'oh hai' })); @@ -183,7 +362,6 @@ describe('html rendering', () => { assert(container.querySelector('#conditional').textContent === 'oh hai'); render(container, getTemplate({ show: true, nestedContent: 'k bye' })); assert(container.querySelector('#conditional').textContent === 'k bye'); - container.remove(); }); it('renders attributes', () => { @@ -191,12 +369,16 @@ describe('html rendering', () => { return html`
Something${content}
`; }; 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: 'bar' })); assert(container.querySelector('#target').getAttribute('attr') === 'bar'); - container.remove(); + }); + + it('renders unbound boolean attributes just before close of tag', () => { + const container = document.createElement('div'); + render(container, html`
`); + assert(container.querySelector('#target').getAttribute('foo') === ''); }); it('renders boolean attributes', () => { @@ -204,7 +386,6 @@ describe('html rendering', () => { return html`
`; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ attr: 'foo' })); assert(container.querySelector('#target').getAttribute('attr') === ''); render(container, getTemplate({ attr: '' })); @@ -219,7 +400,6 @@ describe('html rendering', () => { assert(container.querySelector('#target').getAttribute('attr') === null); render(container, getTemplate({ attr: true })); assert(container.querySelector('#target').getAttribute('attr') === ''); - container.remove(); }); it('renders defined attributes', () => { @@ -227,7 +407,6 @@ describe('html rendering', () => { 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: '' })); @@ -244,7 +423,6 @@ describe('html rendering', () => { assert(container.querySelector('#target').getAttribute('attr') === 'false'); render(container, getTemplate({ attr: true })); assert(container.querySelector('#target').getAttribute('attr') === 'true'); - container.remove(); }); it('renders properties', () => { @@ -257,12 +435,10 @@ describe('html rendering', () => { `; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ prop: 'foo' })); assert(container.querySelector('#target').prop === 'foo'); render(container, getTemplate({ prop: 'bar' })); assert(container.querySelector('#target').prop === 'bar'); - container.remove(); }); it('maintains DOM nodes', () => { @@ -270,7 +446,6 @@ describe('html rendering', () => { return html`
${content}
`; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ content: 'foo' })); container.querySelector('div').classList.add('state'); assert(container.querySelector('div').textContent === 'foo'); @@ -278,7 +453,6 @@ describe('html rendering', () => { render(container, getTemplate({ content: 'bar' })); assert(container.querySelector('div').textContent === 'bar'); assert(container.querySelector('div').classList.contains('state')); - container.remove(); }); it('renders lists', () => { @@ -290,13 +464,11 @@ describe('html rendering', () => { `; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ items: ['foo', 'bar', 'baz'] })); assert(container.querySelector('#target').childElementCount === 3); assert(container.querySelector('#target').children[0].textContent === 'foo'); assert(container.querySelector('#target').children[1].textContent === 'bar'); assert(container.querySelector('#target').children[2].textContent === 'baz'); - container.remove(); }); it('renders lists with multiple elements', () => { @@ -308,7 +480,6 @@ describe('html rendering', () => { `; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ items: ['foo', 'bar', 'baz'] })); assert(container.querySelector('#target').childElementCount === 6); assert(container.querySelector('#target').children[0].textContent === 'foo-'); @@ -317,7 +488,6 @@ describe('html rendering', () => { assert(container.querySelector('#target').children[3].textContent === 'bar'); assert(container.querySelector('#target').children[4].textContent === 'baz-'); assert(container.querySelector('#target').children[5].textContent === 'baz'); - container.remove(); }); it('renders lists with changing templates', () => { @@ -329,7 +499,6 @@ describe('html rendering', () => { `; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ items: [true, true, true] })); assert(container.querySelector('#target').childElementCount === 3); assert(container.querySelector('#target').children[0].classList.contains('true')); @@ -339,7 +508,6 @@ describe('html rendering', () => { assert(container.querySelector('#target').children[0].classList.contains('true')); assert(container.querySelector('#target').children[1].classList.contains('false')); assert(container.querySelector('#target').children[2].classList.contains('true')); - container.remove(); }); it('renders lists with changing length', () => { @@ -351,14 +519,12 @@ describe('html rendering', () => { `; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ items: [1, 2, 3] })); assert(container.querySelector('#target').childElementCount === 3); render(container, getTemplate({ items: [1, 2, 3, 4, 5] })); assert(container.querySelector('#target').childElementCount === 5); render(container, getTemplate({ items: [1, 2] })); assert(container.querySelector('#target').childElementCount === 2); - container.remove(); }); it('renders lists of lists', () => { @@ -370,7 +536,6 @@ describe('html rendering', () => { })}`; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ items: [ { text: 'foo', items: [{ text: 'one' }] }, @@ -392,7 +557,6 @@ describe('html rendering', () => { ], })); assert(container.querySelector('#target').textContent === ':foo-one::foo-two::bar-one::bar-two::baz-one::baz-two:'); - container.remove(); }); it('renders multiple templates', () => { @@ -404,14 +568,12 @@ describe('html rendering', () => { } }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ content: 'oh hai' })); assert(!!container.querySelector('#content')); assert(!container.querySelector('#empty')); render(container, getTemplate({ content: '' })); assert(!container.querySelector('#content')); assert(!!container.querySelector('#empty')); - container.remove(); }); it('renders multiple templates (as content)', () => { @@ -423,93 +585,85 @@ describe('html rendering', () => { `; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ content: 'oh hai' })); assert(!!container.querySelector('#content')); assert(!container.querySelector('#empty')); render(container, getTemplate({ content: '' })); assert(!container.querySelector('#content')); assert(!!container.querySelector('#empty')); - container.remove(); }); it('renders elements with "/" characters in attributes', () => { // Note the "/" character. - const getTemplate = ({ width, height }) => { + const getTemplate = ({ text }) => { return html`\ - - - `; + + ${text} + `; }; const container = document.createElement('div'); - document.body.append(container); - const width = 24; - const height = 24; - render(container, getTemplate({ width, height })); - const svgBox = container.querySelector('#svg').getBoundingClientRect(); - assert(svgBox.width === width); - assert(svgBox.height === height); - const circleBox = container.querySelector('#circle').getBoundingClientRect(); - assert(circleBox.width === width); - assert(circleBox.height === height); - container.remove(); + const text = 'click me'; + render(container, getTemplate({ text })); + const link = container.querySelector('#a'); + assert(link.href === 'https://github.com/Netflix/x-element'); + assert(link.textContent.trim() === text); }); it('renders elements with "<" or ">" characters in attributes', () => { // Note the "/", "<", and ">" characters. - const getTemplate = ({ width, height }) => { + const getTemplate = ({ text }) => { return html`\ - - - `; + + ${text} + `; }; const container = document.createElement('div'); - document.body.append(container); - const width = 24; - const height = 24; - render(container, getTemplate({ width, height })); - const svgBox = container.querySelector('#svg').getBoundingClientRect(); - assert(svgBox.width === width); - assert(svgBox.height === height); - const circleBox = container.querySelector('#circle').getBoundingClientRect(); - assert(circleBox.width === width); - assert(circleBox.height === height); - container.remove(); + const text = 'click me'; + render(container, getTemplate({ text })); + const link = container.querySelector('#a'); + assert(link.href === 'https://github.com/Netflix/x-element'); + assert(link.textContent.trim() === text); + assert(link.dataset.foo === '<><>'); }); - it('self-closing tags work', () => { + it('void elements work', () => { const getTemplate = ({ type }) => { - return html``; + return html``; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ type: null })); assert(container.querySelector('input').type === 'text'); render(container, getTemplate({ type: 'checkbox' })); assert(container.querySelector('input').type === 'checkbox'); - container.remove(); }); - it('textarea element content', () => { - const getTemplate = ({ defaultValue }) => { - return html``; - }; + it('textarea elements with no interpolation work', () => { const container = document.createElement('div'); - document.body.append(container); - render(container, getTemplate({ defaultValue: 'foo' })); + render(container, html``); + assert(container.querySelector('textarea').value === 'this is the “default” value'); + }); + + it('textarea elements with strict interpolation work', () => { + const container = document.createElement('div'); + render(container, html``); assert(container.querySelector('textarea').value === 'foo'); - container.remove(); + }); + + it('title elements with no interpolation work', () => { + const container = document.createElement('div'); + render(container, html`<em>this</em> is the “default” value`); + assert(container.querySelector('title').textContent === 'this is the “default” value'); + }); + + it('title elements with strict interpolation work', () => { + const container = document.createElement('div'); + render(container, html`${'foo'}`); + assert(container.querySelector('title').textContent === 'foo'); }); it('renders instantiated elements as dumb text', () => { @@ -517,11 +671,9 @@ describe('html rendering', () => { return html`${element}`; }; const container = document.createElement('div'); - document.body.append(container); render(container, getTemplate({ element: document.createElement('input') })); assert(container.childElementCount === 0); assert(container.textContent === '[object HTMLInputElement]'); - container.remove(); }); it('renders DocumentFragment nodes with simple append action', () => { @@ -529,7 +681,6 @@ describe('html rendering', () => { return html`${fragment}`; }; const container = document.createElement('div'); - document.body.append(container); const template = document.createElement('template'); template.innerHTML = ''; render(container, getTemplate({ fragment: template.content.cloneNode(true) })); @@ -539,14 +690,12 @@ describe('html rendering', () => { render(container, getTemplate({ fragment: template.content.cloneNode(true) })); assert(container.childElementCount === 1); assert(container.children[0].localName === 'textarea'); - container.remove(); }); it('renders the same template result multiple times for', () => { const rawResult = html`
`; const container1 = document.createElement('div'); const container2 = document.createElement('div'); - document.body.append(container1, container2); render(container1, rawResult); render(container2, rawResult); assert(!!container1.querySelector('#target')); @@ -559,92 +708,20 @@ describe('html rendering', () => { render(container2, rawResult); assert(!!container1.querySelector('#target')); assert(!!container2.querySelector('#target')); - container1.remove(); - container2.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('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', () => { + it('native map renders basic template', () => { const getTemplate = ({ items }) => { return html`
- ${repeat(items, item => item.id, item => { - return html`
${item.id}
`; - })} + ${items.map(item => [ + item.id, + 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'); @@ -690,61 +767,77 @@ describe('html updaters', () => { 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', () => { + it('native map renders depth-first', async () => { + const updates = []; + class TestDepthFirstOuter extends HTMLElement { + #item = null; + set item(value) { updates.push(`outer-${value}`); this.#item = value; } + get item() { return this.#item; } + connectedCallback() { + // Prevent property shadowing by deleting before setting on connect. + const item = this.item ?? '???'; + Reflect.deleteProperty(this, 'item'); + Reflect.set(this, 'item', item); + } + } + customElements.define('test-depth-first-outer', TestDepthFirstOuter); + class TestDepthFirstInner extends HTMLElement { + #item = null; + set item(value) { updates.push(`inner-${value}`); this.#item = value; } + get item() { return this.#item; } + connectedCallback() { + // Prevent property shadowing by deleting before setting on connect. + const item = this.item ?? '???'; + Reflect.deleteProperty(this, 'item'); + Reflect.set(this, 'item', item); + } + } + customElements.define('test-depth-first-inner', TestDepthFirstInner); + const getTemplate = ({ items }) => { return html`
- ${repeat(items, item => { - return html`
${item.id}
`; - })} + ${items.map(item => [ + item.id, + html` + + + + + `, + ])}
`; }; 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); + const items = [{ id: 'foo' }, { id: 'bar'}]; + render(container, getTemplate({ items })); + await Promise.resolve(); + assert(updates[0] === 'outer-foo', updates[0]); + assert(updates[1] === 'inner-foo', updates[1]); + assert(updates[2] === 'outer-bar', updates[2]); + assert(updates[3] === 'inner-bar', updates[3]); + assert(updates.length === 4, updates); container.remove(); }); - it('repeat re-runs each time', () => { + it('native map re-renders each time', () => { const getTemplate = ({ items, lookup }) => { return html` -
- -
- `; +
+ +
+ `; }; 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 })); @@ -758,14 +851,633 @@ describe('html updaters', () => { assert(container.querySelector('#a').textContent === 'fizzle'); assert(container.querySelector('#b').textContent === 'bop'); assert(container.querySelector('#c').textContent === 'fuzz'); + }); + + it('native map renders template changes', () => { + const getTemplate = ({ items }) => { + return html` +
+ ${items.map(item => [ + item.id, + item.show + ? html`
${item.id}
` + : html`
`, + ])} +
+ `; + }; + const container = document.createElement('div'); + render(container, getTemplate({ items: [{ id: 'foo', show: true }] })); + const foo = container.querySelector('#foo'); + assert(container.querySelector('#target').childElementCount === 1); + assert(!!foo); + assert(container.querySelector('#target').children[0] === foo); + render(container, getTemplate({ items: [{ id: 'foo', show: false }] })); + assert(container.querySelector('#target').childElementCount === 1); + assert(!!container.querySelector('#foo')); + assert(container.querySelector('#target').children[0] !== foo); + }); +}); + +describe('changing content values', () => { + // The template engine needs to clear content between cursors if the updater + // changes — it‘d be far too complex to try and allow one updater try and + // take over from a different one. + const getTemplate = ({ value }) => html`
${value}
`; + const run = (...transitions) => { + const container = document.createElement('div'); + for (const transition of transitions) { + transition(container); + } + }; + const toUndefinedContent = container => { + render(container, getTemplate({ value: undefined })); + assert(!!container.querySelector('#target')); + assert(container.querySelector('#target').childElementCount === 0); + }; + const toNullContent = container => { + render(container, getTemplate({ value: null })); + assert(!!container.querySelector('#target')); + assert(container.querySelector('#target').childElementCount === 0); + }; + const toTextContent = container => { + render(container, getTemplate({ value: 'hi there' })); + assert(!!container.querySelector('#target')); + assert(container.querySelector('#target').childElementCount === 0); + assert(container.querySelector('#target').textContent === 'hi there'); + }; + const toFragmentContent = container => { + const fragment = new DocumentFragment(); + fragment.append(document.createElement('p'), document.createElement('p')); + render(container, getTemplate({ value: fragment })); + assert(!!container.querySelector('#target')); + assert(container.querySelector('#target').childElementCount === 2); + assert(container.querySelector('#target').children[0].localName === 'p'); + assert(container.querySelector('#target').children[1].localName === 'p'); + }; + const toArrayContent = container => { + const items = [{ id: 'moo' }, { id: 'mar' }, { id: 'maz' }]; + render(container, getTemplate({ + value: items.map(item => html`
`), + })); + 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 toMapContent = container => { + const items = [{ id: 'foo' }, { id: 'bar' }]; + render(container, getTemplate({ + value: items.map(item => [item.id, html`
`]), + })); + assert(!!container.querySelector('#target')); + assert(!!container.querySelector('#foo')); + assert(!!container.querySelector('#bar')); + 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 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 content', () => run(toUndefinedContent, toMapContent)); + + it('can change from null content to undefined content', () => run(toNullContent, toUndefinedContent)); + it('can change from null content to text content', () => run(toNullContent, toTextContent)); + it('can change from null content to fragment content', () => run(toNullContent, toFragmentContent)); + it('can change from null content to array content', () => run(toNullContent, toArrayContent)); + it('can change from null content to map content', () => run(toNullContent, toMapContent)); + + it('can change from text content to undefined content', () => run(toTextContent, toUndefinedContent)); + it('can change from text content to null content', () => run(toTextContent, toNullContent)); + it('can change from text content to fragment content', () => run(toTextContent, toFragmentContent)); + it('can change from text content to array content', () => run(toTextContent, toArrayContent)); + it('can change from text content to map content', () => run(toTextContent, toMapContent)); + + it('can change from fragment content to undefined content', () => run(toFragmentContent, toUndefinedContent)); + it('can change from fragment content to null content', () => run(toFragmentContent, toNullContent)); + it('can change from fragment content to text content', () => run(toFragmentContent, toTextContent)); + it('can change from fragment content to array content', () => run(toFragmentContent, toArrayContent)); + it('can change from fragment content to map content', () => run(toFragmentContent, toMapContent)); + + it('can change from array content to undefined content', () => run(toArrayContent, toUndefinedContent)); + it('can change from array content to null content', () => run(toArrayContent, toNullContent)); + it('can change from array content to text content', () => run(toArrayContent, toTextContent)); + it('can change from array content to fragment content', () => run(toArrayContent, toFragmentContent)); + it('can change from array content to map content', () => run(toArrayContent, toMapContent)); + + it('can change from map content to undefined content', () => run(toMapContent, toUndefinedContent)); + it('can change from map content to null content', () => run(toMapContent, toNullContent)); + it('can change from map content to text content', () => run(toMapContent, toTextContent)); + it('can change from map content to fragment content', () => run(toMapContent, toFragmentContent)); + it('can change from map content to array content', () => run(toMapContent, toArrayContent)); +}); + +describe('svg rendering', () => { + it('renders a basic string', () => { + const getTemplate = ({ r, cx, cy }) => { + return svg``; + }; + const container = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + container.setAttribute('viewBox', '0 0 100 100'); + container.setAttribute('style', 'height: 100px; width: 100px;'); + document.body.append(container); + render(container, getTemplate({ r: 10, cx: 50, cy: 50 })); + assert(container.querySelector('#target').getBoundingClientRect().width === 20); + assert(container.querySelector('#target').getBoundingClientRect().height === 20); + render(container, getTemplate({ r: 5, cx: 50, cy: 50 })); + assert(container.querySelector('#target').getBoundingClientRect().width === 10); + assert(container.querySelector('#target').getBoundingClientRect().height === 10); container.remove(); }); - it('map', () => { + it('renders lists', () => { + const getTemplate = ({ items }) => { + return html` + + ${items.map((item, index) => { + return svg``; + })} + + `; + }; + const container = document.createElement('div'); + document.body.append(container); + render(container, getTemplate({ items: [{ r: 1 }, { r: 2 }, { r: 3 }, { r: 4 }, { r: 5 }] })); + assert(container.querySelector('#target').childElementCount === 5); + assert(container.querySelector('#target').children[0].getBoundingClientRect().height = 2); + assert(container.querySelector('#target').children[1].getBoundingClientRect().height = 4); + assert(container.querySelector('#target').children[2].getBoundingClientRect().height = 6); + assert(container.querySelector('#target').children[3].getBoundingClientRect().height = 8); + assert(container.querySelector('#target').children[4].getBoundingClientRect().height = 10); + container.remove(); + }); +}); + +describe('container issues', () => { + it('throws when given container is not a node', () => { + const callback = () => render({}, html``); + const expectedMessage = 'Unexpected non-node render container "[object Object]".'; + assertThrows(callback, expectedMessage); + }); +}); + +describe('value issues', () => { + describe('document fragment', () => { + it('throws for empty fragment', () => { + const callback = () => render(document.createElement('div'), html`
${new DocumentFragment()}
`); + const expectedMessage = 'Unexpected child element count of zero for given DocumentFragment.'; + assertThrows(callback, expectedMessage); + }); + }); + + describe('native array', () => { + it('throws for list with non-template value for array item', () => { + const callback = () => render(document.createElement('div'), html` +
+ ${[null].map(item => item ? html`
${item}
` : null)} +
+ `); + const expectedMessage = 'Unexpected non-template value found in array item at 0 "null".'; + assertThrows(callback, expectedMessage); + }); + + it('throws for list with non-template value on re-render', () => { + const getTemplate = ({ items }) => { + return html` +
+ ${items.map(item => item ? html`
${item}
` : null)} +
+ `; + }; + const container = document.createElement('div'); + render(container, getTemplate({ items: ['foo'] })); + const callback = () => render(container, getTemplate({ items: [null] })); + const expectedMessage = 'Unexpected non-template value found in array item at 0 "null".'; + assertThrows(callback, expectedMessage); + }); + + it('throws for list with empty map entry', () => { + const callback = () => render(document.createElement('div'), html`
${[[]]}
`); + const expectedMessage = 'Unexpected entry length found in map entry at 0 with length "0".'; + assertThrows(callback, expectedMessage); + }); + + it('throws for list with non-string key in a map entry', () => { + const callback = () => render(document.createElement('div'), html`
${[[1, html``]]}
`); + const expectedMessage = 'Unexpected non-string key found in map entry at 0 "1".'; + assertThrows(callback, expectedMessage); + }); + + it('throws for list with duplicated key in a map entry', () => { + const callback = () => render( + document.createElement('div'), + html`
${[['1', html``], ['2', html``], ['1', html``]]}
`, + ); + const expectedMessage = 'Unexpected duplicate key found in map entry at 2 "1".'; + assertThrows(callback, expectedMessage); + }); + + it('throws for list with non-template values in a map entry', () => { + const callback = () => render(document.createElement('div'), html`
${[['1', null]]}
`); + const expectedMessage = 'Unexpected non-template value found in map entry at 0 "null".'; + assertThrows(callback, expectedMessage); + }); + }); +}); + +describe('html errors', () => { + it('throws when attempting to interpolate within a style tag', () => { + const container = document.createElement('div'); + const callback = () => render(container, html` + + `); + const expectedMessage = 'Interpolation of "style" tags is not allowed.'; + assertThrows(callback, expectedMessage); + }); + + it('throws when attempting to interpolate within a script tag', () => { + const evil = '\' + prompt(\'evil\') + \''; + const container = document.createElement('div'); + const callback = () => render(container, html` + + `); + const expectedMessage = 'Interpolation of "script" tags is not allowed.'; + assertThrows(callback, expectedMessage); + }); + + it('throws when attempting non-trivial interpolation of a textarea tag', () => { + const container = document.createElement('div'); + const callback = () => render(container, html``); + const expectedMessage = 'Only basic interpolation of "textarea" tags is allowed.'; + assertThrows(callback, expectedMessage); + }); + + it('throws when attempting non-trivial interpolation of a textarea tag via nesting', () => { + const container = document.createElement('div'); + const callback = () => render(container, html``); + const expectedMessage = 'Only basic interpolation of "textarea" tags is allowed.'; + assertThrows(callback, expectedMessage); + }); + + it('throws when attempting non-trivial interpolation of a title tag', () => { + const container = document.createElement('div'); + const callback = () => render(container, html`please ${'foo'} no`); + const expectedMessage = 'Only basic interpolation of "title" tags is allowed.'; + assertThrows(callback, expectedMessage); + }); + + it('throws when attempting non-trivial interpolation of a title tag via nesting', () => { + const container = document.createElement('div'); + const callback = () => render(container, html`<b>please ${'foo'} no</b>`); + const expectedMessage = 'Only basic interpolation of "title" tags is allowed.'; + assertThrows(callback, expectedMessage); + }); + + it('throws for unquoted attributes', () => { + const container = document.createElement('div'); + const callback = () => render(container, html`
Gotta double-quote those.
`); + const expectedMessage = 'Found invalid template on or after line 1 in substring `
{ + const container = document.createElement('div'); + const callback = () => render(container, html`\n
Gotta double-quote those.
`); + const expectedMessage = 'Found invalid template on or after line 2 in substring `\n
{ + const container = document.createElement('div'); + const callback = () => render(container, html`\n\n\n
Gotta double-quote those.
`); + const expectedMessage = 'Found invalid template on or after line 4 in substring `\n\n\n
{ + const container = document.createElement('div'); + const callback = () => render(container, html`
Gotta double-quote those.
`); + const expectedMessage = 'Found invalid template on or after line 1 in substring `
{ + class TestElement1 extends HTMLElement { + constructor() { + super(); + html``; + } + } + customElements.define('test-element-1', TestElement1); + class TestElement2 extends HTMLElement { + constructor() { + super(); + html``; + } + } + customElements.define('test-element-2', TestElement2); + // At one point, we had a bug where the mutable “.lastIndex” was being + // changed mid-parse due to some re-entrance. This test was able to repro + // that specific issue because construction happens + // _as soon as the element is created_ during the parsing routine. + document.createElement('test-element-2'); + }); +}); + +// We plan to investigate adding additional restrictions on html to improve +// developer feedback in the future if the performance and complexity costs +// aren’t too high. +describe.todo('future html errors', () => { + it('throws if open tag starts with a hyphen', () => { + const callback = () => html`<-div>`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if open tag starts with a number', () => { + const callback = () => html`<3h>`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if open tag ends in a hyphen', () => { + const callback = () => html``; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if you give a tag name with capital letters', () => { + const callback = () => html`
`; + const expectedMessage = 'Seems like you have a malformed open start tag — tag names must be alphanumeric, lowercase, cannot start or end with hyphens, and cannot start with a number. See substring `
{ + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if an unbound boolean attribute starts with a number', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if an unbound boolean attribute starts with a capital letter', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if an unbound boolean attribute ends with a hyphen', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if an unbound attribute starts with a hyphen', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if an unbound attribute starts with a number', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if an unbound attribute starts with a capital letter', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if an unbound attribute ends with a hyphen', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound boolean attribute starts with a hyphen', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound boolean attribute starts with a number', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound boolean attribute starts with a capital letter', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound boolean attribute ends with a hyphen', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound defined attribute starts with a hyphen', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound defined attribute starts with a number', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound defined attribute starts with a capital letter', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound defined attribute ends with a hyphen', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound attribute starts with a hyphen', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound attribute starts with a number', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound attribute starts with a capital letter', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound attribute ends with a hyphen', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound property starts with an underscore', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound property starts with a number', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound property starts with a capital letter', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if a bound property ends with an underscore', () => { + const callback = () => html`
`; + const expectedMessage = 'TODO — Write a better error message!'; + assertThrows(callback, expectedMessage); + }); + + it('throws if you forget to close a tag', () => { + const callback = () => html`
`; + const expectedMessage = 'Did you forget a closing
? To avoid unintended markup, non-void tags must explicitly be closed.'; + assertThrows(callback, expectedMessage); + }); + + it('throws for trying to write unicode in a js-y format', () => { + const callback = () => html`
please no\u2026
`; + const expectedMessage = 'No JS unicode characters!'; + assertThrows(callback, expectedMessage); + }); + + it('throws for ambiguous ampersands', () => { + const callback = () => html`
please &a no
`; + const expectedMessage = 'TODO: Weird HTML entity!'; + assertThrows(callback, expectedMessage); + }); + + it('throws for malformed, named html entities', () => { + const callback = () => html`
please ¬athing; no
`; + const expectedMessage = 'TODO: Other case — weird HTML entity!'; + assertThrows(callback, expectedMessage); + }); + + it('throws for malformed decimal html entities', () => { + const callback = () => html`
please � no
`; + const expectedMessage = 'TODO: Malformed decimal entity!'; + assertThrows(callback, expectedMessage); + }); + + it('throws for malformed hexadecimal html entities', () => { + const callback = () => html`
please � no
`; + const expectedMessage = 'TODO: Malformed hexadecimal entity!'; + assertThrows(callback, expectedMessage); + }); +}); + +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('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`
- ${map(items, item => item.id, item => { + ${repeat(items, item => item.id, item => { return html`
${item.id}
`; })}
@@ -821,71 +1533,55 @@ describe('html updaters', () => { container.remove(); }); - it('map: renders depth-first', async () => { - const updates = []; - class TestDepthFirstOuter extends HTMLElement { - #item = null; - set item(value) { updates.push(`outer-${value}`); this.#item = value; } - get item() { return this.#item; } - connectedCallback() { - // Prevent property shadowing by deleting before setting on connect. - const item = this.item ?? '???'; - Reflect.deleteProperty(this, 'item'); - Reflect.set(this, 'item', item); - } - } - customElements.define('test-depth-first-outer', TestDepthFirstOuter); - class TestDepthFirstInner extends HTMLElement { - #item = null; - set item(value) { updates.push(`inner-${value}`); this.#item = value; } - get item() { return this.#item; } - connectedCallback() { - // Prevent property shadowing by deleting before setting on connect. - const item = this.item ?? '???'; - Reflect.deleteProperty(this, 'item'); - Reflect.set(this, 'item', item); - } - } - customElements.define('test-depth-first-inner', TestDepthFirstInner); - + it('repeat works when called with omitted lookup', () => { const getTemplate = ({ items }) => { return html`
- ${map(items, item => item.id, item => { - return html` - - - - - `; + ${repeat(items, item => { + return html`
${item.id}
`; })}
`; }; const container = document.createElement('div'); document.body.append(container); - const items = [{ id: 'foo' }, { id: 'bar'}]; - render(container, getTemplate({ items })); - await Promise.resolve(); - assert(updates[0] === 'outer-foo', updates[0]); - assert(updates[1] === 'inner-foo', updates[1]); - assert(updates[2] === 'outer-bar', updates[2]); - assert(updates[3] === 'inner-bar', updates[3]); - assert(updates.length === 4, updates); + 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('map: re-renders each time', () => { + it('repeat re-runs each time', () => { const getTemplate = ({ items, lookup }) => { return html` -
-
    - ${map(items, item => item.id, item => { - return html`
  • ${ lookup?.[item.id] }
  • `; - })} -
-
- `; +
+
    + ${repeat(items, item => item.id, item => { + return html`
  • ${lookup?.[item.id]}
  • `; + })} +
+
+ `; }; const container = document.createElement('div'); document.body.append(container); @@ -905,170 +1601,63 @@ describe('html updaters', () => { container.remove(); }); - it('map: template changes', () => { - const getTemplate = ({ items }) => { - return html` -
- ${map(items, item => item.id, item => { - return item.show ? html`
${item.id}
` : html`
`; - })} -
- `; - }; - const container = document.createElement('div'); - document.body.append(container); - render(container, getTemplate({ items: [{ id: 'foo', show: true }] })); - const foo = container.querySelector('#foo'); - assert(container.querySelector('#target').childElementCount === 1); - assert(!!foo); - assert(container.querySelector('#target').children[0] === foo); - render(container, getTemplate({ items: [{ id: 'foo', show: false }] })); - assert(container.querySelector('#target').childElementCount === 1); - assert(!!container.querySelector('#foo')); - 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 too complex to try and allow one updater try and - // take over from a different one. - const getTemplate = ({ value }) => html`
${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({ value: undefined })); - assert(!!container.querySelector('#target')); - assert(container.querySelector('#target').childElementCount === 0); - }; - const toNullContent = container => { - render(container, getTemplate({ value: null })); - assert(!!container.querySelector('#target')); - assert(container.querySelector('#target').childElementCount === 0); - }; - const toTextContent = container => { - render(container, getTemplate({ value: 'hi there' })); - assert(!!container.querySelector('#target')); - assert(container.querySelector('#target').childElementCount === 0); - assert(container.querySelector('#target').textContent === 'hi there'); - }; - const toFragmentContent = container => { - const fragment = new DocumentFragment(); - fragment.append(document.createElement('p'), document.createElement('p')); - render(container, getTemplate({ value: fragment })); - assert(!!container.querySelector('#target')); - assert(container.querySelector('#target').childElementCount === 2); - assert(container.querySelector('#target').children[0].localName === 'p'); - assert(container.querySelector('#target').children[1].localName === 'p'); - }; - const toArrayContent = container => { - const items = [{ id: 'moo' }, { id: 'mar' }, { id: 'maz' }]; - render(container, getTemplate({ - value: items.map(item => html`
`), - })); - 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 toMapContent = container => { - const items = [{ id: 'foo' }, { id: 'bar' }]; - render(container, getTemplate({ - value: items.map(item => [item.id, html`
`]), - })); - assert(!!container.querySelector('#target')); - assert(!!container.querySelector('#foo')); - assert(!!container.querySelector('#bar')); - 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 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 content', () => run(toUndefinedContent, toMapContent)); - - it('can change from null content to undefined content', () => run(toNullContent, toUndefinedContent)); - it('can change from null content to text content', () => run(toNullContent, toTextContent)); - it('can change from null content to fragment content', () => run(toNullContent, toFragmentContent)); - it('can change from null content to array content', () => run(toNullContent, toArrayContent)); - it('can change from null content to map content', () => run(toNullContent, toMapContent)); - - it('can change from text content to undefined content', () => run(toTextContent, toUndefinedContent)); - it('can change from text content to null content', () => run(toTextContent, toNullContent)); - it('can change from text content to fragment content', () => run(toTextContent, toFragmentContent)); - it('can change from text content to array content', () => run(toTextContent, toArrayContent)); - it('can change from text content to map content', () => run(toTextContent, toMapContent)); - - it('can change from fragment content to undefined content', () => run(toFragmentContent, toUndefinedContent)); - it('can change from fragment content to null content', () => run(toFragmentContent, toNullContent)); - it('can change from fragment content to text content', () => run(toFragmentContent, toTextContent)); - it('can change from fragment content to array content', () => run(toFragmentContent, toArrayContent)); - it('can change from fragment content to map content', () => run(toFragmentContent, toMapContent)); - - it('can change from array content to undefined content', () => run(toArrayContent, toUndefinedContent)); - it('can change from array content to null content', () => run(toArrayContent, toNullContent)); - it('can change from array content to text content', () => run(toArrayContent, toTextContent)); - it('can change from array content to fragment content', () => run(toArrayContent, toFragmentContent)); - it('can change from array content to map content', () => run(toArrayContent, toMapContent)); - - it('can change from map content to undefined content', () => run(toMapContent, toUndefinedContent)); - it('can change from map content to null content', () => run(toMapContent, toNullContent)); - it('can change from map content to text content', () => run(toMapContent, toTextContent)); - it('can change from map content to fragment content', () => run(toMapContent, toFragmentContent)); - it('can change from map content to array content', () => run(toMapContent, toArrayContent)); - }); -}); - -describe('svg rendering', () => { - it('renders a basic string', () => { - const getTemplate = ({ r, cx, cy }) => { - return svg``; - }; - const container = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - container.setAttribute('viewBox', '0 0 100 100'); - container.setAttribute('style', 'height: 100px; width: 100px;'); - document.body.append(container); - render(container, getTemplate({ r: 10, cx: 50, cy: 50 })); - assert(container.querySelector('#target').getBoundingClientRect().width === 20); - assert(container.querySelector('#target').getBoundingClientRect().height === 20); - render(container, getTemplate({ r: 5, cx: 50, cy: 50 })); - assert(container.querySelector('#target').getBoundingClientRect().width === 10); - assert(container.querySelector('#target').getBoundingClientRect().height === 10); - container.remove(); - }); - - it('renders lists', () => { + it('map', () => { const getTemplate = ({ items }) => { return html` - - ${items.map((item, index) => { - return svg``; +
+ ${map(items, item => item.id, item => { + return html`
${item.id}
`; })} - +
`; }; const container = document.createElement('div'); document.body.append(container); - render(container, getTemplate({ items: [{ r: 1 }, { r: 2 }, { r: 3 }, { r: 4 }, { r: 5 }] })); - assert(container.querySelector('#target').childElementCount === 5); - assert(container.querySelector('#target').children[0].getBoundingClientRect().height = 2); - assert(container.querySelector('#target').children[1].getBoundingClientRect().height = 4); - assert(container.querySelector('#target').children[2].getBoundingClientRect().height = 6); - assert(container.querySelector('#target').children[3].getBoundingClientRect().height = 8); - assert(container.querySelector('#target').children[4].getBoundingClientRect().height = 10); + 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(); }); }); @@ -1100,167 +1689,7 @@ describe('svg updaters', () => { }); }); -describe('rendering errors', () => { - describe('templating', () => { - it('throws when given container is not a node', () => { - let error; - try { - render({}, html``); - } catch (e) { - error = e; - } - assert(error?.message === 'Unexpected non-node render container "[object Object]".', error.message); - }); - - it('throws when attempting to interpolate within a style tag', () => { - const getTemplate = ({ color }) => { - return html` - - `; - }; - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, getTemplate({ color: 'url(evil-url)' })); - } catch (e) { - error = e; - } - assert(error?.message === `Interpolation of "style" tags is not allowed.`, error.message); - container.remove(); - }); - - it('throws when attempting to interpolate within a script tag', () => { - const getTemplate = ({ message }) => { - return html` - - `; - }; - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, getTemplate({ message: '\' + prompt(\'evil\') + \'' })); - } catch (e) { - error = e; - } - assert(error?.message === `Interpolation of "script" tags is not allowed.`, error.message); - container.remove(); - }); - - it('throws when attempting non-trivial interpolation of a textarea tag', () => { - const getTemplate = ({ content }) => { - return html``; - }; - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, getTemplate({ content: 'foo' })); - } catch (e) { - error = e; - } - assert(error?.message === `Only basic interpolation of "textarea" tags is allowed.`, error.message); - container.remove(); - }); - - it('throws when attempting non-trivial interpolation of a title tag', () => { - const getTemplate = ({ content }) => { - return html`please ${content} no`; - }; - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, getTemplate({ defaultValue: 'foo' })); - } catch (e) { - error = e; - } - assert(error?.message === `Only basic interpolation of "title" tags is allowed.`, error.message); - container.remove(); - }); - - it('throws for unquoted attributes', () => { - const rawResult = html`
Gotta double-quote those.
`; - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, rawResult); - } catch (e) { - error = e; - } - assert(error?.message === `Found invalid template on or after line 1 in substring \`
{ - const rawResult = html`\n
Gotta double-quote those.
`; - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, rawResult); - } catch (e) { - error = e; - } - assert(error?.message === `Found invalid template on or after line 2 in substring \`\n
{ - const rawResult = html`\n\n\n
Gotta double-quote those.
`; - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, rawResult); - } catch (e) { - error = e; - } - assert(error?.message === `Found invalid template on or after line 4 in substring \`\n\n\n
{ - const rawResult = html`
Gotta double-quote those.
`; - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, rawResult); - } catch (e) { - error = e; - } - assert(error?.message === `Found invalid template on or after line 1 in substring \`
{ - 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(); - }); - }); - +describe('updater 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.'; @@ -1963,102 +2392,6 @@ describe('rendering errors', () => { container.remove(); }); }); - - describe('native array', () => { - it('throws for list with non-template value for array item', () => { - const getTemplate = ({ items }) => { - return html` - - ${items.map(item => item ? html`
${item}
` : null)} - - `; - }; - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, getTemplate({ items: [null] })); - } catch (e) { - error = e; - } - assert(error?.message === 'Unexpected non-template value found in array item at 0 "null".', error?.message); - container.remove(); - }); - - it('throws for list with non-template value on re-render', () => { - const getTemplate = ({ items }) => { - return html` - - ${items.map(item => item ? html`
${item}
` : null)} - - `; - }; - const container = document.createElement('div'); - document.body.append(container); - render(container, getTemplate({ items: ['foo'] })); - let error; - try { - render(container, getTemplate({ items: [null] })); - } catch (e) { - error = e; - } - assert(error?.message === 'Unexpected non-template value found in array item at 0 "null".', error?.message); - container.remove(); - }); - - it('throws for list with empty map entry', () => { - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, html`
${[[]]}
`); - } catch (e) { - error = e; - } - assert(error?.message === 'Unexpected entry length found in map entry at 0 with length "0".', error?.message); - container.remove(); - }); - - it('throws for list with non-string key in a map entry', () => { - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, html`
${[[1, html``]]}
`); - } catch (e) { - error = e; - } - assert(error?.message === 'Unexpected non-string key found in map entry at 0 "1".', error?.message); - container.remove(); - }); - - it('throws for list with duplicated key in a map entry', () => { - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, html`
${[['1', html``], ['2', html``], ['1', html``]]}
`); - } catch (e) { - error = e; - } - assert(error?.message === 'Unexpected duplicate key found in map entry at 2 "1".', error?.message); - container.remove(); - }); - - it('throws for list with non-template values in a map entry', () => { - const container = document.createElement('div'); - document.body.append(container); - let error; - try { - render(container, html`
${[['1', null]]}
`); - } catch (e) { - error = e; - } - assert(error?.message === 'Unexpected non-template value found in map entry at 0 "null".', error?.message); - container.remove(); - }); - - }); }); describe('interface migration errors', () => {