From 6cfb8805a5a7f0ccf81e3ad3e400fdffdd1331b4 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Fri, 24 Oct 2025 23:54:57 +1300 Subject: [PATCH 1/6] Implement keepInputValues --- README.md | 23 +++++++++++ src/idiomorph.js | 24 ++++++++++-- test_keepInputValues.html | 81 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 test_keepInputValues.html diff --git a/README.md b/README.md index c9f6bb1..85af69e 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Idiomorph supports the following options: | `morphStyle: 'outerHTML'` | The style of morphing to use, either `outerHTML` or `innerHTML` | `Idiomorph.morph(..., {morphStyle:'innerHTML'})` | | `ignoreActive: false` | If `true`, idiomorph will skip the active element | `Idiomorph.morph(..., {ignoreActive:true})` | | `ignoreActiveValue: false` | If `true`, idiomorph will not update the active element's value | `Idiomorph.morph(..., {ignoreActiveValue:true})` | +| `keepInputValues: false` | If `true`, idiomorph will preserve user input values and skip morphing children when innerHTML is unchanged | `Idiomorph.morph(..., {keepInputValues:true})` | | `restoreFocus: true` | If `true`, idiomorph will attempt to restore any lost focus and selection state after the morph. | `Idiomorph.morph(..., {restoreFocus:true})` | | `head: {style: 'merge', ...}` | Allows you to control how the `head` tag is merged. See the [head](#the-head-tag) section for more details | `Idiomorph.morph(..., {head:{style:'merge'}})` | | `callbacks: {...}` | Allows you to insert callbacks when events occur in the morph lifecycle. See the callback table below | `Idiomorph.morph(..., {callbacks:{beforeNodeAdded:function(node){...}})` | @@ -118,6 +119,28 @@ of the algorithm. | afterNodeRemoved(node) | Called after a node is removed from the DOM | none | | beforeAttributeUpdated(attributeName, node, mutationType) | Called before an attribute on an element is updated or removed (`mutationType` is either "update" or "remove") | return false to not update or remove the attribute | +### Input Value Preservation + +The `keepInputValues` option provides a way to preserve user input values during morphing operations. When set to `true`: + +* Input elements (text, checkbox, radio, etc.) will retain their current values instead of being updated to match the new content +* Textarea elements will preserve their current content +* Child morphing is skipped when the innerHTML hasn't changed, improving performance +* Only textarea elements with changed `defaultValue` will have their values updated + +This is particularly useful in scenarios where: +- Users are typing in forms and you want to update other parts of the page without losing their input +- You're doing partial page updates and want to preserve form state +- You're implementing real-time features while maintaining user input + +```js +// Preserve user input values during morph +Idiomorph.morph(formElement, newFormHTML, {keepInputValues: true}); + +// Default behavior - input values are updated to match new content +Idiomorph.morph(formElement, newFormHTML, {keepInputValues: false}); +``` + ### The `head` tag The head tag is treated specially by idiomorph because: diff --git a/src/idiomorph.js b/src/idiomorph.js index 2994d51..25ed6be 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -28,6 +28,7 @@ * @property {'outerHTML' | 'innerHTML'} [morphStyle] * @property {boolean} [ignoreActive] * @property {boolean} [ignoreActiveValue] + * @property {boolean} [keepInputValues] * @property {boolean} [restoreFocus] * @property {ConfigCallbacks} [callbacks] * @property {ConfigHead} [head] @@ -69,6 +70,7 @@ * @property {'outerHTML' | 'innerHTML'} morphStyle * @property {boolean} [ignoreActive] * @property {boolean} [ignoreActiveValue] + * @property {boolean} [keepInputValues] * @property {boolean} [restoreFocus] * @property {ConfigCallbacksInternal} callbacks * @property {ConfigHeadInternal} head @@ -106,6 +108,7 @@ var Idiomorph = (function () { * @property {ConfigInternal['morphStyle']} morphStyle * @property {ConfigInternal['ignoreActive']} ignoreActive * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue + * @property {ConfigInternal['keepInputValues']} keepInputValues * @property {ConfigInternal['restoreFocus']} restoreFocus * @property {Map>} idMap * @property {Set} persistentIds @@ -648,8 +651,11 @@ var Idiomorph = (function () { } else { morphAttributes(oldNode, newContent, ctx); if (!ignoreValueOfActiveElement(oldNode, ctx)) { - // @ts-ignore newContent can be a node here because .firstChild will be null - morphChildren(ctx, oldNode, newContent); + // Only morph children if keepInputValues is false or innerHTML has changed + if (!ctx.keepInputValues || oldNode.innerHTML !== newContent.innerHTML) { + // @ts-ignore newContent can be a node here because .firstChild will be null + morphChildren(ctx, oldNode, newContent); + } } } ctx.callbacks.afterNodeMorphed(oldNode, newContent); @@ -700,7 +706,18 @@ var Idiomorph = (function () { } if (!ignoreValueOfActiveElement(oldElt, ctx)) { - syncInputValue(oldElt, newElt, ctx); + if (!ctx.keepInputValues) { + syncInputValue(oldElt, newElt, ctx); + } else if ( + oldElt instanceof HTMLTextAreaElement && + newElt instanceof HTMLTextAreaElement && + oldElt.defaultValue != newElt.defaultValue + ) { + // handle updates to TextArea value when keepInputValues is true + if (!ignoreAttribute("value", oldElt, "update", ctx)) { + oldElt.value = newElt.value; + } + } } } @@ -1008,6 +1025,7 @@ var Idiomorph = (function () { morphStyle: morphStyle, ignoreActive: mergedConfig.ignoreActive, ignoreActiveValue: mergedConfig.ignoreActiveValue, + keepInputValues: mergedConfig.keepInputValues, restoreFocus: mergedConfig.restoreFocus, idMap: idMap, persistentIds: persistentIds, diff --git a/test_keepInputValues.html b/test_keepInputValues.html new file mode 100644 index 0000000..e40ec15 --- /dev/null +++ b/test_keepInputValues.html @@ -0,0 +1,81 @@ + + + + Test keepInputValues Feature + + + +

