diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c5f53f..a6faa90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,17 @@ jobs: - name: Run tests run: npm run ci + test-move-before: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + cache: 'npm' + - name: Install dependencies + run: npm install + - name: Run tests + run: npm run test-move-before + diff --git a/.gitignore b/.gitignore index 54c6c41..debe6a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules/ /coverage +/test/chrome-profile .idea diff --git a/TESTING.md b/TESTING.md index b6d2b00..18cc99f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -25,6 +25,12 @@ npm run ci ``` This will run the tests using Playwright’s headless browser setup across Chrome, Firefox, and WebKit (Safari-adjacent). This is ultimately what gets run in Github Actions to verify PRs. +To run all tests against Chrome with experimental `moveBefore` support added, execute: +```bash +npm run test-move-before +``` +This will start headless Chrome in a new profile with the `atomic-move` experimental flag set. This runs in a separate job in CI. + ## Running Individual Tests ### Headless Mode diff --git a/package.json b/package.json index 5b4f9d8..6ae5edb 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "scripts": { "test": "web-test-runner", "debug": "web-test-runner --manual --open", + "test-move-before": "USE_MOVE_BEFORE=1 web-test-runner", "ci": "web-test-runner --playwright --browsers chromium firefox webkit", "amd": "(echo \"define(() => {\n\" && cat src/idiomorph.js && echo \"\nreturn Idiomorph});\") > dist/idiomorph.amd.js", "cjs": "(cat src/idiomorph.js && echo \"\nmodule.exports = Idiomorph;\") > dist/idiomorph.cjs.js", diff --git a/src/idiomorph.js b/src/idiomorph.js index a454dcc..c14707c 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -70,6 +70,7 @@ * @property {boolean} [ignoreActiveValue] * @property {ConfigCallbacksInternal} callbacks * @property {ConfigHeadInternal} head + * @property {boolean} [twoPass] */ /** @@ -78,7 +79,7 @@ * @param {Element | Document} oldNode * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent * @param {Config} [config] - * @returns {undefined | HTMLCollection | Node[]} + * @returns {undefined | Node[]} */ // base IIFE to define idiomorph @@ -102,6 +103,7 @@ var Idiomorph = (function () { * @property {Set} deadIds * @property {ConfigInternal['callbacks']} callbacks * @property {ConfigInternal['head']} head + * @property {boolean|HTMLDivElement} [pantry] */ //============================================================================= @@ -152,7 +154,7 @@ var Idiomorph = (function () { * @param {Element | Document} oldNode * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent * @param {Config} [config] - * @returns {undefined | HTMLCollection | Node[]} + * @returns {undefined | Node[]} */ function morph(oldNode, newContent, config = {}) { @@ -176,7 +178,7 @@ var Idiomorph = (function () { * @param {Element} oldNode * @param {Element} normalizedNewContent * @param {MorphContext} ctx - * @returns {undefined | HTMLCollection| Node[]} + * @returns {undefined | Node[]} */ function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { if (ctx.head.block) { @@ -201,6 +203,9 @@ var Idiomorph = (function () { // innerHTML, so we are only updating the children morphChildren(normalizedNewContent, oldNode, ctx); + if (ctx.pantry) { + restoreFromPantry(oldNode, ctx); + } return Array.from(oldNode.children); } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { @@ -219,7 +224,11 @@ var Idiomorph = (function () { // if there was a best match, merge the siblings in too and return the // whole bunch if (morphedNode) { - return insertSiblings(previousSibling, morphedNode, nextSibling); + const elements = insertSiblings(previousSibling, morphedNode, nextSibling); + if (ctx.pantry) { + restoreFromPantry(morphedNode.parentNode, ctx); + } + return elements } } else { // otherwise nothing was added to the DOM @@ -713,11 +722,19 @@ var Idiomorph = (function () { ignoreActiveValue: mergedConfig.ignoreActiveValue, idMap: createIdMap(oldNode, newContent), deadIds: new Set(), + pantry: mergedConfig.twoPass && createPantry(), callbacks: mergedConfig.callbacks, head: mergedConfig.head } } + function createPantry() { + const pantry = document.createElement("div"); + pantry.hidden = true; + document.body.insertAdjacentElement("afterend", pantry); + return pantry; + } + /** * * @param {Node | null} node1 @@ -752,6 +769,9 @@ var Idiomorph = (function () { if (node1 == null || node2 == null) { return false; } + if (node1.id !== node2.id) { + return false; + } return node1.nodeType === node2.nodeType && // ok to cast: if one is not element, `tagName` will be undefined and we'll compare that /** @type {Element} */ (node1).tagName === /** @type {Element} */ (node2).tagName @@ -1063,11 +1083,82 @@ var Idiomorph = (function () { function removeNode(tempNode, ctx) { removeIdsFromConsideration(ctx, tempNode) if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; - - tempNode.parentNode?.removeChild(tempNode); + if (ctx.pantry && tempNode instanceof Element) { + moveToPantry(tempNode, ctx); + } else { + tempNode.parentNode?.removeChild(tempNode); + } ctx.callbacks.afterNodeRemoved(tempNode); } + /** + * + * @param {Element} node + * @param {MorphContext} ctx + */ + function moveToPantry(node, ctx) { + if (ctx.pantry instanceof HTMLDivElement) { + // If the node is a leaf (no children), process it, and then we're done + if (!node.hasChildNodes()) { + if (node.id) { + // @ts-ignore - use proposed moveBefore feature + if (ctx.pantry.moveBefore) { + // @ts-ignore - use proposed moveBefore feature + ctx.pantry.moveBefore(node, null); + } else { + ctx.pantry.insertBefore(node, null); + } + } + + // otherwise we need to process the children first + } else { + Array.from(node.children).forEach(child => { + moveToPantry(child, ctx); + }); + + // After processing children, process the current node + if (node.id) { + node.innerHTML = ''; + ctx.pantry.appendChild(node); + } else { + node.parentNode?.removeChild(node); + } + } + } + } + + /** + * + * @param {Node | null} root + * @param {MorphContext} ctx + */ + function restoreFromPantry(root, ctx) { + if (ctx.pantry instanceof HTMLDivElement && root instanceof Element) { + Array.from(ctx.pantry.children).reverse().forEach(element => { + const matchElement = root.querySelector(`#${element.id}`); + if (matchElement) { + // @ts-ignore - use proposed moveBefore feature + if (matchElement.parentElement?.moveBefore) { + // @ts-ignore - use proposed moveBefore feature + matchElement.parentElement.moveBefore(element, matchElement); + while (matchElement.hasChildNodes()) { + // @ts-ignore - use proposed moveBefore feature + element.moveBefore(matchElement.firstChild,null); + } + } else { + matchElement.before(element); + while (matchElement.firstChild) { + element.insertBefore(matchElement.firstChild,null) + } + } + syncNodeFrom(matchElement, element, ctx); + matchElement.remove(); + } + }); + ctx.pantry.remove(); + } + } + //============================================================================= // ID Set Functions //============================================================================= diff --git a/test/bootstrap.js b/test/bootstrap.js index 3923b42..5215181 100644 --- a/test/bootstrap.js +++ b/test/bootstrap.js @@ -48,7 +48,7 @@ describe("Bootstrap test", function(){ let d2 = div1.querySelector("#d2") let d3 = div1.querySelector("#d3") - let morphTo = '
E
F
D
'; + let morphTo = '
E
F
D
'; let div2 = make(morphTo) print(div1); @@ -56,7 +56,7 @@ describe("Bootstrap test", function(){ print(div1); // first paragraph should have been discarded in favor of later matches - d1.innerHTML.should.equal("A"); + d1.innerHTML.should.not.equal("D"); // second and third paragraph should have morphed d2.innerHTML.should.equal("E"); diff --git a/test/fidelity.js b/test/fidelity.js index ecb746c..665cbe6 100644 --- a/test/fidelity.js +++ b/test/fidelity.js @@ -34,7 +34,7 @@ describe("Tests to ensure that idiomorph merges properly", function(){ it('morphs multiple attributes correctly twice', function () { const a = `
A
`; - const b = `
B
`; + const b = `
B
`; const expectedA = make(a); const expectedB = make(b); const initial = make(a); diff --git a/test/htmx-integration.js b/test/htmx-integration.js index 5118e24..6a623bb 100644 --- a/test/htmx-integration.js +++ b/test/htmx-integration.js @@ -105,4 +105,59 @@ describe("Tests for the htmx integration", function() { initialBtn.classList.contains('bar').should.equal(true); }); + it('plays nice with hx-preserve', function() { + this.server.respondWith("GET", "/test", "
"); + let html = makeForHtmxTest("
"); + let button = document.getElementById('b1'); + button.click(); + this.server.respond(); + let input = document.getElementById('input'); + input.value.should.equal('preserve-me!'); + }); + + it('plays nice with swapping preserved inputs', function() { + this.server.respondWith("GET", "/test", ` +
+ + + +
+ `); + let html = makeForHtmxTest(` +
+ + + +
+ `); + document.getElementById('first').value = 'preserve first!'; + document.getElementById('second').value = 'preserve second!'; + let button = document.getElementById('b1'); + button.click(); + this.server.respond(); + Array.from(html.querySelectorAll("input")).map(e => e.value).should.eql(['preserve second!', 'preserve first!']); + }); + + it('plays nice with swapping preserved textareas', function() { + this.server.respondWith("GET", "/test", ` +
+ + + +
+ `); + let html = makeForHtmxTest(` +
+ + + +
+ `); + document.getElementById('first').innerHTML = 'preserve first!'; + document.getElementById('second').innerHTML = 'preserve second!'; + let button = document.getElementById('b1'); + button.click(); + this.server.respond(); + Array.from(html.querySelectorAll("textarea")).map(e => e.value).should.eql(['preserve second!', 'preserve first!']); + }); }) diff --git a/test/two-pass.js b/test/two-pass.js new file mode 100644 index 0000000..ef75958 --- /dev/null +++ b/test/two-pass.js @@ -0,0 +1,296 @@ +describe("Two-pass option for retaining more state", function(){ + + beforeEach(function() { + clearWorkArea(); + }); + + it('fails to preserve all non-attribute element state with single-pass option', function() + { + getWorkArea().append(make(` +
+ + +
+ `)); + document.getElementById("first").indeterminate = true + document.getElementById("second").indeterminate = true + + let finalSrc = ` +
+ + +
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, {morphStyle:'innerHTML'}); + + getWorkArea().innerHTML.should.equal(finalSrc); + const states = Array.from(getWorkArea().querySelectorAll("input")).map(e => e.indeterminate); + states.should.eql([true, false]); + }); + + it('preserves all non-attribute element state with two-pass option', function() + { + getWorkArea().append(make(` +
+ + +
+ `)); + document.getElementById("first").indeterminate = true + document.getElementById("second").indeterminate = true + + let finalSrc = ` +
+ + +
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, {morphStyle:'innerHTML',twoPass:true}); + + getWorkArea().innerHTML.should.equal(finalSrc); + const states = Array.from(getWorkArea().querySelectorAll("input")).map(e => e.indeterminate); + states.should.eql([true, true]); + }); + + it('preserves all non-attribute element state with two-pass option and outerHTML morphStyle', function() + { + const div = make(` +
+ + +
+ `); + getWorkArea().append(div); + document.getElementById("first").indeterminate = true + document.getElementById("second").indeterminate = true + + let finalSrc = ` +
+ + +
+ `; + Idiomorph.morph(div, finalSrc, {morphStyle:'outerHTML',twoPass:true}); + + getWorkArea().innerHTML.should.equal(finalSrc); + const states = Array.from(getWorkArea().querySelectorAll("input")).map(e => e.indeterminate); + states.should.eql([true, true]); + }); + + it('preserves non-attribute state when elements are moved to different levels of the DOM', function() + { + getWorkArea().append(make(` +
+ +
+ +
+
+ `)); + document.getElementById("first").indeterminate = true + document.getElementById("second").indeterminate = true + + let finalSrc = ` +
+ + +
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, {morphStyle:'innerHTML',twoPass:true}); + + getWorkArea().innerHTML.should.equal(finalSrc); + const states = Array.from(getWorkArea().querySelectorAll("input")).map(e => e.indeterminate); + states.should.eql([true, true]); + }); + + it('preserves non-attribute state when elements are moved between different containers', function() + { + getWorkArea().append(make(` +
+
+ +
+ +
+ `)); + document.getElementById("first").indeterminate = true + document.getElementById("second").indeterminate = true + + let finalSrc = ` +
+
+ +
+ +
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, {morphStyle:'innerHTML',twoPass:true}); + + getWorkArea().innerHTML.should.equal(finalSrc); + const states = Array.from(getWorkArea().querySelectorAll("input")).map(e => e.indeterminate); + states.should.eql([true, true]); + }); + + it('preserves non-attribute state when parents are reorderd', function() + { + getWorkArea().append(make(` +
+
+ +
+ +
+ `)); + document.getElementById("first").indeterminate = true + document.getElementById("second").indeterminate = true + + let finalSrc = ` +
+ +
+ +
+
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, {morphStyle:'innerHTML',twoPass:true}); + + getWorkArea().innerHTML.should.equal(finalSrc); + const states = Array.from(getWorkArea().querySelectorAll("input")).map(e => e.indeterminate); + states.should.eql([true, true]); + }); + + it('preserves focus state with two-pass option and outerHTML morphStyle', function() + { + const div = make(` +
+ + +
+ `); + getWorkArea().append(div); + document.getElementById("first").focus() + + let finalSrc = ` +
+ + +
+ `; + Idiomorph.morph(div, finalSrc, {morphStyle:'outerHTML',twoPass:true}); + + getWorkArea().innerHTML.should.equal(finalSrc); + if(document.body.moveBefore) { + document.activeElement.outerHTML.should.equal(document.getElementById("first").outerHTML); + } else { + document.activeElement.outerHTML.should.equal(document.body.outerHTML); + console.log('preserves focus state with two-pass option and outerHTML morphStyle test needs moveBefore enabled to work properly') + } + }); + + it('preserves focus state when elements are moved to different levels of the DOM', function() + { + getWorkArea().append(make(` +
+ +
+ +
+
+ `)); + document.getElementById("second").focus() + + let finalSrc = ` +
+ + +
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, {morphStyle:'innerHTML',twoPass:true}); + + getWorkArea().innerHTML.should.equal(finalSrc); + if(document.body.moveBefore) { + document.activeElement.outerHTML.should.equal(document.getElementById("second").outerHTML); + } else { + document.activeElement.outerHTML.should.equal(document.body.outerHTML); + console.log('preserves focus state when elements are moved to different levels of the DOM test needs moveBefore enabled to work properly') + } + }); + + it('preserves focus state when elements are moved between different containers', function() + { + getWorkArea().append(make(` +
+
+ +
+ +
+ `)); + document.getElementById("first").focus() + + let finalSrc = ` +
+
+ +
+ +
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, {morphStyle:'innerHTML',twoPass:true}); + + getWorkArea().innerHTML.should.equal(finalSrc); + if(document.body.moveBefore) { + document.activeElement.outerHTML.should.equal(document.getElementById("first").outerHTML); + } else { + document.activeElement.outerHTML.should.equal(document.body.outerHTML); + console.log('preserves focus state when elements are moved between different containers test needs moveBefore enabled to work properly') + } + }); + + it('preserves focus state when parents are reorderd', function() + { + getWorkArea().append(make(` +
+
+ +
+ +
+ `)); + document.getElementById("first").focus() + + let finalSrc = ` +
+ +
+ +
+
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, {morphStyle:'innerHTML',twoPass:true}); + + getWorkArea().innerHTML.should.equal(finalSrc); + if(document.body.moveBefore) { + document.activeElement.outerHTML.should.equal(document.getElementById("first").outerHTML); + } else { + document.activeElement.outerHTML.should.equal(document.body.outerHTML); + console.log('preserves focus state when parents are reorderd test needs moveBefore enabled to work properly') + } + }); +}); diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index 48e8f63..89dd5d3 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -1,7 +1,7 @@ -export default { - nodeResolve: true, - coverage: true, - files: "test/*.js", +import { chromeLauncher } from "@web/test-runner"; +import { exec } from "child_process"; + +let config = { testRunnerHtml: (testFramework) => ` @@ -29,5 +29,22 @@ export default { `, + + nodeResolve: true, + coverage: true, + files: "test/*.js", }; +if (process.env.USE_MOVE_BEFORE) { + // configure chrome to use a custom profile directory we control + config.browsers = [ + chromeLauncher({ launchOptions: { args: ['--user-data-dir=test/chrome-profile'] } }) + ] + exec([ + 'rm -rf test/chrome-profile', // clear profile out from last run + 'mkdir -p test/chrome-profile', // create from scratch + `echo '{"browser":{"enabled_labs_experiments":["atomic-move@1"]}}' > test/chrome-profile/Local\\ State`, // enable experiment + ].join(" && ")); +} + +export default config;