From 88fcf1246a1bf93f6eaea2f3dd3d9f201c9ec49f Mon Sep 17 00:00:00 2001 From: yyh Date: Sat, 11 Jul 2026 21:48:22 +0800 Subject: [PATCH 1/2] fix(config): keep page extensions out of module resolution --- packages/vinext/src/index.ts | 2 +- packages/vinext/src/routing/file-matcher.ts | 36 ++------ tests/app-router-route-file-collision.test.ts | 64 +++++++++++++ .../app-route-file-collision/agents.md | 1 + .../app/agents/page.tsx | 3 + .../app/custom-extension/page.mdx | 1 + .../app-route-file-collision/app/layout.tsx | 7 ++ .../app-route-file-collision/next.config.mjs | 3 + tests/page-extensions-resolve.test.ts | 89 ++++--------------- 9 files changed, 107 insertions(+), 99 deletions(-) create mode 100644 tests/app-router-route-file-collision.test.ts create mode 100644 tests/fixtures/app-route-file-collision/agents.md create mode 100644 tests/fixtures/app-route-file-collision/app/agents/page.tsx create mode 100644 tests/fixtures/app-route-file-collision/app/custom-extension/page.mdx create mode 100644 tests/fixtures/app-route-file-collision/app/layout.tsx create mode 100644 tests/fixtures/app-route-file-collision/next.config.mjs diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 026947e709..ab84094b90 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3273,7 +3273,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // `.cjs`/`.cts` if they need extensionless imports of CJS config files. const extensions = configuredExtensions === null - ? buildViteResolveExtensions(nextConfig.pageExtensions, config.resolve?.extensions) + ? buildViteResolveExtensions(config.resolve?.extensions) : normalizeViteResolveExtensions(configuredExtensions); config.resolve ??= {}; config.resolve.extensions = extensions; diff --git a/packages/vinext/src/routing/file-matcher.ts b/packages/vinext/src/routing/file-matcher.ts index 2aae70f324..81c5154356 100644 --- a/packages/vinext/src/routing/file-matcher.ts +++ b/packages/vinext/src/routing/file-matcher.ts @@ -110,40 +110,22 @@ export function findFileWithExts( } /** - * Vite's default `resolve.extensions` covers `.tsx/.ts/.jsx/.js/.json` (and - * `.mjs/.mts`). When the user configures `pageExtensions` with values Vite - * does not know about — e.g. `["platform.tsx", "tsx", "mdx"]` from the - * Next.js `resolve-extensions` fixture — extensionless imports of those - * files fail to resolve, and the build crashes with "Custom deploy script - * failed: undefined (1)". + * Add the config extensions produced by `vinext init` to Vite's resolver. * - * Build the merged extension list that Vite should use: + * `pageExtensions` is intentionally not part of module resolution. Next.js + * uses it to discover route files; custom module extensions are configured + * separately through `turbopack.resolveExtensions` or webpack + * `resolve.extensions`. * - * 1. User-configured pageExtensions go first (each prefixed with `.`) so - * the user's priority wins. e.g. `.platform.tsx` resolves before `.tsx`. - * 2. Vite's defaults follow, with duplicates removed. - * 3. `.cjs`/`.cts` go last (lowest priority). Neither Vite's defaults nor the - * user's pageExtensions include them, but `vinext init` renames CJS config - * files (e.g. `tailwind.config.js` → `tailwind.config.cjs`) when it adds - * `"type": "module"`, and app code imports those extensionlessly - * (`import cfg from "../tailwind.config"`). Without these, the bundle fails - * with "[UNRESOLVED_IMPORT] Could not resolve '../tailwind.config'". - * - * The user's pageExtensions retain their relative order, which is what - * Next.js / Turbopack do via the `resolveExtensions` config option. - * - * See: cloudflare/vinext#1502 for page-extension ordering, and - * cloudflare/vinext#2435 for extensionless `.cjs` config imports. + * `.cjs`/`.cts` go last because `vinext init` renames CJS config files when it + * adds `"type": "module"`, and app code may import those files extensionlessly. */ export function buildViteResolveExtensions( - pageExtensions?: readonly string[] | null, - viteDefaults: readonly string[] = [".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx", ".json"], + viteExtensions: readonly string[] = [".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx", ".json"], ): string[] { - const normalized = normalizePageExtensions(pageExtensions); - const dotted = normalized.map((ext) => `.${ext}`); const seen = new Set(); const result: string[] = []; - for (const ext of [...dotted, ...viteDefaults, ".cjs", ".cts"]) { + for (const ext of [...viteExtensions, ".cjs", ".cts"]) { if (seen.has(ext)) continue; seen.add(ext); result.push(ext); diff --git a/tests/app-router-route-file-collision.test.ts b/tests/app-router-route-file-collision.test.ts new file mode 100644 index 0000000000..b63c9ebda2 --- /dev/null +++ b/tests/app-router-route-file-collision.test.ts @@ -0,0 +1,64 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ViteDevServer } from "vite"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import { + createRscRequestHeaders, + createRscRequestUrl, +} from "../packages/vinext/src/server/app-rsc-cache-busting.js"; +import { createIsolatedFixture, startFixtureServer } from "./helpers.js"; + +const FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/app-route-file-collision"); + +describe("App Router route and project file collisions", () => { + let server: ViteDevServer; + let baseUrl: string; + let fixtureDir: string; + + beforeAll(async () => { + fixtureDir = await createIsolatedFixture(FIXTURE_DIR, "vinext-route-file-collision-"); + ({ server, baseUrl } = await startFixtureServer(fixtureDir)); + }, 30000); + + afterAll(async () => { + await server?.close(); + if (fixtureDir) await fs.rm(fixtureDir, { recursive: true, force: true }); + }); + + it("does not let a custom page extension shadow an HTML route request", async () => { + const response = await fetch(`${baseUrl}/agents`); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + expect(body).toContain("Agents route response"); + expect(body).not.toContain("root-agents-markdown"); + }); + + it("does not let a custom page extension shadow an RSC route request", async () => { + const headers = createRscRequestHeaders(); + const requestUrl = await createRscRequestUrl("/agents", headers); + const response = await fetch(`${baseUrl}${requestUrl}`, { + headers, + }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/x-component"); + expect(body).toContain("Agents route response"); + expect(body).not.toContain("root-agents-markdown"); + }); + + it("still serves routes discovered through a custom page extension", async () => { + const headers = createRscRequestHeaders(); + const requestUrl = await createRscRequestUrl("/custom-extension", headers); + const response = await fetch(`${baseUrl}${requestUrl}`, { + headers, + }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/x-component"); + expect(body).toContain("Custom MDX route response"); + }); +}); diff --git a/tests/fixtures/app-route-file-collision/agents.md b/tests/fixtures/app-route-file-collision/agents.md new file mode 100644 index 0000000000..0f6501130f --- /dev/null +++ b/tests/fixtures/app-route-file-collision/agents.md @@ -0,0 +1 @@ +# root-agents-markdown diff --git a/tests/fixtures/app-route-file-collision/app/agents/page.tsx b/tests/fixtures/app-route-file-collision/app/agents/page.tsx new file mode 100644 index 0000000000..f06fdbe7c6 --- /dev/null +++ b/tests/fixtures/app-route-file-collision/app/agents/page.tsx @@ -0,0 +1,3 @@ +export default function AgentsPage() { + return

Agents route response

; +} diff --git a/tests/fixtures/app-route-file-collision/app/custom-extension/page.mdx b/tests/fixtures/app-route-file-collision/app/custom-extension/page.mdx new file mode 100644 index 0000000000..e6193af66c --- /dev/null +++ b/tests/fixtures/app-route-file-collision/app/custom-extension/page.mdx @@ -0,0 +1 @@ +# Custom MDX route response diff --git a/tests/fixtures/app-route-file-collision/app/layout.tsx b/tests/fixtures/app-route-file-collision/app/layout.tsx new file mode 100644 index 0000000000..418716c35f --- /dev/null +++ b/tests/fixtures/app-route-file-collision/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-route-file-collision/next.config.mjs b/tests/fixtures/app-route-file-collision/next.config.mjs new file mode 100644 index 0000000000..f753933e92 --- /dev/null +++ b/tests/fixtures/app-route-file-collision/next.config.mjs @@ -0,0 +1,3 @@ +export default { + pageExtensions: ["tsx", "ts", "jsx", "js", "md", "mdx"], +}; diff --git a/tests/page-extensions-resolve.test.ts b/tests/page-extensions-resolve.test.ts index 621d05511a..b28861ebe8 100644 --- a/tests/page-extensions-resolve.test.ts +++ b/tests/page-extensions-resolve.test.ts @@ -11,81 +11,37 @@ import { // Ported in spirit from Next.js: test/e2e/app-dir/resolve-extensions/ // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/resolve-extensions/ // -// When users configure custom extensions via `pageExtensions` or -// `turbopack.resolveExtensions` (e.g. `.platform.tsx`, `.mdx`, or `.png`), Vite must -// know to attempt those extensions when resolving extensionless imports. +// When users configure custom module extensions via `turbopack.resolveExtensions` +// (e.g. `.web.tsx` or `.png`), Vite must know to attempt those extensions when +// resolving extensionless imports. // Otherwise extensionless imports of files with those custom extensions // fail to resolve and the build crashes. // // Regression test for cloudflare/vinext#1502. describe("buildViteResolveExtensions", () => { - it("applies Next.js default pageExtensions priority when pageExtensions is undefined", () => { - // Even without a user-set pageExtensions, vinext normalises to the - // Next.js defaults `["tsx", "ts", "jsx", "js"]` and uses that order. - // `.cjs`/`.cts` trail the list so `vinext init`-renamed CJS configs - // (`tailwind.config.cjs`) resolve extensionlessly. - const extensions = buildViteResolveExtensions(undefined); + it("appends CJS config extensions to Vite defaults", () => { + const extensions = buildViteResolveExtensions(); expect(extensions).toEqual([ - ".tsx", - ".ts", - ".jsx", - ".js", ".mjs", + ".js", ".mts", + ".ts", + ".jsx", + ".tsx", ".json", ".cjs", ".cts", ]); }); - it("returns the same list when pageExtensions matches the Next.js defaults", () => { - const extensions = buildViteResolveExtensions(["tsx", "ts", "jsx", "js"]); - expect(extensions).toEqual([ + it("preserves configured Vite extensions and removes duplicates", () => { + expect(buildViteResolveExtensions([".web.tsx", ".tsx", ".cjs"])).toEqual([ + ".web.tsx", ".tsx", - ".ts", - ".jsx", - ".js", - ".mjs", - ".mts", - ".json", ".cjs", ".cts", ]); }); - - it("prepends configured pageExtensions so user priority wins", () => { - // Next.js resolve-extensions fixture places `.web.tsx` before `.tsx`. - // The user's order must be preserved so `Component` resolves to - // `Component.web.tsx` before `Component.tsx`. - const extensions = buildViteResolveExtensions(["web.tsx", "tsx", "ts", "jsx", "js"]); - expect(extensions[0]).toBe(".web.tsx"); - expect(extensions.indexOf(".web.tsx")).toBeLessThan(extensions.indexOf(".tsx")); - }); - - it("includes custom multi-segment extensions like .platform.tsx", () => { - // This is what made cloudflare/vinext#1502 surface: a user-configured - // multi-segment extension that Vite's defaults can't resolve. - const extensions = buildViteResolveExtensions(["platform.tsx", "tsx", "ts", "jsx", "js"]); - expect(extensions).toContain(".platform.tsx"); - expect(extensions[0]).toBe(".platform.tsx"); - }); - - it("includes .mdx when configured", () => { - const extensions = buildViteResolveExtensions(["tsx", "ts", "jsx", "js", "mdx"]); - expect(extensions).toContain(".mdx"); - }); - - it("dedupes overlapping entries", () => { - const extensions = buildViteResolveExtensions(["tsx", "js"]); - expect(extensions.filter((e) => e === ".tsx").length).toBe(1); - expect(extensions.filter((e) => e === ".js").length).toBe(1); - }); - - it("strips leading dots and whitespace before normalising", () => { - const extensions = buildViteResolveExtensions([".platform.tsx", " tsx ", ""]); - expect(extensions[0]).toBe(".platform.tsx"); - expect(extensions).toContain(".tsx"); - }); }); describe("normalizeViteResolveExtensions", () => { @@ -98,13 +54,8 @@ describe("normalizeViteResolveExtensions", () => { }); }); -describe("vinext plugin wires pageExtensions into Vite resolve.extensions", () => { - it("forwards configured pageExtensions to config.resolve.extensions", async () => { - // Ported in spirit from Next.js: test/e2e/app-dir/resolve-extensions/ - // The deploy suite failure surfaced when pageExtensions/resolveExtensions - // were configured beyond Vite's defaults — Vite couldn't resolve - // extensionless imports of files like `Component.platform.tsx`. - // Regression test for cloudflare/vinext#1502. +describe("vinext plugin configures Vite resolve.extensions", () => { + it("keeps pageExtensions scoped to route discovery", async () => { const vinext = (await import("../packages/vinext/src/index.js")).default; const plugins = vinext(); // oxlint-disable-next-line typescript/no-explicit-any @@ -126,7 +77,7 @@ describe("vinext plugin wires pageExtensions into Vite resolve.extensions", () = ); await fs.writeFile( path.join(tmpDir, "next.config.mjs"), - `export default { pageExtensions: ["platform.tsx", "tsx", "ts", "jsx", "js", "mdx"] };`, + `export default { pageExtensions: ["tsx", "ts", "jsx", "js", "md"] };`, ); try { @@ -137,16 +88,12 @@ describe("vinext plugin wires pageExtensions into Vite resolve.extensions", () = }; // oxlint-disable-next-line typescript/no-explicit-any await (mainPlugin as any).config(mockConfig, { command: "build" }); - const environmentConfig: any = { resolve: { extensions: [".earlier"] } }; + const environmentConfig: any = { resolve: {} }; (mainPlugin as any).configEnvironment("client", environmentConfig); const extensions: string[] = environmentConfig.resolve.extensions; - expect(extensions).toContain(".platform.tsx"); - expect(extensions).toContain(".mdx"); + expect(extensions).not.toContain(".md"); expect(extensions).toContain(".tsx"); - // User priority must win — `.platform.tsx` resolves before `.tsx` - // so a bare `./Component` import lands on `Component.platform.tsx` - // (mirrors Next.js / Turbopack resolveExtensions semantics). - expect(extensions.indexOf(".platform.tsx")).toBeLessThan(extensions.indexOf(".tsx")); + expect(extensions).toContain(".cjs"); } finally { await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } From a1cc5b6fbda53bc6123790b39c3803dadd9fb997 Mon Sep 17 00:00:00 2001 From: yyh Date: Sat, 11 Jul 2026 22:18:36 +0800 Subject: [PATCH 2/2] fix(config): preserve default extension priority --- packages/vinext/src/routing/file-matcher.ts | 17 +++++++++++++++-- tests/app-router-route-file-collision.test.ts | 16 ++++++++++++++++ .../app/default-priority/Component.js | 3 +++ .../app/default-priority/Component.tsx | 3 +++ .../app/default-priority/page.tsx | 9 +++++++++ tests/page-extensions-resolve.test.ts | 10 +++++----- 6 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/app-route-file-collision/app/default-priority/Component.js create mode 100644 tests/fixtures/app-route-file-collision/app/default-priority/Component.tsx create mode 100644 tests/fixtures/app-route-file-collision/app/default-priority/page.tsx diff --git a/packages/vinext/src/routing/file-matcher.ts b/packages/vinext/src/routing/file-matcher.ts index 81c5154356..f22b828450 100644 --- a/packages/vinext/src/routing/file-matcher.ts +++ b/packages/vinext/src/routing/file-matcher.ts @@ -4,6 +4,15 @@ import path, { toSlash } from "pathslash"; import { escapeRegExp } from "../utils/regex.js"; const DEFAULT_PAGE_EXTENSIONS = ["tsx", "ts", "jsx", "js"] as const; +const DEFAULT_VINEXT_RESOLVE_EXTENSIONS = [ + ".tsx", + ".ts", + ".jsx", + ".js", + ".mjs", + ".mts", + ".json", +] as const; export function normalizePageExtensions(pageExtensions?: readonly string[] | null): string[] { if (!Array.isArray(pageExtensions) || pageExtensions.length === 0) { @@ -110,18 +119,22 @@ export function findFileWithExts( } /** - * Add the config extensions produced by `vinext init` to Vite's resolver. + * Add the config extensions produced by `vinext init` to vinext's resolver. * * `pageExtensions` is intentionally not part of module resolution. Next.js * uses it to discover route files; custom module extensions are configured * separately through `turbopack.resolveExtensions` or webpack * `resolve.extensions`. * + * The default order preserves vinext's existing module-resolution behavior + * and matches Next.js Turbopack for overlapping JavaScript and TypeScript + * extensions. + * * `.cjs`/`.cts` go last because `vinext init` renames CJS config files when it * adds `"type": "module"`, and app code may import those files extensionlessly. */ export function buildViteResolveExtensions( - viteExtensions: readonly string[] = [".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx", ".json"], + viteExtensions: readonly string[] = DEFAULT_VINEXT_RESOLVE_EXTENSIONS, ): string[] { const seen = new Set(); const result: string[] = []; diff --git a/tests/app-router-route-file-collision.test.ts b/tests/app-router-route-file-collision.test.ts index b63c9ebda2..1edf5287cb 100644 --- a/tests/app-router-route-file-collision.test.ts +++ b/tests/app-router-route-file-collision.test.ts @@ -61,4 +61,20 @@ describe("App Router route and project file collisions", () => { expect(response.headers.get("content-type")).toBe("text/x-component"); expect(body).toContain("Custom MDX route response"); }); + + it("preserves the default extensionless module resolution priority", async () => { + // Next.js Turbopack prefers .tsx over .js by default. + // https://github.com/vercel/next.js/blob/canary/turbopack/crates/turbopack-resolve/src/resolve.rs + const headers = createRscRequestHeaders(); + const requestUrl = await createRscRequestUrl("/default-priority", headers); + const response = await fetch(`${baseUrl}${requestUrl}`, { + headers, + }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/x-component"); + expect(body).toContain("TypeScript module response"); + expect(body).not.toContain("JavaScript module response"); + }); }); diff --git a/tests/fixtures/app-route-file-collision/app/default-priority/Component.js b/tests/fixtures/app-route-file-collision/app/default-priority/Component.js new file mode 100644 index 0000000000..7e2c1e3ae0 --- /dev/null +++ b/tests/fixtures/app-route-file-collision/app/default-priority/Component.js @@ -0,0 +1,3 @@ +export default function Component() { + return "JavaScript module response"; +} diff --git a/tests/fixtures/app-route-file-collision/app/default-priority/Component.tsx b/tests/fixtures/app-route-file-collision/app/default-priority/Component.tsx new file mode 100644 index 0000000000..ff709d3f82 --- /dev/null +++ b/tests/fixtures/app-route-file-collision/app/default-priority/Component.tsx @@ -0,0 +1,3 @@ +export default function Component() { + return "TypeScript module response"; +} diff --git a/tests/fixtures/app-route-file-collision/app/default-priority/page.tsx b/tests/fixtures/app-route-file-collision/app/default-priority/page.tsx new file mode 100644 index 0000000000..9018117d8d --- /dev/null +++ b/tests/fixtures/app-route-file-collision/app/default-priority/page.tsx @@ -0,0 +1,9 @@ +import Component from "./Component"; + +export default function DefaultPriorityPage() { + return ( +

+ +

+ ); +} diff --git a/tests/page-extensions-resolve.test.ts b/tests/page-extensions-resolve.test.ts index b28861ebe8..5353c0d4d2 100644 --- a/tests/page-extensions-resolve.test.ts +++ b/tests/page-extensions-resolve.test.ts @@ -19,15 +19,15 @@ import { // // Regression test for cloudflare/vinext#1502. describe("buildViteResolveExtensions", () => { - it("appends CJS config extensions to Vite defaults", () => { + it("appends CJS config extensions to vinext defaults", () => { const extensions = buildViteResolveExtensions(); expect(extensions).toEqual([ - ".mjs", - ".js", - ".mts", + ".tsx", ".ts", ".jsx", - ".tsx", + ".js", + ".mjs", + ".mts", ".json", ".cjs", ".cts",