diff --git a/CHANGELOG.md b/CHANGELOG.md index b176dbb..13a7fca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Added: * Warn in the console when duplicate ids are detected during a morph, since they can cause subtle state loss (@botandrose) #142 + * New off-by-default `skipUnchanged` option that skips morphing subtrees whose old and new content are already identical, for large speedups on mostly-unchanged pages (@myabc) #144 * Fixed: * Fix TypeError when restoring focus to an element that doesn't support text selection (@emaia) #150 diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 9ad3326..3f01f01 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -13,6 +13,7 @@ npm run perf [versus=morphdom] [benchmarks...] ### Arguments * The optional `versus` argument can be used to compare with morphdom (the default), previous Idiomorph releases specified by the git release tag, e.g. `v0.3.0`, or a path to a local `.js` file that defines `Idiomorph`. * The optional `benchmarks` argument can be used to run specific benchmarks, defaulting to all of them. +* The optional `--options=''` argument passes a config object to `Idiomorph.morph` in both runs, e.g. `--options='{"skipUnchanged":true}'`. A previous version that does not know the option ignores it, which makes this the way to measure an opt-in option against the code it replaces. Pass it after `--` so npm does not swallow it. Examples: Running only the `table` and `checkboxes` benchmarks against morphdom: @@ -36,6 +37,14 @@ cp src/idiomorph.js tmp/before.js # then edit src/idiomorph.js npm run perf tmp/before.js ``` +Measuring an opt-in option against the current code: +```bash +cp src/idiomorph.js tmp/before.js +npm run perf -- tmp/before.js --options='{"skipUnchanged":true}' +``` + ## Adding Benchmarks You can add more benchmarks by creating new `benchmark-name.old.html` and `benchmark-name.new.html` files in the `perf/benchmarks` directory, containing the starting and final morph HTML respectively. +`deep-last-leaf` is generated by `node perf/generate-deep-last-leaf.js` and is the worst case among the committed benchmarks for the `skipUnchanged` option: every section changes, but only in its last and deepest text node, so every `isEqualNode` call walks a whole subtree before failing. Its equal filler siblings at each level are still individually skippable, so in practice it measures near-parity rather than a large regression. + diff --git a/README.md b/README.md index 422d520..af36ef2 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Idiomorph supports the following options: | `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})` | | `restoreFocus: true` | If `true`, idiomorph will attempt to restore any lost focus and selection state after the morph. | `Idiomorph.morph(..., {restoreFocus:true})` | +| `skipUnchanged: false` | If `true`, idiomorph will not descend into subtrees that are already identical. See the [skipping unchanged content](#skipping-unchanged-content) section | `Idiomorph.morph(..., {skipUnchanged: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){...}})` | @@ -107,6 +108,18 @@ 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 | +### Skipping unchanged content + +Most real-world morphs change only a small part of a large page. With `skipUnchanged: true`, idiomorph compares each pair of old and new elements with [`isEqualNode`](https://developer.mozilla.org/en-US/docs/Web/API/Node/isEqualNode) and, when they are identical, leaves the whole subtree alone instead of walking into it. On pages that mostly stay the same this makes morphs many times faster. On pages where most of the content changes between morphs, though, the extra comparisons can cost slightly more than they save, so the option is best suited to mostly-unchanged pages. + +Idiomorph's own morphing produces the same DOM with or without the option. What changes is what your callbacks see, and callbacks that rely on being called for every node can therefore behave differently: + +* `beforeNodeMorphed` and `afterNodeMorphed` are still called for the root of an unchanged subtree, so you can still veto it, but they are **not** called for its descendants. `beforeAttributeUpdated` is never called inside it either, since nothing changes. +* Hidden state is respected: an ``, ``); + textarea.value = "typed"; + Idiomorph.morph(textarea, ``, { + skipUnchanged: true, + }); + textarea.value.should.equal("foo"); + }); + + // The following five cases morph a `, + ); + select.value = "b"; + Idiomorph.morph( + select, + ``, + { skipUnchanged: true }, + ); + select.value.should.equal("a"); + }); + + it("single select whose selected attribute is on a later option is reset as before", function () { + const select = make( + ``, + ); + select.value = "a"; + Idiomorph.morph( + select, + ``, + { skipUnchanged: true }, + ); + select.value.should.equal("b"); + }); + + it("multiple select with a changed option is reset as before", function () { + const select = make( + ``, + ); + // WebKit leaves select.options empty for a detached-fragment select, + // so address the option through the DOM instead. + const options = select.querySelectorAll("option"); + options[1].selected = true; + Idiomorph.morph( + select, + ``, + { skipUnchanged: true }, + ); + options[1].selected.should.equal(false); + }); + + it("listbox (size > 1) select with a changed option is reset as before", function () { + const select = make( + ``, + ); + select.value = "b"; + Idiomorph.morph( + select, + ``, + { skipUnchanged: true }, + ); + select.selectedIndex.should.equal(-1); + }); + + it("option outside a select uses defaultSelected", function () { + const datalist = make( + ``, + ); + datalist.firstChild.selected = true; + Idiomorph.morph( + datalist, + ``, + { + skipUnchanged: true, + }, + ); + datalist.firstChild.selected.should.equal(false); + }); + + // The cases above morph a container whose own markup never changes, so + // isUnskippable is only ever asked about the container (never the option) + // until Task 3's pre-scan exists. Morphing the option itself as the root + // exercises defaultSelectedOf's branches directly today. + + it("standalone option (outside any select) is reset via defaultSelected", function () { + const option = make(``); + option.selected = true; + Idiomorph.morph(option, ``, { + skipUnchanged: true, + }); + option.selected.should.equal(false); + }); + + it("option inside a multiple select is reset via defaultSelected", function () { + const select = make( + ``, + ); + const option = select.querySelectorAll("option")[1]; + option.selected = true; + Idiomorph.morph(option, ``, { + skipUnchanged: true, + }); + option.selected.should.equal(false); + }); + + it("option inside a listbox (size > 1) select is reset via defaultSelected", function () { + const select = make( + ``, + ); + const option = select.querySelectorAll("option")[1]; + option.selected = true; + Idiomorph.morph(option, ``, { + skipUnchanged: true, + }); + option.selected.should.equal(false); + }); + + it("file input is never considered dirty", function () { + const calls = []; + const input = make(``); + Idiomorph.morph(input, ``, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", ``], + ["after", ``], + ]); + }); + }); + + describe("clean form controls are skipped", function () { + it("skips an attribute-less checkbox (value defaults to 'on')", function () { + const calls = []; + const initial = make(`
`); + Idiomorph.morph(initial, `
`, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", `
`], + ["after", `
`], + ]); + }); + + it("skips a single select whose selected attribute is on a later option", function () { + const calls = []; + const html = `
`; + const initial = make(html); + Idiomorph.morph(initial, html, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", html], + ["after", html], + ]); + }); + + it("skips a single select whose first option is disabled", function () { + const calls = []; + const html = `
`; + const initial = make(html); + Idiomorph.morph(initial, html, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", html], + ["after", html], + ]); + }); + + it("skips a single select whose options are all disabled", function () { + const calls = []; + const html = `
`; + const initial = make(html); + Idiomorph.morph(initial, html, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", html], + ["after", html], + ]); + }); + + it("skips a checkbox with a value attribute", function () { + const calls = []; + const initial = make(`
`); + Idiomorph.morph( + initial, + `
`, + { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }, + ); + calls.should.eql([ + ["before", `
`], + ["after", `
`], + ]); + }); + + it("skips a clean text input, textarea and select", function () { + // the first option of an untouched single-select reports selected=true but + // defaultSelected=false; the effective-default rule must still treat it as clean + const calls = []; + const html = `
`; + const initial = make(html); + Idiomorph.morph(initial, html, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", html], + ["after", html], + ]); + }); + + // Same reasoning as the "option inside a ... select is reset" tests above: + // morphing the option itself as the root exercises defaultSelectedOf's + // single-select branches (explicit `selected`, implicit first option, + // disabled options) directly today. + + it("skips a clean option that is explicitly selected", function () { + const calls = []; + const select = make( + ``, + ); + const option = select.querySelectorAll("option")[1]; + Idiomorph.morph(option, ``, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", ``], + ["after", ``], + ]); + }); + + it("skips a clean, implicitly-selected first option", function () { + const calls = []; + const select = make( + ``, + ); + const option = select.querySelectorAll("option")[0]; + Idiomorph.morph(option, ``, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", ``], + ["after", ``], + ]); + }); + + it("skips a clean option following a disabled option", function () { + const calls = []; + const select = make( + ``, + ); + const option = select.querySelectorAll("option")[1]; + Idiomorph.morph(option, ``, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", ``], + ["after", ``], + ]); + }); + + it("skips a clean option when every option in the select is disabled", function () { + const calls = []; + const select = make( + ``, + ); + const option = select.querySelectorAll("option")[0]; + Idiomorph.morph(option, ``, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", ``], + ["after", ``], + ]); + }); + }); + + describe("templates and the head are never skipped", function () { + it("re-morphs template content even though the template's outerHTML looks unchanged", function () { + // isEqualNode does not look inside .content, so without the special case + // this mutation would be missed and the skip would leave "changed" in place + const template = make(``); + template.content.querySelector("p").textContent = "changed"; + Idiomorph.morph(template, ``, { + skipUnchanged: true, + }); + template.content.querySelector("p").textContent.should.equal("x"); + }); + + it("still applies head re-append side effects when the head looks unchanged", function () { + // im-re-append removes and re-adds a matching head element even when it's + // otherwise identical; isEqualNode can't see that, so without the special + // case the head would be skipped and the re-append would never happen + const parser = new DOMParser(); + const doc = parser.parseFromString( + ``, + "text/html", + ); + const originalMeta = doc.head.firstChild; + Idiomorph.morph(doc.head, ``, { + skipUnchanged: true, + }); + doc.head.firstChild.should.not.equal(originalMeta); + }); + }); + + describe("dirty state on the new side", function () { + // The dirty input inside `final` is a descendant of the pair root `
`, + // not the pair itself; this only passes once Task 3 adds the pre-scan. + it("morphs when the new input carries a programmatic value", function () { + const initial = make(`
`); + const final = make(`
`); + final.querySelector("input").value = "b"; + Idiomorph.morph(initial, final, { skipUnchanged: true }); + initial.querySelector("input").value.should.equal("b"); + }); + }); + + describe("hidden state mutated by beforeNodeMorphed", function () { + it("honours a callback that sets the new node's value", function () { + const initial = make(``); + Idiomorph.morph(initial, ``, { + skipUnchanged: true, + callbacks: { + beforeNodeMorphed: (oldNode, newNode) => { + newNode.value = "x"; + }, + }, + }); + initial.value.should.equal("x"); + }); + + it("keeps working for the #132 two-way-binding pattern (copy the typed value onto the new node)", function () { + // https://github.com/bigskysoftware/idiomorph/issues/132 — incoming markup has no + // value attribute; the app preserves user input from beforeNodeMorphed by setting + // the *attribute* on newNode (syncInputValue only ever consults newNode's + // "value" attribute, never a bare .value property assignment) + const initial = make(`
`); + initial.querySelector("input").value = "typed"; + Idiomorph.morph(initial, `
`, { + skipUnchanged: true, + callbacks: { + beforeNodeMorphed: (oldNode, newNode) => { + if (oldNode instanceof HTMLInputElement) + newNode.setAttribute("value", oldNode.value); + }, + }, + }); + initial.querySelector("input").value.should.equal("typed"); + }); + + it("honours a callback that sets the old node's value (reset to the new value, as today)", function () { + const initial = make(``); + Idiomorph.morph(initial, ``, { + skipUnchanged: true, + callbacks: { + beforeNodeMorphed: (oldNode) => { + oldNode.value = "client"; + }, + }, + }); + initial.value.should.equal("a"); + }); + + it("does not honour a callback that mutates a descendant of an equal root (documented limitation)", function () { + const initial = make(`
`); + Idiomorph.morph(initial, `
`, { + skipUnchanged: true, + callbacks: { + beforeNodeMorphed: (oldNode) => { + if (oldNode.tagName === "DIV") { + oldNode.querySelector("input").value = "client"; + } + }, + }, + }); + // the div was equal and clean at skip time, so the mutated input inside it was never visited + initial.querySelector("input").value.should.equal("client"); + }); + }); + + describe("ancestors of unskippable nodes are not skipped", function () { + it("resets a typed input nested inside an otherwise equal subtree", function () { + const initial = make(`

