diff --git a/README.md b/README.md index c9f6bb1..a75f27e 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 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 +- 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/perf/runner.html b/perf/runner.html index aaa6f17..b0b8b18 100644 --- a/perf/runner.html +++ b/perf/runner.html @@ -28,11 +28,14 @@ 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..6a74876 100755 --- a/perf/runner.js +++ b/perf/runner.js @@ -19,6 +19,8 @@ if (benchmarks.length === 0) { } benchmarks.forEach((benchmark) => { + const keepInputValues = + process.env.KEEP_INPUT_VALUES === "true" ? "&keepInputValues=true" : ""; const config = { root: "..", benchmarks: [ @@ -29,7 +31,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/src/idiomorph.js b/src/idiomorph.js index 2994d51..29e3bd7 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 @@ -646,10 +649,14 @@ var Idiomorph = (function () { ctx, ); } 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); + 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); + } + } } } ctx.callbacks.afterNodeMorphed(oldNode, newContent); @@ -681,6 +688,17 @@ 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 @@ -700,7 +718,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 +1037,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/core.js b/test/core.js index 5e10210..7b37508 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 '); + 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); + }); });