Test keepInputValues Feature

+ +
+ + + +
+ + + + + +
+ + + + \ No newline at end of file From 2484716bf26f41730ea688fe5354f7474590cd0c Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Sat, 25 Oct 2025 00:18:16 +1300 Subject: [PATCH 2/6] Add tests and update per runner --- perf/runner.html | 3 +- perf/runner.js | 3 +- test/core.js | 146 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/perf/runner.html b/perf/runner.html index aaa6f17..23af25b 100644 --- a/perf/runner.html +++ b/perf/runner.html @@ -28,11 +28,12 @@ fetch(endUrl).then((r) => r.text()), ]).then(([start, end]) => { document.body.innerHTML = start; + const keepInputValues = params.get("keepInputValues") === "true"; bench.start(); if (params.get("using").includes("morphdom")) { morphdom(document.body.firstElementChild, end); } else { - Idiomorph.morph(document.body.firstElementChild, end); + Idiomorph.morph(document.body.firstElementChild, end, { keepInputValues }); } bench.stop(); }); diff --git a/perf/runner.js b/perf/runner.js index 7a15e80..94f3d47 100755 --- a/perf/runner.js +++ b/perf/runner.js @@ -19,6 +19,7 @@ if (benchmarks.length === 0) { } benchmarks.forEach((benchmark) => { + const keepInputValues = process.env.KEEP_INPUT_VALUES === "true" ? "&keepInputValues=true" : ""; const config = { root: "..", benchmarks: [ @@ -29,7 +30,7 @@ benchmarks.forEach((benchmark) => { }, { name: `${benchmark}: src/idiomorph.js`, - url: `../perf/runner.html?using=idiomorph&benchmark=${benchmark}`, + url: `../perf/runner.html?using=idiomorph&benchmark=${benchmark}${keepInputValues}`, browser: "chrome-headless", }, ], diff --git a/test/core.js b/test/core.js index 5e10210..b73cabf 100644 --- a/test/core.js +++ b/test/core.js @@ -373,19 +373,25 @@ describe("Core morphing tests", function () { document.body.removeChild(parent); }); - it("can morph input value properly because value property is special and doesnt reflect", function () { + it("can morph input value properly because value property is special and doesnt reflect with keepInputValues false", function () { let initial = make('
'); let final = make(''); final.value = "bar"; - Idiomorph.morph(initial, final, { morphStyle: "innerHTML" }); + Idiomorph.morph(initial, final, { + morphStyle: "innerHTML", + keepInputValues: false, + }); initial.innerHTML.should.equal(''); }); - it("can morph textarea value properly because value property is special and doesnt reflect", function () { + it("can morph textarea value properly because value property is special and doesnt reflect with keepInputValues false", function () { let initial = make(""); let final = make(""); final.value = "bar"; - Idiomorph.morph(initial, final, { morphStyle: "outerHTML" }); + Idiomorph.morph(initial, final, { + morphStyle: "outerHTML", + keepInputValues: false, + }); initial.value.should.equal("bar"); }); @@ -395,11 +401,43 @@ describe("Core morphing tests", function () { final.value = "bar"; Idiomorph.morph(initial, final, { morphStyle: "innerHTML", + keepInputValues: false, callbacks: { beforeAttributeUpdated: (attr, to, updatetype) => false, }, }); initial.innerHTML.should.equal(""); + initial.firstChild.value.should.equal("foo"); + }); + + it("can morph textarea value to the same without changing value if keepInputValues true", function () { + let initial = make(""); + let final = make(""); + initial.value = "bar"; + Idiomorph.morph(initial, final, { + morphStyle: "outerHTML", + keepInputValues: true, + }); + initial.value.should.equal("bar"); + }); + + it("can morph textarea value to the same resets value if keepInputValues false", function () { + let initial = make(""); + let final = make(""); + initial.value = "bar"; + Idiomorph.morph(initial, final, { + morphStyle: "outerHTML", + keepInputValues: false, + }); + initial.value.should.equal("foo"); + }); + + it("can morph textarea and updates if changing value if keepInputValues true", function () { + let initial = make(""); + let final = make(""); + initial.value = "bar"; + Idiomorph.morph(initial, final, { keepInputValues: true }); + initial.value.should.equal("foo2"); }); it("can morph input checked properly, remove checked", function () { @@ -452,7 +490,59 @@ describe("Core morphing tests", function () { document.body.removeChild(parent); }); - it("can morph '); + document.body.append(parent); + let initial = parent.querySelector("input"); + initial.checked = false; + + let finalSrc = ''; + Idiomorph.morph(initial, finalSrc, { keepInputValues: false }); + initial.outerHTML.should.equal(''); + initial.checked.should.equal(true); + document.body.removeChild(parent); + }); + + it("can morph input checked properly, when checked does not reset checkbox state when no change if keepInputValues true", function () { + let parent = make('
'); + document.body.append(parent); + let initial = parent.querySelector("input"); + initial.checked = false; + + let finalSrc = ''; + Idiomorph.morph(initial, finalSrc, { keepInputValues: true }); + initial.outerHTML.should.equal(''); + initial.checked.should.equal(false); + document.body.removeChild(parent); + }); + + it("can morph input checked properly, set checked property to false again if keepInputValues false", function () { + let parent = make('
'); + document.body.append(parent); + let initial = parent.querySelector("input"); + initial.checked = true; + + let finalSrc = ''; + Idiomorph.morph(initial, finalSrc, { keepInputValues: false }); + initial.outerHTML.should.equal(''); + initial.checked.should.equal(false); + document.body.removeChild(parent); + }); + + it("can morph input checked properly, when not checked does not reset checkbox state when no change if keepInputValues true", function () { + let parent = make('
'); + document.body.append(parent); + let initial = parent.querySelector("input"); + initial.checked = true; + + let finalSrc = ''; + Idiomorph.morph(initial, finalSrc, { keepInputValues: true }); + initial.outerHTML.should.equal(''); + initial.checked.should.equal(true); + document.body.removeChild(parent); + }); + + it("can morph @@ -476,7 +566,10 @@ describe("Core morphing tests", function () { `; - Idiomorph.morph(parent, finalSrc, { morphStyle: "innerHTML" }); + Idiomorph.morph(parent, finalSrc, { + morphStyle: "innerHTML", + keepInputValues: false, + }); // FIXME? morph writes different html explicitly selecting first element // is this a problem at all? parent.innerHTML.should.equal(` @@ -492,6 +585,47 @@ describe("Core morphing tests", function () { .should.eql([true, false]); }); + it("can morph + + + + + `); + document.body.append(parent); + let select = parent.querySelector("select"); + let options = parent.querySelectorAll("option"); + select.selectedIndex.should.equal(1); + Array.from(select.selectedOptions).should.eql([options[1]]); + Array.from(options) + .map((o) => o.selected) + .should.eql([false, true]); + + let finalSrc = ` + + `; + Idiomorph.morph(parent, finalSrc, { + morphStyle: "innerHTML", + keepInputValues: true, + }); + parent.innerHTML.should.equal(` + + `); + select.selectedIndex.should.equal(0); + Array.from(select.selectedOptions).should.eql([options[0]]); + Array.from(options) + .map((o) => o.selected) + .should.eql([true, false]); + }); + it("can morph - - - - - - - - -
- - - - \ No newline at end of file From 9a05971c93af4edfe7d2009698f0ed1cde6f1de4 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Tue, 4 Nov 2025 12:51:34 +1300 Subject: [PATCH 4/6] use isEqualNode for better performance before and after attribute and fix input default value change issue --- src/idiomorph.js | 19 +++++++++++++------ test/core.js | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/idiomorph.js b/src/idiomorph.js index 25ed6be..79d3ba2 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -649,12 +649,13 @@ var Idiomorph = (function () { ctx, ); } else { - morphAttributes(oldNode, newContent, ctx); - if (!ignoreValueOfActiveElement(oldNode, ctx)) { - // Only morph children if keepInputValues is false or innerHTML has changed - if (!ctx.keepInputValues || oldNode.innerHTML !== newContent.innerHTML) { - // @ts-ignore newContent can be a node here because .firstChild will be null - morphChildren(ctx, oldNode, newContent); + if (!ctx.keepInputValues || !oldNode.isEqualNode(newContent)) { + morphAttributes(oldNode, newContent, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + if (!ctx.keepInputValues || !oldNode.isEqualNode(newContent)) { + // @ts-ignore newContent can be a node here because .firstChild will be null + morphChildren(ctx, oldNode, newContent); + } } } } @@ -687,6 +688,12 @@ var Idiomorph = (function () { } if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) { oldElt.setAttribute(newAttribute.name, newAttribute.value); + // With keepInputValues, update input.value when value attribute changes + if (ctx.keepInputValues && newAttribute.name === "value" && + oldElt instanceof HTMLInputElement && newElt instanceof HTMLInputElement && + newElt.type !== "file" && !ignoreValueOfActiveElement(oldElt, ctx)) { + oldElt.value = newElt.value; + } } } // iterate backwards to avoid skipping over items when a delete occurs diff --git a/test/core.js b/test/core.js index b73cabf..91d9256 100644 --- a/test/core.js +++ b/test/core.js @@ -740,4 +740,23 @@ describe("Core morphing tests", function () { // included in the persistent ID set or it will pantry the id'ed node in error initial.outerHTML.should.equal("Bar"); }); + + it("updates input when value attribute changes even with user input with keepInputValues true", function () { + let parent = make('
'); + document.body.append(parent); + let initial = parent.querySelector("input"); + + initial.value = "userTyped"; + + let finalSrc = ''; + Idiomorph.morph(initial, finalSrc, { + morphStyle: "outerHTML", + keepInputValues: true + }); + + initial.value.should.equal("newServerValue"); + initial.getAttribute("value").should.equal("newServerValue"); + + document.body.removeChild(parent); + }); }); From e0251d612f3e7d60ffcc682a7828b96ad510eaaf Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Tue, 4 Nov 2025 12:54:57 +1300 Subject: [PATCH 5/6] add input default value change to doco --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 85af69e..a75f27e 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ The `keepInputValues` option provides a way to preserve user input values during * Input elements (text, checkbox, radio, etc.) will retain their current values instead of being updated to match the new content * Textarea elements will preserve their current content * Child morphing is skipped when the innerHTML hasn't changed, improving performance -* Only textarea elements with changed `defaultValue` will have their values updated +* Only inputs and textarea elements with a changed default value will have their values updated This is particularly useful in scenarios where: - Users are typing in forms and you want to update other parts of the page without losing their input From 81467025e20d3e24d26d84d135176cdd5762cfc1 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Tue, 4 Nov 2025 13:09:04 +1300 Subject: [PATCH 6/6] format fix --- perf/runner.html | 4 +++- perf/runner.js | 3 ++- src/idiomorph.js | 11 ++++++++--- test/core.js | 12 ++++++------ 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/perf/runner.html b/perf/runner.html index 23af25b..b0b8b18 100644 --- a/perf/runner.html +++ b/perf/runner.html @@ -33,7 +33,9 @@ if (params.get("using").includes("morphdom")) { morphdom(document.body.firstElementChild, end); } else { - Idiomorph.morph(document.body.firstElementChild, end, { keepInputValues }); + Idiomorph.morph(document.body.firstElementChild, end, { + keepInputValues, + }); } bench.stop(); }); diff --git a/perf/runner.js b/perf/runner.js index 94f3d47..6a74876 100755 --- a/perf/runner.js +++ b/perf/runner.js @@ -19,7 +19,8 @@ if (benchmarks.length === 0) { } benchmarks.forEach((benchmark) => { - const keepInputValues = process.env.KEEP_INPUT_VALUES === "true" ? "&keepInputValues=true" : ""; + const keepInputValues = + process.env.KEEP_INPUT_VALUES === "true" ? "&keepInputValues=true" : ""; const config = { root: "..", benchmarks: [ diff --git a/src/idiomorph.js b/src/idiomorph.js index 79d3ba2..29e3bd7 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -689,9 +689,14 @@ var Idiomorph = (function () { if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) { oldElt.setAttribute(newAttribute.name, newAttribute.value); // With keepInputValues, update input.value when value attribute changes - if (ctx.keepInputValues && newAttribute.name === "value" && - oldElt instanceof HTMLInputElement && newElt instanceof HTMLInputElement && - newElt.type !== "file" && !ignoreValueOfActiveElement(oldElt, ctx)) { + if ( + ctx.keepInputValues && + newAttribute.name === "value" && + oldElt instanceof HTMLInputElement && + newElt instanceof HTMLInputElement && + newElt.type !== "file" && + !ignoreValueOfActiveElement(oldElt, ctx) + ) { oldElt.value = newElt.value; } } diff --git a/test/core.js b/test/core.js index 91d9256..7b37508 100644 --- a/test/core.js +++ b/test/core.js @@ -745,18 +745,18 @@ describe("Core morphing tests", function () { let parent = make('
'); document.body.append(parent); let initial = parent.querySelector("input"); - + initial.value = "userTyped"; - + let finalSrc = ''; - Idiomorph.morph(initial, finalSrc, { + Idiomorph.morph(initial, finalSrc, { morphStyle: "outerHTML", - keepInputValues: true + keepInputValues: true, }); - + initial.value.should.equal("newServerValue"); initial.getAttribute("value").should.equal("newServerValue"); - + document.body.removeChild(parent); }); });