From fd7b660183653f7923864e3df76951290a37adab Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 5 Sep 2026 18:31:28 +0100 Subject: [PATCH 1/3] Add opt-in skipping of unchanged subtrees Preserve root callbacks and exclude hidden form state, templates and head handling from subtree skipping. Include documentation and browser tests, adapted to upstream realm-safe node handling. --- CHANGELOG.md | 1 + README.md | 13 + src/idiomorph.js | 272 ++++++++++++++ test/index.html | 1 + test/skip-unchanged.js | 824 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1111 insertions(+) create mode 100644 test/skip-unchanged.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a82b008..d4e1b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * Added: * Publish TypeScript declarations, generated from the JSDoc types already in the source (@myabc) #152 * 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/README.md b/README.md index dc8abcb..0d489a7 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,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){...}})` | @@ -109,6 +110,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 () { + for (const kind of ["Text", "Comment"]) { + it(`morphs a ${kind.toLowerCase()} root with skipping enabled`, function () { + const factory = kind === "Text" ? "createTextNode" : "createComment"; + const root = document[factory]("old"); + getWorkArea().append(root); + Idiomorph.morph(root, document[factory]("new"), { + skipUnchanged: true, + }); + root.nodeValue.should.equal("new"); + }); + } + + it("resets a dirty input inside an equal subtree from another realm", function () { + const iframe = document.createElement("iframe"); + getWorkArea().append(iframe); + const doc = iframe.contentDocument; + const html = `

`; + doc.body.innerHTML = html; + const root = doc.body.firstElementChild; + const input = root.querySelector("input"); + input.value = "typed"; + Idiomorph.morph(root, html, { skipUnchanged: true }); + input.value.should.equal("default"); + }); + + 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], + ]); + }); + }); + }); +}); From ae65fbc314f0dfde5b086c2d3b8c1bb95da4807e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 5 Sep 2026 18:31:29 +0100 Subject: [PATCH 2/3] Add benchmarks and morph options for subtree skipping --- PERFORMANCE.md | 9 + perf/benchmarks/backlogs.new.html | 947 ++++++++++++++++++++++++ perf/benchmarks/backlogs.old.html | 947 ++++++++++++++++++++++++ perf/benchmarks/deep-last-leaf.new.html | 862 +++++++++++++++++++++ perf/benchmarks/deep-last-leaf.old.html | 862 +++++++++++++++++++++ perf/generate-deep-last-leaf.js | 32 + perf/runner.html | 3 +- perf/runner.js | 14 +- 8 files changed, 3673 insertions(+), 3 deletions(-) create mode 100644 perf/benchmarks/backlogs.new.html create mode 100644 perf/benchmarks/backlogs.old.html create mode 100644 perf/benchmarks/deep-last-leaf.new.html create mode 100644 perf/benchmarks/deep-last-leaf.old.html create mode 100644 perf/generate-deep-last-leaf.js 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/perf/benchmarks/backlogs.new.html b/perf/benchmarks/backlogs.new.html new file mode 100644 index 0000000..2acdf31 --- /dev/null +++ b/perf/benchmarks/backlogs.new.html @@ -0,0 +1,947 @@ + + +
+
+ +
+

+ Backlog283 +

+
+ + + +
+
+
+
+
+
+
+

+ + Foobar + +

+ 2 +
+
+
+
+
+ +
    +
  • +
    +
    +
    +
    + FEATURE +
    +
    + MYPN2-1 +
    +
    + + New +
    +
    +
    +
    + + + + 0 story points + + + + Normal + + + + + +
    +
    + Story 0 +
    +
    +
  • +
+
+ +
+
+ +
+
+
+
+ +
+

+ Sprints17 +

+
+ + + +
+
+
+
+
+
+
+

+ + Sprint 1 + +

+ 8 +
+
+
+ +
+ In planning + + + 0 points + + + +  –  + +
+
+ +
+
+
+ +
    +
  • +
    +
    +
    +
    + FEATURE +
    +
    + MYPN2-5 +
    +
    + + New +
    +
    +
    +
    + + + + 0 story points + + + + Normal + + + + + +
    +
    + Story 4 +
    +
    +
  • +
  • +
    +
    +
    +
    + FEATURE +
    +
    + MYPN2-6 +
    +
    + + New +
    +
    +
    +
    + + + + 0 story points + + + + Normal + + + + + +
    +
    + Story 5 +
    +
    +
  • +
+
+ +
+
+
+
+
+
diff --git a/perf/benchmarks/backlogs.old.html b/perf/benchmarks/backlogs.old.html new file mode 100644 index 0000000..f9ba3b0 --- /dev/null +++ b/perf/benchmarks/backlogs.old.html @@ -0,0 +1,947 @@ + + +
+
+ +
+

+ Backlog283 +

+
+ + + +
+
+
+
+
+
+
+

+ + Foobar + +

+ 2 +
+
+
+
+
+ +
    +
  • +
    +
    +
    +
    + FEATURE +
    +
    + MYPN2-5 +
    +
    + + New +
    +
    +
    +
    + + + + 0 story points + + + + Normal + + + + + +
    +
    + Story 4 +
    +
    +
  • +
  • +
    +
    +
    +
    + FEATURE +
    +
    + MYPN2-1 +
    +
    + + New +
    +
    +
    +
    + + + + 0 story points + + + + Normal + + + + + +
    +
    + Story 0 +
    +
    +
  • +
+
+ +
+
+ +
+
+
+
+ +
+

+ Sprints17 +

+
+ + + +
+
+
+
+
+
+
+

+ + Sprint 1 + +

+ 8 +
+
+
+ +
+ In planning + + + 0 points + + + +  –  + +
+
+ +
+
+
+ +
    +
  • +
    +
    +
    +
    + FEATURE +
    +
    + MYPN2-6 +
    +
    + + New +
    +
    +
    +
    + + + + 0 story points + + + + Normal + + + + + +
    +
    + Story 5 +
    +
    +
  • +
+
+ +
+
+
+
+
+
diff --git a/perf/benchmarks/deep-last-leaf.new.html b/perf/benchmarks/deep-last-leaf.new.html new file mode 100644 index 0000000..fb422d9 --- /dev/null +++ b/perf/benchmarks/deep-last-leaf.new.html @@ -0,0 +1,862 @@ +
+
+
+

s0d1i0

+

s0d1i1

+
+

s0d2i0

+

s0d2i1

+
+

s0d3i0

+

s0d3i1

+
+

s0d4i0

+

s0d4i1

+
+

s0d5i0

+

s0d5i1

+
+

s0d6i0

+

s0d6i1

+
+

s0d7i0

+

s0d7i1

+
+

s0d8i0

+

s0d8i1

+
+

s0d9i0

+

s0d9i1

+
+

s0d10i0

+

s0d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s1d1i0

+

s1d1i1

+
+

s1d2i0

+

s1d2i1

+
+

s1d3i0

+

s1d3i1

+
+

s1d4i0

+

s1d4i1

+
+

s1d5i0

+

s1d5i1

+
+

s1d6i0

+

s1d6i1

+
+

s1d7i0

+

s1d7i1

+
+

s1d8i0

+

s1d8i1

+
+

s1d9i0

+

s1d9i1

+
+

s1d10i0

+

s1d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s2d1i0

+

s2d1i1

+
+

s2d2i0

+

s2d2i1

+
+

s2d3i0

+

s2d3i1

+
+

s2d4i0

+

s2d4i1

+
+

s2d5i0

+

s2d5i1

+
+

s2d6i0

+

s2d6i1

+
+

s2d7i0

+

s2d7i1

+
+

s2d8i0

+

s2d8i1

+
+

s2d9i0

+

s2d9i1

+
+

s2d10i0

+

s2d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s3d1i0

+

s3d1i1

+
+

s3d2i0

+

s3d2i1

+
+

s3d3i0

+

s3d3i1

+
+

s3d4i0

+

s3d4i1

+
+

s3d5i0

+

s3d5i1

+
+

s3d6i0

+

s3d6i1

+
+

s3d7i0

+

s3d7i1

+
+

s3d8i0

+

s3d8i1

+
+

s3d9i0

+

s3d9i1

+
+

s3d10i0

+

s3d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s4d1i0

+

s4d1i1

+
+

s4d2i0

+

s4d2i1

+
+

s4d3i0

+

s4d3i1

+
+

s4d4i0

+

s4d4i1

+
+

s4d5i0

+

s4d5i1

+
+

s4d6i0

+

s4d6i1

+
+

s4d7i0

+

s4d7i1

+
+

s4d8i0

+

s4d8i1

+
+

s4d9i0

+

s4d9i1

+
+

s4d10i0

+

s4d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s5d1i0

+

s5d1i1

+
+

s5d2i0

+

s5d2i1

+
+

s5d3i0

+

s5d3i1

+
+

s5d4i0

+

s5d4i1

+
+

s5d5i0

+

s5d5i1

+
+

s5d6i0

+

s5d6i1

+
+

s5d7i0

+

s5d7i1

+
+

s5d8i0

+

s5d8i1

+
+

s5d9i0

+

s5d9i1

+
+

s5d10i0

+

s5d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s6d1i0

+

s6d1i1

+
+

s6d2i0

+

s6d2i1

+
+

s6d3i0

+

s6d3i1

+
+

s6d4i0

+

s6d4i1

+
+

s6d5i0

+

s6d5i1

+
+

s6d6i0

+

s6d6i1

+
+

s6d7i0

+

s6d7i1

+
+

s6d8i0

+

s6d8i1

+
+

s6d9i0

+

s6d9i1

+
+

s6d10i0

+

s6d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s7d1i0

+

s7d1i1

+
+

s7d2i0

+

s7d2i1

+
+

s7d3i0

+

s7d3i1

+
+

s7d4i0

+

s7d4i1

+
+

s7d5i0

+

s7d5i1

+
+

s7d6i0

+

s7d6i1

+
+

s7d7i0

+

s7d7i1

+
+

s7d8i0

+

s7d8i1

+
+

s7d9i0

+

s7d9i1

+
+

s7d10i0

+

s7d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s8d1i0

+

s8d1i1

+
+

s8d2i0

+

s8d2i1

+
+

s8d3i0

+

s8d3i1

+
+

s8d4i0

+

s8d4i1

+
+

s8d5i0

+

s8d5i1

+
+

s8d6i0

+

s8d6i1

+
+

s8d7i0

+

s8d7i1

+
+

s8d8i0

+

s8d8i1

+
+

s8d9i0

+

s8d9i1

+
+

s8d10i0

+

s8d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s9d1i0

+

s9d1i1

+
+

s9d2i0

+

s9d2i1

+
+

s9d3i0

+

s9d3i1

+
+

s9d4i0

+

s9d4i1

+
+

s9d5i0

+

s9d5i1

+
+

s9d6i0

+

s9d6i1

+
+

s9d7i0

+

s9d7i1

+
+

s9d8i0

+

s9d8i1

+
+

s9d9i0

+

s9d9i1

+
+

s9d10i0

+

s9d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s10d1i0

+

s10d1i1

+
+

s10d2i0

+

s10d2i1

+
+

s10d3i0

+

s10d3i1

+
+

s10d4i0

+

s10d4i1

+
+

s10d5i0

+

s10d5i1

+
+

s10d6i0

+

s10d6i1

+
+

s10d7i0

+

s10d7i1

+
+

s10d8i0

+

s10d8i1

+
+

s10d9i0

+

s10d9i1

+
+

s10d10i0

+

s10d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s11d1i0

+

s11d1i1

+
+

s11d2i0

+

s11d2i1

+
+

s11d3i0

+

s11d3i1

+
+

s11d4i0

+

s11d4i1

+
+

s11d5i0

+

s11d5i1

+
+

s11d6i0

+

s11d6i1

+
+

s11d7i0

+

s11d7i1

+
+

s11d8i0

+

s11d8i1

+
+

s11d9i0

+

s11d9i1

+
+

s11d10i0

+

s11d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s12d1i0

+

s12d1i1

+
+

s12d2i0

+

s12d2i1

+
+

s12d3i0

+

s12d3i1

+
+

s12d4i0

+

s12d4i1

+
+

s12d5i0

+

s12d5i1

+
+

s12d6i0

+

s12d6i1

+
+

s12d7i0

+

s12d7i1

+
+

s12d8i0

+

s12d8i1

+
+

s12d9i0

+

s12d9i1

+
+

s12d10i0

+

s12d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s13d1i0

+

s13d1i1

+
+

s13d2i0

+

s13d2i1

+
+

s13d3i0

+

s13d3i1

+
+

s13d4i0

+

s13d4i1

+
+

s13d5i0

+

s13d5i1

+
+

s13d6i0

+

s13d6i1

+
+

s13d7i0

+

s13d7i1

+
+

s13d8i0

+

s13d8i1

+
+

s13d9i0

+

s13d9i1

+
+

s13d10i0

+

s13d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s14d1i0

+

s14d1i1

+
+

s14d2i0

+

s14d2i1

+
+

s14d3i0

+

s14d3i1

+
+

s14d4i0

+

s14d4i1

+
+

s14d5i0

+

s14d5i1

+
+

s14d6i0

+

s14d6i1

+
+

s14d7i0

+

s14d7i1

+
+

s14d8i0

+

s14d8i1

+
+

s14d9i0

+

s14d9i1

+
+

s14d10i0

+

s14d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s15d1i0

+

s15d1i1

+
+

s15d2i0

+

s15d2i1

+
+

s15d3i0

+

s15d3i1

+
+

s15d4i0

+

s15d4i1

+
+

s15d5i0

+

s15d5i1

+
+

s15d6i0

+

s15d6i1

+
+

s15d7i0

+

s15d7i1

+
+

s15d8i0

+

s15d8i1

+
+

s15d9i0

+

s15d9i1

+
+

s15d10i0

+

s15d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s16d1i0

+

s16d1i1

+
+

s16d2i0

+

s16d2i1

+
+

s16d3i0

+

s16d3i1

+
+

s16d4i0

+

s16d4i1

+
+

s16d5i0

+

s16d5i1

+
+

s16d6i0

+

s16d6i1

+
+

s16d7i0

+

s16d7i1

+
+

s16d8i0

+

s16d8i1

+
+

s16d9i0

+

s16d9i1

+
+

s16d10i0

+

s16d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s17d1i0

+

s17d1i1

+
+

s17d2i0

+

s17d2i1

+
+

s17d3i0

+

s17d3i1

+
+

s17d4i0

+

s17d4i1

+
+

s17d5i0

+

s17d5i1

+
+

s17d6i0

+

s17d6i1

+
+

s17d7i0

+

s17d7i1

+
+

s17d8i0

+

s17d8i1

+
+

s17d9i0

+

s17d9i1

+
+

s17d10i0

+

s17d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s18d1i0

+

s18d1i1

+
+

s18d2i0

+

s18d2i1

+
+

s18d3i0

+

s18d3i1

+
+

s18d4i0

+

s18d4i1

+
+

s18d5i0

+

s18d5i1

+
+

s18d6i0

+

s18d6i1

+
+

s18d7i0

+

s18d7i1

+
+

s18d8i0

+

s18d8i1

+
+

s18d9i0

+

s18d9i1

+
+

s18d10i0

+

s18d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
+
+

s19d1i0

+

s19d1i1

+
+

s19d2i0

+

s19d2i1

+
+

s19d3i0

+

s19d3i1

+
+

s19d4i0

+

s19d4i1

+
+

s19d5i0

+

s19d5i1

+
+

s19d6i0

+

s19d6i1

+
+

s19d7i0

+

s19d7i1

+
+

s19d8i0

+

s19d8i1

+
+

s19d9i0

+

s19d9i1

+
+

s19d10i0

+

s19d10i1

+ changed +
+
+
+
+
+
+
+
+
+
+
+
diff --git a/perf/benchmarks/deep-last-leaf.old.html b/perf/benchmarks/deep-last-leaf.old.html new file mode 100644 index 0000000..b4abe8c --- /dev/null +++ b/perf/benchmarks/deep-last-leaf.old.html @@ -0,0 +1,862 @@ +
+
+
+

s0d1i0

+

s0d1i1

+
+

s0d2i0

+

s0d2i1

+
+

s0d3i0

+

s0d3i1

+
+

s0d4i0

+

s0d4i1

+
+

s0d5i0

+

s0d5i1

+
+

s0d6i0

+

s0d6i1

+
+

s0d7i0

+

s0d7i1

+
+

s0d8i0

+

s0d8i1

+
+

s0d9i0

+

s0d9i1

+
+

s0d10i0

+

s0d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s1d1i0

+

s1d1i1

+
+

s1d2i0

+

s1d2i1

+
+

s1d3i0

+

s1d3i1

+
+

s1d4i0

+

s1d4i1

+
+

s1d5i0

+

s1d5i1

+
+

s1d6i0

+

s1d6i1

+
+

s1d7i0

+

s1d7i1

+
+

s1d8i0

+

s1d8i1

+
+

s1d9i0

+

s1d9i1

+
+

s1d10i0

+

s1d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s2d1i0

+

s2d1i1

+
+

s2d2i0

+

s2d2i1

+
+

s2d3i0

+

s2d3i1

+
+

s2d4i0

+

s2d4i1

+
+

s2d5i0

+

s2d5i1

+
+

s2d6i0

+

s2d6i1

+
+

s2d7i0

+

s2d7i1

+
+

s2d8i0

+

s2d8i1

+
+

s2d9i0

+

s2d9i1

+
+

s2d10i0

+

s2d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s3d1i0

+

s3d1i1

+
+

s3d2i0

+

s3d2i1

+
+

s3d3i0

+

s3d3i1

+
+

s3d4i0

+

s3d4i1

+
+

s3d5i0

+

s3d5i1

+
+

s3d6i0

+

s3d6i1

+
+

s3d7i0

+

s3d7i1

+
+

s3d8i0

+

s3d8i1

+
+

s3d9i0

+

s3d9i1

+
+

s3d10i0

+

s3d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s4d1i0

+

s4d1i1

+
+

s4d2i0

+

s4d2i1

+
+

s4d3i0

+

s4d3i1

+
+

s4d4i0

+

s4d4i1

+
+

s4d5i0

+

s4d5i1

+
+

s4d6i0

+

s4d6i1

+
+

s4d7i0

+

s4d7i1

+
+

s4d8i0

+

s4d8i1

+
+

s4d9i0

+

s4d9i1

+
+

s4d10i0

+

s4d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s5d1i0

+

s5d1i1

+
+

s5d2i0

+

s5d2i1

+
+

s5d3i0

+

s5d3i1

+
+

s5d4i0

+

s5d4i1

+
+

s5d5i0

+

s5d5i1

+
+

s5d6i0

+

s5d6i1

+
+

s5d7i0

+

s5d7i1

+
+

s5d8i0

+

s5d8i1

+
+

s5d9i0

+

s5d9i1

+
+

s5d10i0

+

s5d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s6d1i0

+

s6d1i1

+
+

s6d2i0

+

s6d2i1

+
+

s6d3i0

+

s6d3i1

+
+

s6d4i0

+

s6d4i1

+
+

s6d5i0

+

s6d5i1

+
+

s6d6i0

+

s6d6i1

+
+

s6d7i0

+

s6d7i1

+
+

s6d8i0

+

s6d8i1

+
+

s6d9i0

+

s6d9i1

+
+

s6d10i0

+

s6d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s7d1i0

+

s7d1i1

+
+

s7d2i0

+

s7d2i1

+
+

s7d3i0

+

s7d3i1

+
+

s7d4i0

+

s7d4i1

+
+

s7d5i0

+

s7d5i1

+
+

s7d6i0

+

s7d6i1

+
+

s7d7i0

+

s7d7i1

+
+

s7d8i0

+

s7d8i1

+
+

s7d9i0

+

s7d9i1

+
+

s7d10i0

+

s7d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s8d1i0

+

s8d1i1

+
+

s8d2i0

+

s8d2i1

+
+

s8d3i0

+

s8d3i1

+
+

s8d4i0

+

s8d4i1

+
+

s8d5i0

+

s8d5i1

+
+

s8d6i0

+

s8d6i1

+
+

s8d7i0

+

s8d7i1

+
+

s8d8i0

+

s8d8i1

+
+

s8d9i0

+

s8d9i1

+
+

s8d10i0

+

s8d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s9d1i0

+

s9d1i1

+
+

s9d2i0

+

s9d2i1

+
+

s9d3i0

+

s9d3i1

+
+

s9d4i0

+

s9d4i1

+
+

s9d5i0

+

s9d5i1

+
+

s9d6i0

+

s9d6i1

+
+

s9d7i0

+

s9d7i1

+
+

s9d8i0

+

s9d8i1

+
+

s9d9i0

+

s9d9i1

+
+

s9d10i0

+

s9d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s10d1i0

+

s10d1i1

+
+

s10d2i0

+

s10d2i1

+
+

s10d3i0

+

s10d3i1

+
+

s10d4i0

+

s10d4i1

+
+

s10d5i0

+

s10d5i1

+
+

s10d6i0

+

s10d6i1

+
+

s10d7i0

+

s10d7i1

+
+

s10d8i0

+

s10d8i1

+
+

s10d9i0

+

s10d9i1

+
+

s10d10i0

+

s10d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s11d1i0

+

s11d1i1

+
+

s11d2i0

+

s11d2i1

+
+

s11d3i0

+

s11d3i1

+
+

s11d4i0

+

s11d4i1

+
+

s11d5i0

+

s11d5i1

+
+

s11d6i0

+

s11d6i1

+
+

s11d7i0

+

s11d7i1

+
+

s11d8i0

+

s11d8i1

+
+

s11d9i0

+

s11d9i1

+
+

s11d10i0

+

s11d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s12d1i0

+

s12d1i1

+
+

s12d2i0

+

s12d2i1

+
+

s12d3i0

+

s12d3i1

+
+

s12d4i0

+

s12d4i1

+
+

s12d5i0

+

s12d5i1

+
+

s12d6i0

+

s12d6i1

+
+

s12d7i0

+

s12d7i1

+
+

s12d8i0

+

s12d8i1

+
+

s12d9i0

+

s12d9i1

+
+

s12d10i0

+

s12d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s13d1i0

+

s13d1i1

+
+

s13d2i0

+

s13d2i1

+
+

s13d3i0

+

s13d3i1

+
+

s13d4i0

+

s13d4i1

+
+

s13d5i0

+

s13d5i1

+
+

s13d6i0

+

s13d6i1

+
+

s13d7i0

+

s13d7i1

+
+

s13d8i0

+

s13d8i1

+
+

s13d9i0

+

s13d9i1

+
+

s13d10i0

+

s13d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s14d1i0

+

s14d1i1

+
+

s14d2i0

+

s14d2i1

+
+

s14d3i0

+

s14d3i1

+
+

s14d4i0

+

s14d4i1

+
+

s14d5i0

+

s14d5i1

+
+

s14d6i0

+

s14d6i1

+
+

s14d7i0

+

s14d7i1

+
+

s14d8i0

+

s14d8i1

+
+

s14d9i0

+

s14d9i1

+
+

s14d10i0

+

s14d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s15d1i0

+

s15d1i1

+
+

s15d2i0

+

s15d2i1

+
+

s15d3i0

+

s15d3i1

+
+

s15d4i0

+

s15d4i1

+
+

s15d5i0

+

s15d5i1

+
+

s15d6i0

+

s15d6i1

+
+

s15d7i0

+

s15d7i1

+
+

s15d8i0

+

s15d8i1

+
+

s15d9i0

+

s15d9i1

+
+

s15d10i0

+

s15d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s16d1i0

+

s16d1i1

+
+

s16d2i0

+

s16d2i1

+
+

s16d3i0

+

s16d3i1

+
+

s16d4i0

+

s16d4i1

+
+

s16d5i0

+

s16d5i1

+
+

s16d6i0

+

s16d6i1

+
+

s16d7i0

+

s16d7i1

+
+

s16d8i0

+

s16d8i1

+
+

s16d9i0

+

s16d9i1

+
+

s16d10i0

+

s16d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s17d1i0

+

s17d1i1

+
+

s17d2i0

+

s17d2i1

+
+

s17d3i0

+

s17d3i1

+
+

s17d4i0

+

s17d4i1

+
+

s17d5i0

+

s17d5i1

+
+

s17d6i0

+

s17d6i1

+
+

s17d7i0

+

s17d7i1

+
+

s17d8i0

+

s17d8i1

+
+

s17d9i0

+

s17d9i1

+
+

s17d10i0

+

s17d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s18d1i0

+

s18d1i1

+
+

s18d2i0

+

s18d2i1

+
+

s18d3i0

+

s18d3i1

+
+

s18d4i0

+

s18d4i1

+
+

s18d5i0

+

s18d5i1

+
+

s18d6i0

+

s18d6i1

+
+

s18d7i0

+

s18d7i1

+
+

s18d8i0

+

s18d8i1

+
+

s18d9i0

+

s18d9i1

+
+

s18d10i0

+

s18d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
+
+

s19d1i0

+

s19d1i1

+
+

s19d2i0

+

s19d2i1

+
+

s19d3i0

+

s19d3i1

+
+

s19d4i0

+

s19d4i1

+
+

s19d5i0

+

s19d5i1

+
+

s19d6i0

+

s19d6i1

+
+

s19d7i0

+

s19d7i1

+
+

s19d8i0

+

s19d8i1

+
+

s19d9i0

+

s19d9i1

+
+

s19d10i0

+

s19d10i1

+ original +
+
+
+
+
+
+
+
+
+
+
+
diff --git a/perf/generate-deep-last-leaf.js b/perf/generate-deep-last-leaf.js new file mode 100644 index 0000000..bb03d4f --- /dev/null +++ b/perf/generate-deep-last-leaf.js @@ -0,0 +1,32 @@ +// Generates the worst case for skipUnchanged: every section differs, but only +// in its last, deepest text node, so every ancestor's isEqualNode walks its +// whole subtree before failing. Deterministic; re-run to regenerate. +import fs from "node:fs"; + +const SECTIONS = 20; +const DEPTH = 10; +const FILLERS_PER_LEVEL = 2; + +function level(section, depth, changed) { + const fillers = Array.from( + { length: FILLERS_PER_LEVEL }, + (_, i) => `

s${section}d${depth}i${i}

`, + ).join(""); + if (depth === DEPTH) { + const leaf = changed ? "changed" : "original"; + return `
${fillers}${leaf}
`; + } + return `
${fillers}${level(section, depth + 1, changed)}
`; +} + +function document(changed) { + const sections = Array.from( + { length: SECTIONS }, + (_, i) => `
${level(i, 1, changed)}
`, + ).join("\n"); + return `
\n${sections}\n
\n`; +} + +const dir = new URL("benchmarks/", import.meta.url); +fs.writeFileSync(new URL("deep-last-leaf.old.html", dir), document(false)); +fs.writeFileSync(new URL("deep-last-leaf.new.html", dir), document(true)); diff --git a/perf/runner.html b/perf/runner.html index 6f0ccf0..39c10cf 100644 --- a/perf/runner.html +++ b/perf/runner.html @@ -24,6 +24,7 @@ const benchmark = params.get("benchmark"); const startUrl = `/perf/benchmarks/${benchmark}.old.html`; const endUrl = `/perf/benchmarks/${benchmark}.new.html`; + const options = JSON.parse(params.get("options") || "{}"); Promise.all([ fetch(startUrl).then((r) => r.text()), @@ -34,7 +35,7 @@ if (params.get("using").includes("morphdom")) { morphdom(document.body.firstElementChild, end); } else { - Idiomorph.morph(document.body.firstElementChild, end); + Idiomorph.morph(document.body.firstElementChild, end, options); } bench.stop(); }); diff --git a/perf/runner.js b/perf/runner.js index 482aa5f..823b5cc 100755 --- a/perf/runner.js +++ b/perf/runner.js @@ -4,6 +4,16 @@ import { chromium } from "playwright"; let benchmarks = process.argv.slice(2); let versus; +let options = "{}"; + +benchmarks = benchmarks.filter((arg) => { + const match = arg.match(/^--options=(.*)$/); + if (match) { + options = match[1]; + return false; + } + return true; +}); if ( benchmarks[0] === "morphdom" || @@ -35,12 +45,12 @@ benchmarks.forEach((benchmark) => { benchmarks: [ { name: `${benchmark}: ${versus}`, - url: `../perf/runner.html?using=${versus}&benchmark=${benchmark}`, + url: `../perf/runner.html?using=${versus}&benchmark=${benchmark}&options=${encodeURIComponent(options)}`, browser, }, { name: `${benchmark}: src/idiomorph.js`, - url: `../perf/runner.html?using=idiomorph&benchmark=${benchmark}`, + url: `../perf/runner.html?using=idiomorph&benchmark=${benchmark}&options=${encodeURIComponent(options)}`, browser, }, ], From 21b3bd47fb998dea616042250b96584a96858319 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 5 Sep 2026 18:31:47 +0100 Subject: [PATCH 3/3] Capture differential form-state regressions Compare markup and live state with skipping enabled and disabled for radio groups, optgroups and all-disabled selects. Leave these tests enabled and intentionally failing while the feature is parked pending maintainer discussion. --- test/skip-unchanged.js | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/test/skip-unchanged.js b/test/skip-unchanged.js index 739fbca..4a0e851 100644 --- a/test/skip-unchanged.js +++ b/test/skip-unchanged.js @@ -14,6 +14,67 @@ describe("skipUnchanged option", function () { }; } + describe("differential form-state regressions", function () { + // Keep these enabled while the implementation is parked: equal markup is + // not enough to prove that skipping preserves the normal morph's behavior. + function morphAndSnapshot(oldHTML, newHTML, skipUnchanged, prepare) { + const workArea = getWorkArea(); + // Radio coupling requires a connected tree. Run fixtures sequentially so + // same-name radios from the baseline cannot affect the candidate run. + workArea.innerHTML = oldHTML; + const root = workArea.firstElementChild; + try { + if (prepare) prepare(root); + Idiomorph.morph(root, newHTML, { skipUnchanged }); + return { + html: root.outerHTML, + inputs: Array.from(root.querySelectorAll("input"), (input) => ({ + value: input.value, + checked: input.checked, + })), + selects: Array.from(root.querySelectorAll("select"), (select) => ({ + value: select.value, + selectedIndex: select.selectedIndex, + })), + options: Array.from(root.querySelectorAll("option"), (option) => ({ + value: option.value, + selected: option.selected, + defaultSelected: option.defaultSelected, + })), + }; + } finally { + clearWorkArea(); + } + } + + function assertSameMorph(oldHTML, newHTML, prepare) { + const baseline = morphAndSnapshot(oldHTML, newHTML, false, prepare); + const skipped = morphAndSnapshot(oldHTML, newHTML, true, prepare); + skipped.should.eql(baseline); + } + + it("matches normal morphing when a radio changes a later subtree's checked state", function () { + assertSameMorph( + `
`, + `
`, + ); + }); + + it("matches normal morphing when implicit selection crosses an unchanged optgroup", function () { + assertSameMorph( + `
`, + `
`, + ); + }); + + it("matches normal morphing of a dirty all-disabled select", function () { + const html = `
`; + assertSameMorph(html, html, (root) => { + root.querySelector("select").selectedIndex = 0; + }); + }); + }); + describe("off by default", function () { it("still visits every node of an identical subtree", function () { const calls = [];