@@ -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