Skip to content

Pages of a layer reached through a symlink are prefetched on every route #36401

Description

@silverbackdan

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

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions