Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 21 additions & 26 deletions packages/vinext/src/routing/file-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -110,40 +119,26 @@ 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 vinext'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 default order preserves vinext's existing module-resolution behavior
* and matches Next.js Turbopack for overlapping JavaScript and TypeScript
* extensions.
*
* 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[] = DEFAULT_VINEXT_RESOLVE_EXTENSIONS,
): string[] {
const normalized = normalizePageExtensions(pageExtensions);
const dotted = normalized.map((ext) => `.${ext}`);
const seen = new Set<string>();
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);
Expand Down
80 changes: 80 additions & 0 deletions tests/app-router-route-file-collision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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");
});

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");
});
});
1 change: 1 addition & 0 deletions tests/fixtures/app-route-file-collision/agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# root-agents-markdown
3 changes: 3 additions & 0 deletions tests/fixtures/app-route-file-collision/app/agents/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function AgentsPage() {
return <h1>Agents route response</h1>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Custom MDX route response
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Component() {
return "JavaScript module response";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Component() {
return "TypeScript module response";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Component from "./Component";

export default function DefaultPriorityPage() {
return (
<h1>
<Component />
</h1>
);
}
7 changes: 7 additions & 0 deletions tests/fixtures/app-route-file-collision/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
);
}
3 changes: 3 additions & 0 deletions tests/fixtures/app-route-file-collision/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
pageExtensions: ["tsx", "ts", "jsx", "js", "md", "mdx"],
};
81 changes: 14 additions & 67 deletions tests/page-extensions-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,16 @@ 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 vinext defaults", () => {
const extensions = buildViteResolveExtensions();
expect(extensions).toEqual([
".tsx",
".ts",
Expand All @@ -38,54 +34,14 @@ describe("buildViteResolveExtensions", () => {
]);
});

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", () => {
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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(() => {});
}
Expand Down
Loading