Skip to content

[Bug]: dev mode — assets added by an HMR/lazy patch are served as unresolved __ROLLDOWN_ASSET__ placeholders (404 until full reload) #9812

Description

@h-a-n-a

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:

  1. Config: experimental.devMode enabled and moduleTypes: { '.png': 'asset' } (so the image is emitted as a real hashed asset, not an inlined data URL).
  2. 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.
  3. 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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

Priority

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions