Add skipUnchanged option to skip morphing identical subtrees (#144) - #162
Draft
myabc wants to merge 12 commits into
Draft
Add skipUnchanged option to skip morphing identical subtrees (#144)#162myabc wants to merge 12 commits into
myabc wants to merge 12 commits into
Conversation
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.
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:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 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: