Skip to content

fix(build): bundle nested copies of serverExternalPackages (#1503)#1661

Open
james-elicx wants to merge 2 commits into
mainfrom
fix/issue-1503-transitive-externals-resolution
Open

fix(build): bundle nested copies of serverExternalPackages (#1503)#1661
james-elicx wants to merge 2 commits into
mainfrom
fix/issue-1503-transitive-externals-resolution

Conversation

@james-elicx
Copy link
Copy Markdown
Member

Summary

  • Adds vinext:transitive-externals plugin that mirrors Next.js's webpack baseResolveCheck: when a non-external package nested in node_modules imports an externalised package (e.g. serverExternalPackages: ['lodash']) and its installed version differs from the project-root copy, the plugin returns the importer's nested absolute path so Vite bundles that copy alongside the importer.
  • Without this, both copies collapse to a single runtime node_modules/<dep>/ lookup and one version silently wins — the symptom reported in Next.js's externals-transitive deploy test.
  • Reference: Next.js handle-externals.ts baseResolveCheck.

Test plan

  • pnpm test tests/transitive-externals.test.ts — 10 new unit tests cover: nested-vs-root version mismatch, same-version no-op, unresolvable request, scoped packages, ignoring user-source / virtual / non-external importers.
  • pnpm test tests/server-externals-manifest.test.ts tests/standalone-build.test.ts — adjacent existing suites still pass.
  • pnpm run check.
  • CI: externals-transitive adapter-api-e2e suite passes.

Refs #1503.

When a non-external package nested inside `node_modules` imports an
externalised package (e.g. `serverExternalPackages: ['lodash']`) and
its installed version differs from the project-root copy, Vite would
leave the bare specifier in place and let Node's runtime resolver pick
whichever version sat at `node_modules/<dep>/`. Both copies collapsed
to a single version at runtime, mismatching the version the importer
expected.

Mirror Next.js's webpack `baseResolveCheck`: detect the version
mismatch in a `resolveId` hook and return the importer's nested
absolute path so Vite bundles that copy alongside the importer.

Refs #1503.
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 28, 2026

Open in StackBlitz

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

commit: 8784cb8

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

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: vinext:transitive-externals plugin

Good work — this is a well-scoped, cleanly tested port of the Next.js baseResolveCheck logic. The core resolution function is correct, the test fixture layout mirrors Next.js's externals-transitive fixture, and the plugin guard chain is solid. A few things to address:

Issues

1. Missing environment guard — plugin runs unnecessarily in the client environment

resolve.external is only configured on rsc and ssr environments (and skipped entirely for Cloudflare/Nitro). The client environment never externalizes packages, so the plugin's resolveId hook does unnecessary work there. Other plugins in the codebase (e.g. optimize-imports.ts, async-hooks-stub.ts) already follow this pattern.

Add an early return at the top of resolveId:

// packages/vinext/src/plugins/transitive-externals.ts:122
resolveId(source, importer) {
  // Only relevant in server environments where resolve.external is active.
  if (this.environment?.name === "client") return null;
  // ...rest
}

Not a correctness bug (resolving to an absolute path in the client env would just bundle it normally), but it avoids per-import overhead in the client build.

2. configResolved is called on every runResolveId invocation in tests

In the test helper runResolveId (line 150-154), configResolved is called every time the helper is invoked, not once per plugin instance. This means a new rootResolver (via createRequire) is created on every call and the externalSet is rebuilt every time. This works because the plugin closure reassigns both variables, but it's wasteful and doesn't reflect how Vite actually calls the lifecycle. Consider extracting configResolved invocation to happen once after _createPluginForTest, and having runResolveId only call the resolveId hook.

3. Subpath import resolution (lodash/package.json, @scope/lib/utils)

The plugin correctly extracts pkgName from subpath imports (lines 131-136), but there's no test for subpath imports. Consider adding a test case for e.g. "lodash/package.json" or "lodash/cloneDeep" from dep-b to verify the extraction + resolution works end-to-end.

4. Cloudflare/Nitro targets — plugin is a no-op but still runs

On Cloudflare/Nitro targets, serverExternalPackages defaults to [] and no resolve.external is configured, so externalSet.size === 0 exits early at line 126. This is fine — the guard handles it. But worth adding a one-liner note in the plugin's JSDoc that the plugin is intentionally inert on bundled runtimes.

Observations (non-blocking)

Port accuracy vs Next.js baseResolveCheck: The Next.js version also checks ESM vs CJS resolution mode (isEsm !== baseIsEsm — see baseRes !== res || isEsm !== baseIsEsm). The vinext port only checks path identity, which is correct for Vite's module graph (Vite handles ESM/CJS resolution internally via its own resolver), but worth documenting the deliberate omission.

realpathSync fallback pattern: The triple try-catch in resolveTransitiveExternal (lines 46-50, 55-64) is defensive and correct. Symlinked node_modules (pnpm, Bun) will hit the realpathSync path, and the fallback ensures broken symlinks don't crash the build. No concern here.

createRequire(importer) per call: Each resolveId invocation creates a fresh createRequire(importer). This is unavoidable since the importer changes per call, and createRequire is lightweight. No concern.

Overall this is a clean, well-motivated change. The issues above are minor — the core logic is correct and well-tested.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

Review posted on PR #1661. Here's a summary of the findings:

Core assessment: The plugin is a correct, well-scoped port of Next.js's baseResolveCheck logic. The resolution function, test fixtures, and guard chain are solid.

4 issues raised:

  1. Missing environment guard — The plugin runs in all Vite environments (rsc, ssr, client) but resolve.external is only configured on server environments. Adding if (this.environment?.name === "client") return null; at the top of resolveId avoids unnecessary per-import work in client builds. Not a correctness bug, but follows established patterns from other plugins.

  2. Test helper calls configResolved on every invocation — The runResolveId helper re-initializes the plugin state on each call instead of calling it once per plugin instance. Works but doesn't match Vite's actual lifecycle.

  3. No test coverage for subpath imports — The plugin handles lodash/cloneDeep and @scope/lib/utils style imports via the pkgName extraction logic, but no test exercises this path.

  4. Minor JSDoc gap — The plugin is correctly inert on Cloudflare/Nitro targets (empty external set), but worth documenting this explicitly.

Non-blocking observations: The deliberate omission of the ESM/CJS mode check from Next.js's baseResolveCheck is correct for Vite but worth documenting. The defensive realpathSync fallback pattern is good for pnpm/Bun symlinked layouts.

github run

- Guard resolveId against client environment (resolve.external is
  server-only, so client builds have no work to do).
- Restructure test setup so configResolved fires once per plugin
  instance, matching Vite's real lifecycle instead of re-initializing on
  every resolveId invocation.
- Add focused coverage for subpath imports (lodash/cloneDeep and
  @scope/lib/utils) that exercise the pkgName extraction path.
- Add JSDoc noting the plugin is inert on Cloudflare/Nitro targets
  (empty external set short-circuits via set.size === 0).
@james-elicx
Copy link
Copy Markdown
Member Author

/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: vinext:transitive-externals plugin (post-feedback)

All four items from the previous review have been addressed in 8784cb8:

  1. Client environment guard — added at line 136 with clear comment. Matches the this.environment?.name === "client" pattern used by async-hooks-stub.ts, optimize-imports.ts, and others.

  2. configResolved lifecyclesetupPlugin() now calls configResolved once and reuses the initialized instance across all resolveId calls. Matches Vite's actual lifecycle.

  3. Subpath import coverage — Two new test cases (lodash/cloneDeep at line 246, @scope/lib/utils at line 278) exercise the pkgName extraction path for both unscoped and scoped subpath imports.

  4. Cloudflare/Nitro JSDoc — Plugin-level JSDoc (lines 6-14) explicitly documents the no-op behavior on bundled runtimes.

One non-blocking observation

path.sep vs / in node_modules check (line 161) — The rest of the codebase consistently uses hardcoded /node_modules/ for path checks in Vite plugin hooks (index.ts:948, client-reference-dedup.ts:30, fonts.ts:1126, etc.). Vite normalizes module IDs to forward slashes on all platforms, so path.sep is unnecessary here and is an outlier. It works correctly (since path.sep is / on Linux/macOS, and Vite normalizes on Windows), but consider aligning with the codebase convention in a follow-up. The only place path.sep is used with node_modules is build/standalone.ts, where it operates on real OS filesystem paths from fs.cpSync, not Vite module IDs.

That said, the importer argument to resolveId is an absolute filesystem path that may not be Vite-normalized in all edge cases (e.g. when a non-Vite resolver provides the importer), so path.sep is arguably the more defensive choice here. Fine either way.

Verdict

Clean, well-motivated port of baseResolveCheck. Core resolution logic is correct, test coverage is thorough, all review feedback addressed. CI is green (Check, Vitest, E2E all passing). Ship it.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

Review posted and approved on PR #1661. Here's the summary:

All 4 items from the previous review were addressed in commit 8784cb8:

  • Client environment guard added
  • configResolved lifecycle fixed (called once, not per-resolveId)
  • Subpath import tests added for both scoped and unscoped packages
  • JSDoc documenting Cloudflare/Nitro inertness added

One non-blocking observation noted: the path.sep usage on line 161 is an outlier -- the rest of the codebase uses hardcoded /node_modules/ in Vite plugin hooks. Both approaches work; path.sep is arguably more defensive since importer may not always be Vite-normalized.

Verdict: Approved. Core logic is a correct port of Next.js's baseResolveCheck, tests are thorough, CI is green.

github run

@james-elicx james-elicx marked this pull request as ready for review May 28, 2026 16:20
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