Skip to content

Compute minimal child moves with a longest increasing subsequence#5174

Open
JoviDeCroock wants to merge 4 commits into
mainfrom
lis-minimal-child-moves
Open

Compute minimal child moves with a longest increasing subsequence#5174
JoviDeCroock wants to merge 4 commits into
mainfrom
lis-minimal-child-moves

Conversation

@JoviDeCroock

@JoviDeCroock JoviDeCroock commented Jul 22, 2026

Copy link
Copy Markdown
Member

Note

This PR was authored with Claude Fable 5

What

Replaces the skew heuristic's INSERT_VNODE marking in constructNewChildrenArray with a longest-increasing-subsequence (patience sort) pass over the matched old indices — the approach Inferno and Vue 3 use. Children on the subsequence stay in place; everything else is marked for insertion. This yields the minimal number of DOM moves for every reorder pattern: prefix/suffix displacement, middle swaps, intermingled moves, reversals.

The skew bookkeeping itself stays — it still centers findMatchingIndex's search. Only the move decision changes.

How

  • Matched, non-mounting children get the MATCHED flag on the new vnode as an eligibility marker for the LIS pass (it's already cleared unconditionally in diffChildren's placement loop, which is exactly what maintains the existing "oldChildren enter unmatched" invariant). Unsuspending children keep taking the mount path and are excluded automatically.
  • The pass is gated on moved: it only runs when some match lands more than one position from its skewed index. For pure shift patterns (prepends, removals, insertions) the matched old indices are provably already increasing, so the gate is exact and the common path pays a single boolean.
  • The forward pass finds each child's LIS length with a binary search over the tails stack, keeping the pass at O(n log n) even for pathological reorders. The backward pass keeps the last chain (lisLengths[i] == remaining length) and flags the rest.

Numbers

Size: +99 B brotli on preact.mjs.