`); + initial.querySelector("input").value = "typed"; + Idiomorph.morph(initial, `

`, { + skipUnchanged: true, + }); + initial.querySelector("input").value.should.equal(""); + }); + + it("resets two typed inputs under the same parent", function () { + const initial = make(`
`); + const [first, second] = initial.querySelectorAll("input"); + first.value = "one"; + second.value = "two"; + Idiomorph.morph(initial, `
`, { + skipUnchanged: true, + }); + first.value.should.equal(""); + second.value.should.equal(""); + }); + + it("morphs a template whose content differs", function () { + const initial = make(`
`); + Idiomorph.morph(initial, `
`, { + skipUnchanged: true, + }); + initial.querySelector("template").innerHTML.should.equal(`B`); + }); + + it("resets a typed input inside template content", function () { + const initial = make(`
`); + initial.querySelector("template").content.querySelector("input").value = + "typed"; + Idiomorph.morph(initial, `
`, { + skipUnchanged: true, + }); + initial + .querySelector("template") + .content.querySelector("input") + .value.should.equal(""); + }); + + it("morphs a nested template inside template content", function () { + const initial = make( + `
`, + ); + Idiomorph.morph( + initial, + `
`, + { skipUnchanged: true }, + ); + initial + .querySelector("template") + .content.querySelector("template") + .innerHTML.should.equal(`B`); + }); + }); + + describe("head handling keeps its side effects", function () { + it("re-appends im-re-append elements in an otherwise equal document", function () { + const html = + "Foo"; + const doc = parseHTML(html); + const originalHead = doc.head; + const originalTitle = originalHead.children[0]; + Idiomorph.morph(doc, html, { skipUnchanged: true }); + originalHead.should.equal(doc.head); + originalHead.children.length.should.equal(1); + originalHead.children[0].outerHTML.should.equal( + 'Foo', + ); + originalHead.children[0].should.not.equal(originalTitle); + }); + + it("skips the body of an equal document while still visiting the head", function () { + const calls = []; + const html = + "Foo

x

"; + const doc = parseHTML(html); + Idiomorph.morph(doc, html, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls + .filter( + ([, label]) => label.startsWith("

") || label.startsWith(""), + ) + .should.eql([]); + calls + .some(([, label]) => label.startsWith("")) + .should.equal(true); + }); + }); + + describe("root shapes", function () { + it("scans string content (template fragment root without matches)", function () { + // the dirty input sits under an equal

, so only the pre-scan of the parsed + // fragment can stop the

from being skipped + const initial = make(`

