Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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){...}})` |
Expand All @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion perf/runner.html
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
4 changes: 3 additions & 1 deletion perf/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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",
},
],
Expand Down
40 changes: 35 additions & 5 deletions src/idiomorph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Node, Set<string>>} idMap
* @property {Set<string>} persistentIds
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}
}
}

Expand Down Expand Up @@ -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,
Expand Down
165 changes: 159 additions & 6 deletions test/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div><input value="foo"></div>');
let final = make('<input value="foo">');
final.value = "bar";
Idiomorph.morph(initial, final, { morphStyle: "innerHTML" });
Idiomorph.morph(initial, final, {
morphStyle: "innerHTML",
keepInputValues: false,
});
initial.innerHTML.should.equal('<input value="bar">');
});

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("<textarea>foo</textarea>");
let final = make("<textarea>foo</textarea>");
final.value = "bar";
Idiomorph.morph(initial, final, { morphStyle: "outerHTML" });
Idiomorph.morph(initial, final, {
morphStyle: "outerHTML",
keepInputValues: false,
});
initial.value.should.equal("bar");
});

Expand All @@ -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("<textarea>foo</textarea>");
initial.firstChild.value.should.equal("foo");
});

it("can morph textarea value to the same without changing value if keepInputValues true", function () {
let initial = make("<textarea>foo</textarea>");
let final = make("<textarea>foo</textarea>");
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("<textarea>foo</textarea>");
let final = make("<textarea>foo</textarea>");
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("<textarea>foo</textarea>");
let final = make("<textarea>foo2</textarea>");
initial.value = "bar";
Idiomorph.morph(initial, final, { keepInputValues: true });
initial.value.should.equal("foo2");
});

it("can morph input checked properly, remove checked", function () {
Expand Down Expand Up @@ -452,7 +490,59 @@ describe("Core morphing tests", function () {
document.body.removeChild(parent);
});

it("can morph <select> remove selected option properly", function () {
it("can morph input checked properly, set checked property to true again if keepInputValues false", function () {
let parent = make('<div><input type="checkbox" checked></div>');
document.body.append(parent);
let initial = parent.querySelector("input");
initial.checked = false;

let finalSrc = '<input type="checkbox" checked>';
Idiomorph.morph(initial, finalSrc, { keepInputValues: false });
initial.outerHTML.should.equal('<input type="checkbox" checked="">');
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('<div><input type="checkbox" checked></div>');
document.body.append(parent);
let initial = parent.querySelector("input");
initial.checked = false;

let finalSrc = '<input type="checkbox" checked>';
Idiomorph.morph(initial, finalSrc, { keepInputValues: true });
initial.outerHTML.should.equal('<input type="checkbox" checked="">');
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('<div><input type="checkbox"></div>');
document.body.append(parent);
let initial = parent.querySelector("input");
initial.checked = true;

let finalSrc = '<input type="checkbox">';
Idiomorph.morph(initial, finalSrc, { keepInputValues: false });
initial.outerHTML.should.equal('<input type="checkbox">');
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('<div><input type="checkbox"></div>');
document.body.append(parent);
let initial = parent.querySelector("input");
initial.checked = true;

let finalSrc = '<input type="checkbox">';
Idiomorph.morph(initial, finalSrc, { keepInputValues: true });
initial.outerHTML.should.equal('<input type="checkbox">');
initial.checked.should.equal(true);
document.body.removeChild(parent);
});

it("can morph <select> remove selected option properly with keepInputValues false old behaviour", function () {
let parent = make(`
<div>
<select>
Expand All @@ -476,7 +566,10 @@ describe("Core morphing tests", function () {
<option>1</option>
</select>
`;
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(`
Expand All @@ -492,6 +585,47 @@ describe("Core morphing tests", function () {
.should.eql([true, false]);
});

it("can morph <select> remove selected option properly if keepInputValues true", function () {
let parent = make(`
<div>
<select>
<option>0</option>
<option selected>1</option>
</select>
</div>
`);
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 = `
<select>
<option>0</option>
<option>1</option>
</select>
`;
Idiomorph.morph(parent, finalSrc, {
morphStyle: "innerHTML",
keepInputValues: true,
});
parent.innerHTML.should.equal(`
<select>
<option>0</option>
<option>1</option>
</select>
`);
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 <select> new selected option properly", function () {
let parent = make(`
<div>
Expand Down Expand Up @@ -606,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("<span>Bar</span>");
});

it("updates input when value attribute changes even with user input with keepInputValues true", function () {
let parent = make('<div><input value="foo"></div>');
document.body.append(parent);
let initial = parent.querySelector("input");

initial.value = "userTyped";

let finalSrc = '<input value="newServerValue">';
Idiomorph.morph(initial, finalSrc, {
morphStyle: "outerHTML",
keepInputValues: true,
});

initial.value.should.equal("newServerValue");
initial.getAttribute("value").should.equal("newServerValue");

document.body.removeChild(parent);
});
});