Skip to content

fix(build): keep dynamic third-party imports bare in published dist - #2395

Merged
james-elicx merged 1 commit into
cloudflare:mainfrom
james-elicx:claude/hardcore-mahavira-e74637
Jun 28, 2026
Merged

james-elicx merged 1 commit into
cloudflare:mainfrom
james-elicx:claude/hardcore-mahavira-e74637

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Problem

Consumer (and CI) builds against the published vinext fail with:

[UNRESOLVED_IMPORT] Could not resolve
'/home/runner/work/vinext/vinext/node_modules/.pnpm/next@.../node_modules/next/router.js'
in .../vinext/dist/shims/link.js

The published dist/shims/link.js baked an absolute build-machine path into the lazy Pages Router import:

loadRouter: async () =>
  (await import("/home/runner/work/vinext/.../node_modules/next/router.js")).default,

That path only exists on the machine that built vinext, so it can never resolve on a consumer's machine.

Root cause

#2279 replaced deps.skipNodeModulesBundle: true with a deps.neverBundle predicate that decides externalization on the resolved id (id.includes("node_modules")).

Rolldown resolves a dynamic import() literal to an absolute path before neverBundle runs, so the bare specifier is lost for dynamic imports. Static imports (e.g. import Router from "next/router" in dev-server.js) were unaffected and stayed bare — which is why only the dynamic import("next/router") in link.js broke.

skipNodeModulesBundle: true previously externalized on the unresolved bare specifier, so dynamic imports stayed bare.

Fix

Externalize on the unresolved bare specifier via inputOptions.external, restoring the pre-#2279 behaviour so dynamic imports stay bare like static ones. Excluded from forced-externalization (so they keep resolving/bundling as before):

  • First-party vinext / @vinext/* self-imports — must stay relative/bundled so shim modules are a single instance across Vite's RSC/SSR/client graphs (the instanceof ReadonlyURLSearchParams hazard the old skipNodeModulesBundle comment called out).
  • alwaysBundle packages am-i-vibing / process-ancestry — must stay inlined.

Verification

  • Emitted dist is byte-identical to the current build except the single fixed line: import("/abs/.../next/router.js") → import("next/router").
  • am-i-vibing dependency tree (am-i-vibing → process-ancestry, no further deps) is still inlined under dist/node_modules/ and is checksum-identical to the current build.
  • Zero baked absolute node_modules dynamic imports remain in dist.

The published `dist/shims/link.js` baked an absolute build-machine path into
the lazy Pages Router import:

    loadRouter: async () =>
      (await import("/<build-host>/node_modules/.pnpm/next@.../next/router.js"))

That path only exists on the machine that built vinext, so every consumer (and
CI) build fails with:

    [UNRESOLVED_IMPORT] Could not resolve '/.../next/router.js'

Root cause: cloudflare#2279 replaced `deps.skipNodeModulesBundle: true` with a
`deps.neverBundle` predicate that keys off the *resolved* id
(`id.includes("node_modules")`). Rolldown resolves a dynamic `import()` literal
to an absolute path *before* `neverBundle` runs, so the bare specifier is lost
for dynamic imports (static imports were unaffected).

Restore the pre-cloudflare#2279 behaviour by externalizing on the *unresolved* bare
specifier via `inputOptions.external`, so dynamic imports stay bare like static
ones. First-party `vinext`/`@vinext/*` self-imports and the `alwaysBundle`
packages (`am-i-vibing`, `process-ancestry`) are excluded so they keep being
bundled/relative-rewritten.

Verified the emitted `dist` is byte-identical to the current build except the
single fixed `next/router` line, and the `am-i-vibing` bundle is unchanged.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2395
npm i https://pkg.pr.new/vinext@2395

commit: f344a92

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared f344a92 against base c602026 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 122.0 KB 122.0 KB ⚫ 0.0%
Client entry size (gzip) vinext 117.0 KB 117.0 KB ⚫ 0.0%
Dev server cold start vinext 2.41 s 2.44 s ⚫ +1.5%
Production build time vinext 2.78 s 2.82 s 🔴 +1.7%
RSC entry closure size (gzip) vinext 94.3 KB 94.3 KB ⚫ +0.0%
Server bundle size (gzip) vinext 160.1 KB 160.1 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@ask-bonk

ask-bonk Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(build): keep dynamic third-party imports bare in published dist

The core fix is correct. Switching externalization to the unresolved bare specifier via inputOptions.external restores the pre-#2279 behaviour, so dynamic import() literals stay bare like static imports instead of baking a build-machine absolute path into dist. I verified the diagnosis against the committed local build:

  • packages/vinext/dist/shims/link.js:585 does indeed contain import("/home/runner/work/vinext/vinext/node_modules/.pnpm/next@.../next/router.js") — the exact baked path the PR describes.
  • dist is gitignored (not tracked), so changing only vite.config.ts and letting CI/release rebuild is the right scope.

The exclusion set is correct for the current package layout:

  • am-i-vibing / process-ancestry both expose only a . export (no subpaths), so exact-match exclusion is sufficient.
  • The bundled am-i-vibing imports process-ancestry — handled both as a bare specifier (id === "process-ancestry") and as the resolved relative path (id.startsWith(".")).
  • @vinext/cloudflare/cache/* subpath imports (the only real first-party externals in source) match isFirstParty, so the alias-to-source bundling path is preserved.

Two things I'd want addressed before merge, plus one minor note. None block the correctness of the fix, but the first is what would have prevented this regression in the first place.

1. No regression test guards the published dist (most important)

This exact bug shipped via #2279 because nothing asserts that the emitted dist is free of baked absolute node_modules paths. There is no build/pack test that inspects the published output for this hazard (I checked tests/*build*.test.ts, clean-build-output.test.ts, server-external-packages.test.ts, and scripts/). Without a guard, the next refactor of external/neverBundle/alwaysBundle can silently reintroduce it. Per AGENTS.md ("Add tests first", dev/prod parity), please add a focused test that builds the package (or scans dist after vp pack) and asserts zero import("/...node_modules...") absolute specifiers remain — ideally covering a dynamic import in a shim like link.js.

2. Exclusion list duplicates alwaysBundle and must be kept in sync manually

The am-i-vibing / process-ancestry set now lives in three places (alwaysBundle, the neverBundle !id.includes(...) checks, and the external predicate). Adding/removing an alwaysBundle package requires remembering to update all three, or the new package silently gets externalized (the very failure mode this PR fixes). Deriving the exclusion from a single shared constant would make this self-consistent.

3. Minor: id.includes(":") is broader than the intent

The comment says this targets virtual/protocol-prefixed ids (node:, virtual:, \0...). includes(":") rather than a prefix check is slightly loose — it would also match any bare specifier that happens to contain a colon. No npm package name can contain :, so this is safe in practice today, but a prefix-oriented check (or a comment noting the intent) would be more precise and future-proof.

if (isFirstParty(id)) return false;
// Packages inlined into `dist` via `alwaysBundle` must keep resolving so they
// get bundled rather than externalized.
if (id === "am-i-vibing" || id === "process-ancestry") return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The am-i-vibing / process-ancestry set is now duplicated across three locations: alwaysBundle (line 71), the neverBundle predicate (lines 74-75), and this external predicate. These must stay in sync — if a future alwaysBundle package isn't also added here, it gets externalized instead of inlined, silently reintroducing the class of bug this PR fixes.

Consider hoisting a single shared constant (e.g. const ALWAYS_BUNDLE = ["am-i-vibing", "process-ancestry"]) and reusing it in all three places — here as if (ALWAYS_BUNDLE.includes(id)) return false;, in neverBundle as !ALWAYS_BUNDLE.some((p) => id.includes(p)), and as the alwaysBundle value itself.

Also note this relies on both packages exposing only a . export (verified — no subpaths), so exact === matching is sufficient today. A new alwaysBundle package with subpath exports would need a prefix-aware check instead.

if (isResolved) return false;
// Relative/absolute paths, virtual modules, and protocol-prefixed ids
// resolve normally.
if (id.startsWith(".") || id.startsWith("/") || id.startsWith("\0") || id.includes(":")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

id.includes(":") is broader than the stated intent (matching node: / virtual: / protocol-prefixed ids). It matches a colon anywhere in the specifier. No valid npm package name contains :, so this is safe today, but a prefix-oriented check would more precisely express the intent and avoid surprises if a future resolver hands you an id with an embedded colon. Optional, but worth a clarifying note at minimum.

@ask-bonk

ask-bonk Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my findings:

Review summary for PR #2395

Verdict: the core fix is correct. Switching to externalize on the unresolved bare specifier via inputOptions.external correctly restores pre-#2279 behavior, keeping dynamic import() literals bare instead of baking a build-machine absolute path into dist.

Verified:

  • Confirmed the bug in the committed local build: dist/shims/link.js:585 contains the exact baked absolute path the PR describes.
  • dist is gitignored, so only changing vite.config.ts and letting CI rebuild is the right scope.
  • The exclusion set is correct for the current package layout (both am-i-vibing/process-ancestry have only a . export; process-ancestry's import is handled both as bare and resolved-relative; @vinext/cloudflare/cache/* subpaths preserve alias bundling).

Findings posted:

  1. No regression test guards the published dist (most important) — this bug shipped via feat(init): scaffold for cloudflare and node #2279 precisely because nothing asserts the emitted dist is free of baked absolute node_modules paths. A scan-after-build test would prevent recurrence.
  2. Triple-duplicated exclusion list — am-i-vibing/process-ancestry now appears in alwaysBundle, neverBundle, and the external predicate; suggested a shared constant with a suggestion block.
  3. Minor: id.includes(":") is broader than its stated prefix intent (safe today since npm names can't contain :).

None of these block correctness; the test gap is the only one I'd consider a should-fix.

github run

@james-elicx
james-elicx merged commit 89ab891 into cloudflare:main Jun 28, 2026
50 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.

1 participant