Add skipUnchanged option to skip morphing identical subtrees (#144) - #162
Add skipUnchanged option to skip morphing identical subtrees (#144)#162myabc wants to merge 3 commits into
Conversation
|
@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:
|
|
@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! |
92907f6 to
9f537f3
Compare
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.
9f537f3 to
21b3bd4
Compare
🤖 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
skipUnchangedoption.When enabled,
morphNodereturns early — afterbeforeNodeMorphedhas run and can still veto — whenever the old and new node pair isisEqualNode-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:
<input>,<textarea>or<option>whose livevalue/checked/selecteddiffers 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, sinceisEqualNodedoes not compare<template>content and the head path re-appendsim-re-appendscripts on every morphisEqualNodecan report equality while a descendant differsThe scan also recurses into
<template>.content, since idiomorph morphs into template content butquerySelectorAlldoes 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:
beforeNodeMorphedandafterNodeMorphedstill fire for the root of a skipped subtree, so a veto is still possible there.beforeAttributeUpdatednever fires inside one, since nothing changes.beforeNodeMorphedcallback 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.Correctness invariant
The core invariant tested throughout: with
skipUnchangedon, 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 itsselectedattribute) 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):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 theisEqualNodecall fails almost immediately with nothing to prune, and that failed comparison is pure overhead on top of the normal morph.purechainis a fixture built specifically to isolate the worst case: every branch differs only at its single deepest leaf, with zero equal siblings anywhere forisEqualNodeto prune. That comes back as an 8–16% slowdown on an absolute base of roughly 1.5ms.Both results are why
skipUnchangedships 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
backlogsfixture 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
skipUnchangeddeliberately sidesteps #27 (input value reset semantics) rather than resolving it — it inherits whatever behaviorsyncInputValuealready has for dirty controls, and dirty controls are always excluded from skipping.The #132 two-way-binding workaround — a
beforeNodeMorphedcallback 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 thevalueattribute, not just the.valueproperty, sincesyncInputValueonly preserves a value when the new node has avalueattribute 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.skipUnchangedpluskeepInputValuestogether is the behavior the Datastar fork already ships, and would be a natural pairing to revisit once #146 is in.Deliberately not done
skipUnchangedworks within preserve input value if no attr change #27's existing semantics rather than changing them.tableandpurechainregressions 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.tableregression 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: