v11.0.0-rc.0: bundle size reductions and diffing improvements - #5
Merged
Conversation
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 preactjs#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>
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.
Every normalization branch in the loop wrote through `newParentVNode._children[i]`, reloading the property each time. Bind it once and write through the local instead. -5 B brotli (4582 -> 4577), performance-neutral across update, mount, keyed-reorder and hydration benchmarks. This replaces the earlier "avoid allocating arrays for single children" approach from this branch. Passing the single child unwrapped did win 3-7% on update10th1k, but it cost ~3% on first-paint hydration, so it was dropped. For the record, hydrate1k's reported 45-55% regression on that earlier version was a measurement artifact, not a throughput regression: the benchmark does exactly 5 warmup hydrations and times the 6th, and each iteration clones and discards ~8000 DOM nodes, which produces a deterministic periodic slow hydration every ~4-5 iterations. Each build has its own phase. Timing only #6 sampled the patched build's slow phase. Holding the build pair fixed and varying only the warmup count gave -16.8% / +30.8% / +6.4% / +0.9% / -21.8% / +7.0% for hydrations 5-10 (mean ~+1%), while a main-vs-main control over the same protocol stayed flat at +0.2% / -0.5% / -1.3%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndle-size Reduce bundle size before release candidates go live
…8-algo-learnings Hoist `_children` into a local in `constructNewChildrenArray`
…eact-gzip-size Reduce Preact core compressed size
…le-bits-change Restore `_bits` mangle for Preact ISO
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.
Rolls up the latest core work for the v11 release candidate:
_childreninto a local inconstructNewChildrenArraypreactjs/preact#5198)_bitsmangle for Preact ISO (Restore_bitsmangle for Preact ISO preactjs/preact#5200)