Skip to content

fix(pages-router): collapse doubled basePath in client asset URLs#1730

Merged
james-elicx merged 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/pages-basepath-double-asset-prefix
Jun 4, 2026
Merged

fix(pages-router): collapse doubled basePath in client asset URLs#1730
james-elicx merged 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/pages-basepath-double-asset-prefix

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

What this changes

Pages Router apps built with a basePath now emit their page and _app
client chunk URLs with the basePath applied exactly once
(/docs/_next/static/_slug_.js) instead of twice
(/docs/docs/_next/static/_slug_.js).

Why

With a basePath (e.g. /docs), the rendered HTML pointed every page and
_app chunk at a doubled path:

<link rel="modulepreload" href="/docs/docs/_next/static/_slug_-….js">
<script type="module" src="/docs/docs/_next/static/_slug_-….js"></script>
"__vinext":{"pageModuleUrl":"/docs/docs/_next/static/_slug_-….js", ...}

Those URLs 404, so the client bundle never loads, the page never hydrates,
next.router never initializes, and any client-side navigation silently does
nothing. <div id="__next"> stays empty.

This surfaced as two failing cases in the upstream Next.js suite
test/e2e/basepath/error-pages.test.ts:

  • basePath › client-side navigation › should navigate to /404 correctly client-side
  • basePath › client-side navigation › should navigate to /_error correctly client-side

Both call next.router.push("/404", "/slug-2") and time out waiting for the
404 page, because the router that would handle the push never came online.

Approach

Root cause is a base-applied-twice interaction in the build:

  1. Vite+ bakes the configured base into emitted chunk fileNames on disk
    (dist/client/docs/_next/static/...). vinext relies on that layout so the
    Cloudflare ASSETS binding serves /docs/_next/static/... directly.
  2. Vite then prepends base again when it writes ssr-manifest.json,
    producing docs/docs/_next/static/... entries.
  3. augmentSsrManifestFromBundle
    preserved those doubled entries verbatim (it only stripped a leading slash),
    while its own bundle backfill keyed off chunk.fileName and produced the
    correct single-prefix path. The merged manifest held both, and
    resolveClientModuleUrl returned the first (doubled) entry.

The on-disk chunk fileName carries the basePath exactly once, so the public URL
must too. New collapseDuplicateBase helper collapses an exact
<base>/<base>/ prefix when normalizing the Vite-sourced manifest entries, so
they line up with the single-base paths the backfill already produces. A
correctly single-prefixed entry is left untouched (no over-collapse).

The fix lives at the single boundary that owns the manifest shape, so every
downstream reader (modulepreload tags, __vinext.pageModuleUrl, CSS links) is
corrected at once.

Non-goals

  • App Router is unaffected: its bootstrap script URLs come from the RSC
    plugin's Vite runtime base resolution, which already emitted single-base URLs
    even in the broken output.

Validation

  • New unit tests on augmentSsrManifestFromBundle:
    • collapses a basePath that Vite duplicated in the SSR manifest (fails on the
      old implementation, passes with the fix)
    • leaves a single basePath prefix untouched (guards against over-collapse)
  • Rebuilt the pages-i18n-domains-basepath fixture and confirmed the emitted
    ssr-manifest.json no longer contains any app/app/... entries.
  • Ran the upstream deploy suite end to end:
    test/e2e/basepath/error-pages.test.ts went from 2 failed / 6 passed to
    8 passed / 1 skipped.
  • vp check and the full tests/build-optimization.test.ts +
    tests/deploy.test.ts suites pass.

Risks / follow-ups

  • The collapse only triggers on an exact <base>/<base>/ prefix, so it is a
    no-op for builds where the bundler does not bake base into fileNames (the
    paths are already single-prefixed). No behavior change for apps without a
    basePath.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 4, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1730

commit: aecfec4

A Pages Router app built with a `basePath` emits its page and `_app`
chunk URLs with the basePath applied twice, e.g.
`/docs/docs/_next/static/_slug_.js`. Those URLs 404, so the page never
hydrates, `next.router` never initializes, and any client-side
navigation (router.push to /404, /_error, etc.) silently does nothing.

Vite+ bakes the configured `base` into emitted chunk fileNames on disk
(`dist/client/docs/_next/static/...`) so the Cloudflare ASSETS binding
can serve them directly. Vite then prepends `base` a second time when it
writes `ssr-manifest.json`, producing `docs/docs/_next/static/...`
entries. `augmentSsrManifestFromBundle` preserved those doubled entries
verbatim (it only stripped a leading slash), while its own bundle
backfill produced the correct single-prefix path. The merged manifest
held both, and `resolveClientModuleUrl` returned the doubled one.

The on-disk chunk fileName carries the basePath exactly once, so the
public URL must too. Collapse an exact `<base>/<base>/` prefix when
normalizing the Vite-sourced manifest entries so they match the
single-base paths the backfill produces. A correctly single-prefixed
entry is left untouched.

Regression covered with a focused unit test on the manifest builder and
verified end to end against the Next.js basepath/error-pages deploy
suite (previously 2 failing client-navigation cases, now all green).
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/pages-basepath-double-asset-prefix branch from c35b200 to aecfec4 Compare June 4, 2026 08:20
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: collapse doubled basePath in client asset URLs

Clean, well-scoped fix. The root-cause analysis is accurate and the fix is placed at the single boundary (augmentSsrManifestFromBundle) that owns the SSR manifest shape, so every downstream reader (resolveClientModuleUrl in pages-server-entry.ts:565, modulepreload tags, __vinext.pageModuleUrl, CSS links) is corrected at once.

Verified

  • Bug reproduction is real. resolveClientModuleUrl returns the first .js entry in the manifest file list (pages-server-entry.ts:568-573). The old normalizeManifestFile only stripped a leading slash, so the Vite-doubled docs/docs/_next/static/... entry was preserved verbatim and returned first → 404 → no hydration. The regression test would fail on the old implementation.
  • Dedup works. The collapsed ssr-manifest entry (docs/_next/static/_slug_.js) and the bundle-backfill entry (manifestFileWithBase(chunk.fileName, base), which leaves the already-baked docs/ prefix untouched per manifest-paths.ts:13) produce the same string, so the Set<string> dedups them. No leftover doubled entry.
  • App Router parity / no regression. augmentSsrManifestFromBundle is shared (index.ts:4278), but collapse only triggers on an exact <base>/<base>/ prefix, so it is a no-op for App Router and for builds without a basePath. The over-collapse guard test confirms this.
  • Dev/prod parity. The doubling is build-only (dev uses Vite's runtime module resolution, not this static manifest), so the build-time fix is the correct and complete location. No matching prod-server-only path was left unfixed.
  • vp check passes on both changed source files; the augmentSsrManifestFromBundle test block passes (8 tests).

One minor note (non-blocking)

The collapse keys on an exact <base>/<base>/ prefix. There is a theoretical over-collapse if a real (post-base-strip) asset path itself begins with the base segment, e.g. base /a with a genuine asset at a/a/x.js. For Next.js this can't happen in practice since asset paths always start with _next/static/, and the doc comment already calls out the exact-prefix-only behavior, so this is fine as-is — just worth being aware of if the helper is ever reused for non-_next assets.

LGTM.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 4, 2026

fatal: could not read Username for 'https://github.com': No such device or address

github run

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 4, 2026

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx james-elicx merged commit 1cb89fc into cloudflare:main Jun 4, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants