Skip to content

Long continuous prefix move optimization#5172

Open
JoviDeCroock wants to merge 3 commits into
v10.xfrom
long-continuous-prefix
Open

Long continuous prefix move optimization#5172
JoviDeCroock wants to merge 3 commits into
v10.xfrom
long-continuous-prefix

Conversation

@JoviDeCroock

@JoviDeCroock JoviDeCroock commented Jul 22, 2026

Copy link
Copy Markdown
Member

Note

This PR was co-authored by GPT5.6-sol

Summary

Optimize keyed reconciliation when a short prefix is moved to the end of a list.

The existing skew heuristic only recognizes one-position offsets. For a transition like:

[a, b, c, d, …, z] → [d, …, z, a, b, c]

Preact moved the long suffix instead of the three displaced prefix nodes. In a 1,000-row benchmark, moving the first three rows caused 997 insertBefore calls.

This change detects when the matched node begins a longer contiguous suffix. It adjusts the skew to leave that suffix in place and moves the shorter prefix when reconciliation reaches it.

Results

Measured with Octane’s js-framework-reorder benchmark:

Operation Before After Change
displace3 2.296 ms 0.316 ms −86%
displace4 2.260 ms 0.341 ms −85%
displace5 2.306 ms 0.338 ms −85%
displace6 2.382 ms 0.343 ms −86%
displace8 2.297 ms 0.356 ms −85%
Suite geomean 1.305 ms 0.655 ms −50%

For displace3, DOM moves drop from 997 to 3.

Other reorder operations remain effectively unchanged.

Edge-case coverage

Beyond the displacement test itself, the suite now pins down the paths around the probe: a far swap whose following suffix is shorter than the jump must not trigger it, displacing more than half the list falls back to moving the shorter suffix (also minimal), displacement combined with an appended or removed child, three consecutive displacements (skew accumulation), and a raw-text sibling passing the probe's loose key/type comparison — a false positive that can only cost extra moves, never correctness.

Reproducible benchmark

The displacement pattern is now covered by a dedicated benchmark in the benchmarks repo — see preactjs/benchmarks#17 (reorder1k: move the first 3 of 1,000 keyed rows to the end). Tachometer results on this branch (Chrome headless):

variant reorder1k median
v10.x baseline 31.7 ms
this PR 7.7 ms (−76%)

update10th1k, create10k, and many-updates are unchanged within noise.

Relation to v11

v11 generalizes this with a longest-increasing-subsequence pass that computes the minimal set of moves for every reorder shape (middle swaps, intermingled moves, …), at a higher byte cost — see #5174 (reorder1k median 8.3 ms there). The two supersede each other, so this heuristic stays v10.x-only: it is the cheaper fix (+~40 B vs +99 B brotli) for the release branch, while #5174 is the v11 form.

@JoviDeCroock JoviDeCroock changed the title Long continuous prefix Long continuous prefix move optimization Jul 22, 2026
@JoviDeCroock
JoviDeCroock changed the base branch from main to v10.x July 22, 2026 06:34
@JoviDeCroock
JoviDeCroock force-pushed the long-continuous-prefix branch from 35c62d3 to 178ed65 Compare July 22, 2026 06:35
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

📊 Tachometer Benchmark Results

Summary

duration

  • create10k: unsure 🔍 -1% - +1% (-6.96ms - +7.46ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - +0% (-0.05ms - +0.05ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -1% - +1% (-0.52ms - +0.44ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -1% - +3% (-0.14ms - +0.42ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -2% - +1% (-0.91ms - +0.34ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -7% - +2% (-0.12ms - +0.04ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -2% - +1% (-0.53ms - +0.27ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -2% - +4% (-0.79ms - +1.33ms)
    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 🔍 -4% - +7% (-0.20ms - +0.42ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 +0% - +0% (+0.00ms - +0.01ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -0% - +0% (-0.00ms - +0.01ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -1% - +5% (-0.01ms - +0.05ms)
    preact-local vs preact-main
  • todo: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -0% - +0% (-0.00ms - +0.01ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local587.26ms - 597.05ms-unsure 🔍
-1% - +1%
-6.96ms - +7.46ms
preact-main586.61ms - 597.19msunsure 🔍
-1% - +1%
-7.46ms - +6.96ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local19.21ms - 19.21ms-unsure 🔍
-0% - +0%
-0.01ms - +0.01ms
preact-main19.21ms - 19.22msunsure 🔍
-0% - +0%
-0.01ms - +0.01ms
-
filter-list

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.49ms - 16.54ms-unsure 🔍
-0% - +0%
-0.05ms - +0.05ms
preact-main16.48ms - 16.56msunsure 🔍
-0% - +0%
-0.05ms - +0.05ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.56ms - 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-local54.13ms - 54.86ms-unsure 🔍
-1% - +1%
-0.52ms - +0.44ms
preact-main54.22ms - 54.85msunsure 🔍
-1% - +1%
-0.44ms - +0.52ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local5.58ms - 6.04ms-unsure 🔍
-4% - +7%
-0.20ms - +0.42ms
preact-main5.49ms - 5.91msunsure 🔍
-7% - +3%
-0.42ms - +0.20ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local15.99ms - 16.46ms-unsure 🔍
-1% - +3%
-0.14ms - +0.42ms
preact-main15.94ms - 16.23msunsure 🔍
-3% - +1%
-0.42ms - +0.14ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.74ms - 3.74ms-unsure 🔍
+0% - +0%
+0.00ms - +0.01ms
preact-main3.73ms - 3.74msunsure 🔍
-0% - -0%
-0.01ms - -0.00ms
-
replace1k
  • Browser: chrome-headless
  • Sample size: 110
  • Built by: CI #5713
  • Commit: 7ac2017

duration

VersionAvg timevs preact-localvs preact-main
preact-local45.04ms - 45.93ms-unsure 🔍
-2% - +1%
-0.91ms - +0.34ms
preact-main45.34ms - 46.21msunsure 🔍
-1% - +2%
-0.34ms - +0.91ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.04ms - 3.05ms-unsure 🔍
-0% - +0%
-0.00ms - +0.01ms
preact-main3.03ms - 3.04msunsure 🔍
-0% - +0%
-0.01ms - +0.00ms
-

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local21.04ms - 21.70ms-unsure 🔍
-1% - +3%
-0.28ms - +0.60ms
preact-main20.92ms - 21.50msunsure 🔍
-3% - +1%
-0.60ms - +0.28ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local23.50ms - 24.20ms-unsure 🔍
-1% - +4%
-0.19ms - +0.83ms
preact-main23.16ms - 23.90msunsure 🔍
-3% - +1%
-0.83ms - +0.19ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local22.91ms - 23.60ms-unsure 🔍
-2% - +2%
-0.48ms - +0.47ms
preact-main22.93ms - 23.59msunsure 🔍
-2% - +2%
-0.47ms - +0.48ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local19.89ms - 20.32ms-unsure 🔍
-0% - +2%
-0.07ms - +0.47ms
preact-main19.74ms - 20.07msunsure 🔍
-2% - +0%
-0.47ms - +0.07ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local19.23ms - 20.33ms-unsure 🔍
-4% - +4%
-0.70ms - +0.79ms
preact-main19.23ms - 20.23msunsure 🔍
-4% - +4%
-0.79ms - +0.70ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local15.67ms - 15.95ms-unsure 🔍
-2% - +1%
-0.27ms - +0.15ms
preact-main15.71ms - 16.02msunsure 🔍
-1% - +2%
-0.15ms - +0.27ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 220
  • Built by: CI #5713
  • Commit: 7ac2017

duration

VersionAvg timevs preact-localvs preact-main
preact-local1.73ms - 1.83ms-unsure 🔍
-7% - +2%
-0.12ms - +0.04ms
preact-main1.76ms - 1.88msunsure 🔍
-2% - +7%
-0.04ms - +0.12ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.03ms - 1.08ms-unsure 🔍
-1% - +5%
-0.01ms - +0.05ms
preact-main1.01ms - 1.05msunsure 🔍
-5% - +1%
-0.05ms - +0.01ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local33.41ms - 33.83ms-unsure 🔍
-2% - +1%
-0.53ms - +0.27ms
preact-main33.41ms - 34.09msunsure 🔍
-1% - +2%
-0.27ms - +0.53ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.26ms - 1.26ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main1.26ms - 1.26msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
update10th1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local31.40ms - 32.91ms-unsure 🔍
-2% - +4%
-0.79ms - +1.33ms
preact-main31.14ms - 32.62msunsure 🔍
-4% - +2%
-1.33ms - +0.79ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.99ms - 3.00ms-unsure 🔍
-0% - +0%
-0.00ms - +0.01ms
preact-main2.99ms - 3.00msunsure 🔍
-0% - +0%
-0.01ms - +0.00ms
-

tachometer-reporter-action v2 for CI

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Size Change: +303 B (+0.38%)

Total Size: 79.8 kB

📦 View Changed
Filename Size Change
dist/preact.js 4.87 kB +44 B (+0.91%)
dist/preact.min.js 4.88 kB +41 B (+0.85%)
dist/preact.min.module.js 4.9 kB +49 B (+1.01%)
dist/preact.min.umd.js 4.9 kB +40 B (+0.82%)
dist/preact.mjs 4.89 kB +45 B (+0.93%)
dist/preact.module.js 4.89 kB +45 B (+0.93%)
dist/preact.umd.js 4.9 kB +39 B (+0.8%)
ℹ️ View Unchanged
Filename Size
compat/dist/compat.js 4.19 kB
compat/dist/compat.mjs 4.11 kB
compat/dist/compat.module.js 4.11 kB
compat/dist/compat.umd.js 4.25 kB
debug/dist/debug.js 3.85 kB
debug/dist/debug.mjs 3.85 kB
debug/dist/debug.module.js 3.85 kB
debug/dist/debug.umd.js 3.93 kB
devtools/dist/devtools.js 260 B
devtools/dist/devtools.mjs 274 B
devtools/dist/devtools.module.js 274 B
devtools/dist/devtools.umd.js 346 B
hooks/dist/hooks.js 1.51 kB
hooks/dist/hooks.mjs 1.54 kB
hooks/dist/hooks.module.js 1.54 kB
hooks/dist/hooks.umd.js 1.58 kB
jsx-runtime/dist/jsxRuntime.js 1.01 kB
jsx-runtime/dist/jsxRuntime.mjs 985 B
jsx-runtime/dist/jsxRuntime.module.js 985 B
jsx-runtime/dist/jsxRuntime.umd.js 1.08 kB
test-utils/dist/testUtils.js 473 B
test-utils/dist/testUtils.mjs 477 B
test-utils/dist/testUtils.module.js 477 B
test-utils/dist/testUtils.umd.js 555 B

compressed-size-action

The probe only applies when the match is more than one position ahead,
which the existing matchingIndex > skewedIndex branch already implies
once the off-by-one cases are handled first. Nesting it there drops the
duplicated comparisons; 5 B brotli smaller.
Covers the paths around the displacement probe:
- a far swap where the suffix after the match is shorter than the jump
  must not trigger it (and the fallback already moves the minimal pair)
- displacing more than half the list falls back to moving the shorter
  suffix, which is also minimal
- displacement combined with an appended or removed child
- three consecutive displacements to catch skew accumulation issues
- a raw text sibling passes the probe's key/type comparison against any
  old text node; assert such a false positive can only cost extra moves,
  never correctness
JoviDeCroock added a commit that referenced this pull request Jul 22, 2026
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.

1 participant