Repository: sveltejs/kit (js)

Issue:
+page.server.js no longer uses/respects setHeaders
### Describe the bug

Since [this commit](https://github.com/sveltejs/kit/commit/c530d33793a228fac684c71ed7926e6217101a90) the `+page.server.js` endpoints return a js file rather than json. This js file response no longer uses the headers set with the `setHeaders` function. This means we can't use cache-control etc.

### Reproduction

To reproduce, hover over the 'TODOS' menu item and observer the '__data.js' response headers. There should be a header as defined in `routes/todos/+page.server.js`;

https://stackblitz.com/edit/sveltejs-kit-template-default-8tt4be?file=src/routes/todos/+page.server.js

<img width="1224" alt="Screenshot 2022-08-31 at 17 29 32" src="https://user-images.githubusercontent.com/1055769/187730727-2e8f5dd4-97df-4661-a24e-5d56ec9b95c0.png">


### Logs

_No response_

### System Info

```shell
System:
    OS: macOS 12.5
    CPU: (8) arm64 Apple M2
    Memory: 155.45 MB / 24.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 16.16.0 - ~/.nvm/versions/node/v16.16.0/bin/node
    Yarn: 1.22.19 - /opt/homebrew/bin/yarn
    npm: 8.11.0 - ~/.nvm/versions/node/v16.16.0/bin/npm
  Browsers:
    Chrome: 104.0.5112.101
    Safari: 15.6
  npmPackages:
    @sveltejs/adapter-auto: 1.0.0-next.70 => 1.0.0-next.70 
    @sveltejs/kit: 1.0.0-next.456 => 1.0.0-next.456 
    svelte: ^3.49.0 => 3.49.0 
    vite: 3.1.0-beta.1 => 3.1.0-beta.1
```


### Severity

annoyance

### Additional Information

_No response_

Pull request:
[feat] enable caching for __data.json requests
...using the Vary header, which allows one cached response for each variation of the x-sveltekit-invalidated header, ensuring we cache matching responses for all invalidation scenarios
Closes #6477

### 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
- [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [x] 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/clean-sheep-run.md b/.changeset/clean-sheep-run.md
new file mode 100644
index 000000000..c497a7bb7
--- /dev/null
+++ b/.changeset/clean-sheep-run.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[feat] enable caching for `__data.json` requests
diff --git a/.changeset/curly-ants-guess.md b/.changeset/curly-ants-guess.md
new file mode 100644
index 000000000..9b2911fef
--- /dev/null
+++ b/.changeset/curly-ants-guess.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] prevent double decoding of params
diff --git a/.changeset/many-timers-accept.md b/.changeset/many-timers-accept.md
new file mode 100644
index 000000000..76efaa329
--- /dev/null
+++ b/.changeset/many-timers-accept.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[feat] better navigation interfaces
diff --git a/.changeset/pre.json b/.changeset/pre.json
index 03fa179b4..f08d9a5ac 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -671,6 +671,7 @@
     "many-planes-taste",
     "many-pugs-work",
     "many-suns-push",
+    "many-timers-accept",
     "mean-bananas-live",
     "mean-chefs-swim",
     "mean-crews-unite",
@@ -1254,6 +1255,7 @@
     "ten-apricots-admire",
     "ten-berries-develop",
     "ten-gorillas-itch",
+    "ten-hairs-drum",
     "ten-hairs-perform",
     "ten-mice-kneel",
     "ten-pets-tease",
diff --git a/.changeset/violet-candles-compare.md b/.changeset/violet-candles-compare.md
new file mode 100644
index 000000000..c71675cf0
--- /dev/null
+++ b/.changeset/violet-candles-compare.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] better cookie warning checks
diff --git a/documentation/docs/60-appendix/10-migrating.md b/documentation/docs/60-appendix/10-migrating.md
index 18cef9023..4827b530c 100644
--- a/documentation/docs/60-appendix/10-migrating.md
+++ b/documentation/docs/60-appendix/10-migrating.md
@@ -172,7 +172,7 @@ const minification_options = {
 	minifyCSS: true,
 	minifyJS: false,
 	removeAttributeQuotes: true,
-	removeComments: true,
+	removeComments: false, // some hydration code needs comments, so leave them in
 	removeOptionalTags: true,
 	removeRedundantAttributes: true,
 	removeScriptTypeAttributes: true,
diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md
index 9422035c1..807510270 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,17 @@
 # @sveltejs/kit
 
+## 1.0.0-next.537
+
+### Patch Changes
+
+- [feat] better navigation interfaces ([#7529](https://github.com/sveltejs/kit/pull/7529))
+
+## 1.0.0-next.536
+
+### Patch Changes
+
+- custom aliases resolved in service worker builds ([#7500](https://github.com/sveltejs/kit/pull/7500))
+
 ## 1.0.0-next.535
 
 ### Patch Changes
diff --git a/packages/kit/package.json b/packages/kit/package.json
index a1617e9cb..0a7547329 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/kit",
-	"version": "1.0.0-next.535",
+	"version": "1.0.0-next.537",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index b622af0c4..0e7f5bb51 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -1,5 +1,11 @@
 import { onMount, tick } from 'svelte';
-import { make_trackable, decode_params, normalize_path, add_data_suffix } from '../../utils/url.js';
+import {
+	make_trackable,
+	decode_pathname,
+	decode_params,
+	normalize_path,
+	add_data_suffix
+} from '../../utils/url.js';
 import { find_anchor, get_base_uri, scroll_state } from './utils.js';
 import {
 	lock_fetch,
@@ -83,10 +89,10 @@ export function create_client({ target, base, trailing_slash }) {
 	let load_cache = null;
 
 	const callbacks = {
-		/** @type {Array<(navigation: import('types').Navigation & { cancel: () => void }) => void>} */
+		/** @type {Array<(navigation: import('types').BeforeNavigate) => void>} */
 		before_navigate: [],
 
-		/** @type {Array<(navigation: import('types').Navigation) => void>} */
+		/** @type {Array<(navigation: import('types').AfterNavigate) => void>} */
 		after_navigate: []
 	};
 
@@ -382,7 +388,7 @@ export function create_client({ target, base, trailing_slash }) {
 		});
 		post_update();
 
-		/** @type {import('types').Navigation} */
+		/** @type {import('types').AfterNavigate} */
 		const navigation = {
 			from: null,
 			to: add_url_properties('to', {
@@ -992,7 +998,7 @@ export function create_client({ target, base, trailing_slash }) {
 	function get_navigation_intent(url, invalidating) {
 		if (is_external_url(url)) return;
 
-		const path = decodeURI(url.pathname.slice(base.length) || '/');
+		const path = decode_pathname(url.pathname.slice(base.length) || '/');
 
 		for (const route of routes) {
 			const params = route.exec(path);
@@ -1116,7 +1122,9 @@ export function create_client({ target, base, trailing_slash }) {
 			nav_token,
 			() => {
 				navigating = false;
-				callbacks.after_navigate.forEach((fn) => fn(navigation));
+				callbacks.after_navigate.forEach((fn) =>
+					fn(/** @type {import('types').AfterNavigate} */ (navigation))
+				);
 				stores.navigating.set(null);
 			}
 		);
@@ -1307,7 +1315,7 @@ export function create_client({ target, base, trailing_slash }) {
 				if (!navigating) {
 					// If we're navigating, beforeNavigate was already called. If we end up in here during navigation,
 					// it's due to an external or full-page-reload link, for which we don't want to call the hook again.
-					/** @type {import('types').Navigation & { cancel: () => void }} */
+					/** @type {import('types').BeforeNavigate} */
 					const navigation = {
 						from: add_url_properties('from', {
 							params: current.params,
diff --git a/packages/kit/src/runtime/server/cookie.js b/packages/kit/src/runtime/server/cookie.js
index a6a4a2f9f..75672fe57 100644
--- a/packages/kit/src/runtime/server/cookie.js
+++ b/packages/kit/src/runtime/server/cookie.js
@@ -15,9 +15,35 @@ const cookie_paths = {};
  */
 export function get_cookies(request, url, options) {
 	const header = request.headers.get('cookie') ?? '';
-
 	const initial_cookies = parse(header);
 
+	const normalized_url = normalize_path(
+		// Remove suffix: 'foo/__data.json' would mean the cookie path is '/foo',
+		// whereas a direct hit of /foo would mean the cookie path is '/'
+		has_data_suffix(url.pathname) ? strip_data_suffix(url.pathname) : url.pathname,
+		options.trailing_slash
+	);
+	// Emulate browser-behavior: if the cookie is set at '/foo/bar', its path is '/foo'
+	const default_path = normalized_url.split('/').slice(0, -1).join('/') || '/';
+
+	if (options.dev) {
+		// Remove all cookies that no longer exist according to the request
+		for (const name of Object.keys(cookie_paths)) {
+			cookie_paths[name] = new Set(
+				[...cookie_paths[name]].filter(
+					(path) => !path_matches(normalized_url, path) || name in initial_cookies
+				)
+			);
+		}
+		// Add all new cookies we might not have seen before
+		for (const name in initial_cookies) {
+			cookie_paths[name] = cookie_paths[name] ?? new Set();
+			if (![...cookie_paths[name]].some((path) => path_matches(normalized_url, path))) {
+				cookie_paths[name].add(default_path);
+			}
+		}
+	}
+
 	/** @type {Record<string, import('./page/types').Cookie>} */
 	const new_cookies = {};
 
@@ -57,9 +83,14 @@ export function get_cookies(request, url, options) {
 				return cookie;
 			}
 
-			if (c || cookie_paths[name]?.size > 0) {
+			const paths = new Set([...(cookie_paths[name] ?? [])]);
+			if (c) {
+				paths.add(c.options.path ?? default_path);
+			}
+			if (paths.size > 0) {
 				console.warn(
-					`Cookie with name '${name}' was not found, but a cookie with that name exists at a sub path. Did you mean to set its 'path' to '/'?`
+					// prettier-ignore
+					`Cookie with name '${name}' was not found at path '${url.pathname}', but a cookie with that name exists at these paths: '${[...paths].join("', '")}'. Did you mean to set its 'path' to '/' instead?`
 				);
 			}
 		},
@@ -70,17 +101,7 @@ export function get_cookies(request, url, options) {
 		 * @param {import('cookie').CookieSerializeOptions} opts
 		 */
 		set(name, value, opts = {}) {
-			let path = opts.path;
-			if (!path) {
-				const normalized = normalize_path(
-					// Remove suffix: 'foo/__data.json' would mean the cookie path is '/foo',
-					// whereas a direct hit of /foo would mean the cookie path is '/'
-					has_data_suffix(url.pathname) ? strip_data_suffix(url.pathname) : url.pathname,
-					options.trailing_slash
-				);
-				// Emulate browser-behavior: if the cookie is set at '/foo/bar', its path is '/foo'
-				path = normalized.split('/').slice(0, -1).join('/') || '/';
-			}
+			let path = opts.path ?? default_path;
 
 			new_cookies[name] = {
 				name,
@@ -93,11 +114,12 @@ export function get_cookies(request, url, options) {
 			};
 
 			if (options.dev) {
-				cookie_paths[name] = cookie_paths[name] || new Set();
+				cookie_paths[name] = cookie_paths[name] ?? new Set();
 				if (!value) {
 					if (!cookie_paths[name].has(path) && cookie_paths[name].size > 0) {
+						const paths = `'${Array.from(cookie_paths[name]).join("', '")}'`;
 						console.warn(
-							`Trying to delete cookie '${name}' at path '${path}', but a cookie with that name only exists at a different path.`
+							`Trying to delete cookie '${name}' at path '${path}', but a cookie with that name only exists at these paths: ${paths}.`
 						);
 					}
 					cookie_paths[name].delete(path);
diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js
index 707adf859..ebd7a8375 100644
--- a/packages/kit/src/runtime/server/data/index.js
+++ b/packages/kit/src/runtime/server/data/index.js
@@ -5,6 +5,8 @@ import { load_server_data } from '../page/load_data.js';
 import { clarify_devalue_error, handle_error_and_jsonify, serialize_data_node } from '../utils.js';
 import { normalize_path, strip_data_suffix } from '../../../utils/url.js';
 
+export const INVALIDATED_HEADER = 'x-sveltekit-invalidated';
+
 /**
  * @param {import('types').RequestEvent} event
  * @param {import('types').SSRRoute} route
@@ -24,7 +26,7 @@ export async function render_data(event, route, options, state) {
 		const node_ids = [...route.page.layouts, route.page.leaf];
 
 		const invalidated =
-			event.request.headers.get('x-sveltekit-invalidated')?.split(',').map(Boolean) ??
+			event.request.headers.get(INVALIDATED_HEADER)?.split(',').map(Boolean) ??
 			node_ids.map(() => true);
 
 		let aborted = false;
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index 0b479fc60..4e791508a 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -6,6 +6,7 @@ import { coalesce_to_error } from '../../utils/error.js';
 import { is_form_content_type } from '../../utils/http.js';
 import { GENERIC_ERROR, handle_fatal_error } from './utils.js';
 import {
+	decode_pathname,
 	decode_params,
 	disable_search,
 	has_data_suffix,
@@ -13,7 +14,7 @@ import {
 	strip_data_suffix
 } from '../../utils/url.js';
 import { exec } from '../../utils/routing.js';
-import { render_data } from './data/index.js';
+import { INVALIDATED_HEADER, render_data } from './data/index.js';
 import { add_cookies_to_headers, get_cookies } from './cookie.js';
 import { HttpError } from '../control.js';
 import { create_fetch } from './fetch.js';
@@ -44,7 +45,7 @@ export async function respond(request, options, state) {
 
 	let decoded;
 	try {
-		decoded = decodeURI(url.pathname);
+		decoded = decode_pathname(url.pathname);
 	} catch {
 		return new Response('Malformed URI', { status: 400 });
 	}
@@ -296,14 +297,21 @@ export async function respond(request, options, state) {
 				resolve(event, opts).then((response) => {
 					// add headers/cookies here, rather than inside `resolve`, so that we
 					// can do it once for all responses instead of once per `return`
-					if (!is_data_request) {
-						// we only want to set cookies on __data.json requests, we don't
-						// want to cache stuff erroneously etc
-						for (const key in headers) {
-							const value = headers[key];
-							response.headers.set(key, /** @type {string} */ (value));
+					for (const key in headers) {
+						const value = headers[key];
+						response.headers.set(key, /** @type {string} */ (value));
+					}
+
+					if (is_data_request) {
+						// set the Vary header on __data.json requests to ensure we don't cache
+						// incomplete responses with skipped data loads
+						// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary
+						const vary = response.headers.get('Vary');
+						if (vary !== '*') {
+							response.headers.append('Vary', INVALIDATED_HEADER);
 						}
 					}
+
 					add_cookies_to_headers(response.headers, Object.values(new_cookies));
 
 					if (state.prerendering && event.route.id !== null) {
diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js
index 8647178b7..f3958f80f 100644
--- a/packages/kit/src/runtime/server/page/load_data.js
+++ b/packages/kit/src/runtime/server/page/load_data.js
@@ -1,7 +1,8 @@
 import { disable_search, make_trackable } from '../../../utils/url.js';
 import { unwrap_promises } from '../../../utils/promises.js';
+
 /**
- * Calls the user's `load` function.
+ * Calls the user's server `load` function.
  * @param {{
  *   event: import('types').RequestEvent;
  *   state: import('types').SSRState;
diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js
index 77d60b789..db4e6d882 100644
--- a/packages/kit/src/utils/url.js
+++ b/packages/kit/src/utils/url.js
@@ -54,23 +54,20 @@ export function normalize_path(path, trailing_slash) {
 	return path;
 }
 
+/**
+ * Decode pathname excluding %25 to prevent further double decoding of params
+ * @param {string} pathname
+ */
+export function decode_pathname(pathname) {
+	return pathname.split('%25').map(decodeURI).join('%25');
+}
+
 /** @param {Record<string, string>} params */
 export function decode_params(params) {
 	for (const key in params) {
 		// input has already been decoded by decodeURI
-		// now handle the rest that decodeURIComponent would do
-		params[key] = params[key]
-			.replace(/%23/g, '#')
-			.replace(/%3[Bb]/g, ';')
-			.replace(/%2[Cc]/g, ',')
-			.replace(/%2[Ff]/g, '/')
-			.replace(/%3[Ff]/g, '?')
-			.replace(/%3[Aa]/g, ':')
-			.replace(/%40/g, '@')
-			.replace(/%26/g, '&')
-			.replace(/%3[Dd]/g, '=')
-			.replace(/%2[Bb]/g, '+')
-			.replace(/%24/g, '$');
+		// now handle the rest
+		params[key] = decodeURIComponent(params[key]);
 	}
 
 	return params;
diff --git a/packages/kit/types/ambient.d.ts b/packages/kit/types/ambient.d.ts
index bc96b872e..2f214d7d1 100644
--- a/packages/kit/types/ambient.d.ts
+++ b/packages/kit/types/ambient.d.ts
@@ -171,7 +171,7 @@ declare module '$app/forms' {
  * ```
  */
 declare module '$app/navigation' {
-	import { Navigation } from '@sveltejs/kit';
+	import { BeforeNavigate, AfterNavigate } from '@sveltejs/kit';
 
 	/**
 	 * If called when the page is being updated following a navigation (in `onMount` or `afterNavigate` or an action, for example), this disables SvelteKit's built-in scroll handling.
@@ -261,16 +261,14 @@ declare module '$app/navigation' {
 	 *
 	 * `beforeNavigate` must be called during a component initialization. It remains active as long as the component is mounted.
 	 */
-	export function beforeNavigate(
-		callback: (navigation: Navigation & { cancel(): void }) => void
-	): void;
+	export function beforeNavigate(callback: (navigation: BeforeNavigate) => void): void;
 
 	/**
 	 * A lifecycle function that runs the supplied `callback` when the current component mounts, and also whenever we navigate to a new URL.
 	 *
 	 * `afterNavigate` must be called during a component initialization. It remains active as long as the component is mounted.
 	 */
-	export function afterNavigate(callback: (navigation: Navigation) => void): void;
+	export function afterNavigate(callback: (navigation: AfterNavigate) => void): void;
 }
 
 /**
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index a690057eb..b6fa43320 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -414,13 +414,12 @@ export interface Navigation {
 	to: NavigationTarget | null;
 	/**
 	 * The type of navigation:
-	 * - `enter`: The app has hydrated
 	 * - `leave`: The user is leaving the app by closing the tab or using the back/forward buttons to go to a different document
 	 * - `link`: Navigation was triggered by a link click
 	 * - `goto`: Navigation was triggered by a `goto(...)` call or a redirect
 	 * - `popstate`: Navigation was triggered by back/forward navigation
 	 */
-	type: NavigationType;
+	type: Omit<NavigationType, 'enter'>;
 	/**
 	 * Whether or not the navigation will result in the page being unloaded (i.e. not a client-side navigation)
 	 */
@@ -431,6 +430,31 @@ export interface Navigation {
 	delta?: number;
 }
 
+/**
+ * The interface that corresponds to the `beforeNavigate`'s input parameter.
+ */
+export interface BeforeNavigate extends Navigation {
+	/**
+	 * Call this to prevent the navigation from starting.
+	 */
+	cancel(): void;
+}
+
+/**
+ * The interface that corresponds to the `afterNavigate`'s input parameter.
+ */
+export interface AfterNavigate extends Navigation {
+	/**
+	 * The type of navigation:
+	 * - `enter`: The app has hydrated
+	 * - `link`: Navigation was triggered by a link click
+	 * - `goto`: Navigation was triggered by a `goto(...)` call or a redirect
+	 * - `popstate`: Navigation was triggered by back/forward navigation
+	 */
+	type: Omit<NavigationType, 'leave'>;
+	willUnload: false;
+}
+
 /**
  * The shape of the `$page` store
  */


Test patch:
diff --git a/packages/kit/test/apps/basics/src/app.d.ts b/packages/kit/test/apps/basics/src/app.d.ts
index 36f8f2739..15ac25fc1 100644
--- a/packages/kit/test/apps/basics/src/app.d.ts
+++ b/packages/kit/test/apps/basics/src/app.d.ts
@@ -1,7 +1,7 @@
 declare namespace App {
 	interface Locals {
 		answer: number;
-		name: string;
+		name?: string;
 		key: string;
 		params: Record<string, string>;
 	}
diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js
index d210e114e..564ff59d5 100644
--- a/packages/kit/test/apps/basics/src/hooks.server.js
+++ b/packages/kit/test/apps/basics/src/hooks.server.js
@@ -44,7 +44,10 @@ export const handle = sequence(
 		return resolve(event);
 	},
 	({ event, resolve }) => {
-		event.locals.name = /** @type {string} */ (event.cookies.get('name'));
+		if (event.url.pathname.includes('fetch-credentialed')) {
+			// Only get the cookie at the test where we know it's set to avoid polluting our logs with (correct) warnings
+			event.locals.name = /** @type {string} */ (event.cookies.get('name'));
+		}
 		return resolve(event);
 	},
 	async ({ event, resolve }) => {
diff --git a/packages/kit/test/apps/basics/src/routes/caching/server-data/+page.server.js b/packages/kit/test/apps/basics/src/routes/caching/server-data/+page.server.js
new file mode 100644
index 000000000..1ce4cfeca
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/caching/server-data/+page.server.js
@@ -0,0 +1,6 @@
+/** @type {import('./$types').PageServerLoad} */
+export async function load({ setHeaders }) {
+	setHeaders({
+		'cache-control': 'public, max-age=30'
+	});
+}
diff --git a/packages/kit/test/apps/basics/src/routes/caching/server-data/+page.svelte b/packages/kit/test/apps/basics/src/routes/caching/server-data/+page.svelte
new file mode 100644
index 000000000..313ba446f
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/caching/server-data/+page.svelte
@@ -0,0 +1,1 @@
+<h1>this page's __data.json will be cached for 30 seconds</h1>
diff --git a/packages/kit/test/apps/basics/src/routes/cookies/encoding/+page.server.js b/packages/kit/test/apps/basics/src/routes/cookies/encoding/+page.server.js
index f39937f60..171f89ddd 100644
--- a/packages/kit/test/apps/basics/src/routes/cookies/encoding/+page.server.js
+++ b/packages/kit/test/apps/basics/src/routes/cookies/encoding/+page.server.js
@@ -1,6 +1,6 @@
 /** @type {import('./$types').PageServerLoad} */
 export function load(event) {
 	return {
-		a: event.cookies.get('a')
+		encoding: event.cookies.get('encoding')
 	};
 }
diff --git a/packages/kit/test/apps/basics/src/routes/cookies/encoding/+page.svelte b/packages/kit/test/apps/basics/src/routes/cookies/encoding/+page.svelte
index 4e8537253..c7ce63378 100644
--- a/packages/kit/test/apps/basics/src/routes/cookies/encoding/+page.svelte
+++ b/packages/kit/test/apps/basics/src/routes/cookies/encoding/+page.svelte
@@ -4,5 +4,5 @@
 </script>
 
 Cookie: <span id="cookie-value">
-	{data.a}
+	{data.encoding}
 </span>
diff --git a/packages/kit/test/apps/basics/src/routes/cookies/encoding/not-decoded-twice/+server.js b/packages/kit/test/apps/basics/src/routes/cookies/encoding/not-decoded-twice/+server.js
index e17c5fcb9..e63aae2fb 100644
--- a/packages/kit/test/apps/basics/src/routes/cookies/encoding/not-decoded-twice/+server.js
+++ b/packages/kit/test/apps/basics/src/routes/cookies/encoding/not-decoded-twice/+server.js
@@ -3,6 +3,6 @@ import { redirect } from '@sveltejs/kit';
 /** @type {import('@sveltejs/kit').RequestHandler} */
 export const GET = (event) => {
 	const sneaky = 'teapot%2C%20jane%20austen';
-	event.cookies.set('a', sneaky);
+	event.cookies.set('encoding', sneaky);
 	throw redirect(303, '/cookies/encoding');
 };
diff --git a/packages/kit/test/apps/basics/src/routes/cookies/encoding/set/+server.js b/packages/kit/test/apps/basics/src/routes/cookies/encoding/set/+server.js
index f4f1d48a6..ebecc2e59 100644
--- a/packages/kit/test/apps/basics/src/routes/cookies/encoding/set/+server.js
+++ b/packages/kit/test/apps/basics/src/routes/cookies/encoding/set/+server.js
@@ -3,6 +3,6 @@ import { redirect } from '@sveltejs/kit';
 /** @type {import('@sveltejs/kit').RequestHandler} */
 export const GET = (event) => {
 	const needsEncoding = 'teapot, jane austen';
-	event.cookies.set('a', needsEncoding);
+	event.cookies.set('encoding', needsEncoding);
 	throw redirect(303, '/cookies/encoding');
 };
diff --git a/packages/kit/test/apps/basics/src/routes/encoded/+page.svelte b/packages/kit/test/apps/basics/src/routes/encoded/+page.svelte
index b21e07113..3b96b33a5 100644
--- a/packages/kit/test/apps/basics/src/routes/encoded/+page.svelte
+++ b/packages/kit/test/apps/basics/src/routes/encoded/+page.svelte
@@ -5,5 +5,6 @@
 <a href="/encoded/@svelte">@svelte</a>
 <a href="/encoded/$SVLT">$SVLT</a>
 <a href="/encoded/test%2520me">test%20me</a>
+<a href="/encoded/test%252fme">test%2fme</a>
 <a href="/encoded/AC%2fDC">AC/DC</a>
 <a href="/encoded/%5b">[</a>
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-credentialed.json/+server.js b/packages/kit/test/apps/basics/src/routes/load/fetch-credentialed.json/+server.js
index 7536c51d8..a8ad1d6ff 100644
--- a/packages/kit/test/apps/basics/src/routes/load/fetch-credentialed.json/+server.js
+++ b/packages/kit/test/apps/basics/src/routes/load/fetch-credentialed.json/+server.js
@@ -3,6 +3,6 @@ import { json } from '@sveltejs/kit';
 /** @type {import('./$types').RequestHandler} */
 export function GET(event) {
 	return json({
-		name: event.locals.name
+		name: event.locals.name ?? 'Fail'
 	});
 }
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 9d82bfccf..b0a5b5f5f 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -7,6 +7,18 @@ test.skip(({ javaScriptEnabled }) => !javaScriptEnabled);
 
 test.describe.configure({ mode: 'parallel' });
 
+test.describe('Caching', () => {
+	test('caches __data.json requests with Vary header', async ({ page, app }) => {
+		await page.goto('/');
+		const [, response] = await Promise.all([
+			app.goto('/caching/server-data'),
+			page.waitForResponse((request) => request.url().endsWith('server-data/__data.json'))
+		]);
+		expect(response.headers()['cache-control']).toBe('public, max-age=30');
+		expect(response.headers()['vary']).toBe('x-sveltekit-invalidated');
+	});
+});
+
 test.describe('beforeNavigate', () => {
 	test('prevents navigation triggered by link click', async ({ page, baseURL }) => {
 		await page.goto('/before-navigate/prevent-navigation');
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index 7c7632120..a682cc384 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -331,6 +331,14 @@ test.describe('Encoded paths', () => {
 		expect(await page.innerHTML('h3')).toBe('/encoded/test%2520me: test%20me');
 	});
 
+	test('visits a route with a doubly encoded slash', async ({ page, clicknav }) => {
+		await page.goto('/encoded');
+		await clicknav('[href="/encoded/test%252fme"]');
+		expect(await page.innerHTML('h1')).toBe('dynamic');
+		expect(await page.innerHTML('h2')).toBe('/encoded/test%252fme: test%2fme');
+		expect(await page.innerHTML('h3')).toBe('/encoded/test%252fme: test%2fme');
+	});
+
 	test('visits a route with an encoded slash', async ({ page, clicknav }) => {
 		await page.goto('/encoded');
 		await clicknav('[href="/encoded/AC%2fDC"]');
@@ -1967,7 +1975,8 @@ test.describe('Actions', () => {
 	});
 });
 
-test.describe('Cookies API', () => {
+// Run in serial to not pollute the log with (correct) cookie warnings
+test.describe.serial('Cookies API', () => {
 	// there's a problem running these tests in the CI with webkit,
 	// since AFAICT the browser is using http://localhost and webkit won't
 	// set a `Secure` cookie on that. So we bail...
@@ -1994,18 +2003,6 @@ test.describe('Cookies API', () => {
 		expect(await span.innerText()).toContain('undefined');
 	});
 
-	test('cookies can be set with a path', async ({ page }) => {
-		await page.goto('/cookies/nested/a');
-		let span = page.locator('#cookie-value');
-		expect(await span.innerText()).toContain('teapot');
-		await page.goto('/cookies/nested/b');
-		span = page.locator('#cookie-value');
-		expect(await span.innerText()).toContain('undefined');
-		await page.goto('/cookies');
-		span = page.locator('#cookie-value');
-		expect(await span.innerText()).toContain('undefined');
-	});
-
 	test('more than one cookie can be set in one request', async ({ page }) => {
 		await page.goto('/cookies/set-more-than-one');
 		const span = page.locator('#cookie-value');
@@ -2043,4 +2040,16 @@ test.describe('Cookies API', () => {
 		await page.click('button#janeAusten');
 		await expect(page.locator('#cookie-value')).toHaveText('Jane Austen');
 	});
+
+	test('cookies can be set with a path', async ({ page }) => {
+		await page.goto('/cookies/nested/a');
+		let span = page.locator('#cookie-value');
+		expect(await span.innerText()).toContain('teapot');
+		await page.goto('/cookies/nested/b');
+		span = page.locator('#cookie-value');
+		expect(await span.innerText()).toContain('undefined');
+		await page.goto('/cookies');
+		span = page.locator('#cookie-value');
+		expect(await span.innerText()).toContain('undefined');
+	});
 });