`); + initial.querySelector("input").value = "typed"; + Idiomorph.morph(initial, `

`, { + morphStyle: "innerHTML", + skipUnchanged: true, + }); + initial.querySelector("input").value.should.equal(""); + }); + + it("scans a new node that has siblings (SlicedParentNode root)", function () { + const initial = make(`
`); + const wrapper = make( + `
`, + ); + const final = wrapper.firstElementChild; + final.querySelector("input").value = "b"; + Idiomorph.morph(initial, final, { skipUnchanged: true }); + initial.querySelector("input").value.should.equal("b"); + }); + + it("checks the old root itself when it is a dirty control (outerHTML morph)", function () { + // the pre-scan must include the root: querySelectorAll excludes it + const attributeCalls = []; + const parent = make(`
`); + const input = parent.querySelector("input"); + input.value = "typed"; + Idiomorph.morph(input, ``, { + skipUnchanged: true, + callbacks: { + // neutralise the skip-time re-check so only the pre-scan can catch this + beforeNodeMorphed: (oldNode) => { + if (oldNode.tagName === "INPUT") oldNode.value = ""; + }, + beforeAttributeUpdated: (name, node, type) => { + attributeCalls.push([name, type]); + }, + }, + }); + // syncInputValue ran (it asks before removing the value attribute); a skip would never ask + attributeCalls.should.eql([["value", "remove"]]); + }); + }); + + describe("interplay with other options", function () { + it("ignoreActive still wins over skipUnchanged", function () { + getWorkArea().append(make(`
`)); + const input = document.getElementById("active"); + input.focus(); + input.value = "typed"; + Idiomorph.morph( + getWorkArea(), + `
`, + { morphStyle: "innerHTML", skipUnchanged: true, ignoreActive: true }, + ); + input.getAttribute("class").should.equal("a"); + input.value.should.equal("typed"); + }); + }); + + describe("sibling options coupled through implicit selection", function () { + it("selects the first option when a later option's explicit selection is removed", function () { + // neither option's own markup changes match isUnskippable's per-node check: the + // first option was never dirty, and the second option's own selectedness (now + // false either way) matches its own new-side default too. Only comparing the + // live `selected` property across the pair reveals that the first option's + // effective selectedness has changed as a side effect of the second option's + // attribute being removed. + const select = make( + ``, + ); + Idiomorph.morph( + select, + ``, + { skipUnchanged: true }, + ); + select.value.should.equal("a"); + select.outerHTML.should.equal( + ``, + ); + }); + }); + + describe("a cleared single-select is backed at the select level", function () { + // A single-select whose selection was cleared (selectedIndex = -1) has no + // option whose own `selected` differs from its own default, so the + // per-option check cannot see it; only comparing the select's live + // selectedIndex against the effective parse default reveals the dirtiness. + // The invariant: morphing with skipUnchanged on must produce the same DOM + // as morphing with it off, which re-applies the implicit selection. + // Built with DOM APIs so the fixtures are portable to WebKit (which + // leaves select.options empty for markup parsed in a detached fragment). + function buildDivSelect(specs) { + const div = document.createElement("div"); + const select = document.createElement("select"); + for (const spec of specs) { + const option = document.createElement("option"); + option.textContent = spec.text; + if (spec.disabled) option.disabled = true; + if (spec.selectedAttr) option.setAttribute("selected", ""); + select.appendChild(option); + } + div.appendChild(select); + return div; + } + + it("re-selects the implicit first option of a cleared single-select", function () { + const div = buildDivSelect([{ text: "a" }, { text: "b" }]); + const html = div.outerHTML; + div.querySelector("select").selectedIndex = -1; + Idiomorph.morph(div, html, { skipUnchanged: true }); + div.querySelector("select").selectedIndex.should.equal(0); + }); + + it("re-selects the first enabled option of a cleared single-select whose first option is disabled", function () { + const div = buildDivSelect([ + { text: "a", disabled: true }, + { text: "b" }, + ]); + const html = div.outerHTML; + div.querySelector("select").selectedIndex = -1; + Idiomorph.morph(div, html, { skipUnchanged: true }); + div.querySelector("select").selectedIndex.should.equal(1); + }); + + it("still skips a genuinely clean single-select", function () { + const calls = []; + const div = buildDivSelect([{ text: "a" }, { text: "b" }]); + const html = div.outerHTML; + Idiomorph.morph(div, html, { + skipUnchanged: true, + callbacks: recordingCallbacks(calls), + }); + calls.should.eql([ + ["before", html], + ["after", html], + ]); + }); + }); + }); +});