Skip to content

Commit 40e191d

Browse files
authored
Merge pull request #7 from robtex/perf/memoize-preload-descriptors
perf: memoize preload descriptors (follow-up to #6 — build them once per process)
2 parents 91850a8 + 5da7d6d commit 40e191d

1 file changed

Lines changed: 176 additions & 47 deletions

File tree

src/collector.ts

Lines changed: 176 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -250,17 +250,87 @@ function collectModules(
250250
return preloads;
251251
}
252252

253-
const chunks = collectChunks(manifest, moduleId);
253+
const built = getBuiltChunks(
254+
manifest,
255+
moduleId,
256+
entry,
257+
preloadFonts,
258+
preloadAssets,
259+
nonce,
260+
asyncScript
261+
);
254262

255-
for (const chunk of chunks.values()) {
256-
if (preloads.has(chunk.file)) {
263+
for (const c of built) {
264+
// Skip the whole chunk if its script file is already collected — mirrors the original
265+
// `if (preloads.has(chunk.file)) continue;`, which dropped that chunk's css + assets too.
266+
if (preloads.has(c.file)) {
257267
continue;
258268
}
259269

270+
preloads.set(c.file, c.script);
271+
272+
for (const [cssFile, cssPreload] of c.css) {
273+
if (preloads.has(cssFile)) continue;
274+
preloads.set(cssFile, cssPreload);
275+
}
276+
277+
// Assets were set unconditionally in the original (last-write-wins, no has() guard).
278+
for (const [assetFile, assetPreload] of c.assets) {
279+
preloads.set(assetFile, assetPreload);
280+
}
281+
}
282+
283+
return preloads;
284+
}
285+
286+
/**
287+
* A chunk's preload descriptors, pre-built: the `<script>` / `<link modulepreload>` descriptor
288+
* plus the ordered css and asset `[href, Preload]` entries — exactly what `collectModules`
289+
* would otherwise construct for one chunk on every render.
290+
*/
291+
interface BuiltChunk {
292+
file: string;
293+
script: Preload;
294+
css: Array<[string, Preload]>;
295+
assets: Array<[string, Preload]>;
296+
}
297+
298+
/**
299+
* Per-manifest cache of pre-built per-module descriptors, keyed by
300+
* `${entry}\0${flags}\0${moduleId}`.
301+
*
302+
* Only used when `nonce` is empty. The `Preload` descriptor objects (and their `comment`
303+
* template strings) are otherwise a pure function of
304+
* `(manifest, moduleId, entry, preloadFonts, preloadAssets, asyncScript)` — all stable at
305+
* runtime — so the closure-memo (`collectChunks`) left descriptor construction as the largest
306+
* remaining per-render allocation in this module. Building each module's descriptors once per
307+
* process removes it. A non-empty per-request CSP `nonce` bypasses the cache and builds fresh,
308+
* so cached objects never carry the wrong nonce and the cache cannot grow per request.
309+
* WeakMap-keyed on the manifest object so a replaced manifest (rebuild / dev HMR) drops the
310+
* stale cache.
311+
*
312+
* The cached `Preload` objects are SHARED across renders — callers must treat them as
313+
* immutable (vite-preload only reads them in getChunks/getTags/getLinkHeader/sortPreloads).
314+
*/
315+
const descriptorCache = new WeakMap<Manifest, Map<string, BuiltChunk[]>>();
316+
317+
function buildChunksForModule(
318+
manifest: Manifest,
319+
moduleId: string,
320+
entry: string,
321+
preloadFonts: boolean,
322+
preloadAssets: boolean,
323+
nonce: string,
324+
asyncScript: boolean
325+
): BuiltChunk[] {
326+
const chunks = collectChunks(manifest, moduleId);
327+
const built: BuiltChunk[] = [];
328+
329+
for (const chunk of chunks.values()) {
260330
const isPolyfill = chunk.src === 'vite/legacy-polyfills';
261331
const isPrimaryModule = chunk.src === entry;
262332

263-
preloads.set(chunk.file, {
333+
const script: Preload = {
264334
// Only the entrypoint module is used as <script module>, everything else is <link rel=modulepreload>
265335
rel: isPrimaryModule || isPolyfill ? 'module' : 'modulepreload',
266336
href: chunk.file,
@@ -270,58 +340,117 @@ function collectModules(
270340

271341
// The polyfill chunk should not be async and it should run before the entry chunk
272342
asyncScript: asyncScript && !isPolyfill,
273-
});
343+
};
274344

345+
const css: Array<[string, Preload]> = [];
275346
for (const cssFile of chunk.css || []) {
276-
if (preloads.has(cssFile)) continue;
277-
preloads.set(cssFile, {
278-
rel: 'stylesheet',
279-
href: cssFile,
280-
comment: `chunk: ${chunk.name}, isEntry: ${chunk.isEntry}`,
281-
isEntry: chunk.isEntry,
282-
nonce,
283-
});
347+
css.push([
348+
cssFile,
349+
{
350+
rel: 'stylesheet',
351+
href: cssFile,
352+
comment: `chunk: ${chunk.name}, isEntry: ${chunk.isEntry}`,
353+
isEntry: chunk.isEntry,
354+
nonce,
355+
},
356+
]);
284357
}
285358

286-
if (!preloadFonts && !preloadAssets) {
287-
continue;
359+
const assets: Array<[string, Preload]> = [];
360+
if (preloadFonts || preloadAssets) {
361+
// Assets such as svg, png imports
362+
for (const asset of chunk.assets || []) {
363+
const ext = path.extname(asset).substring(1);
364+
let as;
365+
let mimeType;
366+
let skip = false;
367+
368+
switch (ext) {
369+
case 'png':
370+
case 'jpg':
371+
case 'webp':
372+
case 'svg':
373+
as = 'image';
374+
mimeType =
375+
ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
376+
if (!preloadAssets) skip = true;
377+
break;
378+
case 'woff2':
379+
case 'woff':
380+
case 'ttf':
381+
as = 'font';
382+
mimeType = `font/${ext}`;
383+
if (!preloadFonts) skip = true;
384+
break;
385+
}
386+
if (skip) continue;
387+
388+
assets.push([
389+
asset,
390+
{
391+
rel: 'preload',
392+
href: asset,
393+
as,
394+
type: mimeType,
395+
comment: `Asset from chunk ${chunk.name}: ${chunk.file}`,
396+
},
397+
]);
398+
}
288399
}
289400

290-
// Assets such as svg, png imports
291-
for (const asset of chunk.assets || []) {
292-
const ext = path.extname(asset).substring(1);
293-
let as;
294-
let mimeType;
295-
296-
switch (ext) {
297-
case 'png':
298-
case 'jpg':
299-
case 'webp':
300-
case 'svg':
301-
as = 'image';
302-
mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
303-
if (preloadAssets) break;
304-
else continue;
305-
case 'woff2':
306-
case 'woff':
307-
case 'ttf':
308-
as = 'font';
309-
mimeType = `font/${ext}`;
310-
if (preloadFonts) break;
311-
else continue;
312-
}
401+
built.push({ file: chunk.file, script, css, assets });
402+
}
313403

314-
preloads.set(asset, {
315-
rel: 'preload',
316-
href: asset,
317-
as,
318-
type: mimeType,
319-
comment: `Asset from chunk ${chunk.name}: ${chunk.file}`,
320-
});
321-
}
404+
return built;
405+
}
406+
407+
/**
408+
* Returns the pre-built per-module chunk descriptors, memoized per process when `nonce` is
409+
* empty (see {@link descriptorCache}); built fresh otherwise.
410+
*/
411+
function getBuiltChunks(
412+
manifest: Manifest,
413+
moduleId: string,
414+
entry: string,
415+
preloadFonts: boolean,
416+
preloadAssets: boolean,
417+
nonce: string,
418+
asyncScript: boolean
419+
): BuiltChunk[] {
420+
// A per-request nonce makes the descriptors request-specific: build fresh, never cache.
421+
if (nonce) {
422+
return buildChunksForModule(
423+
manifest,
424+
moduleId,
425+
entry,
426+
preloadFonts,
427+
preloadAssets,
428+
nonce,
429+
asyncScript
430+
);
322431
}
323432

324-
return preloads;
433+
let perManifest = descriptorCache.get(manifest);
434+
if (!perManifest) {
435+
perManifest = new Map();
436+
descriptorCache.set(manifest, perManifest);
437+
}
438+
439+
const key = `${entry}\0${preloadFonts ? 1 : 0}${preloadAssets ? 1 : 0}${asyncScript ? 1 : 0}\0${moduleId}`;
440+
let built = perManifest.get(key);
441+
if (!built) {
442+
built = buildChunksForModule(
443+
manifest,
444+
moduleId,
445+
entry,
446+
preloadFonts,
447+
preloadAssets,
448+
nonce,
449+
asyncScript
450+
);
451+
perManifest.set(key, built);
452+
}
453+
return built;
325454
}
326455

327456
/**

0 commit comments

Comments
 (0)