perf: memoize preload descriptors (follow-up to #6 — build them once per process)#7
Merged
Conversation
The chunk-closure memo removed the per-render import-graph walk, but collectModules still built a fresh Preload descriptor object + `comment` template string for every chunk on every render. Those descriptors are a pure function of (manifest, moduleId, entry, preloadFonts, preloadAssets, asyncScript) — all stable at runtime — UNLESS a per-request nonce is set. Pre-build each module's descriptors once per process and cache them in a WeakMap<Manifest, Map<key, BuiltChunk[]>> (key = entry+flags+moduleId), bypassed when a nonce is present (so cached objects never carry the wrong nonce and the cache can't grow per request). collectModules' control flow is unchanged — same chunk-level skip, css first-wins, asset last-wins — only the object construction is memoized; cached Preload objects are shared read-only across renders. Behaviour-identical: a 5050-case harness over a real 998-module manifest asserts getChunks() deepStrictEquals upstream master across every module individually, cumulative, 50 subsets, non-empty-nonce (cache-bypass), fonts/assets/async combos, cache-warm-twice, and mixed-option key isolation — 0 mismatches.
wille
approved these changes
Jun 16, 2026
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.
Follow-up to #6 (the chunk-closure memo, now merged — thanks!). This adds the second half of the optimization: memoizing the
Preloaddescriptors themselves.Summary
After #6 removed the per-render import-graph re-walk,
collectModulesstill built a fresh descriptor object and acommenttemplate string for every chunk on every render. Those are pure given(manifest, moduleId, entry, preloadFonts, preloadAssets, asyncScript), so they're now built once per process and cached in aWeakMap<manifest, …>(same manifest-keying as #6, so a swapped manifest / dev HMR drops the cache).A
noncebypasses the cache and builds fresh — a per-request CSP nonce is request-specific, so cached objects never carry a wrong nonce and the cache can't grow per request.collectModules' control flow is unchanged (same chunk-level skip, css first-wins, asset last-wins); only object construction is memoized. The cachedPreloadobjects are shared read-only (the library only reads them ingetChunks/getTags/getLinkHeader/sortPreloads).Correctness
A 5050-case equivalence harness drives the original
createChunkCollectorand the memoized one over a real 998-module manifest and assertsgetChunks()deepStrictEquals, across: every module individually, cumulative-all, 50 random subsets, non-empty-nonce (cache-bypass path),preloadFonts/preloadAssets/asyncScriptcombos, cache-warm-twice (cold then warm), and mixed-option key isolation — 0 mismatches.Performance
Warm-cache render loop (entry + 6 lazy modules → 256 preload tags, 200k renders, production), minor GCs:
So ~2.5× fewer GCs on top of #6 (~14× vs the original). In a production-grade SSR A/B under load, the collector dropped out of the heap-allocation profile entirely (it had been a top-4 allocator, ~7 MB per 10s window).
npm test(tsc),prettier --check,npm run build(rollup) all clean.Note for apps that pass a
nonceIf you pass a per-request CSP
nonceto the collector but don't render it on the<script>/<link>preload tags (e.g. your CSP already authorizes same-origin assets via'self'), that nonce is dead weight and keeps this cache bypassed — dropping it lets the memo engage.Happy to squash/adjust naming or style however you prefer, @wille.