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`\nGotta double-quote those.
`;
+ const rawResult = html`\nGotta 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\nGotta double-quote those.
`;
+ const rawResult = html`\n\n\nGotta 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;
}
- 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';