From a6875c98decd0514d246c0c39e3a44fc7ee1bd23 Mon Sep 17 00:00:00 2001 From: Andrew Seier Date: Tue, 4 Mar 2025 21:29:23 -0800 Subject: [PATCH] =?UTF-8?q?Prepare=20codebase=20for=20new=20=E2=80=9CmoveB?= =?UTF-8?q?efore=E2=80=9D=20DOM=20API.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Things seem to be _moving_ along pretty quickly for this new API. The motivation for this (versus `insertBefore`) is that DOM state is not reset when you _move_ DOM nodes around within the same connected tree. Importantly, that means a `disconnectedCallback` followed immediately by a `connectedCallback` will no longer happen. Those two lifecycle events typically do some heavy lifting and we want to avoid them for cases like nodes simply being moved around within the same list or something. Note that this change set _prepares_ us to leverage this new API, but it doesn’t yet commit to it as support is only currently in Chrome 133. We wouldn’t normally get ahead of ourselves like this — but there are a lot of positive signals from other browsers, so it seems like this will go pretty quickly. Final note — I was able to confirm that shuffling a keyed array won’t cause disconnect / connect callbacks in the future! --- test/test-template-engine.js | 20 ++ ts/x-element.d.ts.map | 2 +- ts/x-template.d.ts.map | 2 +- x-element.js | 6 + x-template.js | 374 ++++++++++++++++++++++------------- 5 files changed, 269 insertions(+), 135 deletions(-) diff --git a/test/test-template-engine.js b/test/test-template-engine.js index ae0a64f..f9b1f48 100644 --- a/test/test-template-engine.js +++ b/test/test-template-engine.js @@ -784,11 +784,14 @@ describe('html rendering', () => { assert(container.querySelector('#target').children[0] !== foo); }); + // TODO: #254: Uncomment “moves” lines when we leverage “moveBefore”. it('native map does not cause disconnectedCallback on prefix removal', () => { let connects = 0; + // let moves = 0; let disconnects = 0; class TestPrefixRemoval extends HTMLElement { connectedCallback() { connects++; } + // connectedMoveCallback() { moves++; } disconnectedCallback() { disconnects++; } } customElements.define('test-prefix-removal', TestPrefixRemoval); @@ -807,28 +810,35 @@ describe('html rendering', () => { document.body.append(container); assert(connects === 0); + // assert(moves === 0); assert(disconnects === 0); render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar' }] })); assert(connects === 2); + // assert(moves === 0); assert(disconnects === 0); render(container, getTemplate({ items: [{ id: 'bar' }] })); assert(connects === 2); + // assert(moves === 1); assert(disconnects === 1); render(container, getTemplate({ items: [] })); assert(connects === 2); + // assert(moves === 1); assert(disconnects === 2); container.remove(); }); + // TODO: #254: Uncomment “moves” lines when we leverage “moveBefore”. it('native map does not cause disconnectedCallback on suffix removal', () => { let connects = 0; + // let moves = 0; let disconnects = 0; class TestSuffixRemoval extends HTMLElement { connectedCallback() { connects++; } + // connectedMoveCallback() { moves++; } disconnectedCallback() { disconnects++; } } customElements.define('test-suffix-removal', TestSuffixRemoval); @@ -847,18 +857,22 @@ describe('html rendering', () => { document.body.append(container); assert(connects === 0); + // assert(moves === 0); assert(disconnects === 0); render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar' }] })); assert(connects === 2); + // assert(moves === 0); assert(disconnects === 0); render(container, getTemplate({ items: [{ id: 'foo' }] })); assert(connects === 2); + // assert(moves === 0); assert(disconnects === 1); render(container, getTemplate({ items: [] })); assert(connects === 2); + // assert(moves === 0); assert(disconnects === 2); container.remove(); @@ -867,9 +881,11 @@ describe('html rendering', () => { // TODO: #254: See https://chromestatus.com/feature/5135990159835136. it.todo('native map does not cause disconnectedCallback on list shuffle', () => { let connects = 0; + let moves = 0; let disconnects = 0; class TestListShuffle extends HTMLElement { connectedCallback() { connects++; } + connectedMoveCallback() { moves++; } disconnectedCallback() { disconnects++; } } customElements.define('test-list-shuffle', TestListShuffle); @@ -888,18 +904,22 @@ describe('html rendering', () => { document.body.append(container); assert(connects === 0); + assert(moves === 0); assert(disconnects === 0); render(container, getTemplate({ items: [{ id: 'foo' }, { id: 'bar' }] })); assert(connects === 2); + assert(moves === 0); assert(disconnects === 0); render(container, getTemplate({ items: [{ id: 'bar' }, { id: 'foo' }] })); assert(connects === 2); + assert(moves === 1); assert(disconnects === 0); render(container, getTemplate({ items: [] })); assert(connects === 2); + assert(moves === 1); assert(disconnects === 2); container.remove(); diff --git a/ts/x-element.d.ts.map b/ts/x-element.d.ts.map index 2555a1a..cb518f4 100644 --- a/ts/x-element.d.ts.map +++ b/ts/x-element.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"x-element.d.ts","sourceRoot":"","sources":["../x-element.js"],"names":[],"mappings":"AAEA,uDAAuD;AACvD;IACE;;;OAGG;IACH,iCAFa,MAAM,EAAE,CAKpB;IAED;;;OAGG;IACH,oCAFa;QAAC,CAAC,GAAG,EAAE,MAAM,YAAW;KAAC,CAIrC;IAED;;;;;;OAMG;IACH,6BAFa;QAAC,CAAC,GAAG,EAAE,MAAM,YAAW;KAAC,CAIrC;IAED;;;;;;;;;;;;;;;OAeG;IACH,qBAFa,aAAa,EAAE,CAI3B;IAED;;;;;;OAMG;IAEH;;;;;;;;;;;;;OAaG;IAEH;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,yBAFa;QAAC,CAAC,GAAG,EAAE,MAAM;mBA/BZ,GAAG;wBACH,MAAM;oBACN,MAAM,EAAE;;6BAVX,WAAW,SACX,GAAG,YACH,GAAG;sBAWA,OAAO;uBACP,OAAO;uBACP,OAAO;sBACP,GAAG,WAAS;sBACZ,GAAG,WAAS;UAsBW;KAAC,CAIrC;IAED;;;;;OAKG;IAEH;;;;;;;;;;;;;OAaG;IACH,wBAFa;QAAC,CAAC,GAAG,EAAE,MAAM,UAhBf,WAAW,SACX,KAAK,SAeoC;KAAC,CAIpD;IAED;;;;;OAKG;IACH,8BAHW,WAAW,GACT,WAAW,GAAC,UAAU,CAIlC;IAED;;;;;OAKG;IAEH;;;;;;;;;;;;OAYG;IACH,wCAHW;QAAC,CAAC,GAAG,EAAE,MAAM,YAAW;KAAC,gBAdzB,MAAM,QACN,WAAW,SAkBrB;IA+ID,0DA6BC;IAGD,yFAqCC;IAED,iFA2FC;IAED,0GAMC;IAGD,wFAaC;IAED,uFAQC;IAGD,iGAiBC;IAGD,yEAUC;IAGD,yEAkBC;IAGD,sEAUC;IAGD,yEAaC;IAGD,yEAsBC;IAGD,yEAYC;IAGD,8CAyCC;IAGD,8CA6CC;IAID,gDA6BC;IAGD,4CAMC;IAED,+CAEC;IAED,kDAyBC;IAGD,qDAMC;IAGD;;;MAmBC;IAED,kEAiBC;IAED,kGAGC;IAED,6CAMC;IAED,qGAGC;IAED,gDAMC;IAED,0DAMC;IAED,2CAYC;IAGD,+CAeC;IAED,2EAiBC;IAED,+DAGC;IAED,iFAQC;IAED,4EAQC;IAED,4EAOC;IAED,8EAoBC;IAGD,4DAEC;IAED,4CAEC;IAED,+CAEC;IAED,2DAOC;IAED,6CAKC;IAED,mDAAqC;IACrC,4CAA8B;IAC9B,yCAA8I;IAC9I,kGAA+D;IAC/D,sCAA4B;IAC5B,+CAAqF;IA50BrF;;OAEG;IACH,0BAEC;IAED;;;;;OAKG;IACH,oCAJW,MAAM,YACN,MAAM,GAAC,IAAI,SACX,MAAM,GAAC,IAAI,QAOrB;IAED;;OAEG;IACH,wBAAoB;IAEpB;;OAEG;IACH,6BAEC;IAED;;;;OAIG;IACH,eAYC;IAED;;;;OAIG;IAEH;;;;;;;OAOG;IACH,gBALW,WAAW,QACX,MAAM,oBAPN,KAAK,oBASL,MAAM,QAoBhB;IAED;;;;;;OAMG;IACH,kBALW,WAAW,QACX,MAAM,oBAlCN,KAAK,oBAoCL,MAAM,QAoBhB;IAED;;;OAGG;IACH,qBAFW,KAAK,QAMf;IAED;;;;;OAKG;IACH,gBAFa,MAAM,CAIlB;CA2sBF"} \ No newline at end of file +{"version":3,"file":"x-element.d.ts","sourceRoot":"","sources":["../x-element.js"],"names":[],"mappings":"AAEA,uDAAuD;AACvD;IACE;;;OAGG;IACH,iCAFa,MAAM,EAAE,CAKpB;IAED;;;OAGG;IACH,oCAFa;QAAC,CAAC,GAAG,EAAE,MAAM,YAAW;KAAC,CAIrC;IAED;;;;;;OAMG;IACH,6BAFa;QAAC,CAAC,GAAG,EAAE,MAAM,YAAW;KAAC,CAIrC;IAED;;;;;;;;;;;;;;;OAeG;IACH,qBAFa,aAAa,EAAE,CAI3B;IAED;;;;;;OAMG;IAEH;;;;;;;;;;;;;OAaG;IAEH;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,yBAFa;QAAC,CAAC,GAAG,EAAE,MAAM;mBA/BZ,GAAG;wBACH,MAAM;oBACN,MAAM,EAAE;;6BAVX,WAAW,SACX,GAAG,YACH,GAAG;sBAWA,OAAO;uBACP,OAAO;uBACP,OAAO;sBACP,GAAG,WAAS;sBACZ,GAAG,WAAS;UAsBW;KAAC,CAIrC;IAED;;;;;OAKG;IAEH;;;;;;;;;;;;;OAaG;IACH,wBAFa;QAAC,CAAC,GAAG,EAAE,MAAM,UAhBf,WAAW,SACX,KAAK,SAeoC;KAAC,CAIpD;IAED;;;;;OAKG;IACH,8BAHW,WAAW,GACT,WAAW,GAAC,UAAU,CAIlC;IAED;;;;;OAKG;IAEH;;;;;;;;;;;;OAYG;IACH,wCAHW;QAAC,CAAC,GAAG,EAAE,MAAM,YAAW;KAAC,gBAdzB,MAAM,QACN,WAAW,SAkBrB;IAqJD,0DA6BC;IAGD,yFAqCC;IAED,iFA2FC;IAED,0GAMC;IAGD,wFAaC;IAED,uFAQC;IAGD,iGAiBC;IAGD,yEAUC;IAGD,yEAkBC;IAGD,sEAUC;IAGD,yEAaC;IAGD,yEAsBC;IAGD,yEAYC;IAGD,8CAyCC;IAGD,8CA6CC;IAID,gDA6BC;IAGD,4CAMC;IAED,+CAEC;IAED,kDAyBC;IAGD,qDAMC;IAGD;;;MAmBC;IAED,kEAiBC;IAED,kGAGC;IAED,6CAMC;IAED,qGAGC;IAED,gDAMC;IAED,0DAMC;IAED,2CAYC;IAGD,+CAeC;IAED,2EAiBC;IAED,+DAGC;IAED,iFAQC;IAED,4EAQC;IAED,4EAOC;IAED,8EAoBC;IAGD,4DAEC;IAED,4CAEC;IAED,+CAEC;IAED,2DAOC;IAED,6CAKC;IAED,mDAAqC;IACrC,4CAA8B;IAC9B,yCAA8I;IAC9I,kGAA+D;IAC/D,sCAA4B;IAC5B,+CAAqF;IAl1BrF;;OAEG;IACH,0BAEC;IAQD;;;;;OAKG;IACH,oCAJW,MAAM,YACN,MAAM,GAAC,IAAI,SACX,MAAM,GAAC,IAAI,QAOrB;IAED;;OAEG;IACH,wBAAoB;IAEpB;;OAEG;IACH,6BAEC;IAED;;;;OAIG;IACH,eAYC;IAED;;;;OAIG;IAEH;;;;;;;OAOG;IACH,gBALW,WAAW,QACX,MAAM,oBAPN,KAAK,oBASL,MAAM,QAoBhB;IAED;;;;;;OAMG;IACH,kBALW,WAAW,QACX,MAAM,oBAlCN,KAAK,oBAoCL,MAAM,QAoBhB;IAED;;;OAGG;IACH,qBAFW,KAAK,QAMf;IAED;;;;;OAKG;IACH,gBAFa,MAAM,CAIlB;CA2sBF"} \ No newline at end of file diff --git a/ts/x-template.d.ts.map b/ts/x-template.d.ts.map index 4b3ef0c..15f1805 100644 --- a/ts/x-template.d.ts.map +++ b/ts/x-template.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"x-template.d.ts","sourceRoot":"","sources":["../x-template.js"],"names":[],"mappings":"AAsxBA,yBAA2E;AAC3E,uBAAuE;AAGvE,4BAAiF;AACjF,yBAA2E"} \ No newline at end of file +{"version":3,"file":"x-template.d.ts","sourceRoot":"","sources":["../x-template.js"],"names":[],"mappings":"AAk4BA,yBAA2E;AAC3E,uBAAuE;AAGvE,4BAAiF;AACjF,yBAA2E"} \ No newline at end of file diff --git a/x-element.js b/x-element.js index 41f4c88..16cf74f 100644 --- a/x-element.js +++ b/x-element.js @@ -172,6 +172,12 @@ export default class XElement extends HTMLElement { XElement.#connectHost(this); } + // TODO: #254: Uncomment once we leverage “moveBefore”. + // /** + // * Extends HTMLElement.prototype.connectedMoveCallback. + // */ + // connectedMoveCallback() {} + /** * Extends HTMLElement.prototype.attributeChangedCallback. * @param {string} attribute diff --git a/x-template.js b/x-template.js index 827f3f8..91c1ba4 100644 --- a/x-template.js +++ b/x-template.js @@ -314,113 +314,6 @@ class TemplateEngine { return targets; } - // 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 "". - const id = String(index); - const rawResult = value; - ids.add(id); - if (!TemplateEngine.#isRawResult(rawResult)) { - throw new Error(`Unexpected non-template value found in array item at ${index} "${rawResult}".`); - } - return [id, rawResult]; - } else { - // 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, rawResult] = value; - if (typeof id !== 'string') { - throw new Error(`Unexpected non-string key found in map entry at ${index} "${id}".`); - } - if (ids.has(id)) { - throw new Error(`Unexpected duplicate key found in map entry at ${index} "${id}".`); - } - ids.add(id); - if (!TemplateEngine.#isRawResult(rawResult)) { - throw new Error(`Unexpected non-template value found in map entry at ${index} "${rawResult}".`); - } - return [id, rawResult]; - } - } - - // TODO: #254: Use new “moveBefore” when available with cross-browser support. - // This enables us to preserve things like animations and prevent node - // disconnects. See https://chromestatus.com/feature/5135990159835136 - // Loops over given value array to either create-or-update a list of nodes. - static #list(node, startNode, values, category) { - 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(arrayState); - arrayState.map = new Map(); - const ids = new Set(); // Populated in “parseListValue”. - let index = 0; - for (const value of values) { - const [id, rawResult] = TemplateEngine.#parseListValue(value, index, category, ids); - const cursors = TemplateEngine.#createCursors(node); - const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); - arrayState.map.set(id, { id, preparedResult, ...cursors }); - index++; - } - } else { - // TODO: Can we refactor this all into a _single_ loop? Right now, we do - // the following: - // 1. Loop once to add new things. - // 2. Loop a second time to remove old things. - // 3. Loop a third time to reorder (if we have a mapping). - - // A mapping has already been created — we need to update the items. - const ids = new Set(); // Populated in “parseListValue”. - let index = 0; - for (const value of values) { - const [id, rawResult] = TemplateEngine.#parseListValue(value, index, category, ids); - let item = arrayState.map.get(id); - if (item) { - 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); - item.preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); - item.startNode = cursors.startNode; - item.node = cursors.node; - } else { - TemplateEngine.#update(item.preparedResult, rawResult); - } - } else { - const cursors = TemplateEngine.#createCursors(node); - const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); - item = { id, preparedResult, ...cursors }; - arrayState.map.set(id, item); - } - index++; - } - for (const [id, item] of arrayState.map.entries()) { - if (!ids.has(id)) { - TemplateEngine.#removeThrough(item.startNode, item.node); - arrayState.map.delete(id); - } - } - let lastItem; - for (const id of ids) { - const item = arrayState.map.get(id); - // TODO: We should be able to make the following code more performant. - if (category === 'map') { - const referenceNode = lastItem ? lastItem.node.nextSibling : startNode.nextSibling; - if (referenceNode !== item.startNode) { - const nodesToMove = [item.startNode]; - while (nodesToMove[nodesToMove.length - 1] !== item.node) { - nodesToMove.push(nodesToMove[nodesToMove.length - 1].nextSibling); - } - TemplateEngine.#insertAllBefore(referenceNode.parentNode, referenceNode, nodesToMove); - } - } - lastItem = item; - } - } - } - static #commitAttribute(node, name, value, lastValue) { const update = TemplateEngine.#symbolToUpdate.get(value); if (update) { @@ -477,40 +370,225 @@ class TemplateEngine { // node[name] = value; // } - static #commitContent(node, startNode, value, lastValue) { - const category = TemplateEngine.#getCategory(value); - const lastCategory = TemplateEngine.#getCategory(lastValue); - if (category !== lastCategory && lastValue !== TemplateEngine.#UNSET) { - // Reset content under certain conditions. E.g., `map` >> `null`. - const state = TemplateEngine.#getState(node, TemplateEngine.#STATE); - const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE); + static #commitContentResultValue(node, startNode, value) { + const state = TemplateEngine.#getState(node, TemplateEngine.#STATE); + const rawResult = value; + if (!TemplateEngine.#canReuseDom(state.preparedResult, rawResult)) { TemplateEngine.#removeBetween(startNode, node); TemplateEngine.#clearObject(state); + const preparedResult = TemplateEngine.#inject(rawResult, node, true); + state.preparedResult = preparedResult; + } else { + TemplateEngine.#update(state.preparedResult, rawResult); + } + } + + // Validates array value and returns a “rawResult”. + static #parseArrayValue(value, index) { + // Values should look like "". + const rawResult = value; + if (!TemplateEngine.#isRawResult(rawResult)) { + throw new Error(`Unexpected non-template value found in array item at ${index} "${rawResult}".`); + } + return rawResult; + } + + // Validates array entry and returns an “id” and a “rawResult”. + static #parseArrayEntry(entry, index, ids) { + // Entries should look like "[, ]". + if (entry.length !== 2) { + throw new Error(`Unexpected entry length found in map entry at ${index} with length "${entry.length}".`); + } + const [id, rawResult] = entry; + if (typeof id !== 'string') { + throw new Error(`Unexpected non-string key found in map entry at ${index} "${id}".`); + } + if (ids.has(id)) { + throw new Error(`Unexpected duplicate key found in map entry at ${index} "${id}".`); + } + ids.add(id); + if (!TemplateEngine.#isRawResult(rawResult)) { + throw new Error(`Unexpected non-template value found in map entry at ${index} "${rawResult}".`); + } + return [id, rawResult]; + } + + // Helper to create / insert “cursors” in managed array of nodes. + static #createArrayItem(node, id, rawResult) { + const cursors = TemplateEngine.#createCursors(node); + const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); + return { id, preparedResult, ...cursors }; + } + + // Helper to destroy, create, and replace “cursors” in managed array of nodes. + static #recreateArrayItem(item, rawResult) { + // Add new comment cursors before removing old comment cursors. + const cursors = TemplateEngine.#createCursors(item.startNode); + TemplateEngine.#removeThrough(item.startNode, item.node); + item.preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); + item.startNode = cursors.startNode; + item.node = cursors.node; + } + + // Loops over given array of “values” to manage an array of nodes. + static #commitContentArrayValues(node, startNode, values) { + const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE); + if (!arrayState.map) { + // There is no mapping in our state — create an empty one as our base. TemplateEngine.#clearObject(arrayState); + arrayState.map = new Map(); } - if (category === 'result') { - const state = TemplateEngine.#getState(node, TemplateEngine.#STATE); - const rawResult = value; - if (!TemplateEngine.#canReuseDom(state.preparedResult, rawResult)) { - TemplateEngine.#removeBetween(startNode, node); - TemplateEngine.#clearObject(state); - const preparedResult = TemplateEngine.#inject(rawResult, node, true); - state.preparedResult = preparedResult; + + if (values.length > 0 && arrayState.map.size > 0) { + // Update existing values. + for (let index = 0; index < Math.min(arrayState.map.size, values.length); index++) { + const id = String(index); + const value = values[index]; + const rawResult = TemplateEngine.#parseArrayValue(value, index); + const item = arrayState.map.get(id); + if (!TemplateEngine.#canReuseDom(item.preparedResult, rawResult)) { + TemplateEngine.#recreateArrayItem(item, rawResult); + } else { + TemplateEngine.#update(item.preparedResult, rawResult); + } + } + } + + if (values.length > arrayState.map.size) { + // Add new values. + for (let index = arrayState.map.size; index < values.length; index++) { + const id = String(index); + const value = values[index]; + const rawResult = TemplateEngine.#parseArrayValue(value, index); + const item = TemplateEngine.#createArrayItem(node, id, rawResult); + arrayState.map.set(id, item); + } + } + + if (arrayState.map.size > values.length) { + // Delete removed values. + const index = values.length; + const id = String(index); + const item = arrayState.map.get(id); + TemplateEngine.#removeThrough(item.startNode, node); + arrayState.map.delete(id); + } + } + + // Loops over given array of “entries” to manage an array of nodes. + static #commitContentArrayEntries(node, startNode, entries) { + const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE); + if (!arrayState.map) { + // There is no mapping in our state — create an empty one as our base. + TemplateEngine.#clearObject(arrayState); + arrayState.map = new Map(); + } + + // A mapping has already been created — we need to update the items. + const ids = new Set(); // Populated in “parseListValue”. + let index = 0; + for (const entry of entries) { + const [id, rawResult] = TemplateEngine.#parseArrayEntry(entry, index, ids); + let item = arrayState.map.get(id); + if (item) { + if (!TemplateEngine.#canReuseDom(item.preparedResult, rawResult)) { + TemplateEngine.#recreateArrayItem(item, rawResult); + } else { + TemplateEngine.#update(item.preparedResult, rawResult); + } } else { - TemplateEngine.#update(state.preparedResult, rawResult); + item = TemplateEngine.#createArrayItem(node, id, rawResult); + arrayState.map.set(id, item); } - } else if (category === 'array' || category === 'map') { - TemplateEngine.#list(node, startNode, value, category); - } else if (category === 'fragment') { - if (value.childElementCount === 0) { - throw new Error(`Unexpected child element count of zero for given DocumentFragment.`); + index++; + } + for (const [id, item] of arrayState.map.entries()) { + if (!ids.has(id)) { + TemplateEngine.#removeThrough(item.startNode, item.node); + arrayState.map.delete(id); } - const previousSibling = node.previousSibling; - if (previousSibling !== startNode) { - TemplateEngine.#removeBetween(startNode, node); + } + let lastItem; + for (const id of ids) { + const item = arrayState.map.get(id); + // TODO: We should be able to make the following code more performant. + const referenceNode = lastItem ? lastItem.node.nextSibling : startNode.nextSibling; + if (referenceNode !== item.startNode) { + const nodesToMove = [item.startNode]; + while (nodesToMove[nodesToMove.length - 1] !== item.node) { + nodesToMove.push(nodesToMove[nodesToMove.length - 1].nextSibling); + } + TemplateEngine.#insertAllBefore(referenceNode.parentNode, referenceNode, nodesToMove); } - node.parentNode.insertBefore(value, node); - } else { + lastItem = item; + } + } + + // TODO: #254: Future state where the “moveBefore” API is better-supported. + // // Loops over given array of “entries” to manage an array of nodes. + // static #commitContentArrayEntries(node, startNode, entries) { + // const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE); + // if (!arrayState.map) { + // // There is no mapping in our state — create an empty one as our base. + // TemplateEngine.#clearObject(arrayState); + // arrayState.map = new Map(); + // } + // + // const idsToRemove = new Set(arrayState.map.keys()); + // const ids = new Set(); // Populated in “parseArrayEntry”. + // let reference = startNode.nextSibling; + // for (let index = 0; index < entries.length; index++) { + // const entry = entries[index]; + // const [id, rawResult] = TemplateEngine.#parseArrayEntry(entry, index, ids); + // let item = arrayState.map.get(id); + // if (item) { + // // Update existing item. + // idsToRemove.delete(id); + // if (!TemplateEngine.#canReuseDom(item.preparedResult, rawResult)) { + // const referenceWasStartNode = reference === item.startNode; + // TemplateEngine.#recreateArrayItem(item, rawResult); + // reference = referenceWasStartNode ? item.startNode : reference; + // } else { + // TemplateEngine.#update(item.preparedResult, rawResult); + // } + // } else { + // // Create new item. + // item = TemplateEngine.#createArrayItem(node, id, rawResult); + // arrayState.map.set(id, item); + // } + // // Move to the correct location + // if (item.startNode !== reference) { + // const nodesToMove = [item.startNode]; + // while (nodesToMove[nodesToMove.length - 1] !== item.node) { + // nodesToMove.push(nodesToMove[nodesToMove.length - 1].nextSibling); + // } + // TemplateEngine.#moveAllBefore(reference.parentNode, reference, nodesToMove); + // } + // + // // Move our position forward. + // reference = item.node.nextSibling; + // } + // + // // Remove any ids which are not longer in the entries. + // for (const id of idsToRemove) { + // const item = arrayState.map.get(id); + // TemplateEngine.#removeThrough(item.startNode, item.node); + // arrayState.map.delete(id); + // } + // } + + static #commitContentFragmentValue(node, startNode, value) { + if (value.childElementCount === 0) { + throw new Error(`Unexpected child element count of zero for given DocumentFragment.`); + } + const previousSibling = node.previousSibling; + if (previousSibling !== startNode) { + TemplateEngine.#removeBetween(startNode, node); + } + node.parentNode.insertBefore(value, node); + } + + static #commitContentTextValue(node, startNode, value) { // TODO: Is there a way to more-performantly skip this init step? E.g., if // the prior value here was not “unset” and we didn’t just reset? We // could cache the target node in these cases or something? @@ -526,6 +604,25 @@ class TemplateEngine { } else { previousSibling.textContent = value; } + } + + static #commitContent(node, startNode, value, lastValue) { + const category = TemplateEngine.#getCategory(value); + const lastCategory = TemplateEngine.#getCategory(lastValue); + if (category !== lastCategory && lastValue !== TemplateEngine.#UNSET) { + // Reset content under certain conditions. E.g., `map` >> `null`. + 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); + } + switch (category) { + case 'result': TemplateEngine.#commitContentResultValue(node, startNode, value); break; + case 'array': TemplateEngine.#commitContentArrayValues(node, startNode, value); break; + case 'map': TemplateEngine.#commitContentArrayEntries(node, startNode, value); break; + case 'fragment': TemplateEngine.#commitContentFragmentValue(node, startNode, value); break; + default: TemplateEngine.#commitContentTextValue(node, startNode, value); break; } } @@ -678,6 +775,17 @@ class TemplateEngine { return { startNode, node }; } + // TODO: #254: Future state when we leverage “moveBefore”. + // static #moveAllBefore(parentNode, referenceNode, nodes) { + // // Iterate backwards over the live node collection since we’re mutating it. + // // Note that passing “null” as the reference node moves nodes to the end. + // for (let iii = nodes.length - 1; iii >= 0; iii--) { + // const node = nodes[iii]; + // parentNode.moveBefore(node, referenceNode); + // referenceNode = node; + // } + // } + static #insertAllBefore(parentNode, referenceNode, nodes) { // Iterate backwards over the live node collection since we’re mutating it. // Note that passing “null” as the reference node appends nodes to the end.