Tachometer (Chrome headless, 3-way vs 10.29.7-era baseline and the #5172 heuristic, using the new reorder1k benchmark — move first 3 of 1,000 keyed rows to the end):

bench baseline #5172 heuristic this PR
reorder1k (median) 31.7 ms 7.7 ms 8.3 ms
update10th1k 15.9 ms 16.3 ms 16.4 ms (noise)
create10k 385.9 ms 385.5 ms 385.6 ms (noise)
many-updates 8.0 ms 8.0 ms 8.2 ms (noise)

reorder1k goes from 997 insertBefore calls to 3. The non-reorder benches confirm the moved gate keeps in-place updates free. Relative to #5172 (the v10.x prefix-move heuristic) this generalizes the same win to every reorder shape for ~55 B more; the two approaches supersede each other, so #5172 stays v10.x-only and this is the v11 form.

Notes

  • skew is reused as the backward-pass counter purely for bytes.

Replace the skew heuristic's insertion marking with a patience-sort pass
over the matched old indices. Children on the longest increasing
subsequence stay in place; everything else is marked INSERT_VNODE. The
pass only runs when a match lands more than one position away from its
skewed index - pure shifts provably keep matched old indices in
increasing order and skip it entirely.

Reorders like moving a prefix to the end (997 insertBefore calls -> 3 on
a 1000 row list), middle swaps, and intermingled moves now perform the
minimal number of DOM operations. Costs +85 B brotli. Updated DOM op
expectations are equal or better in every changed test.
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

📊 Tachometer Benchmark Results

Summary

duration

  • create10k: unsure 🔍 -1% - +0% (-5.08ms - +3.93ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -1% - +0% (-0.10ms - +0.06ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -2% - +2% (-1.26ms - +0.96ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -2% - +3% (-0.40ms - +0.51ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -0% - +3% (-0.03ms - +1.45ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -1% - +7% (-0.02ms - +0.15ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -2% - +3% (-0.47ms - +0.90ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -5% - +2% (-1.68ms - +0.62ms)
    preact-local vs preact-main

usedJSHeapSize

  • create10k: unsure 🔍 +0% - +0% (+0.01ms - +0.01ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -5% - +2% (-0.23ms - +0.11ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 +0% - +0% (+0.00ms - +0.01ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 +0% - +0% (+0.01ms - +0.02ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -1% - +4% (-0.01ms - +0.04ms)
    preact-local vs preact-main
  • todo: slower ❌ 1% - 1% (0.01ms - 0.01ms)
    preact-local vs preact-main
  • update10th1k: slower ❌ 0% - 1% (0.00ms - 0.02ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local804.39ms - 811.14ms-unsure 🔍
-1% - +0%
-5.08ms - +3.93ms
preact-main805.36ms - 811.33msunsure 🔍
-0% - +1%
-3.93ms - +5.08ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local19.10ms - 19.10ms-unsure 🔍
+0% - +0%
+0.01ms - +0.01ms
preact-main19.09ms - 19.09msunsure 🔍
-0% - -0%
-0.01ms - -0.01ms
-
filter-list

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.39ms - 16.54ms-unsure 🔍
-1% - +0%
-0.10ms - +0.06ms
preact-main16.45ms - 16.52msunsure 🔍
-0% - +1%
-0.06ms - +0.10ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.57ms - 1.57ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main1.56ms - 1.56msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
hydrate1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local56.40ms - 57.58ms-unsure 🔍
-2% - +2%
-1.26ms - +0.96ms
preact-main56.20ms - 58.08msunsure 🔍
-2% - +2%
-0.96ms - +1.26ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local4.99ms - 5.13ms-unsure 🔍
-5% - +2%
-0.23ms - +0.11ms
preact-main4.96ms - 5.28msunsure 🔍
-2% - +5%
-0.11ms - +0.23ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local15.91ms - 16.71ms-unsure 🔍
-2% - +3%
-0.40ms - +0.51ms
preact-main16.03ms - 16.48msunsure 🔍
-3% - +2%
-0.51ms - +0.40ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.76ms - 3.77ms-unsure 🔍
+0% - +0%
+0.00ms - +0.01ms
preact-main3.76ms - 3.76msunsure 🔍
-0% - -0%
-0.01ms - -0.00ms
-
replace1k
  • Browser: chrome-headless
  • Sample size: 100
  • Built by: CI #5714
  • Commit: cacd13d

duration

VersionAvg timevs preact-localvs preact-main
preact-local53.40ms - 54.59ms-unsure 🔍
-0% - +3%
-0.03ms - +1.45ms
preact-main52.84ms - 53.73msunsure 🔍
-3% - +0%
-1.45ms - +0.03ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.03ms - 3.04ms-unsure 🔍
+0% - +0%
+0.01ms - +0.02ms
preact-main3.02ms - 3.03msunsure 🔍
-0% - -0%
-0.02ms - -0.01ms
-

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local27.97ms - 28.77ms-unsure 🔍
-1% - +2%
-0.41ms - +0.68ms
preact-main27.86ms - 28.61msunsure 🔍
-2% - +1%
-0.68ms - +0.41ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local32.47ms - 33.79ms-unsure 🔍
-3% - +3%
-0.86ms - +0.98ms
preact-main32.44ms - 33.72msunsure 🔍
-3% - +3%
-0.98ms - +0.86ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local30.27ms - 31.60ms-slower ❌
2% - 9%
0.73ms - 2.50ms
preact-main28.73ms - 29.90msfaster ✔
2% - 8%
0.73ms - 2.50ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local25.09ms - 25.35ms-unsure 🔍
-2% - +1%
-0.40ms - +0.14ms
preact-main25.12ms - 25.59msunsure 🔍
-1% - +2%
-0.14ms - +0.40ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local26.00ms - 27.27ms-slower ❌
7% - 16%
1.83ms - 3.80ms
preact-main23.07ms - 24.58msfaster ✔
7% - 14%
1.83ms - 3.80ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local20.00ms - 20.50ms-unsure 🔍
-1% - +2%
-0.20ms - +0.40ms
preact-main19.98ms - 20.33msunsure 🔍
-2% - +1%
-0.40ms - +0.20ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 220
  • Built by: CI #5714
  • Commit: cacd13d

duration

VersionAvg timevs preact-localvs preact-main
preact-local2.23ms - 2.35ms-unsure 🔍
-1% - +7%
-0.02ms - +0.15ms
preact-main2.16ms - 2.29msunsure 🔍
-7% - +1%
-0.15ms - +0.02ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.04ms - 1.08ms-unsure 🔍
-1% - +4%
-0.01ms - +0.04ms
preact-main1.02ms - 1.06msunsure 🔍
-4% - +1%
-0.04ms - +0.01ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local30.31ms - 31.54ms-unsure 🔍
-2% - +3%
-0.47ms - +0.90ms
preact-main30.41ms - 31.02msunsure 🔍
-3% - +2%
-0.90ms - +0.47ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.29ms - 1.29ms-slower ❌
1% - 1%
0.01ms - 0.01ms
preact-main1.28ms - 1.28msfaster ✔
1% - 1%
0.01ms - 0.01ms
-
update10th1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local33.89ms - 35.26ms-unsure 🔍
-5% - +2%
-1.68ms - +0.62ms
preact-main34.18ms - 36.02msunsure 🔍
-2% - +5%
-0.62ms - +1.68ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.99ms - 3.00ms-slower ❌
0% - 1%
0.00ms - 0.02ms
preact-main2.98ms - 2.99msfaster ✔
0% - 1%
0.00ms - 0.02ms
-

tachometer-reporter-action v2 for CI

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Size Change: +106 B (+0.67%)

Total Size: 15.9 kB

📦 View Changed
Filename Size Change
dist/preact.mjs 5.11 kB +106 B (+2.12%)
ℹ️ View Unchanged
Filename Size
compat/dist/compat.mjs 3.7 kB
debug/dist/debug.mjs 3.89 kB
devtools/dist/devtools.mjs 274 B
hooks/dist/hooks.mjs 1.55 kB
jsx-runtime/dist/jsxRuntime.mjs 861 B
test-utils/dist/testUtils.mjs 473 B

compressed-size-action

@coveralls

coveralls commented Jul 22, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 98.492%. remained the same — lis-minimal-child-moves into main

Replaces the linear top-down scan of the tails stack. The scan was
smaller (~14 B) and O(1) amortized for mostly in-order children, but
degraded to O(n^2) for large half-shuffled lists; binary search keeps
the whole pass at a guaranteed O(n log n).
Truthiness check instead of the NULL comparison in the forward pass, and
reuse i (left at newChildrenLength by the forward loop) for the backward
walk. Same algorithm and complexity; 9 B brotli smaller.

Measured but rejected: replacing the skew if/else chain with offset
arithmetic and hoisting repeated member reads into locals both minify
smaller yet compress larger, since they break up byte sequences brotli
matches elsewhere in the bundle.
Mirrors the edge-case coverage added to the v10.x displacement heuristic
(#5172), where the minimal-move pass produces identical operation logs
for every case:
- a far swap moves only the two swapped children
- displacing more than half the list moves the shorter suffix
- displacement combined with an appended or removed child
- three consecutive displacements to catch state accumulation issues
- correctness of raw text siblings around displaced keyed children
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