Files
cloudflare__vinext/tests/app-route-handler-trailing-slash.test.ts
T
James Anderson 97947ce775 fix(app-router): apply trailingSlash to route handler request URL (#1827) (#1835)
* fix(app-router): apply trailingSlash to route handler request URL (#1827)

App Router route handlers received a NextRequest whose `url` was
normalized with the default `trailingSlash: false` policy, so a request
to `/runtime/edge/` (with `trailingSlash: true`) surfaced `req.url`
without the trailing slash even though `req.nextUrl.pathname` kept it.

Thread the configured `trailingSlash` through the route-handler dispatch
chain (dispatch -> execution/cache -> tracked request -> NextRequest)
so `NextURL` applies the correct policy when computing `request.url`,
matching Next.js (test/e2e/app-dir/app-routes-trailing-slash).

Closes #1827

* test(app-router): cover node runtime for trailingSlash:false route handler (#1827)

Address ask-bonk review note on PR #1835: the trailingSlash:false suite
only exercised the edge runtime. Parameterize it over edge + node to
match the trailingSlash:true suite's coverage.
2026-06-08 19:02:38 +01:00

101 lines
3.5 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression coverage for issue #1827 — `trailingSlash` not applied to App
* Router route handlers (`app/**​/route.ts`).
*
* Mirrors Next.js test/e2e/app-dir/app-routes-trailing-slash:
* - With `trailingSlash: true`, a request to `/runtime/edge` returns
* 308 → `/runtime/edge/`
* - The redirected `/runtime/edge/` request returns 200 and the handler
* observes the trailing-slash pathname.
*
* Refs cloudflare/vinext#1827
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
import type { ViteDevServer } from "vite-plus";
import { APP_FIXTURE_DIR, startFixtureServer } from "./helpers.js";
function copyAppFixtureWithTrailingSlash(prefix: string, trailingSlash: boolean): string {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.cpSync(APP_FIXTURE_DIR, tmpDir, { recursive: true });
fs.rmSync(path.join(tmpDir, "node_modules", ".vite"), { recursive: true, force: true });
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import type { NextConfig } from "vinext";
const nextConfig: NextConfig = { trailingSlash: ${trailingSlash} };
export default nextConfig;
`,
);
return tmpDir;
}
describe("App Router route-handler trailingSlash: true (#1827)", () => {
let tmpDir: string;
let server: ViteDevServer;
let baseUrl: string;
beforeAll(async () => {
tmpDir = copyAppFixtureWithTrailingSlash("vinext-route-ts-true-", true);
({ server, baseUrl } = await startFixtureServer(tmpDir));
}, 60000);
afterAll(async () => {
await server?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
it.each(["edge", "node"])(
"redirects /runtime/%s with 308 and serves the slashed path",
async (runtime) => {
const res = await fetch(`${baseUrl}/runtime/${runtime}`, { redirect: "manual" });
expect(res.status).toBe(308);
const location = res.headers.get("location");
expect(location).not.toBeNull();
expect(new URL(location!, baseUrl).pathname).toBe(`/runtime/${runtime}/`);
const slashed = await fetch(`${baseUrl}/runtime/${runtime}/`, { redirect: "manual" });
expect(slashed.status).toBe(200);
await expect(slashed.json()).resolves.toEqual({
url: `/runtime/${runtime}/`,
nextUrl: `/runtime/${runtime}/`,
});
},
30000,
);
});
describe("App Router route-handler trailingSlash: false (#1827)", () => {
let tmpDir: string;
let server: ViteDevServer;
let baseUrl: string;
beforeAll(async () => {
tmpDir = copyAppFixtureWithTrailingSlash("vinext-route-ts-false-", false);
({ server, baseUrl } = await startFixtureServer(tmpDir));
}, 60000);
afterAll(async () => {
await server?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
it.each(["edge", "node"])(
"redirects /runtime/%s/ with 308 and serves the unslashed path",
async (runtime) => {
const res = await fetch(`${baseUrl}/runtime/${runtime}/`, { redirect: "manual" });
expect(res.status).toBe(308);
expect(new URL(res.headers.get("location")!, baseUrl).pathname).toBe(`/runtime/${runtime}`);
const unslashed = await fetch(`${baseUrl}/runtime/${runtime}`, { redirect: "manual" });
expect(unslashed.status).toBe(200);
await expect(unslashed.json()).resolves.toEqual({
url: `/runtime/${runtime}`,
nextUrl: `/runtime/${runtime}`,
});
},
30000,
);
});