@netflix/x-element
Advanced tools
Comparing version 1.0.0-rc.54 to 1.0.0-rc.55
@@ -5,67 +5,3 @@ import XElement from '../../x-element.js'; | ||
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` | ||
<style> | ||
:host { | ||
display: block; | ||
} | ||
:host(:not([valid])) { | ||
color: red; | ||
} | ||
:host([perfect]) { | ||
color: green; | ||
} | ||
</style> | ||
<code base="${base}" height="${height}" .title="${title}"> | ||
Math.hypot(${base}, ${height}) = ${hypotenuse} | ||
<code> | ||
`; | ||
} | ||
} | ||
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. | ||
@@ -78,5 +14,4 @@ static get templateEngine() { | ||
} | ||
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. | ||
@@ -87,2 +22,79 @@ static get templateEngine() { | ||
} | ||
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. | ||
<div id="p1" attr="${attr}"><div id="p2" data-foo><div id="p3" data-bar="bar"><div id="${id}" boolean ?hidden="${hidden}" .title="${title}">${content1} -- ${content2}</div></div></div></div> | ||
*/ | ||
const getStrings = () => { | ||
const strings = ['<div id="p1" attr="', '"><div id="p2" data-foo><div id="p3" data-bar="bar"><div id="', '" boolean ?hidden="', '" .title="', '">', ' -- ', '</div></div></div></div>']; | ||
strings.raw = ['<div id="p1" attr="', '"><div id="p2" data-foo><div id="p3" data-bar="bar"><div id="', '" boolean ?hidden="', '" .title="', '">', ' -- ', '</div></div></div></div>']; | ||
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); |
@@ -5,3 +5,3 @@ { | ||
"description": "Custom Element base class.", | ||
"version": "1.0.0-rc.54", | ||
"version": "1.0.0-rc.55", | ||
"license": "SEE LICENSE IN LICENSE", | ||
@@ -8,0 +8,0 @@ "repository": "https://github.com/Netflix/x-element", |
@@ -68,42 +68,2 @@ import XElement from '../x-element.js'; | ||
it('does not recognize single-quoted attributes', () => { | ||
const getTemplate = () => { | ||
return html`<div id="target" ignore-me='${'foo'}'>Gotta double-quote those.</div>`; | ||
}; | ||
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`<div id="target" .ignoreMe='${'foo'}'>Gotta double-quote those.</div>`; | ||
}; | ||
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`<div id="target"></div>`; | ||
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', () => { | ||
@@ -128,3 +88,3 @@ const getTemplate = () => { | ||
const getTemplate = ({ content }) => { | ||
return html`<div id="target">${content}</div>`; | ||
return html`<div id="target">a b ${content}</div>`; | ||
}; | ||
@@ -134,5 +94,5 @@ const container = document.createElement('div'); | ||
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(); | ||
@@ -176,4 +136,4 @@ }); | ||
it('renders attributes', () => { | ||
const getTemplate = ({ attr }) => { | ||
return html`<div id="target" attr="${attr}"></div>`; | ||
const getTemplate = ({ attr, content }) => { | ||
return html`<div id="target" attr="${attr}" f="b">Something<span>${content}</span></div>`; | ||
}; | ||
@@ -214,3 +174,8 @@ const container = document.createElement('div'); | ||
const getTemplate = ({ prop }) => { | ||
return html`<div id="target" .prop="${prop}"></div>`; | ||
return html`\ | ||
<div | ||
id="target" | ||
foo-bar | ||
.prop="${prop}"> | ||
</div>`; | ||
}; | ||
@@ -387,6 +352,3 @@ const container = document.createElement('div'); | ||
// 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. | ||
@@ -770,2 +732,82 @@ const getTemplate = ({ width, height }) => { | ||
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`<div id="target" not-ok=${'foo'}>Gotta double-quote those.</div>`; | ||
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`<div id="target" not-ok='${'foo'}'>Gotta double-quote those.</div>`; | ||
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`<div id="target" .notOk=${'foo'}>Gotta double-quote those.</div>`; | ||
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`<div id="target" .notOk='${'foo'}'>Gotta double-quote those.</div>`; | ||
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`<div id="target"></div>`; | ||
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', () => { | ||
@@ -772,0 +814,0 @@ it('throws if used on a "boolean-attribute"', () => { |
@@ -1065,5 +1065,7 @@ /** Base element class for creating custom elements. */ | ||
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; | ||
@@ -1081,30 +1083,44 @@ #type = null; | ||
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`<div title="${value}"></div>`. | ||
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`<div ?hidden="${!!value}"></div>`. | ||
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`<div .title="${value}"></div>`. | ||
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`<div ?hidden="${!!value}"></div>`. | ||
html = html.slice(0, -3 - name.length) + `x-element-boolean-attribute-$${iii}="${name}`; | ||
} else { | ||
// We found a match like this: html`<div title="${value}"></div>`. | ||
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`<div .title="${value}"></div>`. | ||
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`<div>${value}</div>`. | ||
string += `<!--x-element-content-$${key}-->`; | ||
html += `<!--x-element-content-$${iii}-->`; | ||
state.index = 0; // No characters to account for. Reset to zero. | ||
} | ||
} | ||
if (this.#type === 'svg') { | ||
string = `<svg xmlns="http://www.w3.org/2000/svg">${string}</svg>`; | ||
html = `<svg xmlns="http://www.w3.org/2000/svg">${html}</svg>`; | ||
} | ||
const element = document.createElement('template'); | ||
element.innerHTML = string; | ||
element.innerHTML = html; | ||
const blueprint = Template.#evaluate(element.content); // mutates element. | ||
@@ -1212,2 +1228,30 @@ this.#analysis = { element, blueprint }; | ||
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) { | ||
@@ -1214,0 +1258,0 @@ path = path ?? []; |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
267318
6869