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 @@
- + -
+ 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 = ``;
+ 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 = [];