Skip to content

perf: memoize preload descriptors (follow-up to #6 — build them once per process)#7

Merged
wille merged 1 commit into
wille:masterfrom
robtex:perf/memoize-preload-descriptors
Jun 16, 2026
Merged

perf: memoize preload descriptors (follow-up to #6 — build them once per process)#7
wille merged 1 commit into
wille:masterfrom
robtex:perf/memoize-preload-descriptors

Conversation

@robtex

@robtex robtex commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #6 (the chunk-closure memo, now merged — thanks!). This adds the second half of the optimization: memoizing the Preload descriptors themselves.

Summary

After #6 removed the per-render import-graph re-walk, collectModules still built a fresh descriptor object and a comment template 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 a WeakMap<manifest, …> (same manifest-keying as #6, so a swapped manifest / dev HMR drops the cache).

A nonce bypasses 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 cached Preload objects are shared read-only (the library only reads them in getChunks/getTags/getLinkHeader/sortPreloads).

Correctness

A 5050-case equivalence harness drives the original createChunkCollector and the memoized one over a real 998-module manifest and asserts getChunks() deepStrictEquals, across: every module individually, cumulative-all, 50 random subsets, non-empty-nonce (cache-bypass path), preloadFonts/preloadAssets/asyncScript combos, 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:

minor GCs
before #6 1505
#6 (chunk-closure memo, == current master) 266
this PR (+ descriptor memo) 106

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 nonce

If you pass a per-request CSP nonce to 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.

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.
@robtex robtex changed the title perf: memoize chunk-closure walk + preload descriptors (compute preloads once per process) perf: memoize preload descriptors (follow-up to #6 — build them once per process) Jun 9, 2026
@robtex robtex marked this pull request as ready for review June 10, 2026 16:32
@wille wille merged commit 40e191d into wille:master Jun 16, 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.

2 participants