From 6e20c991ddb12e7f49cf0aedb3d37e8fcd40de37 Mon Sep 17 00:00:00 2001 From: kakiuwang-ui Date: Thu, 27 Aug 2026 23:28:04 +0800 Subject: [PATCH 1/3] fix(html): don't inline modulepreload link targets A `` whose target is smaller than `build.assetsInlineLimit` was rewritten to a `data:` URL. The browser fetches that data URL as a module distinct from the emitted chunk, so nothing referenced by the page is warmed and the preload is a no-op. Add `modulepreload` to `noInlineLinkRels` so these targets are always emitted as files, matching the existing handling for `icon` and `manifest` links. `preload`/`prefetch` links have the same problem but are left alone here; they interact with #16269 and need to be handled separately. --- packages/vite/src/node/plugins/html.ts | 3 +++ playground/assets/__tests__/assets.spec.ts | 10 ++++++++++ playground/assets/index.html | 1 + playground/assets/preload-module.js | 2 ++ 4 files changed, 16 insertions(+) create mode 100644 playground/assets/preload-module.js diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index d3488d5bb092c9..67dc6197962194 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -163,11 +163,14 @@ export function addToHTMLProxyTransformResult( // - `shortcut` : only valid for IE <9, use `icon` // - `mask-icon` : deprecated since Safari 12 (for pinned tabs) // - `apple-touch-icon-precomposed` : only valid for iOS <7 (for avoiding gloss effect) +// `modulepreload` is also listed because a `data:` URL is fetched as a separate +// module from the emitted chunk, so inlining defeats the preload entirely. const noInlineLinkRels = new Set([ 'icon', 'apple-touch-icon', 'apple-touch-startup-image', 'manifest', + 'modulepreload', ]) export const isAsyncScriptMap: WeakMap< diff --git a/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 46e540d5c7a610..6094d7576b0e38 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -314,6 +314,16 @@ describe('css url() references', () => { expect(await getBg('.css-url-quotes-base64-inline')).toMatch(match) }) + test('no base64 inline for modulepreload links', async () => { + const el = await page.$(`link[rel="modulepreload"]`) + const href = await el.getAttribute('href') + expect(href).toMatch( + isBundled + ? /\/foo\/bar\/assets\/preload-module-[-\w]{8}\.js/ + : 'preload-module.js', + ) + }) + test('no base64 inline for icon and manifest links', async () => { const iconEl = await page.$(`link.ico`) const href = await iconEl.getAttribute('href') diff --git a/playground/assets/index.html b/playground/assets/index.html index 6ba5233498bcd4..56525493ebab29 100644 --- a/playground/assets/index.html +++ b/playground/assets/index.html @@ -4,6 +4,7 @@ + , small enough to hit assetsInlineLimit +export const preloadedModule = 'preloaded' From 5802860af68204f4c2f98db4caba3207612ae6a1 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Tue, 1 Sep 2026 16:28:13 +0800 Subject: [PATCH 2/3] Apply suggestion from @bluwy --- packages/vite/src/node/plugins/html.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index 67dc6197962194..ff4119bcf9166d 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -163,8 +163,6 @@ export function addToHTMLProxyTransformResult( // - `shortcut` : only valid for IE <9, use `icon` // - `mask-icon` : deprecated since Safari 12 (for pinned tabs) // - `apple-touch-icon-precomposed` : only valid for iOS <7 (for avoiding gloss effect) -// `modulepreload` is also listed because a `data:` URL is fetched as a separate -// module from the emitted chunk, so inlining defeats the preload entirely. const noInlineLinkRels = new Set([ 'icon', 'apple-touch-icon', From 104f236f04f6281a98359d48ed5c09430c571eff Mon Sep 17 00:00:00 2001 From: kakiuwang-ui Date: Wed, 2 Sep 2026 01:04:58 +0800 Subject: [PATCH 3/3] fix(html): don't inline preload and prefetch link targets Extend the previous commit to `rel="preload"` and `rel="prefetch"`, which are defeated by inlining for the same reason as `modulepreload`. `` goes through the srcset branch, which called `processAssetUrl` without a `shouldInline` argument and so ignored `noInlineLinkRels` entirely. Hoist the no-inline decision out of the `src` branch so both use it. Also drop the comment above `noInlineLinkRels` per review. --- packages/vite/src/node/plugins/html.ts | 23 ++++++++++++--------- playground/assets/__tests__/assets.spec.ts | 20 ++++++++++++++++++ playground/assets/index.html | 17 +++++++++++++++ playground/assets/nested/preload-asset.png | Bin 0 -> 3395 bytes 4 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 playground/assets/nested/preload-asset.png diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index ff4119bcf9166d..2f3e005a782423 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -169,6 +169,8 @@ const noInlineLinkRels = new Set([ 'apple-touch-startup-image', 'manifest', 'modulepreload', + 'preload', + 'prefetch', ]) export const isAsyncScriptMap: WeakMap< @@ -623,6 +625,16 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { config.html?.additionalAssetSources, ) for (const attr of assetAttributes) { + // If the node is a link, check if it can be inlined. If not, set `shouldInline` + // to `false` to force no inline. If `undefined`, it leaves to the default heuristics. + const isNoInlineLink = + node.nodeName === 'link' && + attr.attributes.rel && + parseRelAttr(attr.attributes.rel).some((v) => + noInlineLinkRels.has(v), + ) + const shouldInline = isNoInlineLink ? false : undefined + if (attr.type === 'remove') { s.remove(attr.location.startOffset, attr.location.endOffset) continue @@ -637,7 +649,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { decodedUrl !== undefined && !isExcludedUrl(decodedUrl) ) { - const result = await processAssetUrl(url) + const result = await processAssetUrl(url, shouldInline) return result !== decodedUrl ? encodeURIPath(result) : url @@ -676,15 +688,6 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { }) js += importExpression } else { - // If the node is a link, check if it can be inlined. If not, set `shouldInline` - // to `false` to force no inline. If `undefined`, it leaves to the default heuristics. - const isNoInlineLink = - node.nodeName === 'link' && - attr.attributes.rel && - parseRelAttr(attr.attributes.rel).some((v) => - noInlineLinkRels.has(v), - ) - const shouldInline = isNoInlineLink ? false : undefined assetUrlsPromises.push( (async () => { const processedUrl = await processAssetUrl( diff --git a/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 6094d7576b0e38..f568d4c3eeb5dd 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -324,6 +324,26 @@ describe('css url() references', () => { ) }) + test('no base64 inline for preload and prefetch links', async () => { + const preloadAssetMatch = isBundled + ? /\/foo\/bar\/assets\/preload-asset-[-\w]{8}\.png/ + : '/foo/bar/nested/preload-asset.png' + + const preloadEl = await page.$('link.preload-href') + expect(await preloadEl.getAttribute('href')).toMatch(preloadAssetMatch) + + const prefetchEl = await page.$('link.prefetch-href') + expect(await prefetchEl.getAttribute('href')).toMatch(preloadAssetMatch) + + // `imagesrcset` goes through the srcset branch, which has to honour the + // same no-inline decision as `href` + const imageSrcSetEl = await page.$('link.preload-imagesrcset') + const imageSrcSet = await imageSrcSetEl.getAttribute('imagesrcset') + imageSrcSet.split(', ').forEach((s) => { + expect(s).toMatch(preloadAssetMatch) + }) + }) + test('no base64 inline for icon and manifest links', async () => { const iconEl = await page.$(`link.ico`) const href = await iconEl.getAttribute('href') diff --git a/playground/assets/index.html b/playground/assets/index.html index 56525493ebab29..728c3687e5b554 100644 --- a/playground/assets/index.html +++ b/playground/assets/index.html @@ -5,6 +5,23 @@ + + + KF&@kJcVR{+lVnpAW=nV5g|H(io-_}8K6*NcqG{pEa>P0gCYr*U~dy=m^0Oe z7!hfoL?e16xp?A}qVY%q7;OczNI;1QNJIt>lt79h(@_bQ;BUMr(S5@W1%tkYFrqEN ze~R*PJ`A#<(1;)t15+!P8!LgB{xFgOZ^M8V*o?+;j% zjYbGVxnu3V=Mq_#;0OkTih@F!Or`4OCV95o&O>x)4w-L)G}xSjtYevz@Q}3MqS^c z=?r(`-!lF&n(moMB|_babV?izFPcY~_7AYAcmJMfBT%FUg{9!*NJKKj0c!~sc?<}V z1e6KP(DZx{!kk~eI~MsL4MCDJ0}i3B?ug#`N698}~# z2*4l^2$-pjof!hNA>QZ!0*1A7WRL!P>~qu#$^9z(m!0H z_1U=owYMVUugxctMe9xz?e+DZ*aiSVrr80D@l4=N_`ae9j3}c&b|M6c^Scw&#v42` zs@=W24^$JB&ZnFHq$6hgI?L<)7qPYdpMFDMm5r*2NZZ#;hNLU%;UMB}lBX&zgLa?a z^q;(Rv!#SE3*p7_>9&tfp}FA;>iv!v_p|!wd;>mn_^936qi>ng*>kFww|a^}qJTRK znA`1@E!Y|;+Yc3d2h!d=ZZPN&V<0ppw;;&qMLk zs8GnX$?aj;YSC%6t7ryd|Y^9Qc9F|ZTi%4o)Pe;3_TsRPSb<*4*B>QW0CG(vI z>y^yW0M4}!mMf3!4z1fO+L~w1pRfz#LUd11)b_hvE5tyLhoKw+{E?+obcpiN*0LU8 z+U$wi{2kPnH5r4PMp&T>SNDmqNn*??;K@i|?J!$E9U15d+24QdeF^V4qsj`m?4KR1 zo+530cOpA+R_oI`YVpGHn|g}#XF^ggiIq#Q$r!(y$dA${=6vmyc6@muL~LaHK9;_U zM6ianv&69LoqCIN7iY5Nk~w}(YQ_@J^_HeBva+0R_9?+f_e~4spT$RYhq*aCNMrO$ zANmbE3u(ID)?XQ7A$ZW2RXgnhnaPNg>o$T&swba`imtoDTfX-ox*^fNY~&|)r*EP9 zDjZoQYgH3!&1P!g6r^)r`PMo_8VTHjZZPV`=6oa69GQI3%5zMi(iWaa;uA~o4%J^) zTvx7E_z4|v`9%k4yvRr!>a=q&CRMXs)EtsGU0BaC=tm1rZCwe$9zABiZcVuVM#f_O zEcY-xE#-a$$qK0SptY*~;s&%&{MxjMyzUJK)Ro2HOShN#i zdmd(sJrh3Lv2smAU0Uf)tzU1B4BTO~oK-hJav|YH;*QbtQMXN4?1g)YYj*9zWn;WS zf1=g6CNYN>p%StDbcLOA=ocrq)8-NU#|p81bRF3}y8w-f<_em-45$3L1@~e-{F^+_ zxkl==Ne8Vus0W)|3%}tC9-OE+5bT+_6f|Q*jZeS-JXh8E3%`TziByq04bJ6a1d7>H z;RYj*F8l4WsVGR4UERkbN(jGxSrPD|EDl*hJV;*`c%6d`F)1W~@pz_2+lh zX8Y6jap-cIJ8qTxvL@N3GJ-7LiuUjog<%wMNATus+JP&V$uD}fv7BWwUYMmGCIMGj z{8~4f?dWx0LX>Y-qi=!s6hE7+ubMW78h!`|R=KV%8FK zd9Sek(lv`S^5+9xYR`{X>yATq2OfeBt(}qyYA3{aLq|2d#^vB$R4#D)8lf9t!Y;+P z_6W7^{$}!gg?;4KjI39niyeEj#~ble%M>uw{nu#b`CHdh{gg9q+d8BrP^@~lDBI@d z7PMHwiV3pH(=wk3x<4;S!E(K=DJ>2!RAm*jrN_O@8jfGh%FKDK2Jufl-PsHs+4lH9 zDVKmh4;9;;mTf^PV;hRc zGZ1`0sMNGF2Q#}5#R#bM)tW_A=dSME$5(a2i}Ihk4u>_zBv%vI*myG)hHABMyPQj@ zM)WDfPOZ2LDPxwQFP3s9qT2x$(n(71$z;Qxd;tB$vX<;ZX<>Nsq(dQ>mhaX*MIFCf zb0p{bXj}2S`bD#rpHdTdNv!#{OR);Jsq8JJ*l1;(96~iudG#aZgj0Qo3WuL}YVZek z(SM)#(x7!4_(saUC6(W&b$isUNe3O4T~w_02Y#{Bs)T0h-r|>RiXN`FZb$o39EXiL z*RJ#X)VH`&nA(%q0JE_w3>ZQUex|DOv-oSu#vm zNS0_maoG$tauoZ;v)jT-lc=;EaufphG>)@S1@N~a5rr)*{bw*r-?o*rX6>8&zHcc*`CR~1#1jSw~d|R zwXi;)w4*Di+agG^bKz87RbPQwK!stmYMB_=wK@dlhtu9wFD@&T*Rq=*u7gj@78PR;rI9VW}r3UUH0!vA30jS z9UC??hXtMukpyu2>X*~v8;@W;`1_RbZHs(`$_8)1P;u9mwi4Rc0`sT=V!Y1o!I{Qq zTuHw3d^RR%uB5r{Ro1<>F8RPbj_b`$75gQNggjNu*H}~8;-HRVpN?w*H+KzOf}(^U z)d8)S!qI%E&Bf@3i1yh^Px1-jguC#MCUGbC&Rv0bg=h2Ms1B6?sLoFXEM>DdK;(^h zBZ=9iZF8CfgvLGLDUDAC>!hByzwDss&zxy_dFFE+ps2snc=RE+p+zrQ*E_9YM0{{Y z!fhZyCOLFWcsB~(RGiu+Zuz#hisxiIQ_CT@V-K)JH skXH~y@{pZ%H2RLzTI|$xG-@5%4N!D?9MV?={PtVpVC#adKM-=_zfn5szW@LL literal 0 HcmV?d00001