Skip to content

Add skipUnchanged option to skip morphing identical subtrees (#144) - #162

Draft
myabc wants to merge 3 commits into
bigskysoftware:mainfrom
myabc:feature/skip-unchanged
Draft

Add skipUnchanged option to skip morphing identical subtrees (#144)#162
myabc wants to merge 3 commits into
bigskysoftware:mainfrom
myabc:feature/skip-unchanged

Conversation

@myabc

@myabc myabc commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

🤖 This PR was prepared with an AI coding agent (Claude Code) and reviewed by me before opening.

Motivation

Most real-world morphs change only a small part of a large page: a form control's error state, a moved list item, a single updated card.

Idiomorph currently recurses into every subtree regardless, even one whose old and new content are byte-for-byte identical, which is wasted work on pages that mostly stay the same between morphs.

This addresses #144, which proposes pruning the morph walk wherever oldNode.isEqualNode(newNode) holds.

What it does

Adds an off-by-default skipUnchanged option.

When enabled, morphNode returns early — after beforeNodeMorphed has run and can still veto — whenever the old and new node pair is isEqualNode-equal, skipping the entire subtree instead of recursing into it.

Before the walk, both trees are pre-scanned to build a set of "unskippable" nodes so hidden DOM state is never silently dropped by a skip:

  • dirty form controls — an <input>, <textarea> or <option> whose live value/checked/selected differs from its effective default (what parsing the same markup would produce)
  • <select> elements whose live selection differs from a fresh parse of the same markup, checked separately from individual <option> dirtiness (see "Callback contract" below for why)
  • <template> and <head> elements, always, since isEqualNode does not compare <template> content and the head path re-appends im-re-append scripts on every morph
  • every ancestor of the above, up to the morph root, since an ancestor's isEqualNode can report equality while a descendant differs

The scan also recurses into <template>.content, since idiomorph morphs into template content but querySelectorAll does not descend into it.

Callback contract

Idiomorph's own output — the resulting DOM — is identical with the option on or off. What changes is which callbacks fire:

  • beforeNodeMorphed and afterNodeMorphed still fire for the root of a skipped subtree, so a veto is still possible there.
  • Neither callback fires for any descendant of a skipped subtree, and beforeAttributeUpdated never fires inside one, since nothing changes.
  • If a beforeNodeMorphed callback mutates hidden state (value/checked/selected) on the two nodes it was handed, that mutation is honoured — the pair is re-checked for dirtiness after the callback runs, at the point of the skip decision.
  • If that callback instead mutates a descendant of the nodes it was handed, that mutation is not honoured: the subtree may already be skipped by the time the descendant would otherwise be visited. This is a documented, deliberate narrowing of the callback contract under this option, not a bug.

Correctness invariant

The core invariant tested throughout: with skipUnchanged on, the resulting DOM is identical to a morph with the option off.

This is verified by a dedicated test suite (test/skip-unchanged.js) covering the pre-scan predicates, ancestor propagation, template content recursion, head handling, and the callback-mutation cases above, run to green with 100% line/function/branch coverage across Chromium, Firefox and WebKit.

WebKit needed particular care around <select>/<option> semantics: implicit selection (an untouched option becoming "selected" when a sibling loses its selected attribute) is handled by checking dirtiness at the <select> level — comparing live selection against a fresh parse of the same markup — rather than relying solely on per-option comparisons, which is why that check exists as a separate step from the individual option-dirtiness predicate.

Benchmarks

Measured with tachometer against main's pre-option code as a paired baseline, Playwright Chromium, headless, auto-sample. Ratio is option-on idiomorph.js mean ÷ baseline mean (below 1.0 is faster):

benchmark option ON ratio notes
checkboxes 0.50–0.52 (~2x faster) large equal subtrees, best case
backlogs (real page fixture) 0.66–0.69 (~1.5x faster) one card moved among many unchanged sprint sections
html5 0.91–0.94 (6–9% faster) realistic ~12% of lines changed
persistent-ids 0.99–1.00 (0–1% faster) id-heavy, move-driven, little whole-subtree no-op content
deep-last-leaf 1.00–1.01 (0–1% slower) near parity
table 1.06–1.07 (6–7% slower, reproduced) see below
purechain (isolation fixture, not committed) 1.08–1.16 (8–16% slower, reproduced) worst case, see below

Two results are worth being upfront about, since they are costs, not wins:

  • table, an existing fixture where nearly every row differs, regresses 6–7%. It's an "early-fail" case: the first cells already differ near the root, so the isEqualNode call fails almost immediately with nothing to prune, and that failed comparison is pure overhead on top of the normal morph.
  • purechain is a fixture built specifically to isolate the worst case: every branch differs only at its single deepest leaf, with zero equal siblings anywhere for isEqualNode to prune. That comes back as an 8–16% slowdown on an absolute base of roughly 1.5ms.

Both results are why skipUnchanged ships off by default and is pitched as suited to mostly-unchanged pages rather than a universal win. A tree that differs almost everywhere pays for the comparisons without recouping them in pruning.

The backlogs fixture is drawn from a real page's before/after morph and is the shape this option was built for. A page-level, end-to-end measurement on that real page (rather than just the extracted DOM fixture) is in progress and not included here — worth following up with once available, so the fixture-level numbers above shouldn't be read as a page-level claim yet.

Relation to #27, #132, #146

skipUnchanged deliberately sidesteps #27 (input value reset semantics) rather than resolving it — it inherits whatever behavior syncInputValue already has for dirty controls, and dirty controls are always excluded from skipping.

The #132 two-way-binding workaround — a beforeNodeMorphed callback that copies a user's typed value onto the new node before idiomorph compares it — keeps working under this option, since the callback runs before the equality check and the mutated pair is honoured at the point of the skip decision. This is pinned by a test; note that the workaround must set the value attribute, not just the .value property, since syncInputValue only preserves a value when the new node has a value attribute to compare against.

If keepInputValues (#146) lands, dirty inputs would no longer need to defeat the skip, since that option would handle preserving their value itself. skipUnchanged plus keepInputValues together is the behavior the Datastar fork already ships, and would be a natural pairing to revisit once #146 is in.

Deliberately not done

  • Resolving preserve input value if no attr change #27. Out of scope here; skipUnchanged works within preserve input value if no attr change #27's existing semantics rather than changing them.
  • Flipping the option on by default. The table and purechain regressions above are the input for that future decision, not a reason to avoid shipping the option at all — they're the tradeoff a maintainer or downstream consumer should weigh with real numbers in hand, which this PR provides.
  • A subtree-size gate to avoid the worst case. The table regression fails near the root of a comparison, not deep inside a large subtree, so gating on subtree size wouldn't prevent it — this was measured, not assumed, and is why a size gate isn't included here.

Commits

12 commits on the branch, happy to squash on request:

aa71ff9 add skipUnchanged option to prune equal subtrees
895b644 never skip dirty form controls, templates or heads
de6ae87 pre-scan unskippable nodes and their ancestors
9ae2772 let perf runs pass morph options
2877691 add deep-last-leaf worst-case perf benchmark
c967f51 shrink deep-last-leaf fixture size
abe63ef add backlogs perf benchmark from a real page morph
bf1348a document the skipUnchanged option
8a82d69 fix GitHub handle attribution to @myabc
5f3fdd1 fix skipUnchanged select handling across browsers
c88b71a back selection dirtiness at the select level
92907f6 clarify skip-predicate comments and perf wording

@botandrose

Copy link
Copy Markdown
Collaborator

@myabc Hey Alex, thanks for putting some time and effort into exploring this and coming up with this excellent proof-of-concept! I'm happy to see all the edge cases carefully considered. I'm getting ready to release v0.8.0 after I run it for a bit in production, and then lets take a look at this in earnest. This is definitely something I want to pursue for v0.9.0.

A couple of brief notes I can tell you right away:

  1. I'm very interested in collapsing the configuration space and thus behavioral space around input handling, particularly if it makes a big speed up like this possible without extra ceremony. I think some of the other implementations like D* and Morphlex have done work on this, so let's see if we can steal their best ideas. I expect we'll want to land that before this, but the decision there should be informed by the goal of enabling less work while morphing i.e. something like this PR.
  2. Same here. I'd like to have this not be an configuration option, but the only behavior, if we can make it a big enough win and it looks like we can, at least from a performance standpoint. Maybe the missing callback issue will force an off-by-default fullDescent option or something. I hope not! To be explored...

@myabc

myabc commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

@botandrose Many thanks for the early feedback, Michah!

I agree that v0.9.0 makes sense. I realize this is an unsolicited PR, and I want to make sure whatever I come up works with your vision for the roadmap. There is no sense in rushing things.

This does stem from a real-world need however. I'm currently hitting some performance issues on a page with a large number of DOM nodes (benchmarking a Turbo Frame morph with ~44k nodes)

I just pushed what I have for some visibility. I'm on vacation for a few days now, but will take your feedback on board and be in touch once I have some more concrete questions!

@myabc
myabc force-pushed the feature/skip-unchanged branch from 92907f6 to 9f537f3 Compare September 5, 2026 17:28
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.
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.
@myabc
myabc force-pushed the feature/skip-unchanged branch from 9f537f3 to 21b3bd4 Compare September 5, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants