Reproduction link or steps
Originally surfaced through Vite — vitejs/vite#22596 (minimal repro: https://github.com/baevm/vite-rolldown-lazy-import-repro/tree/assets-load-reproduction). This is the rolldown-side root cause.
Rolldown-native reproduction (no Vite), using a experimental.devMode dev server (e.g. @rolldown/test-dev-server) with native asset handling:
- Config:
experimental.devMode enabled and moduleTypes: { '.png': 'asset' } (so the image is emitted as a real hashed asset, not an inlined data URL).
- Make an asset reach the page through an HMR/lazy patch rather than the initial bundle — either of:
- Lazy compilation: a dynamically-imported module that does
import url from './img.png', opened for the first time; or
- HMR edit: edit an already-loaded, self-accepting module to add
import url from './img.png' and img.src = url.
- Open the page / trigger the path and watch the
<img> and the network panel.
Both scenarios are covered by regression tests under packages/test-dev-server/tests/playground/: lazy-compilation › emitted-asset and hmr-full-bundle-mode › HMR adds an asset import.
What is expected?
The asset reference carried by the HMR/lazy patch is resolved to its final hashed filename (e.g. ./assets/img-<hash>.png) and the bytes are served, so the asset loads on first appearance — without needing a full page reload.
What is actually happening?
The patch ships the raw asset placeholder, so the browser ends up with:
img.src = http://localhost:<port>/__ROLLDOWN_ASSET__#VRAku6fjghkApIISiBWPzg
GET /__ROLLDOWN_ASSET__ -> 404 (the `#…` is parsed as a URL fragment)
The image never decodes (naturalWidth === 0). A full page reload fixes it, because the reload serves the fully-generated bundle where the reference is already rewritten to ./assets/img-<hash>.png (HTTP 200).
System Info
System:
OS: macOS 26.5.1
CPU: (14) arm64 Apple M4 Pro
Binaries:
Node: 24.12.0
npm: 11.6.2
pnpm: 11.4.0
Browsers:
Chrome: 149.0.7827.115
Safari: 26.5
npmPackages:
rolldown: 1.1.1 (also reproduces on workspace HEAD)
Any additional comments?
Root cause. The HMR/lazy codegen in crates/rolldown/src/hmr/hmr_stage.rs renders patch code with EcmaCompiler::print_with and never runs the renderChunk plugin hook — which is the only place the builtin asset plugin (crates/rolldown_plugin_asset_module/src/lib.rs) rewrites __ROLLDOWN_ASSET__#<refId> into the resolved filename via ctx.get_file_name(refId). This affects both code paths in that file — compile_lazy_entry (lazy compilation) and compute_hmr_update (regular HMR updates) — so any asset newly introduced by a patch is affected.
Two layers:
- Placeholder not resolved (both paths): the patch contains
__ROLLDOWN_ASSET__#<refId>. The refId → filename mapping already exists in FileEmitter at patch time (the asset's load hook ran during the partial scan and emitted it), so this is a missing substitution step rather than a re-architecture.
- Bytes not served (pure-HMR path): an HMR patch runs no
generate, so emitted asset bytes never reach the served output until a later full rebuild.
Cross-reference. The Vite-side fix proposed in vitejs/vite#22599 (await ensureLatestBuildOutput() after compileEntry) only addresses serving timing (layer 2) for the lazy path; it does not resolve the placeholder (layer 1), which must be fixed here in rolldown.
Reproduction link or steps
Originally surfaced through Vite — vitejs/vite#22596 (minimal repro: https://github.com/baevm/vite-rolldown-lazy-import-repro/tree/assets-load-reproduction). This is the rolldown-side root cause.
Rolldown-native reproduction (no Vite), using a
experimental.devModedev server (e.g.@rolldown/test-dev-server) with native asset handling:experimental.devModeenabled andmoduleTypes: { '.png': 'asset' }(so the image is emitted as a real hashed asset, not an inlined data URL).import url from './img.png', opened for the first time; orimport url from './img.png'andimg.src = url.<img>and the network panel.Both scenarios are covered by regression tests under
packages/test-dev-server/tests/playground/:lazy-compilation › emitted-assetandhmr-full-bundle-mode › HMR adds an asset import.What is expected?
The asset reference carried by the HMR/lazy patch is resolved to its final hashed filename (e.g.
./assets/img-<hash>.png) and the bytes are served, so the asset loads on first appearance — without needing a full page reload.What is actually happening?
The patch ships the raw asset placeholder, so the browser ends up with:
The image never decodes (
naturalWidth === 0). A full page reload fixes it, because the reload serves the fully-generated bundle where the reference is already rewritten to./assets/img-<hash>.png(HTTP 200).System Info
Any additional comments?
Root cause. The HMR/lazy codegen in
crates/rolldown/src/hmr/hmr_stage.rsrenders patch code withEcmaCompiler::print_withand never runs therenderChunkplugin hook — which is the only place the builtin asset plugin (crates/rolldown_plugin_asset_module/src/lib.rs) rewrites__ROLLDOWN_ASSET__#<refId>into the resolved filename viactx.get_file_name(refId). This affects both code paths in that file —compile_lazy_entry(lazy compilation) andcompute_hmr_update(regular HMR updates) — so any asset newly introduced by a patch is affected.Two layers:
__ROLLDOWN_ASSET__#<refId>. TherefId → filenamemapping already exists inFileEmitterat patch time (the asset'sloadhook ran during the partial scan and emitted it), so this is a missing substitution step rather than a re-architecture.generate, so emitted asset bytes never reach the served output until a later full rebuild.Cross-reference. The Vite-side fix proposed in vitejs/vite#22599 (
await ensureLatestBuildOutput()aftercompileEntry) only addresses serving timing (layer 2) for the lazy path; it does not resolve the placeholder (layer 1), which must be fixed here in rolldown.