diff --git a/test/test-template-engine.js b/test/test-template-engine.js index df0d467..884b141 100644 --- a/test/test-template-engine.js +++ b/test/test-template-engine.js @@ -541,6 +541,27 @@ describe('html rendering', () => { 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')); + assert(!!container2.querySelector('#target')); + render(container1, null); + render(container2, null); + assert(!container1.querySelector('#target')); + assert(!container2.querySelector('#target')); + render(container1, rawResult); + render(container2, rawResult); + assert(!!container1.querySelector('#target')); + assert(!!container2.querySelector('#target')); + container1.remove(); + container2.remove(); + }); }); describe('html updaters', () => { @@ -1085,6 +1106,16 @@ 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` @@ -1160,12 +1191,12 @@ describe('rendering errors', () => { }); it('throws for unquoted attributes', () => { - const templateResultReference = html`
Gotta double-quote those.
`; + const rawResult = html`
Gotta double-quote those.
`; const container = document.createElement('div'); document.body.append(container); let error; try { - render(container, templateResultReference); + render(container, rawResult); } catch (e) { error = e; } @@ -1174,12 +1205,12 @@ describe('rendering errors', () => { }); it('throws for single-quoted attributes', () => { - const templateResultReference = html`\n
Gotta double-quote those.
`; + const rawResult = html`\n
Gotta double-quote those.
`; const container = document.createElement('div'); document.body.append(container); let error; try { - render(container, templateResultReference); + render(container, rawResult); } catch (e) { error = e; } @@ -1188,12 +1219,12 @@ describe('rendering errors', () => { }); it('throws for unquoted properties', () => { - const templateResultReference = html`\n\n\n
Gotta double-quote those.
`; + const rawResult = html`\n\n\n
Gotta double-quote those.
`; const container = document.createElement('div'); document.body.append(container); let error; try { - render(container, templateResultReference); + render(container, rawResult); } catch (e) { error = e; } @@ -1202,12 +1233,12 @@ describe('rendering errors', () => { }); it('throws for single-quoted properties', () => { - const templateResultReference = html`
Gotta double-quote those.
`; + const rawResult = html`
Gotta double-quote those.
`; const container = document.createElement('div'); document.body.append(container); let error; try { - render(container, templateResultReference); + render(container, rawResult); } catch (e) { error = e; } @@ -1232,24 +1263,6 @@ describe('rendering errors', () => { assert(actual === expected, actual); 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', () => { diff --git a/x-element.js b/x-element.js index c33a547..e0bef62 100644 --- a/x-element.js +++ b/x-element.js @@ -1030,21 +1030,22 @@ class TemplateEngine { static #ATTRIBUTE_OR_PROPERTY_REGEX = /\s+(?:(?\?{0,2})?(?([a-z][a-zA-Z0-9-]*))|\.(?[a-z][a-zA-Z0-9_]*))="$/y; static #CLOSE_REGEX = />/g; + // Sentinel to hold raw result language. Also leveraged to determine whether a + // value is a raw result or not. Template engine supports html and svg. + static #LANGUAGE = Symbol(); + static #HTML = 'html'; + static #SVG = 'svg'; + // Sentinel to initialize the “last values” array. static #UNSET = Symbol(); - // Mapping of container nodes to internal state. - static #nodeToState = new WeakMap(); - - // Mapping of starting comment cursors to internal array state. - static #nodeToArrayState = new WeakMap(); + // Sentinels to manage internal state on nodes. + static #STATE = Symbol(); + static #ARRAY_STATE = Symbol(); // Mapping of tagged template function “strings” to caches computations. static #stringsToAnalysis = new WeakMap(); - // Mapping of opaque references to internal result objects. - static #symbolToResult = new WeakMap(); - // Mapping of opaque references to internal update objects. static #symbolToUpdate = new WeakMap(); @@ -1089,10 +1090,7 @@ class TemplateEngine { * @returns {any} */ static html(strings, ...values) { - const symbol = Object.create(null); - const result = { type: 'html', strings, values }; - TemplateEngine.#symbolToResult.set(symbol, result); - return symbol; + return TemplateEngine.#createRawResult(TemplateEngine.#HTML, strings, values); } /** @@ -1105,28 +1103,28 @@ class TemplateEngine { * @returns {any} */ static svg(strings, ...values) { - const symbol = Object.create(null); - const result = { type: 'svg', strings, values }; - TemplateEngine.#symbolToResult.set(symbol, result); - return symbol; + return TemplateEngine.#createRawResult(TemplateEngine.#SVG, strings, values); } /** * Core rendering entry point for x-element template engine. - * Accepts a "container" element and renders the given "result" into it. + * Accepts a "container" element and renders the given "raw result" into it. * @param {HTMLElement} container - * @param {any} resultReference + * @param {any} rawResult */ - static render(container, resultReference) { - const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToState, container, () => ({})); - if (resultReference) { - const result = TemplateEngine.#symbolToResult.get(resultReference); - if (TemplateEngine.#cannotReuseResult(state.result, result)) { + static render(container, rawResult) { + if (!(container instanceof Node)) { + throw new Error(`Unexpected non-node render container "${container}".`); + } + rawResult = TemplateEngine.#isRawResult(rawResult) ? rawResult : null; + const state = TemplateEngine.#getState(container, TemplateEngine.#STATE); + if (rawResult) { + if (!TemplateEngine.#canReuseDom(state.preparedResult, rawResult)) { TemplateEngine.#removeWithin(container); - TemplateEngine.#inject(result, container); - state.result = result; + const preparedResult = TemplateEngine.#inject(rawResult, container); + state.preparedResult = preparedResult; } else { - TemplateEngine.#update(state.result, result); + TemplateEngine.#update(state.preparedResult, rawResult); } } else { TemplateEngine.#clearObject(state); @@ -1387,7 +1385,7 @@ class TemplateEngine { // // // - static #createHtml(type, strings) { + static #createHtml(language, strings) { const keyToKeyState = new Map(); const htmlStrings = []; const state = { inside: false, index: 0, lastOpenContext: 0, lastOpenIndex: 0 }; @@ -1461,14 +1459,14 @@ class TemplateEngine { htmlStrings[iii] = `${htmlString.slice(0, index)}${comment}${htmlString.slice(index)}`; } const html = htmlStrings.join(''); - return type === 'svg' + return language === TemplateEngine.#SVG ? `${html}` : html; } - static #createFragment(type, strings) { + static #createFragment(language, strings) { const template = document.createElement('template'); - const html = TemplateEngine.#createHtml(type, strings); + const html = TemplateEngine.#createHtml(language, strings); template.innerHTML = html; return template.content; } @@ -1508,7 +1506,7 @@ class TemplateEngine { if (node.textContent.includes(TemplateEngine.#CONTENT_MARKER)) { if (node.textContent === ``) { node.textContent = ''; - lookups.push({ path, type: TemplateEngine.#TEXT }); + lookups.push({ path, binding: TemplateEngine.#TEXT }); } else { throw new Error(`Only basic interpolation of "${localName}" tags is allowed.`); } @@ -1529,13 +1527,13 @@ class TemplateEngine { const startNode = document.createComment(''); node.insertBefore(startNode, childNode); iii++; - lookups.push({ path: [...path, iii], type: TemplateEngine.#CONTENT }); + lookups.push({ path: [...path, iii], binding: TemplateEngine.#CONTENT }); } else if (textContent.startsWith(TemplateEngine.#NEXT_MARKER)) { const data = textContent.slice(TemplateEngine.#NEXT_MARKER.length); const items = data.split(','); for (const item of items) { - const [type, name] = item.split('='); - lookups.push({ path: [...path, iii], type, name }); + const [binding, name] = item.split('='); + lookups.push({ path: [...path, iii], binding, name }); } iii--; node.removeChild(childNode); @@ -1566,9 +1564,9 @@ class TemplateEngine { } return node; }; - for (const { path, type, name } of lookups) { + for (const { path, binding, name } of lookups) { const node = find(path); - switch (type) { + switch (binding) { case TemplateEngine.#ATTRIBUTE: targets.push(TemplateEngine.#commitAttribute.bind(null, node, name)); break; @@ -1592,24 +1590,23 @@ class TemplateEngine { return targets; } - // Validates array item or map entry and returns an “id” and a “result”. + // Validates array item or map entry and returns an “id” and a “rawResult”. static #parseListValue(value, index, category, ids) { if (category === 'array') { - // Values should look like "". + // Values should look like "". const id = String(index); - const reference = value; + const rawResult = value; ids.add(id); - const result = TemplateEngine.#symbolToResult.get(reference); - if (!result) { - throw new Error(`Unexpected non-template value found in array item at ${index} "${reference}".`); + if (!TemplateEngine.#isRawResult(rawResult)) { + throw new Error(`Unexpected non-template value found in array item at ${index} "${rawResult}".`); } - return [id, result]; + return [id, rawResult]; } else { - // Values should look like "[, ]". + // Values should look like "[, ]". if (value.length !== 2) { throw new Error(`Unexpected entry length found in map entry at ${index} with length "${value.length}".`); } - const [id, reference] = value; + const [id, rawResult] = value; if (typeof id !== 'string') { throw new Error(`Unexpected non-string key found in map entry at ${index} "${id}".`); } @@ -1617,28 +1614,27 @@ class TemplateEngine { throw new Error(`Unexpected duplicate key found in map entry at ${index} "${id}".`); } ids.add(id); - const result = TemplateEngine.#symbolToResult.get(reference); - if (!result) { - throw new Error(`Unexpected non-template value found in map entry at ${index} "${reference}".`); + if (!TemplateEngine.#isRawResult(rawResult)) { + throw new Error(`Unexpected non-template value found in map entry at ${index} "${rawResult}".`); } - return [id, result]; + return [id, rawResult]; } } // Loops over given value array to either create-or-update a list of nodes. static #list(node, startNode, values, category) { - const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({})); - if (!state.map) { + const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE); + if (!arrayState.map) { // There is no mapping in our state — we have a clean slate to work with. - TemplateEngine.#clearObject(state); - state.map = new Map(); + TemplateEngine.#clearObject(arrayState); + arrayState.map = new Map(); const ids = new Set(); // Populated in “parseListValue”. let index = 0; for (const value of values) { - const [id, result] = TemplateEngine.#parseListValue(value, index, category, ids); + const [id, rawResult] = TemplateEngine.#parseListValue(value, index, category, ids); const cursors = TemplateEngine.#createCursors(node); - TemplateEngine.#inject(result, cursors.node, true); - state.map.set(id, { id, result, ...cursors }); + const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); + arrayState.map.set(id, { id, preparedResult, ...cursors }); index++; } } else { @@ -1647,25 +1643,25 @@ class TemplateEngine { const ids = new Set(); // Populated in “parseListValue”. let index = 0; for (const value of values) { - const [id, result] = TemplateEngine.#parseListValue(value, index, category, ids); - if (state.map.has(id)) { - const item = state.map.get(id); - if (TemplateEngine.#cannotReuseResult(item.result, result)) { + const [id, rawResult] = TemplateEngine.#parseListValue(value, index, category, ids); + if (arrayState.map.has(id)) { + const item = arrayState.map.get(id); + if (!TemplateEngine.#canReuseDom(item.preparedResult, rawResult)) { // Add new comment cursors before removing old comment cursors. const cursors = TemplateEngine.#createCursors(item.startNode); TemplateEngine.#removeThrough(item.startNode, item.node); - TemplateEngine.#inject(result, cursors.node, true); - Object.assign(item, { result, ...cursors }); + const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); + Object.assign(item, { preparedResult, ...cursors }); } else { - TemplateEngine.#update(item.result, result); + TemplateEngine.#update(item.preparedResult, rawResult); } } else { const cursors = TemplateEngine.#createCursors(node); - TemplateEngine.#inject(result, cursors.node, true); - const item = { id, result, ...cursors }; - state.map.set(id, item); + const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); + const item = { id, preparedResult, ...cursors }; + arrayState.map.set(id, item); } - const item = state.map.get(id); + const item = arrayState.map.get(id); const referenceNode = lastItem ? lastItem.node.nextSibling : startNode.nextSibling; if (referenceNode !== item.startNode) { const nodesToMove = [item.startNode]; @@ -1677,10 +1673,10 @@ class TemplateEngine { lastItem = item; index++; } - for (const [id, item] of state.map.entries()) { + for (const [id, item] of arrayState.map.entries()) { if (!ids.has(id)) { TemplateEngine.#removeThrough(item.startNode, item.node); - state.map.delete(id); + arrayState.map.delete(id); } } } @@ -1760,8 +1756,8 @@ class TemplateEngine { ) ) { // Reset content under certain conditions. E.g., `map(…)` >> `null`. - const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToState, node, () => ({})); - const arrayState = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToArrayState, startNode, () => ({})); + const state = TemplateEngine.#getState(node, TemplateEngine.#STATE); + const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE); TemplateEngine.#removeBetween(startNode, node); TemplateEngine.#clearObject(state); TemplateEngine.#clearObject(arrayState); @@ -1786,15 +1782,15 @@ class TemplateEngine { // generated, so it’s ok to leave inside this value check. if (value !== lastValue) { if (introspection?.category === 'result') { - const state = TemplateEngine.#setIfMissing(TemplateEngine.#nodeToState, node, () => ({})); - const { result } = introspection; - if (TemplateEngine.#cannotReuseResult(state.result, result)) { + const state = TemplateEngine.#getState(node, TemplateEngine.#STATE); + const rawResult = value; + if (!TemplateEngine.#canReuseDom(state.preparedResult, rawResult)) { TemplateEngine.#removeBetween(startNode, node); TemplateEngine.#clearObject(state); - TemplateEngine.#inject(result, node, true); - state.result = result; + const preparedResult = TemplateEngine.#inject(rawResult, node, true); + state.preparedResult = preparedResult; } else { - TemplateEngine.#update(state.result, result); + TemplateEngine.#update(state.preparedResult, rawResult); } } else if (introspection?.category === 'array' || introspection?.category === 'map') { TemplateEngine.#list(node, startNode, value, introspection.category); @@ -1838,9 +1834,10 @@ class TemplateEngine { // Bind the current values from a result by walking through each target and // updating the DOM if things have changed. - static #commit(result) { - result.lastValues ??= result.values.map(() => TemplateEngine.#UNSET); - const { targets, values, lastValues } = result; + static #commit(preparedResult) { + preparedResult.values ??= preparedResult.rawResult.values; + preparedResult.lastValues ??= preparedResult.values.map(() => TemplateEngine.#UNSET); + const { targets, values, lastValues } = preparedResult; for (let iii = 0; iii < targets.length; iii++) { const target = targets[iii]; const value = values[iii]; @@ -1853,44 +1850,48 @@ class TemplateEngine { // the template “strings” before, we also have to generate html, parse it, // and find out binding targets. Then, we commit the values by iterating over // our targets. Finally, we actually attach our new DOM into our node. - static #inject(result, node, before) { - // If we see the _exact_ same result again… that’s an error. We don’t allow - // integrators to reuse template results. - if (result.readied) { - throw new Error(`Unexpected re-injection of template result.`); - } - + static #inject(rawResult, node, before) { // Create and prepare a document fragment to be injected. - result.readied = true; - const { type, strings } = result; + const { [TemplateEngine.#LANGUAGE]: language, strings } = rawResult; + const preparedResult = { rawResult }; const analysis = TemplateEngine.#setIfMissing(TemplateEngine.#stringsToAnalysis, strings, () => ({})); if (!analysis.done) { analysis.done = true; - const fragment = TemplateEngine.#createFragment(type, strings); + const fragment = TemplateEngine.#createFragment(language, strings); const lookups = TemplateEngine.#findLookups(fragment); Object.assign(analysis, { fragment, lookups }); } const fragment = analysis.fragment.cloneNode(true); const targets = TemplateEngine.#findTargets(fragment, analysis.lookups); - Object.assign(result, { fragment, targets }); + Object.assign(preparedResult, { fragment, targets }); // Bind values via our live targets into our disconnected DOM. - TemplateEngine.#commit(result); + TemplateEngine.#commit(preparedResult); // Attach a document fragment into the node. Note that all the DOM in the // fragment will already have values correctly committed on the line above. - const nodes = result.type === 'svg' - ? result.fragment.firstChild.childNodes - : result.fragment.childNodes; + const nodes = language === TemplateEngine.#SVG + ? fragment.firstChild.childNodes + : fragment.childNodes; before ? TemplateEngine.#insertAllBefore(node.parentNode, node, nodes) : TemplateEngine.#insertAllBefore(node, null, nodes); - result.fragment = null; + + return preparedResult; + } + + static #update(preparedResult, rawResult) { + Object.assign(preparedResult, { lastValues: preparedResult.values, values: rawResult.values }); + TemplateEngine.#commit(preparedResult); + } + + static #createRawResult(language, strings, values) { + Object.freeze(values); + return Object.freeze({ [TemplateEngine.#LANGUAGE]: language, strings, values }); } - static #update(result, newResult) { - Object.assign(result, { lastValues: result.values, values: newResult.values }); - TemplateEngine.#commit(result); + static #isRawResult(value) { + return !!value?.[TemplateEngine.#LANGUAGE]; } // TODO: Revisit this concept when we delete deprecated interfaces. Once that @@ -1902,9 +1903,8 @@ class TemplateEngine { } else if (value instanceof DocumentFragment) { return { category: 'fragment' }; } else if (value !== null && typeof value === 'object') { - const result = TemplateEngine.#symbolToResult.get(value); - if (result) { - return { category: 'result', result }; + if (TemplateEngine.#isRawResult(value)) { + return { category: 'result' }; } else { const update = TemplateEngine.#symbolToUpdate.get(value); if (update) { @@ -1914,25 +1914,26 @@ class TemplateEngine { } } - static #throwUpdaterError(updater, type) { + static #throwUpdaterError(updater, binding) { switch (updater) { // We’ll delete these updaters later. case TemplateEngine.#live: - throw new Error(`The live update must be used on ${TemplateEngine.#getTypeText(TemplateEngine.#PROPERTY)}, not on ${TemplateEngine.#getTypeText(type)}.`); + throw new Error(`The live update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#PROPERTY)}, not on ${TemplateEngine.#getBindingText(binding)}.`); case TemplateEngine.#unsafeHTML: - throw new Error(`The unsafeHTML update must be used on ${TemplateEngine.#getTypeText(TemplateEngine.#CONTENT)}, not on ${TemplateEngine.#getTypeText(type)}.`); + throw new Error(`The unsafeHTML update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#CONTENT)}, not on ${TemplateEngine.#getBindingText(binding)}.`); case TemplateEngine.#unsafeSVG: - throw new Error(`The unsafeSVG update must be used on ${TemplateEngine.#getTypeText(TemplateEngine.#CONTENT)}, not on ${TemplateEngine.#getTypeText(type)}.`); + throw new Error(`The unsafeSVG update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#CONTENT)}, not on ${TemplateEngine.#getBindingText(binding)}.`); case TemplateEngine.#ifDefined: - throw new Error(`The ifDefined update must be used on ${TemplateEngine.#getTypeText(TemplateEngine.#ATTRIBUTE)}, not on ${TemplateEngine.#getTypeText(type)}.`); + throw new Error(`The ifDefined update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#ATTRIBUTE)}, not on ${TemplateEngine.#getBindingText(binding)}.`); case TemplateEngine.#nullish: - throw new Error(`The nullish update must be used on ${TemplateEngine.#getTypeText(TemplateEngine.#ATTRIBUTE)}, not on ${TemplateEngine.#getTypeText(type)}.`); + throw new Error(`The nullish update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#ATTRIBUTE)}, not on ${TemplateEngine.#getBindingText(binding)}.`); } } - static #cannotReuseResult(result, newResult) { + static #canReuseDom(preparedResult, rawResult) { return ( - result?.type !== newResult.type || result?.strings !== newResult.strings + preparedResult?.rawResult[TemplateEngine.#LANGUAGE] === rawResult?.[TemplateEngine.#LANGUAGE] && + preparedResult?.rawResult.strings === rawResult?.strings ); } @@ -1989,8 +1990,18 @@ class TemplateEngine { return value; } - static #getTypeText(type) { - switch (type) { + static #getState(object, key) { + // Values set in this file are ALL truthy. + let value = object[key]; + if (!value) { + value = {}; + object[key] = value; + } + return value; + } + + static #getBindingText(binding) { + switch (binding) { case TemplateEngine.#ATTRIBUTE: return 'an attribute'; case TemplateEngine.#BOOLEAN: return 'a boolean attribute'; case TemplateEngine.#DEFINED: return 'a defined attribute';