Repository: sveltejs/kit (JavaScript)

Issue:
trailingSlash: "always" changes external URLs
### Describe the bug

When using configuration: `trailingSlash: "always"` then external URLs will also get a trailing slash.

There is a workaround if you set some kind of taget to the anchor element. For example `target="_self"` but it's still a bug that shoud be fixed.

### Reproduction

You can see repro: https://sveltejs-kit-template-default-tq3lbk--3000.local.webcontainer.io/

"Click me" should link to the URL: https://example.com/foo.php?bar=baz&more=params
But it actually navigates to: https://example.com/foo.php/?bar=baz&more=params

Notice the extra slash before the question mark.

You can see source of this example: https://stackblitz.com/edit/sveltejs-kit-template-default-tq3lbk?file=src/lib/header/Header.svelte

### Logs

_No response_

### System Info

```shell
not applicable
```


### Severity

annoyance

### Additional Information

_No response_


Discussion:
A workaround right now is adding `rel="external"` to the `a` tag to mark the link as external.

(To other maintainers: I hope I got the labels right.)

I'm seeing similar problems, but it is even worse. I have a statically generated site and the HTML has the links right (no trailing slash), but on click, something adds the slash before navigating.  This happens for external links, but also to internal links to static assets like PDFs. So `<a href="/pdfs/xyz.pdf">Download PDF</a>` is generated correctly, but if someone clicks it, they get a 404.  The `rel="external"` trick doesn't make sense for the static asset use case. Finding and modifying all of the links to add extra attributes isn't ideal, so I'm hoping someone picks this up.  The broken links on our site are not a good look.
Update: the `target="_self"` trick does not seem to work for me as a workaround -- at least not when the link is to a local asset. The problem also happens when referencing things like images.
> [...] The `rel="external"` trick doesn't make sense for the static asset use case.

You can use `rel="external"`, the `download` attribute or the `sveltekit:reload`, see https://github.com/sveltejs/kit/blob/373ff63433cfca3ac7119a5d6da7956dfb5f4740/packages/kit/src/runtime/client/client.js#L1158
I think, in your case I would use the `download` attribute.

----

Edit: `rel="external"` makes sense as well.
From http://microformats.org/wiki/rel-external
> The external keyword indicates that the link is leading to a document that is not part of the site that the current document forms a part of.

----

> Update: the target="_self" trick does not seem to work for me as a workaround -- at least not when the link is to a local asset. The problem also happens when referencing things like images.

`target="whatever"` should work as well.
https://github.com/sveltejs/kit/blob/373ff63433cfca3ac7119a5d6da7956dfb5f4740/packages/kit/src/runtime/client/client.js#L1167
Nice, the [sveltekit-reload](https://kit.svelte.dev/docs/a-options#sveltekit-reload) option looks like the "right" way to bypass the javascript navigation when that's needed and that will workaround. Appreciate the code reference.

The function that adds the slash is here: https://github.com/sveltejs/kit/blob/373ff63433cfca3ac7119a5d6da7956dfb5f4740/packages/kit/src/utils/url.js#L49

Seems like it could detect non-relative links easy enough, but unfortunately, I'm not sure how that function (or the navigate function that calls it) might be able to discern static assets from routes before adding the slash.  For now I guess I'll go annotate all the links I can find that are broken, but hopefully someone with more experience in Svelte Kit can craft a fix.



Patch:
diff --git a/.changeset/witty-carrots-notice.md b/.changeset/witty-carrots-notice.md
new file mode 100644
index 000000000000..7c0f5cd7de08
--- /dev/null
+++ b/.changeset/witty-carrots-notice.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Only normalise internal URLs
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index bd07f012ba6f..16a2889d61ad 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -238,6 +238,10 @@ export function create_client({ target, session, base, trailing_slash }) {
 			return false; // unnecessary, but TypeScript prefers it this way
 		}
 
+		// if this is an internal navigation intent, use the normalized
+		// URL for the rest of the function
+		url = intent?.url || url;
+
 		// abort if user navigated during update
 		if (token !== current_token) return false;
 
@@ -891,9 +895,12 @@ export function create_client({ target, session, base, trailing_slash }) {
 			const params = route.exec(path);
 
 			if (params) {
-				const id = normalize_path(url.pathname, trailing_slash) + url.search;
+				const normalized = new URL(
+					url.origin + normalize_path(url.pathname, trailing_slash) + url.search + url.hash
+				);
+				const id = normalized.pathname + normalized.search;
 				/** @type {import('./types').NavigationIntent} */
-				const intent = { id, route, params: decode_params(params), url };
+				const intent = { id, route, params: decode_params(params), url: normalized };
 				return intent;
 			}
 		}
@@ -930,9 +937,6 @@ export function create_client({ target, session, base, trailing_slash }) {
 			return;
 		}
 
-		const pathname = normalize_path(url.pathname, trailing_slash);
-		const normalized = new URL(url.origin + pathname + url.search + url.hash);
-
 		update_scroll_positions(current_history_index);
 
 		accepted();
@@ -940,12 +944,12 @@ export function create_client({ target, session, base, trailing_slash }) {
 		if (started) {
 			stores.navigating.set({
 				from: current.url,
-				to: normalized
+				to: url
 			});
 		}
 
 		await update(
-			normalized,
+			url,
 			redirect_chain,
 			false,
 			{
@@ -954,7 +958,7 @@ export function create_client({ target, session, base, trailing_slash }) {
 				details
 			},
 			() => {
-				const navigation = { from, to: normalized };
+				const navigation = { from, to: url };
 				callbacks.after_navigate.forEach((fn) => fn(navigation));
 
 				stores.navigating.set(null);


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/routing/slashes/index.svelte b/packages/kit/test/apps/basics/src/routes/routing/slashes/index.svelte
index b363f6435561..1c2295b61be0 100644
--- a/packages/kit/test/apps/basics/src/routes/routing/slashes/index.svelte
+++ b/packages/kit/test/apps/basics/src/routes/routing/slashes/index.svelte
@@ -1,3 +1,8 @@
+<script>
+	import { page } from '$app/stores';
+</script>
+
 <a href="/routing/">/routing/</a>
 <a href="/routing/?">/routing/?</a>
-<a href="/routing/?foo=bar">/routing/?foo=bar</a>
\ No newline at end of file
+<a href="/routing/?foo=bar">/routing/?foo=bar</a>
+<a href="http://localhost:{$page.url.searchParams.get('port')}/with-slash/">external</a>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index b64954822ca2..0a491d5fdf68 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -1,5 +1,5 @@
 import { expect } from '@playwright/test';
-import { test } from '../../../utils.js';
+import { start_server, test } from '../../../utils.js';
 
 /** @typedef {import('@playwright/test').Response} Response */
 
@@ -544,6 +544,24 @@ test.describe('Routing', () => {
 		expect(await page.textContent('#window-hash')).toBe('#target');
 		expect(await page.textContent('#page-url-hash')).toBe('#target');
 	});
+
+	test('does not normalize external path', async ({ page }) => {
+		const urls = [];
+
+		const { port, close } = await start_server((req, res) => {
+			urls.push(req.url);
+			res.end('ok');
+		});
+
+		try {
+			await page.goto(`/routing/slashes?port=${port}`);
+			await page.click(`a[href="http://localhost:${port}/with-slash/"]`);
+
+			expect(urls).toEqual(['/with-slash/']);
+		} finally {
+			await close();
+		}
+	});
 });
 
 test.describe('Shadow DOM', () => {