Environment
- Operating System: Darwin 23.6.0 (arm64)
- Node Version: v25.9.0
- Nuxt Version: 4.5.2
- Vue Version: 3.5.43
- Vite Version: 8.3.0
- Package Manager: npm@11.12.1 (also reproduced with pnpm@11.22.0)
- Builder: vite
- Nuxt Modules: -
- Build Modules: -
(npx nuxi info printed an empty table in this project, so the above is filled in by hand from the installed package versions.)
Also present on main at 99af6de.
Reproduction
https://github.com/silverbackdan/nuxt-layer-symlink-prefetch
npm install
npm run build
npm run check
Describe the bug
packages/nuxt/src/pages/module.ts strips page chunks out of the entry chunk's dynamicImports in a build:manifest hook, so that pages are not prefetched on every route:
const getSources = (pages: NuxtPage[]): string[] => pages
.filter(p => Boolean(p.file))
.flatMap(p =>
[relative(nuxt.options.srcDir, p.file as string), ...(p.children?.length ? getSources(p.children) : [])],
)
// Do not prefetch page chunks
nuxt.hook('build:manifest', (manifest) => {
if (nuxt.options.dev) { return }
const sourceFiles = nuxt.apps.default?.pages?.length ? getSources(nuxt.apps.default.pages) : []
for (const [key, chunk] of Object.entries(manifest)) {
// …
if (chunk.isEntry) {
chunk.dynamicImports =
chunk.dynamicImports?.filter(i => !sourceFiles.includes(i))
}
}
})
(packages/nuxt/src/pages/module.ts#L745-L766 on main; nuxt/dist/index.mjs:1620-1629 in the 4.5.2 release.)
The filter is a string comparison between relative(srcDir, page.file) and the Vite manifest keys. When a layer is reached through a symlink, those two strings describe the same file differently: page.file keeps the symlink path, while Vite resolves symlinks when it builds manifest keys.
In the reproduction:
|
|
relative(srcDir, page.file) |
../node_modules/my-layer/app/pages/layer-page-1.vue |
| Vite manifest key |
../my-layer/app/pages/layer-page-1.vue |
They never match, so nothing from that layer is filtered and every page of it stays in the entry's dynamicImports. Each one becomes a <link rel="prefetch"> on every route.
The app's own pages are filtered correctly in the same build, which shows the filter itself works when the two paths agree.
What triggers it
A layer extended by a filesystem path that traverses a symlink. Measured in the reproduction, all with the same files on disk:
extends |
layer pages in entry dynamicImports |
['./node_modules/my-layer'] (npm, file: dep) |
3 of 3 — not filtered |
['./node_modules/my-layer'] (pnpm install) |
3 of 3 — not filtered |
['my-layer'] (bare specifier) |
0 of 3 — filtered correctly |
A bare specifier goes through Node module resolution, which applies realpath, so page.file and the Vite key agree and the filter works. A path entry is used as written.
This is why it shows up for layers shipped inside a package: a package rarely exposes its layer directory in exports, so apps reference it as extends: ['./node_modules/<pkg>/dist/layer'], and under pnpm every node_modules entry is a symlink. That is the configuration the app I hit this in uses.
Effect
In the reproduction — 3 layer pages, 3 app pages — the built page at / carries 5 <link rel="prefetch"> instead of 2. The three extra ones are the layer's pages.
It scales with the number of pages the layer ships, and it is the same on every route: a layer with 20 pages adds 20 prefetched chunks to every page of the site. These numbers come from the control reproduction linked above, not from a production measurement.
Additional context
The same hook has a second path comparison a few lines up, for removing server-only page chunks:
if (chunk.src && Object.values(nuxt.apps).some(app => app.pages?.some(page => page.mode === 'server' && page.file === join(nuxt.options.srcDir, chunk.src!)))) {
It compares the same two kinds of string, so it should be affected in the same way for a server-only page in a symlinked layer. I have not built a case for that one.
I have a patch that resolves both sides through realpath before comparing, with a regression test, and am happy to open a PR.
Logs
# extends: ['./node_modules/my-layer'] — layer symlinked into node_modules
entry chunk dynamicImports:
../my-layer/app/pages/layer-page-1.vue
../my-layer/app/pages/layer-page-2.vue
../my-layer/app/pages/layer-page-3.vue
../node_modules/nuxt/dist/app/components/error-404.vue
../node_modules/nuxt/dist/app/components/error-500.vue
# extends: ['my-layer'] — same files, same symlink, bare specifier
entry chunk dynamicImports:
../node_modules/nuxt/dist/app/components/error-404.vue
../node_modules/nuxt/dist/app/components/error-500.vue
Environment
(
npx nuxi infoprinted an empty table in this project, so the above is filled in by hand from the installed package versions.)Also present on
mainat 99af6de.Reproduction
https://github.com/silverbackdan/nuxt-layer-symlink-prefetch
Describe the bug
packages/nuxt/src/pages/module.tsstrips page chunks out of the entry chunk'sdynamicImportsin abuild:manifesthook, so that pages are not prefetched on every route:(
packages/nuxt/src/pages/module.ts#L745-L766onmain;nuxt/dist/index.mjs:1620-1629in the 4.5.2 release.)The filter is a string comparison between
relative(srcDir, page.file)and the Vite manifest keys. When a layer is reached through a symlink, those two strings describe the same file differently:page.filekeeps the symlink path, while Vite resolves symlinks when it builds manifest keys.In the reproduction:
relative(srcDir, page.file)../node_modules/my-layer/app/pages/layer-page-1.vue../my-layer/app/pages/layer-page-1.vueThey never match, so nothing from that layer is filtered and every page of it stays in the entry's
dynamicImports. Each one becomes a<link rel="prefetch">on every route.The app's own pages are filtered correctly in the same build, which shows the filter itself works when the two paths agree.
What triggers it
A layer extended by a filesystem path that traverses a symlink. Measured in the reproduction, all with the same files on disk:
extendsdynamicImports['./node_modules/my-layer'](npm,file:dep)['./node_modules/my-layer'](pnpm install)['my-layer'](bare specifier)A bare specifier goes through Node module resolution, which applies
realpath, sopage.fileand the Vite key agree and the filter works. A path entry is used as written.This is why it shows up for layers shipped inside a package: a package rarely exposes its layer directory in
exports, so apps reference it asextends: ['./node_modules/<pkg>/dist/layer'], and under pnpm everynode_modulesentry is a symlink. That is the configuration the app I hit this in uses.Effect
In the reproduction — 3 layer pages, 3 app pages — the built page at
/carries 5<link rel="prefetch">instead of 2. The three extra ones are the layer's pages.It scales with the number of pages the layer ships, and it is the same on every route: a layer with 20 pages adds 20 prefetched chunks to every page of the site. These numbers come from the control reproduction linked above, not from a production measurement.
Additional context
The same hook has a second path comparison a few lines up, for removing server-only page chunks:
It compares the same two kinds of string, so it should be affected in the same way for a server-only page in a symlinked layer. I have not built a case for that one.
I have a patch that resolves both sides through
realpathbefore comparing, with a regression test, and am happy to open a PR.Logs