Skip to content

Dep scan fails with UNRESOLVED_IMPORT in virtual-module: when a .svelte file in node_modules is imported by a deep path (html-types branch return no longer falls through to bare-import resolve) #23669

Description

@iksnae

Describe the bug

In Vite 8, a bare import that ends in .svelte and resolves into node_modules (for example import Banner from 'svelte-lib/Banner.svelte') is not recorded as an optimizable dependency. The scanner crawls it as source instead. When that file has a relative import (import Icon from './Icon.svelte'), the import is resolved against the virtual-module: importer and fails. The whole scan is then skipped:

(!) Failed to run dependency scan. Skipping dependency pre-bundling. Error:   Failed to scan for dependencies from entries:
  <root>/index.html

  Build failed with 1 error:

[UNRESOLVED_IMPORT] Could not resolve './Icon.svelte' in virtual-module:<root>/node_modules/svelte-lib/Banner.svelte?id=0

The visible consequence is that dependencies are only discovered at first page load. The optimizer re-runs mid-load (optimized dependencies changed. reloading), and requests for the stale /node_modules/.vite/deps/* URLs get 504. Depending on timing, the first load can fail with "Importing a module script failed" and leave a blank page until a manual reload.

Importing the same component through the package's barrel (import { Banner } from 'svelte-lib') scans cleanly, and svelte-lib is pre-bundled at startup.

Cause. In vite:dep-scan:resolve, the html-types branch intends to hand optimizable node_modules files to the bare-import branch below it:

if (htmlTypesRE.test(id)) {
const resolved = await resolve(id, importer)
if (!resolved) return
// It is possible for the scanner to scan html types in node_modules.
// If we can optimize this html type, skip it so it's handled by the
// bare import resolve, and recorded as optimization dep.
if (
isInNodeModules(resolved) &&
isOptimizable(resolved, optimizeDepsOptions)
)
return
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
return resolved

if (htmlTypesRE.test(id)) {
  const resolved = await resolve(id, importer)
  if (!resolved) return
  // It is possible for the scanner to scan html types in node_modules.
  // If we can optimize this html type, skip it so it's handled by the
  // bare import resolve, and recorded as optimization dep.
  if (
    isInNodeModules(resolved) &&
    isOptimizable(resolved, optimizeDepsOptions)
  )
    return
  if (shouldExternalizeDep(resolved, id)) {
    return externalUnlessEntry({ path: id })
  }
  return resolved
}

With esbuild these were separate onResolve callbacks, so returning undefined passed control to the next callback (the bare-import onResolve), which recorded the dep and externalized it. In the rolldown version both branches live in the same resolveId function. The return exits that function, so the bare-import branch (// bare imports: record and externalize, L624 onward) never runs for this id. Rolldown's own resolution then picks the file up, vite:dep-scan:load:html extracts its <script> into virtual-module:…?id=0, and relative imports inside it fail to resolve.

The trigger is any framework whose plugin adds its extension to optimizeDeps.extensions (@sveltejs/vite-plugin-svelte 7 always adds .svelte, so isOptimizable is true), combined with a deep .svelte import from a package installed in node_modules.

Experiment that fixes it. Make the optimizable case fall through instead of returning. Only lines 618-621 change, and they are wrapped:

if (!(isInNodeModules(resolved) && isOptimizable(resolved, optimizeDepsOptions))) {
  if (shouldExternalizeDep(resolved, id)) {
    return externalUnlessEntry({ path: id })
  }
  return resolved
}
// otherwise fall through to the bare-import branch below

I applied the equivalent edit to node_modules/vite/dist/node/chunks/node.js in the reproduction below (vite 8.3.3). With it, the scan succeeds and _metadata.json lists svelte-lib/Banner.svelte as an optimized dependency.

Reproduction

Minimal project, described inline. There are seven files plus a packed library tarball.

package.json

{
  "name": "vite-svelte-deep-import-scan-repro",
  "private": true,
  "type": "module",
  "scripts": { "dev": "vite --force" },
  "dependencies": { "svelte-lib": "file:./svelte-lib-1.0.0.tgz" },
  "devDependencies": {
    "@sveltejs/vite-plugin-svelte": "^7.3.0",
    "svelte": "^5.56.9",
    "vite": "8.3.3"
  }
}

vite.config.js

import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'

export default defineConfig({ plugins: [svelte()] })

index.html

<!doctype html>
<html>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

src/main.js

import { mount } from 'svelte'
import App from './App.svelte'

mount(App, { target: document.getElementById('app') })

src/App.svelte

<script>
  // Deep import of a .svelte file from a dependency: the dependency scan fails.
  import Banner from 'svelte-lib/Banner.svelte'
  // Barrel import of the same component: the dependency scan succeeds.
  // import { Banner } from 'svelte-lib'
</script>

<Banner />

The library lives in lib/ and is packed with npm pack into svelte-lib-1.0.0.tgz. A tarball is used so the package is a real copy under node_modules. A file:./lib folder dependency is symlinked, resolves outside node_modules, and does not take this code path.

lib/package.json

{
  "name": "svelte-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./index.js",
    "./*": "./*"
  },
  "peerDependencies": { "svelte": "^5.0.0" }
}

lib/index.js

export { default as Banner } from './Banner.svelte'
export { default as Icon } from './Icon.svelte'

lib/Banner.svelte

<script>
  import Icon from './Icon.svelte'
</script>

<p><Icon /> hello</p>

lib/Icon.svelte

<span>*</span>

Steps to reproduce

  1. Create the files above.
  2. (cd lib && npm pack --pack-destination ..)
  3. npm install
  4. npx vite --force. The output shows Failed to run dependency scan with [UNRESOLVED_IMPORT] Could not resolve './Icon.svelte' in virtual-module:…/node_modules/svelte-lib/Banner.svelte?id=0.
  5. In src/App.svelte, switch to the barrel import (import { Banner } from 'svelte-lib') and run npx vite --force again. The scan succeeds and node_modules/.vite/deps/_metadata.json lists svelte-lib under optimized.

This minimal project reproduces it on vite 8.3.3. The same error appears with vite 8.2.1 in a larger project, and the code is unchanged on main at the commit linked above.

System Info

  System:
    OS: macOS 27.0.1
    CPU: (16) arm64 Apple M4 Max
    Memory: 2.01 GB / 48.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 26.9.0 - /opt/homebrew/bin/node
    npm: 11.19.1 - /opt/homebrew/bin/npm
    pnpm: 12.4.2 - /opt/homebrew/bin/pnpm
    Deno: 2.9.7 - /opt/homebrew/bin/deno
  Browsers:
    Brave Browser: 143.1.85.111
    Chrome: 154.0.8037.95
    Safari: 27.0.1
  npmPackages:
    @sveltejs/vite-plugin-svelte: ^7.3.0 => 7.3.1
    svelte: ^5.56.9 => 5.57.2
    vite: 8.3.3 => 8.3.3

rolldown is not a direct dependency, so envinfo does not list it. npm ls rolldown reports rolldown@1.2.12.

Used Package Manager

npm

Logs

Click to expand!

Deep import (import Banner from 'svelte-lib/Banner.svelte'), vite 8.3.3:

[vite-plugin-svelte] no Svelte config found at <root> - using default configuration.
[vite] (client) Forced re-optimization of dependencies
[vite] (ssr) Forced re-optimization of dependencies
(!) Failed to run dependency scan. Skipping dependency pre-bundling. Error:   Failed to scan for dependencies from entries:
  <root>/index.html

  Build failed with 1 error:

[UNRESOLVED_IMPORT] Could not resolve './Icon.svelte' in virtual-module:<root>/node_modules/svelte-lib/Banner.svelte?id=0
   ╭─[ virtual-module:<root>/node_modules/svelte-lib/Banner.svelte?id=0:2:20 ]
   │
 2 │   import Icon from './Icon.svelte'
   │                    ───────┬───────
   │                           ╰───────── Module not found.
   │
   │ Help: 'virtual-module:<root>/node_modules/svelte-lib/Banner.svelte?id=0' is imported by the following path:
   │         - virtual-module:<root>/node_modules/svelte-lib/Banner.svelte?id=0
   │         - node_modules/svelte-lib/Banner.svelte
   │         - virtual-module:<root>/src/App.svelte?id=0
   │         - src/App.svelte
   │         - src/main.js
   │         - index.html
───╯

    at aggregateBindingErrorsIntoJsError (<root>/node_modules/rolldown/dist/shared/error-Bj1xBdEY.mjs:49:18)
    at unwrapBindingResult (<root>/node_modules/rolldown/dist/shared/error-Bj1xBdEY.mjs:19:128)
    at scan (<root>/node_modules/rolldown/dist/experimental-index.mjs:241:3)
    at async Object.build (<root>/node_modules/vite/dist/node/chunks/node.js:28068:3)
    at async scan (<root>/node_modules/vite/dist/node/chunks/node.js:28007:4)
    at async <root>/node_modules/vite/dist/node/chunks/node.js:35783:15

  VITE v8.3.3  ready in 605 ms

Barrel import (import { Banner } from 'svelte-lib'), vite 8.3.3. The scan succeeds, and _metadata.json optimized includes svelte-lib:

[vite] (client) Forced re-optimization of dependencies
[vite] (ssr) Forced re-optimization of dependencies

  VITE v8.3.3  ready in 310 ms

Deep import with the fall-through experiment applied to vite 8.3.3. The scan succeeds, and _metadata.json optimized includes svelte-lib/Banner.svelte:

[vite] (client) Forced re-optimization of dependencies
[vite] (ssr) Forced re-optimization of dependencies

  VITE v8.3.3  ready in 296 ms

Validations

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feat: deps optimizerDependencies Optimizationp2-edge-caseBug, but has workaround or limited in scope (priority)

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions