Skip to content

Commit 86a1e40

Browse files
authored
fix(astro): Astro.url.pathname respects trailingSlash: 'never' with base path (#14260)
* fix: Astro.url.pathname respects trailingSlash: 'never' with base path Fixes that Astro.url.pathname incorrectly returned '/base/' instead of '/base' for root path when trailingSlash was set to 'never'. * feat: add .changeset
1 parent e81c4bd commit 86a1e40

8 files changed

Lines changed: 190 additions & 3 deletions

File tree

.changeset/plenty-colts-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes `Astro.url.pathname` to respect `trailingSlash: 'never'` configuration when using a base path. Previously, the root path with a base would incorrectly return `/base/` instead of `/base` when `trailingSlash` was set to 'never'.

packages/astro/src/vite-plugin-astro-server/request.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type http from 'node:http';
2-
import { removeTrailingForwardSlash } from '../core/path.js';
2+
import { hasFileExtension } from '@astrojs/internal-helpers/path';
3+
import { appendForwardSlash, removeTrailingForwardSlash } from '../core/path.js';
34
import type { RoutesList } from '../types/astro.js';
45
import type { DevServerController } from './controller.js';
56
import { runWithErrorHandling } from './controller.js';
@@ -41,6 +42,13 @@ export async function handleRequest({
4142

4243
// Add config.base back to url before passing it to SSR
4344
url.pathname = removeTrailingForwardSlash(config.base) + url.pathname;
45+
46+
// Apply trailing slash configuration consistently
47+
if (config.trailingSlash === 'never') {
48+
url.pathname = removeTrailingForwardSlash(url.pathname);
49+
} else if (config.trailingSlash === 'always' && !hasFileExtension(url.pathname)) {
50+
url.pathname = appendForwardSlash(url.pathname);
51+
}
4452

4553
let body: ArrayBuffer | undefined = undefined;
4654
if (!(incomingRequest.method === 'GET' || incomingRequest.method === 'HEAD')) {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from 'astro/config';
2+
import node from '@astrojs/node';
3+
4+
export default defineConfig({
5+
base: '/mybase',
6+
trailingSlash: 'never',
7+
output: 'server',
8+
adapter: node({ mode: 'standalone' })
9+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "test-ssr-trailing-slash",
3+
"type": "module",
4+
"scripts": {
5+
"dev": "astro dev",
6+
"build": "astro build",
7+
"start": "node dist/server/entry.mjs"
8+
},
9+
"dependencies": {
10+
"astro": "file:../../",
11+
"@astrojs/node": "^8.0.0"
12+
}
13+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
export const prerender = false;
3+
const pathname = Astro.url.pathname;
4+
---
5+
<html>
6+
<body>
7+
<h1>Test: {pathname}</h1>
8+
<code>{pathname}</code>
9+
</body>
10+
</html>

packages/astro/test/ssr-trailing-slash.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,51 @@ describe('Redirecting trailing slashes in SSR', () => {
231231
});
232232
});
233233

234+
describe('trailingSlash: never with base path', () => {
235+
before(async () => {
236+
fixture = await loadFixture({
237+
root: './fixtures/ssr-response/',
238+
adapter: testAdapter(),
239+
output: 'server',
240+
trailingSlash: 'never',
241+
base: '/mybase',
242+
});
243+
await fixture.build();
244+
});
245+
246+
it('Redirects to remove a trailing slash on base path', async () => {
247+
const app = await fixture.loadTestAdapterApp();
248+
const request = new Request('http://example.com/mybase/');
249+
const response = await app.render(request);
250+
assert.equal(response.status, 301);
251+
assert.equal(response.headers.get('Location'), '/mybase');
252+
});
253+
254+
it('Does not redirect when base path has no trailing slash', async () => {
255+
const app = await fixture.loadTestAdapterApp();
256+
const request = new Request('http://example.com/mybase');
257+
const response = await app.render(request);
258+
// Should not redirect, but will 404 since we don't have an index page
259+
assert.notEqual(response.status, 301);
260+
assert.notEqual(response.status, 308);
261+
});
262+
263+
it('Redirects to remove trailing slash on sub-paths with base', async () => {
264+
const app = await fixture.loadTestAdapterApp();
265+
const request = new Request('http://example.com/mybase/another/');
266+
const response = await app.render(request);
267+
assert.equal(response.status, 301);
268+
assert.equal(response.headers.get('Location'), '/mybase/another');
269+
});
270+
271+
it('Does not redirect sub-paths without trailing slash with base', async () => {
272+
const app = await fixture.loadTestAdapterApp();
273+
const request = new Request('http://example.com/mybase/another');
274+
const response = await app.render(request);
275+
assert.equal(response.status, 200);
276+
});
277+
});
278+
234279
describe('trailingSlash: ignore', () => {
235280
before(async () => {
236281
fixture = await loadFixture({

packages/astro/test/units/routing/trailing-slash.test.js

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ import {
1010
} from '../test-utils.js';
1111

1212
const fileSystem = {
13-
'/src/pages/api.ts': `export const GET = () => Response.json({ success: true })`,
14-
'/src/pages/dot.json.ts': `export const GET = () => Response.json({ success: true })`,
13+
'/src/pages/api.ts': `export const GET = () => new Response(JSON.stringify({ success: true }), { headers: { 'content-type': 'application/json' } })`,
14+
'/src/pages/dot.json.ts': `export const GET = () => new Response(JSON.stringify({ success: true }), { headers: { 'content-type': 'application/json' } })`,
15+
'/src/pages/pathname.ts': `export const GET = (ctx) => new Response(JSON.stringify({ pathname: ctx.url.pathname }), { headers: { 'content-type': 'application/json' } })`,
16+
'/src/pages/subpage.ts': `export const GET = (ctx) => new Response(JSON.stringify({ pathname: ctx.url.pathname }), { headers: { 'content-type': 'application/json' } })`,
1517
};
1618

1719
describe('trailingSlash', () => {
1820
let fixture;
1921
let container;
2022
let baseContainer;
23+
let rootPathContainer;
2124

2225
before(async () => {
2326
fixture = await createFixture(fileSystem);
@@ -80,11 +83,39 @@ describe('trailingSlash', () => {
8083
settings: baseSettings,
8184
logger: defaultLogger,
8285
});
86+
87+
// Create a container specifically for testing root path with base
88+
const rootPathSettings = await createBasicSettings({
89+
root: fixture.path,
90+
trailingSlash: 'never',
91+
base: '/mybase',
92+
output: 'server',
93+
adapter: testAdapter(),
94+
integrations: [
95+
{
96+
name: 'test',
97+
hooks: {
98+
'astro:config:setup': ({ injectRoute }) => {
99+
// Inject a route at the root that returns Astro.url.pathname
100+
injectRoute({
101+
pattern: '/',
102+
entrypoint: './src/pages/pathname.ts',
103+
});
104+
},
105+
},
106+
},
107+
],
108+
});
109+
rootPathContainer = await createContainer({
110+
settings: rootPathSettings,
111+
logger: defaultLogger,
112+
});
83113
});
84114

85115
after(async () => {
86116
await container.close();
87117
await baseContainer.close();
118+
await rootPathContainer.close();
88119
});
89120

90121
// Tests for trailingSlash: 'always'
@@ -181,4 +212,42 @@ describe('trailingSlash', () => {
181212
assert.equal(html.includes(`<span class="statusMessage">Not found</span>`), true);
182213
assert.equal(res.statusCode, 404);
183214
});
215+
216+
// Test for issue #13736: Astro.url.pathname should respect trailingSlash config with base
217+
it('Astro.url.pathname should not have trailing slash on root path when base is set and trailingSlash is never', async () => {
218+
const { req, res, text } = createRequestAndResponse({
219+
method: 'GET',
220+
url: '/mybase',
221+
});
222+
rootPathContainer.handle(req, res);
223+
const json = await text();
224+
const data = JSON.parse(json);
225+
// The pathname should be /mybase without trailing slash (the core issue from #13736)
226+
assert.equal(data.pathname, '/mybase');
227+
assert.equal(res.statusCode, 200);
228+
});
229+
230+
it('should return correct Astro.url.pathname for pages with base and trailingSlash never', async () => {
231+
const { req, res, text } = createRequestAndResponse({
232+
method: 'GET',
233+
url: '/base/pathname',
234+
});
235+
baseContainer.handle(req, res);
236+
const json = await text();
237+
const data = JSON.parse(json);
238+
// The pathname should be /base/pathname without trailing slash
239+
assert.equal(data.pathname, '/base/pathname');
240+
});
241+
242+
it('should return correct Astro.url.pathname for subpage with base and trailingSlash never', async () => {
243+
const { req, res, text } = createRequestAndResponse({
244+
method: 'GET',
245+
url: '/base/subpage',
246+
});
247+
baseContainer.handle(req, res);
248+
const json = await text();
249+
const data = JSON.parse(json);
250+
// The pathname should be /base/subpage without trailing slash
251+
assert.equal(data.pathname, '/base/subpage');
252+
});
184253
});

pnpm-lock.yaml

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)