Repository: sveltejs/kit (js)

Issue:
Optional route with matcher followed by rest parameters
### Describe the bug

Given a `[[lang=lang]]/[...path]` route, kit will fail to catch non-matched URLs with the rest parameters route.

The same doesn't happen if the optional route is followed by a static one: `[[lang=lang]]/t/[...path]`.

```js
// params/lang.js
export const match = (param) => {
  return ['fr', 'de'].includes(param);
};
```

I would expect:
```
/home -> { lang: undefined, path: 'home' } -> gives 404
/de/home -> { lang: 'de', path: 'home' } -> correct
```

In the `[[lang=lang]]/t/[...path]` scenario, it works as expected:
```
/t/home -> { lang: undefined, path: 'home' } -> correct
/de/t/home -> { lang: 'de', path: 'home' } -> correct
```

A common use case for this would be an i18n site with dynamic pages being served by a CMS.

### Reproduction

Here's a stackblitz reproducing the issue:
https://stackblitz.com/edit/sveltejs-kit-template-default-4srbff?file=src/routes/+layout.svelte

And here's another one showing the correct expected behaviour by placing a static route after the optional one:
https://stackblitz.com/edit/sveltejs-kit-template-default-a3vmdd?file=src/routes/+layout.svelte

### Logs

_No response_

### System Info

```Shell
System:
    OS: macOS 13.0
    CPU: (8) arm64 Apple M2
    Memory: 50.08 MB / 16.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 18.10.0 - ~/.nvm/versions/node/v18.10.0/bin/node
    Yarn: 1.22.19 - /opt/homebrew/bin/yarn
    npm: 8.19.2 - ~/.nvm/versions/node/v18.10.0/bin/npm
    Watchman: 2022.10.03.00 - /opt/homebrew/bin/watchman
  Browsers:
    Brave Browser: 107.1.45.116
    Chrome: 107.0.5304.87
    Firefox: 106.0.5
    Safari: 16.1
  npmPackages:
    @sveltejs/adapter-auto: next => 1.0.0-next.87
    @sveltejs/kit: 1.0.0-next.538  => 1.0.0-next.538
    svelte: ^3.44.0 => 3.52.0
    vite: ^3.1.0 => 3.2.3
```


### Severity

serious, but I can work around it

### Additional Information

_No response_

Pull request:
Allow chained optional parameters
Closes #7548, alternative to #7549.

This is late night code — it's not exactly self-explanatory, but it works. I think. It might be possible to neaten it up a bit, but not now 😴 

It solves the `/[[lang=lang]]/[...rest]` case, where you want `/some-path` to result in `{ rest: 'some-path' }`. Currently, that won't match anything, because the `[[lang=lang]]` param 'eats' the first segment even if it doesn't match, which violates intuitions about how this stuff should work.

The solution is more general than just that case, however — any time an optional parameter with a matcher fails to match, we try it again with the next one by essentially shifting the regex matches rightwards.

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- [x] This message body should clearly illustrate what problems it solves.
- [x] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [ ] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [ ] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running `pnpm changeset` and following the prompts. All changesets should be `patch` until SvelteKit 1.0


