diff --git a/packages/vinext/src/routing/file-matcher.ts b/packages/vinext/src/routing/file-matcher.ts index f22b828450..10affcd0f7 100644 --- a/packages/vinext/src/routing/file-matcher.ts +++ b/packages/vinext/src/routing/file-matcher.ts @@ -14,6 +14,15 @@ const DEFAULT_VINEXT_RESOLVE_EXTENSIONS = [ ".json", ] as const; +// Extensions vinext must always be able to resolve for framework-internal +// entries — most notably the Nitro Bun preset entry +// `nitro/dist/presets/bun/runtime/bun`, which is a `.mjs` file. A user +// `resolve.extensions` / `resolveExtensions` override replaces the resolver +// defaults for their own code, but it must not shadow these, or a list that +// omits `.mjs` makes that internal entry unresolvable +// (`[UNRESOLVED_ENTRY] nitro/dist/presets/bun/runtime/bun`). +const FRAMEWORK_REQUIRED_RESOLVE_EXTENSIONS = [".mjs", ".js", ".mts", ".cjs", ".cts"] as const; + export function normalizePageExtensions(pageExtensions?: readonly string[] | null): string[] { if (!Array.isArray(pageExtensions) || pageExtensions.length === 0) { return [...DEFAULT_PAGE_EXTENSIONS]; @@ -157,14 +166,20 @@ export function buildViteResolveExtensions( export function normalizeViteResolveExtensions(extensions: readonly string[]): string[] { const seen = new Set(); const result: string[] = []; - for (const extension of extensions) { - const trimmed = extension.trim(); - if (!trimmed) continue; + const push = (candidate: string): void => { + const trimmed = candidate.trim(); + if (!trimmed) return; const dotted = trimmed.startsWith(".") ? trimmed : `.${trimmed}`; - if (seen.has(dotted)) continue; + if (seen.has(dotted)) return; seen.add(dotted); result.push(dotted); - } + }; + // User extensions keep highest precedence (their app code wins)... + for (const extension of extensions) push(extension); + // ...but the framework-required extensions are appended at the lowest + // precedence so a replace-style override cannot shadow vinext-internal / + // Nitro Bun preset entry resolution. + for (const extension of FRAMEWORK_REQUIRED_RESOLVE_EXTENSIONS) push(extension); return result; } diff --git a/tests/file-matcher.test.ts b/tests/file-matcher.test.ts index bca4b1e50c..b02f974279 100644 --- a/tests/file-matcher.test.ts +++ b/tests/file-matcher.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { createValidFileMatcher, normalizePageExtensions, + normalizeViteResolveExtensions, scanWithExtensions, } from "../packages/vinext/src/routing/file-matcher.js"; import { shouldInvalidateAppRouteFile } from "../packages/vinext/src/server/dev-route-files.js"; @@ -114,3 +115,24 @@ describe("file matcher", () => { ); }); }); + +describe("normalizeViteResolveExtensions", () => { + it("keeps framework-internal extensions resolvable when a user override omits them", () => { + // A webpack `resolve.extensions` / Turbopack `resolveExtensions` override + // replaces the resolver defaults. If it omits `.mjs`, vinext's own internal + // entry `nitro/dist/presets/bun/runtime/bun` (a `.mjs` file) becomes + // unresolvable → `[UNRESOLVED_ENTRY]`. The framework-required extensions + // must still be present, at lowest precedence. + const result = normalizeViteResolveExtensions([".foo.js", ".ts", ".tsx"]); + expect(result).toContain(".mjs"); + expect(result).toContain(".js"); + // User extensions retain highest precedence (their app code wins). + expect(result.indexOf(".foo.js")).toBe(0); + expect(result.indexOf(".foo.js")).toBeLessThan(result.indexOf(".mjs")); + }); + + it("does not duplicate an internal extension the user already listed", () => { + const result = normalizeViteResolveExtensions([".mjs", ".js"]); + expect(result.filter((e) => e === ".mjs")).toHaveLength(1); + }); +});