Skip to content

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

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

Add skipUnchanged option to skip morphing identical subtrees (#144)#162
myabc wants to merge 12 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

myabc added 12 commits August 25, 2026 19:20
Off by default. When enabled, morphNode returns early for a pair
of nodes that are isEqualNode-equal, after announcing the root via
beforeNodeMorphed/afterNodeMorphed. Hidden-state handling follows
in the next commits. Refs bigskysoftware#144.
isEqualNode ignores the value/checked/selected properties, template
content and the side effects of head merging. Re-check the pair at
skip time so a beforeNodeMorphed callback that mutates the nodes it
was handed keeps working.
isEqualNode reports two ancestors equal even when a nested template's
content differs or a nested input holds a typed value, so mark every
unskippable node and its ancestors up to the morph root before the
walk. Scan template content explicitly since querySelectorAll does
not enter it.

Also compare live `selected` state directly between an old/new
option pair: a single-select's implicit default selection depends on
every option in the select, so removing `selected` from one option
can silently flip an untouched sibling's effective selectedness,
which neither side's own dirtiness check alone can see. Found via
the default-flip smoke run against the full test suite.

Corrected the bigskysoftware#132 regression test too: it set a bare `.value`
property that idiomorph's value sync never reads, so it passed only
by accident before this pre-scan existed. It now sets the "value"
attribute, as the workaround actually requires.
Needed to benchmark opt-in options such as skipUnchanged against a
version that predates them.
Trimmed from a real OpenProject backlogs_container capture (a
Backlog/Foobar list plus one Sprint list). "new" is the same tree
with one work-package card moved between lists, synthesised rather
than a server re-render, so most of the tree is unchanged.
The select/option skip predicate relied on HTMLSelectElement.options and
on live .selected reflecting a select's implicit default selection. Both
assumptions break on WebKit for a select parsed in a detached fragment:

- select.options stays empty (querySelectorAll("option") still works), so
  defaultSelectedOf computed a false default for every option, wrongly
  flagging clean selects as dirty.
- WebKit does not apply the implicit first-option selection that Chromium
  and Firefox do, so a clean first option reports selected=false there and
  selected=true elsewhere; and when every option is disabled WebKit's
  template parse selects the first option anyway (selected=true) while the
  others select nothing.

Scan options with querySelectorAll instead of .options, treat the first
option as the effective default when none is enabled, and mark an option
dirty only when its live selected disagrees with BOTH the effective
default and its own selected attribute. Either agreement means a fresh
parse reproduces the live state, so the skip stays DOM-correct on every
engine.

The tests addressed options through select.options too; read them through
querySelectorAll so the fixtures work under WebKit.
The per-option skip check could not see a single-select whose selection
was cleared (selectedIndex = -1) or coupled through a sibling option: no
option's own `selected` differed from its own default, so the select was
reported equal by isEqualNode and skipped wholesale before any option was
visited. Morphing with skipUnchanged off re-applies the implicit
first-option selection, so the option-on and option-off DOM diverged on
all three engines.

Add `select` to the unskippable pre-scan and give isUnskippable an
HTMLSelectElement branch. For a single-select, compare the live
selectedIndex against the effective parse-default index (last option with
a `selected` attribute, else first enabled option, else none). selectedIndex
is reliable across engines even where select.options is not, and an
all-disabled select yields no definitive default, so it is left clean.
A multiple/size>1 select has no single selectedIndex; its options remain
individually dirty-checked by the option branch.
@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...

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