Patch:
diff --git a/.changeset/empty-fireants-fetch.md b/.changeset/empty-fireants-fetch.md
new file mode 100644
index 000000000..10d449551
--- /dev/null
+++ b/.changeset/empty-fireants-fetch.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+external fetch calls: ensure serialized cookie values are url-encoded [#7736]
diff --git a/.changeset/forty-icons-switch.md b/.changeset/forty-icons-switch.md
new file mode 100644
index 000000000..51ef8c465
--- /dev/null
+++ b/.changeset/forty-icons-switch.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Roll over non-matching optional parameters instead of 404ing
diff --git a/.changeset/four-lamps-walk.md b/.changeset/four-lamps-walk.md
new file mode 100644
index 000000000..d2abb0ef2
--- /dev/null
+++ b/.changeset/four-lamps-walk.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[breaking] Disallow error status codes outside 400-599
diff --git a/.changeset/pre.json b/.changeset/pre.json
index f953433e2..6c1f316b0 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -301,6 +301,7 @@
     "empty-donuts-smell",
     "empty-doors-count",
     "empty-falcons-run",
+    "empty-fireants-fetch",
     "empty-fishes-design",
     "empty-grapes-own",
     "empty-islands-lie",
diff --git a/.changeset/strange-roses-sell.md b/.changeset/strange-roses-sell.md
new file mode 100644
index 000000000..563e74485
--- /dev/null
+++ b/.changeset/strange-roses-sell.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[breaking] Make client-side router ignore links outside %sveltekit.body%
diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md
index 7ee38c327..9a492b13d 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,11 @@
 # @sveltejs/kit
 
+## 1.0.0-next.557
+
+### Patch Changes
+
+- external fetch calls: ensure serialized cookie values are url-encoded [#7736] ([#7736](https://github.com/sveltejs/kit/pull/7736))
+
 ## 1.0.0-next.556
 
 ### Patch Changes
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 03459a623..bba341df9 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/kit",
-	"version": "1.0.0-next.556",
+	"version": "1.0.0-next.557",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js
index 429fd27cc..a77685b96 100644
--- a/packages/kit/src/core/generate_manifest/index.js
+++ b/packages/kit/src/core/generate_manifest/index.js
@@ -99,8 +99,8 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 			],
 			routes: [
 				${routes.map(route => {
-					route.types.forEach(type => {
-						if (type) matchers.add(type);
+					route.params.forEach(param => {
+						if (param.matcher) matchers.add(param.matcher);
 					});
 
 					if (!route.page && !route.endpoint) return;
@@ -108,9 +108,7 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 					return `{
 					id: ${s(route.id)},
 					pattern: ${route.pattern},
-					names: ${s(route.names)},
-					types: ${s(route.types)},
-					optional: ${s(route.optional)},
+					params: ${s(route.params)},
 					page: ${route.page ? `{ layouts: ${get_nodes(route.page.layouts)}, errors: ${get_nodes(route.page.errors)}, leaf: ${reindexed.get(route.page.leaf)} }` : 'null'},
 					endpoint: ${route.endpoint ? loader(join_relative(relative_path, resolve_symlinks(build_data.server.vite_manifest, route.endpoint.file).chunk.file)) : 'null'}
 				}`;
diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js
index bd797a9ae..4fc738f92 100644
--- a/packages/kit/src/core/sync/create_manifest_data/index.js
+++ b/packages/kit/src/core/sync/create_manifest_data/index.js
@@ -24,6 +24,14 @@ export default function create_manifest_data({
 	const matchers = create_matchers(config, cwd);
 	const { nodes, routes } = create_routes_and_nodes(cwd, config, fallback);
 
+	for (const route of routes) {
+		for (const param of route.params) {
+			if (param.matcher && !matchers[param.matcher]) {
+				throw new Error(`No matcher found for parameter '${param.matcher}' in route ${route.id}`);
+			}
+		}
+	}
+
 	return {
 		assets,
 		matchers,
@@ -153,7 +161,7 @@ function create_routes_and_nodes(cwd, config, fallback) {
 				);
 			}
 
-			const { pattern, names, types, optional } = parse_route_id(id);
+			const { pattern, params } = parse_route_id(id);
 
 			/** @type {import('types').RouteData} */
 			const route = {
@@ -162,9 +170,7 @@ function create_routes_and_nodes(cwd, config, fallback) {
 
 				segment,
 				pattern,
-				names,
-				types,
-				optional,
+				params,
 
 				layout: null,
 				error: null,
@@ -273,9 +279,7 @@ function create_routes_and_nodes(cwd, config, fallback) {
 			id: '/',
 			segment: '',
 			pattern: /^$/,
-			names: [],
-			types: [],
-			optional: [],
+			params: [],
 			parent: null,
 			layout: null,
 			error: null,
diff --git a/packages/kit/src/core/sync/create_manifest_data/index.spec.js b/packages/kit/src/core/sync/create_manifest_data/index.spec.js
index e036d3f74..c386d8ac0 100644
--- a/packages/kit/src/core/sync/create_manifest_data/index.spec.js
+++ b/packages/kit/src/core/sync/create_manifest_data/index.spec.js
@@ -87,7 +87,7 @@ test('creates routes', () => {
 		},
 		{
 			id: '/blog.json',
-			pattern: '/^/blog.json$/',
+			pattern: '/^/blog.json/?$/',
 			endpoint: { file: 'samples/basic/blog.json/+server.js' }
 		},
 		{
@@ -97,7 +97,7 @@ test('creates routes', () => {
 		},
 		{
 			id: '/blog/[slug].json',
-			pattern: '/^/blog/([^/]+?).json$/',
+			pattern: '/^/blog/([^/]+?).json/?$/',
 			endpoint: {
 				file: 'samples/basic/blog/[slug].json/+server.ts'
 			}
@@ -308,7 +308,7 @@ test('allows rest parameters inside segments', () => {
 		},
 		{
 			id: '/[...rest].json',
-			pattern: '/^/(.*?).json$/',
+			pattern: '/^/(.*?).json/?$/',
 			endpoint: {
 				file: 'samples/rest-prefix-suffix/[...rest].json/+server.js'
 			}
@@ -408,7 +408,7 @@ test('allows multiple slugs', () => {
 	assert.equal(routes.filter((route) => route.endpoint).map(simplify_route), [
 		{
 			id: '/[file].[ext]',
-			pattern: '/^/([^/]+?).([^/]+?)$/',
+			pattern: '/^/([^/]+?).([^/]+?)/?$/',
 			endpoint: {
 				file: 'samples/multiple-slugs/[file].[ext]/+server.js'
 			}
@@ -467,7 +467,7 @@ test('works with custom extensions', () => {
 		},
 		{
 			id: '/blog.json',
-			pattern: '/^/blog.json$/',
+			pattern: '/^/blog.json/?$/',
 			endpoint: {
 				file: 'samples/custom-extension/blog.json/+server.js'
 			}
@@ -479,7 +479,7 @@ test('works with custom extensions', () => {
 		},
 		{
 			id: '/blog/[slug].json',
-			pattern: '/^/blog/([^/]+?).json$/',
+			pattern: '/^/blog/([^/]+?).json/?$/',
 			endpoint: {
 				file: 'samples/custom-extension/blog/[slug].json/+server.js'
 			}
diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js
index 17cb18125..a10a654b5 100644
--- a/packages/kit/src/core/sync/write_types/index.js
+++ b/packages/kit/src/core/sync/write_types/index.js
@@ -195,8 +195,8 @@ function update_types(config, routes, route, to_delete = new Set()) {
 	// Makes sure a type is "repackaged" and therefore more readable
 	declarations.push('type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;');
 	declarations.push(
-		`type RouteParams = { ${route.names
-			.map((param, idx) => `${param}${route.optional[idx] ? '?' : ''}: string`)
+		`type RouteParams = { ${route.params
+			.map((param) => `${param.name}${param.optional ? '?' : ''}: string`)
 			.join('; ')} }`
 	);
 
@@ -270,8 +270,8 @@ function update_types(config, routes, route, to_delete = new Set()) {
 			if (leaf) {
 				if (leaf.route.page) ids.push(`"${leaf.route.id}"`);
 
-				for (const name of leaf.route.names) {
-					layout_params.add(name);
+				for (const param of leaf.route.params) {
+					layout_params.add(param.name);
 				}
 
 				ensureProxies(page, leaf.proxies);
diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js
index b0ae3243e..0ad15b8d9 100644
--- a/packages/kit/src/exports/index.js
+++ b/packages/kit/src/exports/index.js
@@ -8,12 +8,22 @@ import { HttpError, Redirect, ValidationError } from '../runtime/control.js';
  * @param {any} message
  */
 export function error(status, message) {
+	if (
+		(!__SVELTEKIT_BROWSER__ || __SVELTEKIT_DEV__) &&
+		(isNaN(status) || status < 400 || status > 599)
+	) {
+		throw new Error(`HTTP error status codes must be between 400 and 599 — ${status} is invalid`);
+	}
+
 	return new HttpError(status, message);
 }
 
 /** @type {import('@sveltejs/kit').redirect} */
 export function redirect(status, location) {
-	if (isNaN(status) || status < 300 || status > 308) {
+	if (
+		(!__SVELTEKIT_BROWSER__ || __SVELTEKIT_DEV__) &&
+		(isNaN(status) || status < 300 || status > 308)
+	) {
 		throw new Error('Invalid status code');
 	}
 
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index 6684163a5..7feaa7cfe 100644
--- a/packages/kit/src/exports/vite/dev/index.js
+++ b/packages/kit/src/exports/vite/dev/index.js
@@ -156,9 +156,7 @@ export async function dev(vite, vite_config, svelte_config) {
 						return {
 							id: route.id,
 							pattern: route.pattern,
-							names: route.names,
-							types: route.types,
-							optional: route.optional,
+							params: route.params,
 							page: route.page,
 							endpoint: endpoint
 								? async () => {
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index 2096b1836..7d05d6490 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -75,7 +75,7 @@ function check_for_removed_attributes() {
 
 /**
  * @param {{
- *   target: Element;
+ *   target: HTMLElement;
  *   base: string;
  * }} opts
  * @returns {import('./types').Client}
@@ -1385,12 +1385,12 @@ export function create_client({ target, base }) {
 				}, 20);
 			};
 
-			addEventListener('touchstart', trigger_prefetch);
-			addEventListener('mousemove', handle_mousemove);
-			addEventListener('sveltekit:trigger_prefetch', trigger_prefetch);
+			target.addEventListener('touchstart', trigger_prefetch);
+			target.addEventListener('mousemove', handle_mousemove);
+			target.addEventListener('sveltekit:trigger_prefetch', trigger_prefetch);
 
 			/** @param {MouseEvent} event */
-			addEventListener('click', (event) => {
+			target.addEventListener('click', (event) => {
 				// Adapted from https://github.com/visionmedia/page.js
 				// MIT license https://github.com/visionmedia/page.js#license
 				if (event.button || event.which !== 1) return;
diff --git a/packages/kit/src/runtime/client/parse.js b/packages/kit/src/runtime/client/parse.js
index 63909d0fc..b452e3b87 100644
--- a/packages/kit/src/runtime/client/parse.js
+++ b/packages/kit/src/runtime/client/parse.js
@@ -11,14 +11,14 @@ export function parse(nodes, server_loads, dictionary, matchers) {
 	const layouts_with_server_load = new Set(server_loads);
 
 	return Object.entries(dictionary).map(([id, [leaf, layouts, errors]]) => {
-		const { pattern, names, types, optional } = parse_route_id(id);
+		const { pattern, params } = parse_route_id(id);
 
 		const route = {
 			id,
 			/** @param {string} path */
 			exec: (path) => {
 				const match = pattern.exec(path);
-				if (match) return exec(match, { names, types, optional }, matchers);
+				if (match) return exec(match, params, matchers);
 			},
 			errors: [1, ...(errors || [])].map((n) => nodes[n]),
 			layouts: [0, ...(layouts || [])].map(create_layout_loader),
diff --git a/packages/kit/src/runtime/client/start.js b/packages/kit/src/runtime/client/start.js
index 941e564a6..7b113f53f 100644
--- a/packages/kit/src/runtime/client/start.js
+++ b/packages/kit/src/runtime/client/start.js
@@ -12,7 +12,7 @@ import { set_version } from '../env.js';
  *     assets: string;
  *     base: string;
  *   },
- *   target: Element;
+ *   target: HTMLElement;
  *   version: string;
  * }} opts
  */
diff --git a/packages/kit/src/runtime/control.js b/packages/kit/src/runtime/control.js
index 2bcf96398..16ea65416 100644
--- a/packages/kit/src/runtime/control.js
+++ b/packages/kit/src/runtime/control.js
@@ -43,56 +43,3 @@ export class ValidationError {
 		this.data = data;
 	}
 }
-
-/**
- * Creates an `HttpError` object with an HTTP status code and an optional message.
- * This object, if thrown during request handling, will cause SvelteKit to
- * return an error response without invoking `handleError`
- * @param {number} status
- * @param {string | undefined} [message]
- */
-export function error(status, message) {
-	return new HttpError(status, message);
-}
-
-/**
- * Creates a `Redirect` object. If thrown during request handling, SvelteKit will
- * return a redirect response.
- * @param {300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308} status
- * @param {string} location
- */
-export function redirect(status, location) {
-	if (isNaN(status) || status < 300 || status > 308) {
-		throw new Error('Invalid status code');
-	}
-
-	return new Redirect(status, location);
-}
-
-/**
- * Generates a JSON `Response` object from the supplied data.
- * @param {any} data
- * @param {ResponseInit} [init]
- */
-export function json(data, init) {
-	// TODO deprecate this in favour of `Response.json` when it's
-	// more widely supported
-	const headers = new Headers(init?.headers);
-	if (!headers.has('content-type')) {
-		headers.set('content-type', 'application/json');
-	}
-
-	return new Response(JSON.stringify(data), {
-		...init,
-		headers
-	});
-}
-
-/**
- * Generates a `ValidationError` object.
- * @param {number} status
- * @param {Record<string, any> | undefined} [data]
- */
-export function invalid(status, data) {
-	return new ValidationError(status, data);
-}
diff --git a/packages/kit/src/runtime/server/cookie.js b/packages/kit/src/runtime/server/cookie.js
index 4496d87b7..71985a704 100644
--- a/packages/kit/src/runtime/server/cookie.js
+++ b/packages/kit/src/runtime/server/cookie.js
@@ -8,6 +8,10 @@ import { has_data_suffix, normalize_path, strip_data_suffix } from '../../utils/
  * @type {Record<string, Set<string>>} */
 const cookie_paths = {};
 
+// default encoding functions for header cookie values
+const encode = encodeURIComponent;
+const decode = decodeURIComponent;
+
 /**
  * @param {Request} request
  * @param {URL} url
@@ -16,7 +20,7 @@ const cookie_paths = {};
  */
 export function get_cookies(request, url, dev, trailing_slash) {
 	const header = request.headers.get('cookie') ?? '';
-	const initial_cookies = parse(header);
+	const initial_cookies = parse(header, { decode });
 
 	const normalized_url = normalize_path(
 		// Remove suffix: 'foo/__data.json' would mean the cookie path is '/foo',
@@ -76,8 +80,8 @@ export function get_cookies(request, url, dev, trailing_slash) {
 				return c.value;
 			}
 
-			const decode = opts?.decode || decodeURIComponent;
-			const req_cookies = parse(header, { decode });
+			const decoder = opts?.decode || decode;
+			const req_cookies = parse(header, { decode: decoder });
 			const cookie = req_cookies[name]; // the decoded string or undefined
 
 			if (!dev || cookie) {
@@ -166,7 +170,7 @@ export function get_cookies(request, url, dev, trailing_slash) {
 
 		// cookies sent by the user agent have lowest precedence
 		for (const name in initial_cookies) {
-			combined_cookies[name] = initial_cookies[name];
+			combined_cookies[name] = encode(initial_cookies[name]);
 		}
 
 		// cookies previous set during this event with cookies.set have higher precedence
@@ -175,14 +179,15 @@ export function get_cookies(request, url, dev, trailing_slash) {
 			if (!domain_matches(destination.hostname, cookie.options.domain)) continue;
 			if (!path_matches(destination.pathname, cookie.options.path)) continue;
 
-			combined_cookies[cookie.name] = cookie.value;
+			const encoder = cookie.options.encode || encode;
+			combined_cookies[cookie.name] = encoder(cookie.value);
 		}
 
 		// explicit header has highest precedence
 		if (header) {
-			const parsed = parse(header);
+			const parsed = parse(header, { decode });
 			for (const name in parsed) {
-				combined_cookies[name] = parsed[name];
+				combined_cookies[name] = encode(parsed[name]);
 			}
 		}
 
diff --git a/packages/kit/src/runtime/server/cookie.spec.js b/packages/kit/src/runtime/server/cookie.spec.js
index 84f8bdc36..f4d28bb13 100644
--- a/packages/kit/src/runtime/server/cookie.spec.js
+++ b/packages/kit/src/runtime/server/cookie.spec.js
@@ -42,12 +42,13 @@ paths.negative.forEach(([path, constraint]) => {
 	});
 });
 
-/** @param {string} href */
-const cookies_setup = (href = 'https://example.com') => {
-	const url = new URL(href);
+/** @param {{ href?: string, headers?: Record<string, string> }} href */
+const cookies_setup = ({ href, headers } = {}) => {
+	const url = new URL(href || 'https://example.com');
 	const request = new Request(url, {
 		headers: new Headers({
-			cookie: 'a=b;'
+			cookie: 'a=b;',
+			...headers
 		})
 	});
 	return get_cookies(request, url, false, 'ignore');
@@ -72,7 +73,7 @@ test('default values when set is called', () => {
 });
 
 test('default values when set is called on sub path', () => {
-	const { cookies, new_cookies } = cookies_setup('https://example.com/foo/bar');
+	const { cookies, new_cookies } = cookies_setup({ href: 'https://example.com/foo/bar' });
 	cookies.set('a', 'b');
 	const opts = new_cookies['a']?.options;
 	assert.equal(opts?.secure, true);
@@ -82,7 +83,7 @@ test('default values when set is called on sub path', () => {
 });
 
 test('default values when on localhost', () => {
-	const { cookies, new_cookies } = cookies_setup('http://localhost:1234');
+	const { cookies, new_cookies } = cookies_setup({ href: 'http://localhost:1234' });
 	cookies.set('a', 'b');
 	const opts = new_cookies['a']?.options;
 	assert.equal(opts?.secure, false);
@@ -146,4 +147,18 @@ test('cookie names are case sensitive', () => {
 	assert.equal(entryA?.value, 'bar');
 });
 
+test('serialized cookie header should be url-encoded', () => {
+	const href = 'https://example.com';
+	const { cookies, get_cookie_header } = cookies_setup({
+		href,
+		headers: {
+			cookie: 'a=f%C3%BC' // a=fü
+		}
+	});
+	cookies.set('b', 'fö'); // should use default encoding
+	cookies.set('c', 'fö', { encode: () => 'öf' }); // should respect `encode`
+	const header = get_cookie_header(new URL(href), 'd=f%C3%A4');
+	assert.equal(header, 'a=f%C3%BC; b=f%C3%B6; c=öf; d=f%C3%A4');
+});
+
 test.run();
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index 18a9fe003..f5554c4c6 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -77,7 +77,7 @@ export async function respond(request, options, state) {
 			const match = candidate.pattern.exec(decoded);
 			if (!match) continue;
 
-			const matched = exec(match, candidate, matchers);
+			const matched = exec(match, candidate.params, matchers);
 			if (matched) {
 				route = candidate;
 				params = decode_params(matched);
diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js
index 7991499df..6c71be0ef 100644
--- a/packages/kit/src/utils/routing.js
+++ b/packages/kit/src/utils/routing.js
@@ -5,44 +5,40 @@ const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/;
  * @param {string} id
  */
 export function parse_route_id(id) {
-	/** @type {string[]} */
-	const names = [];
-
-	/** @type {string[]} */
-	const types = [];
-
-	/** @type {boolean[]} */
-	const optional = [];
-
-	// `/foo` should get an optional trailing slash, `/foo.json` should not
-	// const add_trailing_slash = !/\.[a-z]+$/.test(key);
-	let add_trailing_slash = true;
+	/** @type {import('types').RouteParam[]} */
+	const params = [];
 
 	const pattern =
 		id === '/'
 			? /^\/$/
 			: new RegExp(
 					`^${get_route_segments(id)
-						.map((segment, i, segments) => {
+						.map((segment) => {
 							// special case — /[...rest]/ could contain zero segments
 							const rest_match = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment);
 							if (rest_match) {
-								names.push(rest_match[1]);
-								types.push(rest_match[2]);
-								optional.push(false);
+								params.push({
+									name: rest_match[1],
+									matcher: rest_match[2],
+									optional: false,
+									rest: true,
+									chained: true
+								});
 								return '(?:/(.*))?';
 							}
 							// special case — /[[optional]]/ could contain zero segments
 							const optional_match = /^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(segment);
 							if (optional_match) {
-								names.push(optional_match[1]);
-								types.push(optional_match[2]);
-								optional.push(true);
+								params.push({
+									name: optional_match[1],
+									matcher: optional_match[2],
+									optional: true,
+									rest: false,
+									chained: true
+								});
 								return '(?:/([^/]+))?';
 							}
 
-							const is_last = i === segments.length - 1;
-
 							if (!segment) {
 								return;
 							}
@@ -73,29 +69,31 @@ export function parse_route_id(id) {
 											);
 										}
 
-										const [, is_optional, is_rest, name, type] = match;
+										const [, is_optional, is_rest, name, matcher] = match;
 										// It's assumed that the following invalid route id cases are already checked
 										// - unbalanced brackets
 										// - optional param following rest param
 
-										names.push(name);
-										types.push(type);
-										optional.push(!!is_optional);
+										params.push({
+											name,
+											matcher,
+											optional: !!is_optional,
+											rest: !!is_rest,
+											chained: is_rest ? i === 1 && parts[0] === '' : false
+										});
 										return is_rest ? '(.*?)' : is_optional ? '([^/]*)?' : '([^/]+?)';
 									}
 
-									if (is_last && content.includes('.')) add_trailing_slash = false;
-
 									return escape(content);
 								})
 								.join('');
 
 							return '/' + result;
 						})
-						.join('')}${add_trailing_slash ? '/?' : ''}$`
+						.join('')}/?$`
 			  );
 
-	return { pattern, names, types, optional };
+	return { pattern, params };
 }
 
 /**
@@ -119,35 +117,70 @@ export function get_route_segments(route) {
 
 /**
  * @param {RegExpMatchArray} match
- * @param {{
- *   names: string[];
- *   types: string[];
- *   optional: boolean[];
- * }} candidate
+ * @param {import('types').RouteParam[]} params
  * @param {Record<string, import('types').ParamMatcher>} matchers
  */
-export function exec(match, { names, types, optional }, matchers) {
+export function exec(match, params, matchers) {
 	/** @type {Record<string, string>} */
-	const params = {};
+	const result = {};
+
+	const values = match.slice(1);
 
-	for (let i = 0; i < names.length; i += 1) {
-		const name = names[i];
-		const type = types[i];
-		let value = match[i + 1];
+	let buffered = '';
 
-		if (value || !optional[i]) {
-			if (type) {
-				const matcher = matchers[type];
-				if (!matcher) throw new Error(`Missing "${type}" param matcher`); // TODO do this ahead of time?
+	for (let i = 0; i < params.length; i += 1) {
+		const param = params[i];
+		let value = values[i];
+
+		if (param.chained && param.rest && buffered) {
+			// in the `[[lang=lang]]/[...rest]` case, if `lang` didn't
+			// match, we roll it over into the rest value
+			value = value ? buffered + '/' + value : buffered;
+		}
 
-				if (!matcher(value)) return;
+		buffered = '';
+
+		if (value === undefined) {
+			// if `value` is undefined, it means this is
+			// an optional or rest parameter
+			if (param.rest) result[param.name] = '';
+		} else {
+			if (param.matcher && !matchers[param.matcher](value)) {
+				// in the `/[[a=b]]/[[c=d]]` case, if the value didn't satisfy the `b` matcher,
+				// try again with the next segment by shifting values rightwards
+				if (param.optional && param.chained) {
+					// @ts-expect-error TypeScript is... wrong
+					let j = values.indexOf(undefined, i);
+
+					if (j === -1) {
+						// we can't shift values any further, so hang on to this value
+						// so it can be rolled into a subsequent `[...rest]` param
+						const next = params[i + 1];
+						if (next?.rest && next.chained) {
+							buffered = value;
+						} else {
+							return;
+						}
+					}
+
+					while (j >= i) {
+						values[j] = values[j - 1];
+						j -= 1;
+					}
+
+					continue;
+				}
+
+				// otherwise, if the matcher returns `false`, the route did not match
+				return;
 			}
 
-			params[name] = value ?? '';
+			result[param.name] = value;
 		}
 	}
 
-	return params;
+	if (buffered) return;
+	return result;
 }
 
 /** @param {string} str */
diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js
index c4b585ac5..18d144ce3 100644
--- a/packages/kit/src/utils/routing.spec.js
+++ b/packages/kit/src/utils/routing.spec.js
@@ -5,63 +5,51 @@ import { exec, parse_route_id } from './routing.js';
 const tests = {
 	'/': {
 		pattern: /^\/$/,
-		names: [],
-		types: []
+		params: []
 	},
 	'/blog': {
 		pattern: /^\/blog\/?$/,
-		names: [],
-		types: []
+		params: []
 	},
 	'/blog.json': {
-		pattern: /^\/blog\.json$/,
-		names: [],
-		types: []
+		pattern: /^\/blog\.json\/?$/,
+		params: []
 	},
 	'/blog/[slug]': {
 		pattern: /^\/blog\/([^/]+?)\/?$/,
-		names: ['slug'],
-		types: [undefined]
+		params: [{ name: 'slug', matcher: undefined, optional: false, rest: false, chained: false }]
 	},
 	'/blog/[slug].json': {
-		pattern: /^\/blog\/([^/]+?)\.json$/,
-		names: ['slug'],
-		types: [undefined]
+		pattern: /^\/blog\/([^/]+?)\.json\/?$/,
+		params: [{ name: 'slug', matcher: undefined, optional: false, rest: false, chained: false }]
 	},
 	'/blog/[[slug]]': {
 		pattern: /^\/blog(?:\/([^/]+))?\/?$/,
-		names: ['slug'],
-		types: [undefined]
+		params: [{ name: 'slug', matcher: undefined, optional: true, rest: false, chained: true }]
 	},
 	'/blog/[[slug=type]]/sub': {
 		pattern: /^\/blog(?:\/([^/]+))?\/sub\/?$/,
-		names: ['slug'],
-		types: ['type']
+		params: [{ name: 'slug', matcher: 'type', optional: true, rest: false, chained: true }]
 	},
 	'/blog/[[slug]].json': {
-		pattern: /^\/blog\/([^/]*)?\.json$/,
-		names: ['slug'],
-		types: [undefined]
+		pattern: /^\/blog\/([^/]*)?\.json\/?$/,
+		params: [{ name: 'slug', matcher: undefined, optional: true, rest: false, chained: false }]
 	},
 	'/[...catchall]': {
 		pattern: /^(?:\/(.*))?\/?$/,
-		names: ['catchall'],
-		types: [undefined]
+		params: [{ name: 'catchall', matcher: undefined, optional: false, rest: true, chained: true }]
 	},
 	'/foo/[...catchall]/bar': {
 		pattern: /^\/foo(?:\/(.*))?\/bar\/?$/,
-		names: ['catchall'],
-		types: [undefined]
+		params: [{ name: 'catchall', matcher: undefined, optional: false, rest: true, chained: true }]
 	},
 	'/matched/[id=uuid]': {
 		pattern: /^\/matched\/([^/]+?)\/?$/,
-		names: ['id'],
-		types: ['uuid']
+		params: [{ name: 'id', matcher: 'uuid', optional: false, rest: false, chained: false }]
 	},
 	'/@-symbol/[id]': {
 		pattern: /^\/@-symbol\/([^/]+?)\/?$/,
-		names: ['id'],
-		types: [undefined]
+		params: [{ name: 'id', matcher: undefined, optional: false, rest: false, chained: false }]
 	}
 };
 
@@ -70,8 +58,7 @@ for (const [key, expected] of Object.entries(tests)) {
 		const actual = parse_route_id(key);
 
 		assert.equal(actual.pattern.toString(), expected.pattern.toString());
-		assert.equal(actual.names, expected.names);
-		assert.equal(actual.types, expected.types);
+		assert.equal(actual.params, expected.params);
 	});
 }
 
@@ -160,22 +147,33 @@ const exec_tests = [
 		route: '/[...slug1=doesntmatch]',
 		path: '/',
 		expected: undefined
+	},
+	{
+		route: '/[[slug=doesntmatch]]/[...rest]',
+		path: '/foo',
+		expected: { rest: 'foo' }
+	},
+	{
+		route: '/[[slug1=doesntmatch]]/[[slug2=matches]]/[[slug3=doesntmatch]]/[...rest].json',
+		path: '/foo/bar/baz.json',
+		expected: { slug2: 'foo', rest: 'bar/baz' }
+	},
+	{
+		route: '/[[a=doesntmatch]]/[[b=matches]]/c',
+		path: '/a/b/c',
+		expected: undefined
 	}
 ];
 
 for (const { path, route, expected } of exec_tests) {
 	test(`exec extracts params correctly for ${path} from ${route}`, () => {
-		const { pattern, names, types, optional } = parse_route_id(route);
+		const { pattern, params } = parse_route_id(route);
 		const match = pattern.exec(path);
 		if (!match) throw new Error(`Failed to match ${path}`);
-		const actual = exec(
-			match,
-			{ names, types, optional },
-			{
-				matches: () => true,
-				doesntmatch: () => false
-			}
-		);
+		const actual = exec(match, params, {
+			matches: () => true,
+			doesntmatch: () => false
+		});
 		assert.equal(actual, expected);
 	});
 }
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index 33e084bc3..a2ae2f371 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -158,6 +158,14 @@ export interface Respond {
 	(request: Request, options: SSROptions, state: SSRState): Promise<Response>;
 }
 
+export interface RouteParam {
+	name: string;
+	matcher: string;
+	optional: boolean;
+	rest: boolean;
+	chained: boolean;
+}
+
 /**
  * Represents a route segment in the app. It can either be an intermediate node
  * with only layout/error pages, or a leaf, at which point either `page` and `leaf`
@@ -169,9 +177,7 @@ export interface RouteData {
 
 	segment: string;
 	pattern: RegExp;
-	names: string[];
-	types: string[];
-	optional: boolean[];
+	params: RouteParam[];
 
 	layout: PageNode | null;
 	error: PageNode | null;
@@ -337,12 +343,8 @@ export type SSREndpoint = Partial<Record<HttpMethod, RequestHandler>> & {
 export interface SSRRoute {
 	id: string;
 	pattern: RegExp;
-	names: string[];
-	types: string[];
-	optional: boolean[];
-
+	params: RouteParam[];
 	page: PageNodeIndexes | null;
-
 	endpoint: (() => Promise<SSREndpoint>) | null;
 }
 
diff --git a/sites/kit.svelte.dev/src/lib/docs/server/index.js b/sites/kit.svelte.dev/src/lib/docs/server/index.js
index 878c2be6b..c7465541d 100644
--- a/sites/kit.svelte.dev/src/lib/docs/server/index.js
+++ b/sites/kit.svelte.dev/src/lib/docs/server/index.js
@@ -124,7 +124,7 @@ export async function read_file(file) {
 					}
 					if (source.includes('./$types') && !source.includes('@filename: $types.d.ts')) {
 						const params = parse_route_id(options.file || `+page.${language}`)
-							.names.map((name) => `${name}: string`)
+							.params.map((param) => `${param.name}: string`)
 							.join(', ');
 
 						injected.push(


Test patch:
diff --git a/packages/kit/test/apps/basics/src/app.html b/packages/kit/test/apps/basics/src/app.html
index dd9910661..4f4316bd9 100644
--- a/packages/kit/test/apps/basics/src/app.html
+++ b/packages/kit/test/apps/basics/src/app.html
@@ -8,6 +8,7 @@
 		%sveltekit.head%
 	</head>
 	<body>
-		%sveltekit.body%
+		<div>%sveltekit.body%</div>
+		<a href="/routing/link-outside-app-target/target">outside app target</a>
 	</body>
 </html>
diff --git a/packages/kit/test/apps/basics/src/routes/routing/link-outside-app-target/source/+page.svelte b/packages/kit/test/apps/basics/src/routes/routing/link-outside-app-target/source/+page.svelte
new file mode 100644
index 000000000..f30d440fe
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/routing/link-outside-app-target/source/+page.svelte
@@ -0,0 +1,8 @@
+<script>
+	import { onMount } from 'svelte';
+	import { increment, count } from '../state.js';
+
+	onMount(increment);
+</script>
+
+<h1>source: {count}</h1>
diff --git a/packages/kit/test/apps/basics/src/routes/routing/link-outside-app-target/state.js b/packages/kit/test/apps/basics/src/routes/routing/link-outside-app-target/state.js
new file mode 100644
index 000000000..ab2ad88ec
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/routing/link-outside-app-target/state.js
@@ -0,0 +1,5 @@
+export let count = 0;
+
+export function increment() {
+	count += 1;
+}
diff --git a/packages/kit/test/apps/basics/src/routes/routing/link-outside-app-target/target/+page.svelte b/packages/kit/test/apps/basics/src/routes/routing/link-outside-app-target/target/+page.svelte
new file mode 100644
index 000000000..13dfee7b0
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/routing/link-outside-app-target/target/+page.svelte
@@ -0,0 +1,5 @@
+<script>
+	import { count } from '../state.js';
+</script>
+
+<h1>target: {count}</h1>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 7531f81ff..65aa6cdc7 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -805,6 +805,13 @@ test.describe('Routing', () => {
 		await page.goForward();
 		expect(await page.textContent('h1')).toBe('hello');
 	});
+
+	test('ignores clicks outside the app target', async ({ page }) => {
+		await page.goto('/routing/link-outside-app-target/source');
+
+		await page.click('[href="/routing/link-outside-app-target/target"]');
+		expect(await page.textContent('h1')).toBe('target: 0');
+	});
 });
 
 test.describe('Shadow DOM', () => {
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index be47fcb68..8678d1b1a 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -1295,28 +1295,32 @@ test.describe('Redirects', () => {
 
 		await clicknav('[href="/redirect/missing-status/a"]');
 
+		const message = process.env.DEV || !javaScriptEnabled ? 'Invalid status code' : 'Redirect loop';
+
 		expect(page.url()).toBe(`${baseURL}/redirect/missing-status/a`);
 		expect(await page.textContent('h1')).toBe('500');
 		expect(await page.textContent('#message')).toBe(
-			'This is your custom error page saying: "Invalid status code"'
+			`This is your custom error page saying: "${message}"`
 		);
 
 		if (!javaScriptEnabled) {
 			// handleError is not invoked for client-side navigation
 			const lines = read_errors('/redirect/missing-status/a').stack.split('\n');
-			expect(lines[0]).toBe('Error: Invalid status code');
+			expect(lines[0]).toBe(`Error: ${message}`);
 		}
 	});
 
-	test('errors on invalid status', async ({ baseURL, page, clicknav }) => {
+	test('errors on invalid status', async ({ baseURL, page, clicknav, javaScriptEnabled }) => {
 		await page.goto('/redirect');
 
 		await clicknav('[href="/redirect/missing-status/b"]');
 
+		const message = process.env.DEV || !javaScriptEnabled ? 'Invalid status code' : 'Redirect loop';
+
 		expect(page.url()).toBe(`${baseURL}/redirect/missing-status/b`);
 		expect(await page.textContent('h1')).toBe('500');
 		expect(await page.textContent('#message')).toBe(
-			'This is your custom error page saying: "Invalid status code"'
+			`This is your custom error page saying: "${message}"`
 		);
 	});