Skip to content

Reduce bundle size before release candidates go live - #5197

Merged
JoviDeCroock merged 17 commits into
mainfrom
JoviDeCroock/reduce-bundle-size
Aug 7, 2026
Merged

Reduce bundle size before release candidates go live#5197
JoviDeCroock merged 17 commits into
mainfrom
JoviDeCroock/reduce-bundle-size

Conversation

@JoviDeCroock

@JoviDeCroock JoviDeCroock commented Aug 6, 2026

Copy link
Copy Markdown
Member

Ran an autoresearch loop with Claude Opus 5 and came to the following result.

Currently looking through perf regressions

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

📊 Tachometer Benchmark Results

Summary

duration

  • create10k: unsure 🔍 -1% - +0% (-5.76ms - +3.27ms)
    preact-local vs preact-main
  • filter-list: faster ✔ 0% - 1% (0.00ms - 0.22ms)
    preact-local vs preact-main
  • hydrate1k: faster ✔ 1% - 4% (0.78ms - 2.08ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -4% - +2% (-0.58ms - +0.27ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -4% - +0% (-2.35ms - +0.25ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -3% - +6% (-0.06ms - +0.12ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -4% - +4% (-1.19ms - +1.36ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -3% - +4% (-1.12ms - +1.47ms)
    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 🔍 -3% - +4% (-0.14ms - +0.22ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -0% - -0% (-0.01ms - -0.00ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -0% - +0% (-0.01ms - +0.00ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -4% - +3% (-0.05ms - +0.03ms)
    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.01ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local857.61ms - 864.90ms-unsure 🔍
-1% - +0%
-5.76ms - +3.27ms
preact-main859.84ms - 865.17msunsure 🔍
-0% - +1%
-3.27ms - +5.76ms
-

usedJSHeapSize

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

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.54ms - 16.58ms-faster ✔
0% - 1%
0.00ms - 0.22ms
preact-main16.56ms - 16.77msslower ❌
0% - 1%
0.00ms - 0.22ms
-

usedJSHeapSize

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

duration

VersionAvg timevs preact-localvs preact-main
preact-local54.63ms - 55.52ms-faster ✔
1% - 4%
0.78ms - 2.08ms
preact-main56.02ms - 56.98msslower ❌
1% - 4%
0.78ms - 2.08ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local5.03ms - 5.32ms-unsure 🔍
-3% - +4%
-0.14ms - +0.22ms
preact-main5.03ms - 5.25msunsure 🔍
-4% - +3%
-0.22ms - +0.14ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local15.95ms - 16.32ms-unsure 🔍
-4% - +2%
-0.58ms - +0.27ms
preact-main15.91ms - 16.67msunsure 🔍
-2% - +4%
-0.27ms - +0.58ms
-

usedJSHeapSize

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

duration

VersionAvg timevs preact-localvs preact-main
preact-local58.05ms - 59.73ms-unsure 🔍
-4% - +0%
-2.35ms - +0.25ms
preact-main58.95ms - 60.94msunsure 🔍
-0% - +4%
-0.25ms - +2.35ms
-

usedJSHeapSize

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

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local26.93ms - 27.64ms-slower ❌
1% - 4%
0.20ms - 1.18ms
preact-main26.26ms - 26.93msfaster ✔
1% - 4%
0.20ms - 1.18ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local33.33ms - 34.45ms-slower ❌
1% - 5%
0.25ms - 1.76ms
preact-main32.38ms - 33.40msfaster ✔
1% - 5%
0.25ms - 1.76ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local31.96ms - 33.35ms-unsure 🔍
-3% - +2%
-1.14ms - +0.78ms
preact-main32.17ms - 33.50msunsure 🔍
-2% - +4%
-0.78ms - +1.14ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local26.60ms - 26.92ms-unsure 🔍
-1% - +1%
-0.14ms - +0.33ms
preact-main26.50ms - 26.83msunsure 🔍
-1% - +1%
-0.33ms - +0.14ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local26.51ms - 27.54ms-unsure 🔍
-1% - +4%
-0.26ms - +1.16ms
preact-main26.08ms - 27.07msunsure 🔍
-4% - +1%
-1.16ms - +0.26ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local21.89ms - 22.33ms-unsure 🔍
-1% - +2%
-0.25ms - +0.39ms
preact-main21.80ms - 22.27msunsure 🔍
-2% - +1%
-0.39ms - +0.25ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 220
  • Built by: CI #5815
  • Commit: 69b0935

duration

VersionAvg timevs preact-localvs preact-main
preact-local1.89ms - 2.02ms-unsure 🔍
-3% - +6%
-0.06ms - +0.12ms
preact-main1.86ms - 1.99msunsure 🔍
-6% - +3%
-0.12ms - +0.06ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.05ms - 1.10ms-unsure 🔍
-4% - +3%
-0.05ms - +0.03ms
preact-main1.05ms - 1.11msunsure 🔍
-3% - +4%
-0.03ms - +0.05ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local29.37ms - 31.42ms-unsure 🔍
-4% - +4%
-1.19ms - +1.36ms
preact-main29.56ms - 31.06msunsure 🔍
-4% - +4%
-1.36ms - +1.19ms
-

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-local32.15ms - 34.06ms-unsure 🔍
-3% - +4%
-1.12ms - +1.47ms
preact-main32.06ms - 33.80msunsure 🔍
-4% - +3%
-1.47ms - +1.12ms
-

usedJSHeapSize

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

tachometer-reporter-action v2 for CI

JoviDeCroock and others added 13 commits August 6, 2026 17:59
getDomSibling, updateParentDomPointers and useId's root walk all compared
against null where the values can only be a vnode/DOM node or null, so a
plain truthiness check is equivalent.

preact  4549 -> 4548 B br
hooks   1331 -> 1324 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only converted comparisons whose operands are already known to be the same
type (typeof results, for-in keys, string literals, numbers, object
identity). Value comparisons that can see coercible operands (value ===
true/false/null/'', defaultProps undefined checks, prop diffing) stay
strict. Core was left alone: the same change measured +1 B br there.

compat       3490 -> 3482 B br
hooks        1324 -> 1322 B br
jsx-runtime   740 ->  735 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
options._render already clears _pendingArgs on every render, and nothing
reads it between options.diffed and the next options._render, so clearing
it a second time in diffed is dead work.

hooks 1322 -> 1316 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…guards

- `'setAttribute' in value == !!nodeType` is implied by the branch that
  follows it: `localName` only exists on elements and `nodeType == 3` only
  on text nodes, so the `in` check can never change the outcome.
- reuse the `i = 'value'` local for `removeAttribute`
- fold the two `isClassComponent &&` guards into one block

preact 4548 -> 4533 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The do/while advanced oldDom in the body and re-tested in the condition;
doing the advance inside the condition is equivalent and compresses far
better than the do/while form.

preact 4533 -> 4519 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both call sites just call removeNode for every entry (removeNode already
no-ops on null), so the manual reverse index loops can be replaced by
`excessDomChildren.some(removeNode)`. Order is irrelevant: each entry is a
distinct node and removeNode only detaches it from its parent.

preact 4519 -> 4502 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- the resume scan's `depth > 0` loop condition is dead: depth only reaches
  zero through the `break` that follows the decrement
- flatten the catch-side close-marker branch into a single condition and use
  `!--commentMarkersToFind` instead of `--commentMarkersToFind == 0`

preact 4502 -> 4494 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- _catchError: getDerivedStateFromError/componentDidCatch can only be a
  function or undefined, so truthiness suffices
- children: `childVNode.ref || NULL` instead of the ternary
- setStyle: normalise a nullish value once instead of per branch
- portal container swap: Array#some instead of forEach

preact 4494 -> 4491 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`subs = null` on the Provider's componentWillUnmount was added in #4396 as a
hot path: back then `subs` was an array and each unmounting consumer paid an
O(n) `splice(indexOf(c), 1)`, so nulling the list short-circuited an O(n^2)
teardown. `subs` is a Set now and `delete` is O(1), so the short-circuit no
longer buys anything and the null guard in the consumer wrapper can go too.
The Set is still released with the Provider instance: every subscriber is a
descendant, so they all unmount and remove themselves first.

preact 4491 -> 4483 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`firstChildDom = firstChildDom || newDom` is equivalent to the guarded
assignment, and `_nextState` is only ever an object or null so the explicit
null comparison in setState is redundant.

preact 4483 -> 4481 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The replaceNode path (non-hydrating render into a container that already has
DOM) is the only caller, so trading the index loop for a copied NamedNodeMap
is cheap and compresses better.

The callback captures `oldProps`, which V8 then context-allocates in
diffElementNodes — a synthetic micro-benchmark of that shape runs ~50%
slower. It does not show up in practice: create10k, replace1k, hydrate1k,
update10th1k, filter-list and many-updates are all statistically unchanged
against the same build without this.

preact 4481 -> 4472 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
encodeEntities is only reachable through jsxAttr/jsxEscape, which exist for
the precompiled JSX transform (pracht) — nothing in preact, preact/compat or
preact-render-to-string calls them, so the hand-rolled charCodeAt scan is
paying for a path that only precompiled templates hit.

Keeping the `test()` early-out is what matters: it keeps entity-free strings
(the common case) on the fast path — dropping it makes them ~2.5x slower.
Strings that do need escaping get ~1.7x slower, which is the trade being made
here. `&` is replaced first so the inserted entities aren't re-escaped.

jsx-runtime 735 -> 656 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The callback captures `oldDom` so V8 context-allocates it in
constructNewChildrenArray, which is the hottest function in the diff — but
it does not show up in the repo benchmarks: replace1k, create10k, hydrate1k,
update10th1k, filter-list and many-updates are all statistically unchanged
against the same build without this (tachometer, 20+ samples, chrome
headless).

preact 4472 -> 4465 B br

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JoviDeCroock
JoviDeCroock force-pushed the JoviDeCroock/reduce-bundle-size branch from 130129b to 532e114 Compare August 6, 2026 16:00
@JoviDeCroock
JoviDeCroock force-pushed the JoviDeCroock/reduce-bundle-size branch from 532e114 to 8a6e574 Compare August 7, 2026 03:16
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Size Change: -78 B (-0.49%)

Total Size: 15.9 kB

📦 View Changed
Filename Size Change
compat/dist/compat.mjs 3.89 kB -4 B (-0.1%)
dist/preact.mjs 4.95 kB -56 B (-1.12%)
hooks/dist/hooks.mjs 1.56 kB -13 B (-0.83%)
jsx-runtime/dist/jsxRuntime.mjs 856 B -5 B (-0.58%)
ℹ️ View Unchanged
Filename Size
debug/dist/debug.mjs 3.88 kB
devtools/dist/devtools.mjs 274 B
test-utils/dist/testUtils.mjs 473 B

compressed-size-action

Comment thread jsx-runtime/src/utils.js Outdated
JoviDeCroock and others added 2 commits August 7, 2026 05:50
The `.some(arrow)` rewrites in `diffElementNodes` and
`constructNewChildrenArray` both capture a local of their enclosing
function (`oldProps` and `oldDom`). V8 has to context-allocate any
variable an inner closure touches, so both functions started heap-
allocating a Context on every call and reading those locals through it —
including the hot `for (i in oldProps)` loop that runs for every element
we diff.

Measured against main with the repo benchmarks (tachometer, chrome 151):
replace1k was 20-22% slower and hydrate1k 7.5-10.7% slower with 5-15%
more heap; both are statistically unchanged again after this. Costs 5 B
brotli back on core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chained-replace version is functionally equivalent (fuzz-verified over
211k inputs) but 2-6x slower whenever the string actually contains an
entity, and the -84 B brotli it bought never reaches bundled apps:
esbuild and rollup both tree-shake encodeEntities out entirely for a
jsx/Fragment-only import. The bytes only landed for unbundled consumers
(Deno, esm.sh, no-build) — i.e. exactly the precompile users who run this
in their SSR hot path — so they paid the slowdown to collect them.

Keeps the loose-equality golf from 5e96b7a.
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 99.297% (-0.002%) from 99.299% — JoviDeCroock/reduce-bundle-size into main

@JoviDeCroock
JoviDeCroock merged commit f2a65b8 into main Aug 7, 2026
13 checks passed
@JoviDeCroock
JoviDeCroock deleted the JoviDeCroock/reduce-bundle-size branch August 7, 2026 08:24
@JoviDeCroock JoviDeCroock mentioned this pull request Aug 8, 2026
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.

4 participants