Describe the bug
When the build walks a page's chunk graph and reaches a shared chunk through a dynamic import() before it reaches the same chunk through a static import, the chunk, and any of its static dependencies that were not already reached statically, can be left out of the page's preload list, and their CSS can be left out of the server-rendered HTML.
In the reproduction, the root page statically imports a.js and b.js. a.js dynamically imports c.js, and b.js statically imports c.js, which imports c.css. c.js runs when the page loads (b() calls it during render), but after vite build:
- the root page's server node has no entry for the
c chunk in imports, so the HTML for / has no <link rel="modulepreload"> for it, and
- the node's
stylesheets is empty, so the HTML for / has no <link rel="stylesheet"> for c.css. With JavaScript disabled the paragraph stays unstyled. With JavaScript enabled the stylesheet is only added later, by Vite's preload helper when the client imports node 2, so the c chunk and c.css are fetched in a second round-trip.
The /b page, which reaches c.js only statically, gets both.
I'd like to submit a PR for this.
Reproduction
https://github.com/xyrolle/sveltekit-find-deps-repro
npm install && npx vite build, then look at .svelte-kit/output/server/nodes/2.js (the / page), or run npx vite preview and view the source of /.
Expected: node 2's imports includes the c chunk and its stylesheets includes c.css, and the HTML for / has a modulepreload link for the c chunk and a stylesheet link for c.css.
Actual: both are missing (output below).
The reproduction's files
package.json:
{
"name": "find-deps-repro",
"private": true,
"type": "module",
"scripts": { "build": "vite build", "preview": "vite preview" },
"devDependencies": {
"@sveltejs/kit": "3.0.0",
"@sveltejs/vite-plugin-svelte": "7.3.1",
"svelte": "5.57.1",
"vite": "8.3.2"
}
}
vite.config.js:
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit({ adapter: { name: 'noop', adapt() {} } })]
});
src/app.html:
<!doctype html>
<html><head>%sveltekit.head%</head><body>%sveltekit.body%</body></html>
src/lib/a.js, src/lib/b.js, src/lib/c.js, src/lib/c.css:
// src/lib/a.js
export const load_c = () => import('./c.js');
// src/lib/b.js
import { c } from './c.js';
export const b = () => c();
// src/lib/c.js
import './c.css';
export const c = () => 'styled by c.css';
/* src/lib/c.css */
.c { color: red; }
src/routes/+page.svelte:
<script>
import { load_c } from '../lib/a.js';
import { b } from '../lib/b.js';
</script>
<p class="c">{b()}</p>
<button onclick={load_c}>load c</button>
src/routes/a/+page.svelte and src/routes/b/+page.svelte only exist so that a.js and b.js end up in separate chunks:
<script>
import { load_c } from '../../lib/a.js';
</script>
<button onclick={load_c}>load c</button>
<script>
import { b } from '../../lib/b.js';
</script>
<p>{b()}</p>
Logs
# .svelte-kit/output/client/.vite/manifest.json (relevant entries)
_aT2b8Kyi.js (a) imports: ["_BaNbYf_w.js"] dynamicImports: ["_BdQ6-hIb.js"]
_44AlM8TR.js (b) imports: ["_BdQ6-hIb.js"]
_BdQ6-hIb.js (c) css: ["_app/immutable/assets/c.BJ3smcyB.css"]
# .svelte-kit/output/server/nodes/2.js
export const imports = ["_app/immutable/nodes/2.D-v6z5Iy.js","_app/immutable/chunks/Dym8azR3.js","_app/immutable/chunks/7_WjaUPM.js","_app/immutable/chunks/aT2b8Kyi.js","_app/immutable/chunks/BaNbYf_w.js","_app/immutable/chunks/44AlM8TR.js"];
export const stylesheets = [];
System Info
System:
OS: macOS 27.2
CPU: (18) arm64 Apple M5 Pro
Binaries:
Node: 24.16.0
npm: 11.13.0
npmPackages:
@sveltejs/kit: 3.0.0 => 3.0.0
@sveltejs/vite-plugin-svelte: 7.3.1 => 7.3.1
svelte: 5.57.1 => 5.57.1
vite: 8.3.2 => 8.3.2
Severity
serious, but I can work around it
Additional Information
The cause is in find_deps in packages/kit/src/exports/vite/build/utils.js. For page nodes it is called with add_dynamic_css = true, so it walks the manifest depth-first. It starts with add_js = true, keeps the current value when it follows imports, and passes add_js = false when it follows dynamicImports. Both walks share one seen set. In the reproduction, the walk reaches a, then follows a's dynamicImports into c with add_js = false, which marks c as seen without adding it to imports. When the walk then reaches c again through b, c is already in seen and is skipped, so c is not added to imports, and neither is anything it statically imports that wasn't already reached statically. For the same reason, c's CSS is grouped in stylesheet_map under c (the dynamic import) and not under the page, so in the reproduction it does not end up in the page node's stylesheets. The same code is on main.
Whether a chunk is affected depends on the order in which the walk meets its importers, and nothing warns when it happens. In our app, which groups chunks manually, one route preloaded 25 of the 203 chunks in its static closure, and simulated LCP went from 1.25 s to 2.5 s until we patched find_deps locally.
Describe the bug
When the build walks a page's chunk graph and reaches a shared chunk through a dynamic
import()before it reaches the same chunk through a static import, the chunk, and any of its static dependencies that were not already reached statically, can be left out of the page's preload list, and their CSS can be left out of the server-rendered HTML.In the reproduction, the root page statically imports
a.jsandb.js.a.jsdynamically importsc.js, andb.jsstatically importsc.js, which importsc.css.c.jsruns when the page loads (b()calls it during render), but aftervite build:cchunk inimports, so the HTML for/has no<link rel="modulepreload">for it, andstylesheetsis empty, so the HTML for/has no<link rel="stylesheet">forc.css. With JavaScript disabled the paragraph stays unstyled. With JavaScript enabled the stylesheet is only added later, by Vite's preload helper when the client imports node 2, so thecchunk andc.cssare fetched in a second round-trip.The
/bpage, which reachesc.jsonly statically, gets both.I'd like to submit a PR for this.
Reproduction
https://github.com/xyrolle/sveltekit-find-deps-repro
npm install && npx vite build, then look at.svelte-kit/output/server/nodes/2.js(the/page), or runnpx vite previewand view the source of/.Expected: node 2's
importsincludes thecchunk and itsstylesheetsincludesc.css, and the HTML for/has amodulepreloadlink for thecchunk and a stylesheet link forc.css.Actual: both are missing (output below).
The reproduction's files
package.json:{ "name": "find-deps-repro", "private": true, "type": "module", "scripts": { "build": "vite build", "preview": "vite preview" }, "devDependencies": { "@sveltejs/kit": "3.0.0", "@sveltejs/vite-plugin-svelte": "7.3.1", "svelte": "5.57.1", "vite": "8.3.2" } }vite.config.js:src/app.html:src/lib/a.js,src/lib/b.js,src/lib/c.js,src/lib/c.css:src/routes/+page.svelte:src/routes/a/+page.svelteandsrc/routes/b/+page.svelteonly exist so thata.jsandb.jsend up in separate chunks:Logs
System Info
Severity
serious, but I can work around it
Additional Information
The cause is in
find_depsinpackages/kit/src/exports/vite/build/utils.js. For page nodes it is called withadd_dynamic_css = true, so it walks the manifest depth-first. It starts withadd_js = true, keeps the current value when it followsimports, and passesadd_js = falsewhen it followsdynamicImports. Both walks share oneseenset. In the reproduction, the walk reachesa, then followsa'sdynamicImportsintocwithadd_js = false, which markscas seen without adding it toimports. When the walk then reachescagain throughb,cis already inseenand is skipped, socis not added toimports, and neither is anything it statically imports that wasn't already reached statically. For the same reason,c's CSS is grouped instylesheet_mapunderc(the dynamic import) and not under the page, so in the reproduction it does not end up in the page node'sstylesheets. The same code is onmain.Whether a chunk is affected depends on the order in which the walk meets its importers, and nothing warns when it happens. In our app, which groups chunks manually, one route preloaded 25 of the 203 chunks in its static closure, and simulated LCP went from 1.25 s to 2.5 s until we patched
find_depslocally.