Skip to content

perf(hooks): resolve unmount error routing lazily (fixes #5217) - #5218

Open
JoviDeCroock wants to merge 1 commit into
mainfrom
JoviDeCroock/issue-5217
Open

perf(hooks): resolve unmount error routing lazily (fixes #5217)#5218
JoviDeCroock wants to merge 1 commit into
mainfrom
JoviDeCroock/issue-5217

Conversation

@JoviDeCroock

@JoviDeCroock JoviDeCroock commented Aug 27, 2026

Copy link
Copy Markdown
Member

Fixes #5217

The ancestor walk added in 46ddd2f ran for every unmounted component with hooks, but its result is only consumed when a passive cleanup gets deferred to the after-paint flush. During a whole-tree unmount every ancestor's _parentDom is already nulled, so each walk scanned all the way to the root — making teardown O(hook components × depth). The ReactLynx repro (useState-only leaves) paid the full cost for a value it never used.

This resolves the surviving error-routing component lazily: only when a state is both passive and has a cleanup to defer (s._passive && s._cleanup), and at most once per component. Consequences:

  • useState/useReducer-only components no longer run the walk at all
  • effects that never returned a cleanup no longer run the walk, and no longer get pushed into unmountCleanups / schedule an after-paint flush on teardown (deferring them was a no-op anyway — a pending effect of an unmounted component never runs, so no cleanup can appear later)
  • components with real passive cleanups run the walk once, as before

Reproducing the issue's benchmark (5000 useState leaves, best of 7, fastest run) with a throwaway test/browser case:

depth main this PR
1 0.70ms 0.70ms
60 1.80ms (2.57x) 0.70ms (1.00x)

Size: hooks/dist/hooks.mjs 1423 → 1446 B brotli (+23 B).

Existing coverage guards the semantics this touches: deferred-cleanup error routing ("should route deferred cleanup errors after a state update", "should route a deferred cleanup error past boundaries inside the removed subtree") and the #4299 deferral-ordering tests. Full suite green (1301 passed).

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

📊 Tachometer Benchmark Results

Summary

duration

  • create10k: unsure 🔍 -1% - +0% (-8.87ms - +3.21ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -1% - +1% (-0.13ms - +0.16ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -2% - +1% (-1.25ms - +0.76ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -1% - +1% (-0.21ms - +0.14ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -1% - +2% (-0.37ms - +1.08ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -3% - +6% (-0.05ms - +0.13ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -3% - +2% (-1.16ms - +0.54ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -5% - +2% (-1.51ms - +0.66ms)
    preact-local vs preact-main

usedJSHeapSize

  • create10k: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -4% - +1% (-0.22ms - +0.03ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -0% - +0% (-0.01ms - +0.00ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -2% - +6% (-0.02ms - +0.06ms)
    preact-local vs preact-main
  • todo: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -0% - +0% (-0.01ms - +0.00ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local861.27ms - 867.37ms-unsure 🔍
-1% - +0%
-8.87ms - +3.21ms
preact-main861.93ms - 872.37msunsure 🔍
-0% - +1%
-3.21ms - +8.87ms
-

usedJSHeapSize

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

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.56ms - 16.74ms-unsure 🔍
-1% - +1%
-0.13ms - +0.16ms
preact-main16.53ms - 16.74msunsure 🔍
-1% - +1%
-0.16ms - +0.13ms
-

usedJSHeapSize

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

duration

VersionAvg timevs preact-localvs preact-main
preact-local55.99ms - 57.47ms-unsure 🔍
-2% - +1%
-1.25ms - +0.76ms
preact-main56.29ms - 57.66msunsure 🔍
-1% - +2%
-0.76ms - +1.25ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local5.02ms - 5.16ms-unsure 🔍
-4% - +1%
-0.22ms - +0.03ms
preact-main5.08ms - 5.29msunsure 🔍
-1% - +4%
-0.03ms - +0.22ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.38ms - 16.58ms-unsure 🔍
-1% - +1%
-0.21ms - +0.14ms
preact-main16.38ms - 16.66msunsure 🔍
-1% - +1%
-0.14ms - +0.21ms
-

usedJSHeapSize

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

duration

VersionAvg timevs preact-localvs preact-main
preact-local50.52ms - 51.68ms-unsure 🔍
-1% - +2%
-0.37ms - +1.08ms
preact-main50.31ms - 51.18msunsure 🔍
-2% - +1%
-1.08ms - +0.37ms
-

usedJSHeapSize

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

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local23.87ms - 24.61ms-unsure 🔍
-1% - +3%
-0.34ms - +0.64ms
preact-main23.77ms - 24.41msunsure 🔍
-3% - +1%
-0.64ms - +0.34ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local27.19ms - 28.18ms-unsure 🔍
-2% - +3%
-0.59ms - +0.76ms
preact-main27.14ms - 28.05msunsure 🔍
-3% - +2%
-0.76ms - +0.59ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local27.68ms - 28.63ms-unsure 🔍
-1% - +4%
-0.14ms - +1.18ms
preact-main27.17ms - 28.10msunsure 🔍
-4% - +0%
-1.18ms - +0.14ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local22.70ms - 22.89ms-unsure 🔍
-1% - +1%
-0.13ms - +0.15ms
preact-main22.68ms - 22.88msunsure 🔍
-1% - +1%
-0.15ms - +0.13ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local24.60ms - 25.51ms-unsure 🔍
-4% - +1%
-0.93ms - +0.35ms
preact-main24.90ms - 25.79msunsure 🔍
-1% - +4%
-0.35ms - +0.93ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local18.55ms - 19.05ms-slower ❌
0% - 3%
0.03ms - 0.61ms
preact-main18.32ms - 18.63msfaster ✔
0% - 3%
0.03ms - 0.61ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 210
  • Built by: CI #5866
  • Commit: 8cb218f

duration

VersionAvg timevs preact-localvs preact-main
preact-local2.01ms - 2.14ms-unsure 🔍
-3% - +6%
-0.05ms - +0.13ms
preact-main1.98ms - 2.11msunsure 🔍
-6% - +3%
-0.13ms - +0.05ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.05ms - 1.12ms-unsure 🔍
-2% - +6%
-0.02ms - +0.06ms
preact-main1.04ms - 1.09msunsure 🔍
-6% - +2%
-0.06ms - +0.02ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local33.69ms - 34.50ms-unsure 🔍
-3% - +2%
-1.16ms - +0.54ms
preact-main33.66ms - 35.16msunsure 🔍
-2% - +3%
-0.54ms - +1.16ms
-

usedJSHeapSize

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

duration

VersionAvg timevs preact-localvs preact-main
preact-local30.92ms - 32.38ms-unsure 🔍
-5% - +2%
-1.51ms - +0.66ms
preact-main31.28ms - 32.89msunsure 🔍
-2% - +5%
-0.66ms - +1.51ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.97ms - 2.98ms-unsure 🔍
-0% - +0%
-0.01ms - +0.00ms
preact-main2.98ms - 2.99msunsure 🔍
-0% - +0%
-0.00ms - +0.01ms
-

tachometer-reporter-action v2 for CI

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Size Change: +28 B (+0.18%)

Total Size: 15.8 kB

📦 View Changed
Filename Size Change
hooks/dist/hooks.mjs 1.58 kB +28 B (+1.8%)
ℹ️ View Unchanged
Filename Size
compat/dist/compat.mjs 3.9 kB
debug/dist/debug.mjs 3.88 kB
devtools/dist/devtools.mjs 273 B
dist/preact.mjs 4.88 kB
jsx-runtime/dist/jsxRuntime.mjs 856 B
test-utils/dist/testUtils.mjs 473 B

compressed-size-action

@coveralls

coveralls commented Aug 27, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 99.534% (+0.001%) from 99.533% — JoviDeCroock/issue-5217 into main

The ancestor walk added in 46ddd2f ran for every unmounted component
with hooks, even though its result is only consumed when a passive
cleanup gets deferred to the after-paint flush. During a whole-tree
unmount every ancestor's `_parentDom` is already nulled, so each walk
scanned all the way to the root, making teardown
O(hook components x depth) — a measurable regression for hook-heavy
trees (#5217).

Only resolve the surviving error-routing component when a passive
cleanup actually needs deferring (`_passive && _cleanup`), at most once
per component. Components that only use useState/useReducer, or whose
effects have no cleanup, no longer pay for the walk at all; effects
without cleanups also no longer schedule a pointless after-paint flush.

Fixes #5217

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants