diff --git a/demo/performance/index.css b/demo/performance/index.css new file mode 100644 index 0000000..cc065b4 --- /dev/null +++ b/demo/performance/index.css @@ -0,0 +1,19 @@ +:root { + line-height: 20px; +} + +.label { + font-weight: bold; +} + +.output { + height: calc(20px * 3); +} + +p { + width: 70ch; +} + +p:first-of-type { + margin-top: 2em; +} diff --git a/demo/performance/index.html b/demo/performance/index.html index ba98237..06461d1 100644 --- a/demo/performance/index.html +++ b/demo/performance/index.html @@ -2,20 +2,50 @@ - + -

default

-
-

lit html

-
-

µhtml

-
+
+
default
+

+    
+
+
lit html
+

+    
+
+
µhtml
+

+    
+

+ This tests the performance of html and render. + By testing only these two functions, we isolate time spent by the + templating engine from time spent by the element base class. +

+

+ The term “inject” refers to the process of taking an array + of template strings, injecting special markup strings, instantiating a + <template> element, mapping DOM elements based on the + special markup previously injected, cloning that template element, and + finally rendering it within a container element. Injection happens only + once per template function declaration. This is the most + time-consuming step of the process, but it also is only typically needed + once per element base class definition. +

+

+ The term “initial” refers to the process of taking a + template that’s already been injected and rendering it into + a new container element. For example, if you render multiple elements in + the page, the templating function is likely shared — this means that + the engine can skip the injection phase altogether. This happens + whenever a new element of the same type is created (i.e., the same element + appearing in a list over-and-over again). +

- The elements in this test all share a common base element. The only - difference is that they override the templating engine. The test consists - of two measurements — time to initialize and time to update. + The term “update” refers to the process of changing the values + which are interpolated into a given template. This is the most common + thing the engine needs to do.

- + diff --git a/demo/performance/index.js b/demo/performance/index.js index 09bb899..132d075 100644 --- a/demo/performance/index.js +++ b/demo/performance/index.js @@ -2,71 +2,7 @@ import XElement from '../../x-element.js'; import { html as litHtmlHtml, render as litHtmlRender } from 'https://unpkg.com/lit-html/lit-html.js?module'; import { render as uhtmlRender, html as uhtmlHtml } from 'https://unpkg.com/uhtml?module'; -class DefaultPerformanceElement extends XElement { - static get properties() { - return { - base: { - type: Number, - initial: 3, - }, - height: { - type: Number, - initial: 4, - }, - hypotenuse: { - type: Number, - input: ['base', 'height'], - compute: Math.hypot, - }, - valid: { - type: Boolean, - input: ['hypotenuse'], - compute: hypotenuse => !!hypotenuse, - reflect: true, - }, - perfect: { - type: Boolean, - input: ['hypotenuse'], - compute: hypotenuse => Number.isInteger(hypotenuse), - reflect: true, - }, - title: { - type: String, - internal: true, - input: ['valid', 'perfect'], - compute: (valid, perfect) => { - return !valid - ? 'This is not a triangle.' - : perfect - ? 'This is a perfect triangle.' - : 'This is a triangle.'; - }, - }, - }; - } - - static template(html) { - return ({ base, height, hypotenuse, title }) => html` - - - Math.hypot(${base}, ${height}) = ${hypotenuse} - - `; - } -} -customElements.define('default-performance', DefaultPerformanceElement); - -class LitHtmlPerformanceElement extends DefaultPerformanceElement { +class LitHtmlElement extends XElement { // Use lit-html's template engine rather than the built-in x-element engine. static get templateEngine() { const render = (container, template) => litHtmlRender(template, container); @@ -74,12 +10,88 @@ class LitHtmlPerformanceElement extends DefaultPerformanceElement { return { render, html }; } } -customElements.define('lit-html-performance', LitHtmlPerformanceElement); -class UhtmlPerformanceElement extends DefaultPerformanceElement { +class UhtmlElement extends XElement { // Use µhtml's template engine rather than the built-in x-element engine. static get templateEngine() { return { render: uhtmlRender, html: uhtmlHtml }; } } -customElements.define('uhtml-performance', UhtmlPerformanceElement); + +/* + // Reference template string (since it's hard to read as an interpolated array). + // Note that there are no special characters here so strings == strings.raw. +
${content1} -- ${content2}
+*/ +const getStrings = () => { + const strings = ['
', ' -- ', '
']; + strings.raw = ['
', ' -- ', '
']; + return strings; +}; +const getValues = ({ attr, id, hidden, title, content1, content2 }) => { + const values = [attr, id, hidden, title, content1, content2]; + return values; +}; + +const run = async (output, constructor) => { + output.textContent = ''; + const { render, html } = constructor.templateEngine; + const injectCount = 100000; + const initialCount = 1000000; + const updateCount = 1000000; + + // Test inject performance. + await new Promise(resolve => setTimeout(resolve, 0)); + let injectSum = 0; + const injectProperties = { attr: '123', id: 'foo', hidden: false, title: 'test', content1: 'AAA', content2: 'BBB' }; + const injectContainer = document.createElement('div'); + for (let iii = 0; iii < injectCount; iii++) { + const strings = getStrings(); + const values = getValues(injectProperties); + const t0 = performance.now(); + render(injectContainer, html(strings, ...values)); + const t1 = performance.now(); + injectSum += t1 - t0; + } + const injectAverage = `${(injectSum / injectCount * 1000).toFixed(1).padStart(6)} µs`; + output.textContent += `${output.textContent ? '\n' : ''}inject: ${injectAverage} (tested ${injectCount.toLocaleString()} times)`; + + // Test initial performance. + await new Promise(resolve => setTimeout(resolve, 0)); + let initialSum = 0; + const initialStrings = getStrings(); + const initialProperties = { attr: '123', id: 'foo', hidden: false, title: 'test', content1: 'AAA', content2: 'BBB' }; + const initialValues = getValues(initialProperties); + const initialContainer = document.createElement('div'); + for (let iii = 0; iii < initialCount; iii++) { + const t0 = performance.now(); + render(initialContainer, html(initialStrings, ...initialValues)); + const t1 = performance.now(); + initialSum += t1 - t0; + } + const initialAverage = `${(initialSum / initialCount * 1000).toFixed(1).padStart(4)} µs`; + output.textContent += `${output.textContent ? '\n' : ''}initial: ${initialAverage} (tested ${initialCount.toLocaleString()} times)`; + + // Test update performance. + await new Promise(resolve => setTimeout(resolve, 0)); + let updateSum = 0; + const updateStrings = getStrings(); + const updateProperties = [ + { attr: '123', id: 'foo', hidden: false, title: 'test', content1: 'AAA', content2: 'BBB' }, + { attr: '456', id: 'bar', hidden: false, title: 'test', content1: 'ZZZ', content2: 'BBB' }, + ]; + const updateContainer = document.createElement('div'); + for (let iii = 0; iii < updateCount; iii++) { + const values = getValues(updateProperties[iii % 2]); + const t0 = performance.now(); + render(updateContainer, html(updateStrings, ...values)); + const t1 = performance.now(); + updateSum += t1 - t0; + } + const updateAverage = `${(updateSum / updateCount * 1000).toFixed(1).padStart(5)} µs`; + output.textContent += `${output.textContent ? '\n' : ''}update: ${updateAverage} (tested ${updateCount.toLocaleString()} times)`; +}; + +await run(document.getElementById('default'), XElement); +await run(document.getElementById('lit-html'), LitHtmlElement); +await run(document.getElementById('uhtml'), UhtmlElement); diff --git a/demo/performance/performance.js b/demo/performance/performance.js deleted file mode 100644 index dea8056..0000000 --- a/demo/performance/performance.js +++ /dev/null @@ -1,49 +0,0 @@ -import ready from '../../etc/ready.js'; - -await ready(document); - -const log = (container, count, initializeAverage, updateAverage) => { - const element = document.createElement('pre'); - element.textContent = `initialize: ${initializeAverage}, update: ${updateAverage} (tested ${count.toLocaleString()} times)`; - container.append(element); -}; - -const run = (container, localName) => { - const count = 100000; - - // Test initialize performance. - let initializeSum = 0; - for (let iii = 0; iii < count; iii++) { - const element = document.createElement(localName); - const t0 = performance.now(); - container.append(element); // Initial, sync render happens on connection. - const t1 = performance.now(); - initializeSum += t1 - t0; - element.remove(); // cleanup - } - const initializeAverage = `${(initializeSum / count * 1000).toFixed(1).padStart(4)} µs`; - - // Test update performance. - let updateSum = 0; - const updates = [{ base: 5, height: 12 }, { base: 3, height: 4 }]; - const element = document.createElement(localName); - container.append(element); - for (let iii = 0; iii < count; iii++) { - const update = updates[iii % 2]; - Object.assign(element, update); - const t0 = performance.now(); - element.render(); // Force a sync render. - const t1 = performance.now(); - updateSum += t1 - t0; - } - const updateAverage = `${(updateSum / count * 1000).toFixed(1).padStart(4)} µs`; - - log(container, count, initializeAverage, updateAverage); -}; - -await new Promise(resolve => setTimeout(resolve, 0)); -run(document.getElementById('default'), 'default-performance'); -await new Promise(resolve => setTimeout(resolve, 0)); -run(document.getElementById('lit-html'), 'lit-html-performance'); -await new Promise(resolve => setTimeout(resolve, 0)); -run(document.getElementById('uhtml'), 'uhtml-performance'); diff --git a/test/test-template-engine.js b/test/test-template-engine.js index ccd24e5..2ac9a15 100644 --- a/test/test-template-engine.js +++ b/test/test-template-engine.js @@ -65,46 +65,6 @@ describe('html rendering', () => { container.remove(); }); - it('does not recognize single-quoted attributes', () => { - const getTemplate = () => { - return html`
Gotta double-quote those.
`; - }; - const container = document.createElement('div'); - document.body.append(container); - render(container, getTemplate()); - assert(container.querySelector('#target').getAttribute('ignore-me') !== 'foo'); - container.remove(); - }); - - it('does not recognize single-quoted properties', () => { - const getTemplate = () => { - return html`
Gotta double-quote those.
`; - }; - const container = document.createElement('div'); - document.body.append(container); - render(container, getTemplate()); - assert(container.querySelector('#target').ignoreMe !== 'foo'); - container.remove(); - }); - - it('refuses to reuse a template', () => { - const templateResultReference = html`
`; - const container = document.createElement('div'); - document.body.append(container); - render(container, templateResultReference); - assert(!!container.querySelector('#target')); - render(container, null); - assert(!container.querySelector('#target')); - let error; - try { - render(container, templateResultReference); - } catch (e) { - error = e; - } - assert(error?.message === 'Unexpected re-injection of template result.', error.message); - container.remove(); - }); - it('renders nullish templates', () => { const getTemplate = () => { return html`
`; @@ -124,14 +84,14 @@ describe('html rendering', () => { it('renders interpolated content', () => { const getTemplate = ({ content }) => { - return html`
${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 === 'Interpolated.'); + assert(container.querySelector('#target').textContent === 'a b Interpolated.'); render(container, getTemplate({ content: 'Updated.' })); - assert(container.querySelector('#target').textContent === 'Updated.'); + assert(container.querySelector('#target').textContent === 'a b Updated.'); container.remove(); }); @@ -170,8 +130,8 @@ describe('html rendering', () => { }); it('renders attributes', () => { - const getTemplate = ({ attr }) => { - return html`
`; + const getTemplate = ({ attr, content }) => { + return html`
Something${content}
`; }; const container = document.createElement('div'); document.body.append(container); @@ -207,7 +167,12 @@ describe('html rendering', () => { it('renders properties', () => { const getTemplate = ({ prop }) => { - return html`
`; + return html`\ +
+
`; }; const container = document.createElement('div'); document.body.append(container); @@ -379,10 +344,7 @@ describe('html rendering', () => { container.remove(); }); - // TODO: trying to find escaped values in strings is possible via a regex, but - // making that performant is nuanced. I.e., it's easy to cause catastrophic - // backtracking. - it.todo('renders elements with "<" or ">" characters in attributes', () => { + it('renders elements with "<" or ">" characters in attributes', () => { // Note the "/", "<", and ">" characters. const getTemplate = ({ width, height }) => { return html`\ @@ -761,6 +723,86 @@ describe('svg updaters', () => { }); describe('rendering errors', () => { + describe('templating', () => { + it.skip('throws when attempting to interpolate within a style tag', () => {}); + + it.skip('throws when attempting to interpolate within a script tag', () => {}); + + it('throws for unquoted attributes', () => { + const templateResultReference = html`
Gotta double-quote those.
`; + const container = document.createElement('div'); + document.body.append(container); + let error; + try { + render(container, templateResultReference); + } catch (e) { + error = e; + } + assert(error?.message === `Found invalid template near " not-ok=".`, error.message); + container.remove(); + }); + + it('throws for single-quoted attributes', () => { + const templateResultReference = html`
Gotta double-quote those.
`; + const container = document.createElement('div'); + document.body.append(container); + let error; + try { + render(container, templateResultReference); + } catch (e) { + error = e; + } + assert(error?.message === `Found invalid template near " not-ok='".`, error.message); + container.remove(); + }); + + it('throws for unquoted properties', () => { + const templateResultReference = html`
Gotta double-quote those.
`; + const container = document.createElement('div'); + document.body.append(container); + let error; + try { + render(container, templateResultReference); + } catch (e) { + error = e; + } + assert(error?.message === `Found invalid template near " .notOk=".`, error.message); + container.remove(); + }); + + it('throws for single-quoted properties', () => { + const templateResultReference = html`
Gotta double-quote those.
`; + const container = document.createElement('div'); + document.body.append(container); + let error; + try { + render(container, templateResultReference); + } catch (e) { + error = e; + } + assert(error?.message === `Found invalid template near " .notOk='".`, error.message); + container.remove(); + }); + + it('throws for re-injection of template result', () => { + const templateResultReference = html`
`; + const container = document.createElement('div'); + document.body.append(container); + render(container, templateResultReference); + assert(!!container.querySelector('#target')); + render(container, null); + assert(!container.querySelector('#target')); + let error; + try { + render(container, templateResultReference); + } catch (e) { + error = e; + } + assert(error?.message === 'Unexpected re-injection of template result.', error.message); + container.remove(); + }); + }); + describe('ifDefined', () => { it('throws if used on a "boolean-attribute"', () => { const expected = 'The ifDefined update must be used on an attribute, not on a boolean-attribute.'; diff --git a/x-element.js b/x-element.js index 1964e40..079b57e 100644 --- a/x-element.js +++ b/x-element.js @@ -1062,9 +1062,11 @@ class Template { static #templates = new WeakMap(); static #templateResults = new WeakMap(); static #updaters = new WeakMap(); - static #ATTRIBUTE = /<[a-zA-Z0-9-]+[^>]* ([a-z][a-z-]*)="$/; - static #BOOLEAN_ATTRIBUTE = /<[a-zA-Z0-9-]+[^>]* \?([a-z][a-z-]*)="$/; - static #PROPERTY = /<[a-zA-Z0-9-]+[^>]* \.([a-z][a-zA-Z0-9_]*)="$/; + 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 #CLOSE = />/g; + static #ATTRIBUTE = /\s+(\??([a-z][a-zA-Z0-9-]*))="$/y; + static #PROPERTY = /\s+\.([a-z][a-zA-Z0-9_]*)="$/y; #type = null; #strings = null; @@ -1077,34 +1079,48 @@ class Template { inject(node, options) { if (!this.#analysis) { - let string = ''; - for (const [key, value] of Object.entries(this.#strings)) { - string += value; - const attributeMatch = string.match(Template.#ATTRIBUTE); - const booleanAttributeMatch = !attributeMatch ? string.match(Template.#BOOLEAN_ATTRIBUTE) : null; - const propertyMatch = !attributeMatch && !booleanAttributeMatch ? string.match(Template.#PROPERTY) : null; - if (attributeMatch) { - // We found a match like this: html`
`. - const name = attributeMatch[1]; - string = string.slice(0, -2 - name.length) + `x-element-attribute-$${key}="${name}`; - } else if (booleanAttributeMatch) { - // We found a match like this: html`
`. - const name = booleanAttributeMatch[1]; - string = string.slice(0, -3 - name.length) + `x-element-boolean-attribute-$${key}="${name}`; - } else if (propertyMatch) { - // We found a match like this: html`
`. - const name = propertyMatch[1]; - string = string.slice(0, -3 - name.length) + `x-element-property-$${key}="${name}`; - } else if (Number(key) < this.#strings.length - 1) { + let html = ''; + const state = { inside: false, index: 0 }; + for (let iii = 0; iii < this.#strings.length; iii++) { + const string = this.#strings[iii]; + html += string; + Template.#exhaustString(string, state); + if (state.inside) { + Template.#ATTRIBUTE.lastIndex = state.index; + const attributeMatch = Template.#ATTRIBUTE.exec(string); + if (attributeMatch) { + const name = attributeMatch[2]; + if (attributeMatch[1].startsWith('?')) { + // We found a match like this: html`
`. + html = html.slice(0, -3 - name.length) + `x-element-boolean-attribute-$${iii}="${name}`; + } else { + // We found a match like this: html`
`. + html = html.slice(0, -2 - name.length) + `x-element-attribute-$${iii}="${name}`; + } + state.index = 1; // Accounts for an expected quote character next. + } else { + Template.#PROPERTY.lastIndex = state.index; + const propertyMatch = Template.#PROPERTY.exec(string); + if (propertyMatch) { + // We found a match like this: html`
`. + const name = propertyMatch[1]; + html = html.slice(0, -3 - name.length) + `x-element-property-$${iii}="${name}`; + state.index = 1; // Accounts for an expected quote character next. + } else { + throw new Error(`Found invalid template near "${string.slice(state.index)}".`); + } + } + } else { // Assume it's a match like this: html`
${value}
`. - string += ``; + html += ``; + state.index = 0; // No characters to account for. Reset to zero. } } if (this.#type === 'svg') { - string = `${string}`; + html = `${html}`; } const element = document.createElement('template'); - element.innerHTML = string; + element.innerHTML = html; const blueprint = Template.#evaluate(element.content); // mutates element. this.#analysis = { element, blueprint }; } @@ -1207,6 +1223,34 @@ class Template { return reference; } + static #exhaustString(string, state) { + if (!state.inside) { + // We're outside the opening tag. + Template.#OPEN.lastIndex = state.index; + const openMatch = Template.#OPEN.exec(string); + if (openMatch) { + state.inside = true; + state.index = Template.#OPEN.lastIndex; + Template.#exhaustString(string, state); + } + } else { + // We're inside the opening tag. + Template.#STEP.lastIndex = state.index; + let stepMatch = Template.#STEP.exec(string); + while (stepMatch) { + state.index = Template.#STEP.lastIndex; + stepMatch = Template.#STEP.exec(string); + } + Template.#CLOSE.lastIndex = state.index; + const closeMatch = Template.#CLOSE.exec(string); + if (closeMatch) { + state.inside = false; + state.index = Template.#CLOSE.lastIndex; + Template.#exhaustString(string, state); + } + } + } + static #evaluate(node, path) { path = path ?? []; const items = [];