Repository: sveltejs/kit
Pull request #7516 (2022-11-05 19:04:54)

[fix] don't clean build and output directories when using `--watch`
When watching, static directory is missing on outDir because the vite svelte-kit plugin clean them

How to reproduce:

Create a clean svelte-kit project (choose skeleton and no to all questions)
- npm create svelte@latest watch_bug
- cd watch_bug
- npm install
- touch static/file.txt
- npx vite build -w
- Check files on .svelte-kit/output/client/, is missing file.txt and favicon.png

Couldn't devote too much time to do a better solution, so my PR only avoid cleaning directories when using --watch

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [ ] 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
- [ ] This message body should clearly illustrate what problems it solves.
- [ ] 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


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: fd6fd368f310c4d3e4d1c21840de0531c8aa79c7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/samuelabreu/svelte_kit/new/bug_watch?filename=.changeset/four-peaches-shake.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A---%0A%0ADont%20clean%20build%20and%20output_dir%20when%20watching%0A)



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/cold-monkeys-relate.md b/.changeset/cold-monkeys-relate.md
new file mode 100644
index 000000000..2fb08abfd
--- /dev/null
+++ b/.changeset/cold-monkeys-relate.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Serve prerendered non-page files when running preview
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/curvy-years-sin.md b/.changeset/curvy-years-sin.md
new file mode 100644
index 000000000..4d51c5960
--- /dev/null
+++ b/.changeset/curvy-years-sin.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] caching takes now into account the body payload
diff --git a/.changeset/funny-planes-judge.md b/.changeset/funny-planes-judge.md
new file mode 100644
index 000000000..824e96084
--- /dev/null
+++ b/.changeset/funny-planes-judge.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] handle locked readable stream when reading body
diff --git a/.changeset/gentle-shoes-run.md b/.changeset/gentle-shoes-run.md
new file mode 100644
index 000000000..bb65d8db3
--- /dev/null
+++ b/.changeset/gentle-shoes-run.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] prefetch should ignore links ignored by the router
diff --git a/.changeset/happy-panthers-perform.md b/.changeset/happy-panthers-perform.md
new file mode 100644
index 000000000..0c4be21a6
--- /dev/null
+++ b/.changeset/happy-panthers-perform.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] migration error when using $page.routeId
diff --git a/.changeset/large-starfishes-relate.md b/.changeset/large-starfishes-relate.md
new file mode 100644
index 000000000..42adfdecc
--- /dev/null
+++ b/.changeset/large-starfishes-relate.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': minor
+---
+
+[fix] don't clean build and output directories when using `--watch`
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/plenty-crabs-relax.md b/.changeset/plenty-crabs-relax.md
new file mode 100644
index 000000000..8b36c0792
--- /dev/null
+++ b/.changeset/plenty-crabs-relax.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Omit prerendered routes from server manifest
diff --git a/.changeset/pre.json b/.changeset/pre.json
index 03fa179b4..9bc0b1ec6 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -168,6 +168,7 @@
     "clean-islands-do",
     "clean-jars-float",
     "clean-schools-doubt",
+    "clean-sheep-run",
     "clean-spiders-sin",
     "clever-dolls-poke",
     "clever-donuts-smile",
@@ -184,6 +185,7 @@
     "cold-cycles-report",
     "cold-jars-know",
     "cold-llamas-brake",
+    "cold-monkeys-relate",
     "cold-panthers-learn",
     "cold-pants-unite",
     "cold-penguins-share",
@@ -207,6 +209,7 @@
     "cuddly-months-sip",
     "cuddly-pens-buy",
     "cuddly-ravens-carry",
+    "curly-ants-guess",
     "curly-doors-type",
     "curly-pets-vanish",
     "curvy-dots-join",
@@ -217,6 +220,7 @@
     "curvy-sloths-brake",
     "curvy-students-march",
     "curvy-suits-sleep",
+    "curvy-years-sin",
     "curvy-zebras-tan",
     "cyan-ads-pump",
     "cyan-cobras-explode",
@@ -401,6 +405,7 @@
     "funny-elephants-brake",
     "funny-keys-appear",
     "funny-needles-talk",
+    "funny-planes-judge",
     "funny-radios-attend",
     "funny-terms-repair",
     "funny-trainers-hammer",
@@ -421,6 +426,7 @@
     "gentle-maps-pull",
     "gentle-news-shout",
     "gentle-rules-camp",
+    "gentle-shoes-run",
     "gentle-years-leave",
     "giant-ants-hang",
     "giant-ghosts-give",
@@ -479,6 +485,7 @@
     "happy-eggs-shop",
     "happy-nails-draw",
     "happy-otters-provide",
+    "happy-panthers-perform",
     "happy-peaches-visit",
     "happy-pumas-thank",
     "healthy-carrots-cross",
@@ -671,6 +678,7 @@
     "many-planes-taste",
     "many-pugs-work",
     "many-suns-push",
+    "many-timers-accept",
     "mean-bananas-live",
     "mean-chefs-swim",
     "mean-crews-unite",
@@ -838,6 +846,7 @@
     "pink-gorillas-watch",
     "pink-poets-begin",
     "plenty-cougars-invite",
+    "plenty-crabs-relax",
     "plenty-cups-watch",
     "plenty-donuts-check",
     "plenty-mirrors-clap",
@@ -1135,6 +1144,7 @@
     "smooth-waves-accept",
     "soft-bikes-bake",
     "soft-ears-share",
+    "soft-gorillas-hear",
     "soft-houses-pump",
     "soft-masks-trade",
     "soft-months-fail",
@@ -1186,6 +1196,7 @@
     "strange-hounds-invite",
     "strange-kiwis-tell",
     "strange-poems-design",
+    "strange-walls-carry",
     "strong-apples-walk",
     "strong-baboons-travel",
     "strong-carrots-work",
@@ -1254,6 +1265,7 @@
     "ten-apricots-admire",
     "ten-berries-develop",
     "ten-gorillas-itch",
+    "ten-hairs-drum",
     "ten-hairs-perform",
     "ten-mice-kneel",
     "ten-pets-tease",
@@ -1388,6 +1400,7 @@
     "unlucky-planets-battle",
     "unlucky-vans-allow",
     "unlucky-wasps-rest",
+    "violet-candles-compare",
     "violet-goats-wave",
     "violet-jars-give",
     "violet-jars-visit",
@@ -1480,6 +1493,7 @@
     "young-cougars-poke",
     "young-ears-rhyme",
     "young-flies-camp",
+    "young-geckos-sit",
     "young-hairs-cough",
     "young-horses-kick",
     "young-ligers-admire",
diff --git a/.changeset/soft-gorillas-hear.md b/.changeset/soft-gorillas-hear.md
new file mode 100644
index 000000000..6b3d8affe
--- /dev/null
+++ b/.changeset/soft-gorillas-hear.md
@@ -0,0 +1,5 @@
+---
+"@sveltejs/kit": patch
+---
+
+prevent loading of illegal modules in the browser, rather than during SSR
diff --git a/.changeset/strange-walls-carry.md b/.changeset/strange-walls-carry.md
new file mode 100644
index 000000000..06bea220c
--- /dev/null
+++ b/.changeset/strange-walls-carry.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] take into account Request input when serializing fetch data
diff --git a/.changeset/ten-hairs-drum.md b/.changeset/ten-hairs-drum.md
new file mode 100644
index 000000000..79f452719
--- /dev/null
+++ b/.changeset/ten-hairs-drum.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+custom aliases resolved in service worker builds
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/.changeset/young-geckos-sit.md b/.changeset/young-geckos-sit.md
new file mode 100644
index 000000000..1cbe59b87
--- /dev/null
+++ b/.changeset/young-geckos-sit.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] prevent double decoding of path segment
diff --git a/documentation/docs/20-core-concepts/30-form-actions.md b/documentation/docs/20-core-concepts/30-form-actions.md
index c497c0e4a..98a30ffe6 100644
--- a/documentation/docs/20-core-concepts/30-form-actions.md
+++ b/documentation/docs/20-core-concepts/30-form-actions.md
@@ -225,6 +225,70 @@ export const actions = {
 };
 ```
 
+### Loading data
+
+After an action runs, the page will be re-rendered (unless a redirect or an unexpected error occurs), with the action's return value available to the page as the `form` prop. This means that your page's `load` functions will run after the action completes.
+
+Note that `handle` runs before the action is invoked, and does not re-run before the `load` functions. This means that if, for example, you use `handle` to populate `event.locals` based on a cookie, you must update `event.locals` when you set or delete the cookie in an action:
+
+```js
+/// file: src/hooks.server.js
+// @filename: ambient.d.ts
+declare namespace App {
+	interface Locals {
+		user: {
+			name: string;
+		} | null
+	}
+}
+
+// @filename: global.d.ts
+declare global {
+	function getUser(sessionid: string | undefined): {
+		name: string;
+	};
+}
+
+export {};
+
+// @filename: index.js
+// ---cut---
+/** @type {import('@sveltejs/kit').Handle} */
+export async function handle({ event, resolve }) {
+	event.locals.user = await getUser(event.cookies.get('sessionid'));
+	return resolve(event);
+}
+```
+
+```js
+/// file: src/routes/account/+page.server.js
+// @filename: ambient.d.ts
+declare namespace App {
+	interface Locals {
+		user: {
+			name: string;
+		} | null
+	}
+}
+
+// @filename: index.js
+// ---cut---
+/** @type {import('./$types').PageServerLoad} */
+export function load(event) {
+	return {
+		user: event.locals.user
+	};
+}
+
+/** @type {import('./$types').Actions} */
+export const actions = {
+	logout: async (event) => {
+		event.cookies.delete('sessionid');
+		event.locals.user = null;
+	}
+};
+```
+
 ### Progressive enhancement
 
 In the preceding sections we built a `/login` action that [works without client-side JavaScript](https://kryogenix.org/code/browser/everyonehasjs.html) — not a `fetch` in sight. That's great, but when JavaScript _is_ available we can progressively enhance our form interactions to provide a better user experience.
diff --git a/documentation/docs/20-core-concepts/40-page-options.md b/documentation/docs/20-core-concepts/40-page-options.md
index 6da5921c7..9ad9153d1 100644
--- a/documentation/docs/20-core-concepts/40-page-options.md
+++ b/documentation/docs/20-core-concepts/40-page-options.md
@@ -83,7 +83,7 @@ Since these routes cannot be dynamically server-rendered, this will cause errors
 
 ### ssr
 
-Normally, SvelteKit renders your page on the server first and sends that HTML to the client where it's hydrated. If you set `ssr` to `false`, it renders an empty 'shell' page instead. This is useful if your page is unable to be rendered on the server (because you use browser-only globals like `document` for example), but in most situations it's not recommended ([see appendix](/docs/glossary#ssr)). If you put this into your root `+layout.js`, you create an SPA (all pages are client-side rendered only).
+Normally, SvelteKit renders your page on the server first and sends that HTML to the client where it's hydrated. If you set `ssr` to `false`, it renders an empty 'shell' page instead. This is useful if your page is unable to be rendered on the server (because you use browser-only globals like `document` for example), but in most situations it's not recommended ([see appendix](/docs/glossary#ssr)).
 
 ```js
 /// file: +page.js
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..9005b806f 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,53 @@
 # @sveltejs/kit
 
+## 1.0.0-next.540
+
+### Patch Changes
+
+- Serve prerendered non-page files when running preview ([#7576](https://github.com/sveltejs/kit/pull/7576))
+
+- [fix] caching takes now into account the body payload ([#7546](https://github.com/sveltejs/kit/pull/7546))
+
+- [fix] handle locked readable stream when reading body ([#7565](https://github.com/sveltejs/kit/pull/7565))
+
+- [fix] prefetch should ignore links ignored by the router ([#7580](https://github.com/sveltejs/kit/pull/7580))
+
+- [fix] migration error when using $page.routeId ([#7574](https://github.com/sveltejs/kit/pull/7574))
+
+- prevent loading of illegal modules in the browser, rather than during SSR ([#7507](https://github.com/sveltejs/kit/pull/7507))
+
+- [fix] prevent double decoding of path segment ([#7550](https://github.com/sveltejs/kit/pull/7550))
+
+## 1.0.0-next.539
+
+### Patch Changes
+
+- Omit prerendered routes from server manifest ([#7541](https://github.com/sveltejs/kit/pull/7541))
+
+## 1.0.0-next.538
+
+### Patch Changes
+
+- [feat] enable caching for `__data.json` requests ([#7532](https://github.com/sveltejs/kit/pull/7532))
+
+- [fix] prevent double decoding of params ([#7521](https://github.com/sveltejs/kit/pull/7521))
+
+- [fix] take into account Request input when serializing fetch data ([#7531](https://github.com/sveltejs/kit/pull/7531))
+
+- [fix] better cookie warning checks ([#7528](https://github.com/sveltejs/kit/pull/7528))
+
+## 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..c9fae0d78 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.540",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/kit/scripts/special-types/$env+dynamic+private.md b/packages/kit/scripts/special-types/$env+dynamic+private.md
index b4b63a9df..689e8e879 100644
--- a/packages/kit/scripts/special-types/$env+dynamic+private.md
+++ b/packages/kit/scripts/special-types/$env+dynamic+private.md
@@ -1,6 +1,6 @@
 This module provides access to runtime environment variables, as defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/master/packages/adapter-node) (or running [`vite preview`](https://kit.svelte.dev/docs/cli)), this is equivalent to `process.env`. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env).
 
-This module cannot be imported into public-facing code.
+This module cannot be imported into client-side code.
 
 ```ts
 import { env } from '$env/dynamic/private';
diff --git a/packages/kit/scripts/special-types/$env+static+private.md b/packages/kit/scripts/special-types/$env+static+private.md
index cf2454a30..536ce64de 100644
--- a/packages/kit/scripts/special-types/$env+static+private.md
+++ b/packages/kit/scripts/special-types/$env+static+private.md
@@ -1,4 +1,4 @@
-Environment variables [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env`. Like [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), this module cannot be imported into public-facing code. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env).
+Environment variables [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env`. Like [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), this module cannot be imported into client-side code. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env).
 
 _Unlike_ [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), the values exported from this module are statically injected into your bundle at build time, enabling optimisations like dead code elimination.
 
diff --git a/packages/kit/scripts/special-types/$lib.md b/packages/kit/scripts/special-types/$lib.md
index 444a39cab..56a1d7489 100644
--- a/packages/kit/scripts/special-types/$lib.md
+++ b/packages/kit/scripts/special-types/$lib.md
@@ -2,4 +2,4 @@ This is a simple alias to `src/lib`, or whatever directory is specified as [`con
 
 #### `$lib/server`
 
-A subdirectory of `$lib`. SvelteKit will prevent you from importing any modules in `$lib/server` into public-facing code. See [server-only modules](/docs/server-only-modules).
+A subdirectory of `$lib`. SvelteKit will prevent you from importing any modules in `$lib/server` into client-side code. See [server-only modules](/docs/server-only-modules).
diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js
index 4c85d46a3..12433b523 100644
--- a/packages/kit/src/core/generate_manifest/index.js
+++ b/packages/kit/src/core/generate_manifest/index.js
@@ -1,6 +1,7 @@
 import { s } from '../../utils/misc.js';
 import { get_mime_lookup } from '../utils.js';
 import { resolve_symlinks } from '../../exports/vite/build/utils.js';
+import { compact } from '../../utils/array.js';
 
 /**
  * Generates the data used to write the server-side manifest.js file. This data is used in the Vite
@@ -13,6 +14,32 @@ import { resolve_symlinks } from '../../exports/vite/build/utils.js';
  * }} opts
  */
 export function generate_manifest({ build_data, relative_path, routes, format = 'esm' }) {
+	/**
+	 * @type {Map<any, number>} The new index of each node in the filtered nodes array
+	 */
+	const reindexed = new Map();
+	/**
+	 * @type {Set<any>} All nodes actually used in the routes definition (prerendered routes are omitted)
+	 */
+	const used_nodes = new Set();
+
+	for (const route of routes) {
+		if (route.page) {
+			for (const i of route.page.layouts) used_nodes.add(i);
+			for (const i of route.page.errors) used_nodes.add(i);
+			used_nodes.add(route.page.leaf);
+		}
+	}
+
+	const node_paths = compact(
+		build_data.manifest_data.nodes.map((_, i) => {
+			if (used_nodes.has(i)) {
+				reindexed.set(i, reindexed.size);
+				return `${relative_path}/nodes/${i}.js`;
+			}
+		})
+	);
+
 	/** @typedef {{ index: number, path: string }} LookupEntry */
 	/** @type {Map<import('types').PageNode, LookupEntry>} */
 	const bundled_nodes = new Map();
@@ -40,6 +67,20 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 
 	const matchers = new Set();
 
+	/** @param {Array<number | undefined>} indexes */
+	function get_nodes(indexes) {
+		let string = indexes.map((n) => reindexed.get(n) ?? '').join(',');
+
+		if (indexes.at(-1) === undefined) {
+			// since JavaScript ignores trailing commas, we need to insert a dummy
+			// comma so that the array has the correct length if the last item
+			// is undefined
+			string += ',';
+		}
+
+		return `[${string}]`;
+	}
+
 	// prettier-ignore
 	// String representation of
 	/** @type {import('types').SSRManifest} */
@@ -51,7 +92,7 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 		_: {
 			entry: ${s(build_data.client.entry)},
 			nodes: [
-				${Array.from(bundled_nodes.values()).map(node => loader(node.path)).join(',\n\t\t\t\t')}
+				${(node_paths).map(loader).join(',\n\t\t\t\t')}
 			],
 			routes: [
 				${routes.map(route => {
@@ -67,7 +108,7 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 					names: ${s(route.names)},
 					types: ${s(route.types)},
 					optional: ${s(route.optional)},
-					page: ${route.page ? `{ layouts: ${get_nodes(route.page.layouts)}, errors: ${get_nodes(route.page.errors)}, leaf: ${route.page.leaf} }` : 'null'},
+					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(`${relative_path}/${resolve_symlinks(build_data.server.vite_manifest, route.endpoint.file).chunk.file}`) : 'null'}
 				}`;
 				}).filter(Boolean).join(',\n\t\t\t\t')}
@@ -79,17 +120,3 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 		}
 	}`.replace(/^\t/gm, '');
 }
-
-/** @param {Array<number | undefined>} indexes */
-function get_nodes(indexes) {
-	let string = indexes.map((n) => n ?? '').join(',');
-
-	if (indexes.at(-1) === undefined) {
-		// since JavaScript ignores trailing commas, we need to insert a dummy
-		// comma so that the array has the correct length if the last item
-		// is undefined
-		string += ',';
-	}
-
-	return `[${string}]`;
-}
diff --git a/packages/kit/src/core/prerender/prerender.js b/packages/kit/src/core/prerender/prerender.js
index 9b57a87b1..aa11d36af 100644
--- a/packages/kit/src/core/prerender/prerender.js
+++ b/packages/kit/src/core/prerender/prerender.js
@@ -12,7 +12,7 @@ import { load_config } from '../config/index.js';
 import { get_route_segments } from '../../utils/routing.js';
 import { get_option } from '../../runtime/server/utils.js';
 
-const [, , client_out_dir, results_path, verbose, env] = process.argv;
+const [, , client_out_dir, manifest_path, results_path, verbose, env] = process.argv;
 
 prerender();
 
@@ -108,7 +108,7 @@ export async function prerender() {
 	const { Server, override } = await import(pathToFileURL(`${server_root}/server/index.js`).href);
 
 	/** @type {import('types').SSRManifest} */
-	const manifest = (await import(pathToFileURL(`${server_root}/server/manifest.js`).href)).manifest;
+	const manifest = (await import(pathToFileURL(manifest_path).href)).manifest;
 
 	override({
 		paths: config.paths,
diff --git a/packages/kit/src/exports/node/index.js b/packages/kit/src/exports/node/index.js
index b910250f6..2d7414d43 100644
--- a/packages/kit/src/exports/node/index.js
+++ b/packages/kit/src/exports/node/index.js
@@ -122,6 +122,15 @@ export async function setResponse(res, response) {
 		return;
 	}
 
+	if (response.body.locked) {
+		res.write(
+			'Fatal error: Response body is locked. ' +
+				`This can happen when the response was already read (for example through 'response.json()' or 'response.text()').`
+		);
+		res.end();
+		return;
+	}
+
 	const reader = response.body.getReader();
 
 	if (res.destroyed) {
diff --git a/packages/kit/src/exports/vite/build/build_service_worker.js b/packages/kit/src/exports/vite/build/build_service_worker.js
index 01649aee7..1bfecfbfb 100644
--- a/packages/kit/src/exports/vite/build/build_service_worker.js
+++ b/packages/kit/src/exports/vite/build/build_service_worker.js
@@ -1,6 +1,7 @@
 import fs from 'fs';
 import * as vite from 'vite';
 import { s } from '../../../utils/misc.js';
+import { get_config_aliases } from '../utils.js';
 import { assets_base } from './utils.js';
 
 /**
@@ -82,10 +83,10 @@ export async function build_service_worker(
 		define: vite_config.define,
 		configFile: false,
 		resolve: {
-			alias: {
-				'$service-worker': service_worker,
-				$lib: config.kit.files.lib
-			}
+			alias: [
+				...get_config_aliases(config.kit),
+				{ find: '$service-worker', replacement: service_worker }
+			]
 		}
 	});
 }
diff --git a/packages/kit/src/exports/vite/build/utils.js b/packages/kit/src/exports/vite/build/utils.js
index e7e1ea77b..9f51ee9a8 100644
--- a/packages/kit/src/exports/vite/build/utils.js
+++ b/packages/kit/src/exports/vite/build/utils.js
@@ -1,7 +1,7 @@
 import fs from 'fs';
 import path from 'path';
 import * as vite from 'vite';
-import { get_aliases } from '../utils.js';
+import { get_config_aliases, get_app_aliases } from '../utils.js';
 
 /**
  * @typedef {import('rollup').RollupOutput} RollupOutput
@@ -143,7 +143,7 @@ export function get_default_build_config({ config, input, ssr, outDir }) {
 		},
 		publicDir: ssr ? false : config.kit.files.assets,
 		resolve: {
-			alias: get_aliases(config.kit)
+			alias: [...get_app_aliases(config.kit), ...get_config_aliases(config.kit)]
 		},
 		optimizeDeps: {
 			exclude: ['@sveltejs/kit']
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index 938acd639..a00afc67c 100644
--- a/packages/kit/src/exports/vite/dev/index.js
+++ b/packages/kit/src/exports/vite/dev/index.js
@@ -11,9 +11,7 @@ import { load_error_page, load_template } from '../../../core/config/index.js';
 import { SVELTE_KIT_ASSETS } from '../../../constants.js';
 import * as sync from '../../../core/sync/sync.js';
 import { get_mime_lookup, runtime_base, runtime_prefix } from '../../../core/utils.js';
-import { prevent_illegal_vite_imports } from '../graph_analysis/index.js';
 import { compact } from '../../../utils/array.js';
-import { normalizePath } from 'vite';
 
 // Vite doesn't expose this so we just copy the list for now
 // https://github.com/vitejs/vite/blob/3edd1af56e980aef56641a5a51cf2932bb580d41/packages/vite/src/node/plugins/css.ts#L96
@@ -43,8 +41,6 @@ export async function dev(vite, vite_config, svelte_config) {
 	/** @type {import('types').SSRManifest} */
 	let manifest;
 
-	const extensions = [...svelte_config.extensions, ...svelte_config.kit.moduleExtensions];
-
 	/** @param {string} id */
 	async function resolve(id) {
 		const url = id.startsWith('..') ? `/@fs${path.posix.resolve(id)}` : `/${id}`;
@@ -94,12 +90,6 @@ export async function dev(vite, vite_config, svelte_config) {
 								module_nodes.push(module_node);
 								result.file = url.endsWith('.svelte') ? url : url + '?import'; // TODO what is this for?
 
-								prevent_illegal_vite_imports(
-									module_node,
-									normalizePath(svelte_config.kit.files.lib),
-									extensions
-								);
-
 								return module.default;
 							};
 						}
@@ -110,12 +100,6 @@ export async function dev(vite, vite_config, svelte_config) {
 							module_nodes.push(module_node);
 
 							result.shared = module;
-
-							prevent_illegal_vite_imports(
-								module_node,
-								normalizePath(svelte_config.kit.files.lib),
-								extensions
-							);
 						}
 
 						if (node.server) {
diff --git a/packages/kit/src/exports/vite/graph_analysis/index.js b/packages/kit/src/exports/vite/graph_analysis/index.js
index 6c4050326..f74376203 100644
--- a/packages/kit/src/exports/vite/graph_analysis/index.js
+++ b/packages/kit/src/exports/vite/graph_analysis/index.js
@@ -1,277 +1,107 @@
 import path from 'path';
-import { normalizePath } from 'vite';
-import { remove_query_from_id, get_module_types } from './utils.js';
+import { posixify } from '../../../utils/filesystem.js';
 
-/** @typedef {import('./types').ImportGraph} ImportGraph */
-
-const CWD_ID = normalizePath(process.cwd());
-const NODE_MODULES_ID = normalizePath(path.resolve(process.cwd(), 'node_modules'));
-const ILLEGAL_IMPORTS = new Set([
-	'/@id/__x00__$env/dynamic/private', //dev
-	'\0$env/dynamic/private', // prod
-	'/@id/__x00__$env/static/private', // dev
-	'\0$env/static/private' // prod
-]);
+const ILLEGAL_IMPORTS = new Set(['\0$env/dynamic/private', '\0$env/static/private']);
 const ILLEGAL_MODULE_NAME_PATTERN = /.*\.server\..+/;
 
-export class IllegalModuleGuard {
-	/** @type {string} */
-	#lib_dir;
-
-	/** @type {string} */
-	#server_dir;
-
-	/** @type {Array<ImportGraph>} */
-	#chain = [];
-
-	/**
-	 * @param {string} lib_dir
-	 */
-	constructor(lib_dir) {
-		this.#lib_dir = normalizePath(lib_dir);
-		this.#server_dir = normalizePath(path.resolve(lib_dir, 'server'));
-	}
+/**
+ * Checks if given id imports a module that is not allowed to be imported into client-side code.
+ * @param {string} id
+ * @param {{
+ *   cwd: string;
+ *   node_modules: string;
+ *   server: string;
+ * }} dirs
+ */
+export function is_illegal(id, dirs) {
+	if (ILLEGAL_IMPORTS.has(id)) return true;
+	if (!id.startsWith(dirs.cwd) || id.startsWith(dirs.node_modules)) return false;
+	return ILLEGAL_MODULE_NAME_PATTERN.test(path.basename(id)) || id.startsWith(dirs.server);
+}
 
-	/**
-	 * Assert that a node imports no illegal modules.
-	 * @param {ImportGraph} node
-	 * @returns {void}
-	 */
-	assert_legal(node) {
-		this.#chain.push(node);
-		for (const child of node.children) {
-			if (this.#is_illegal(child.id)) {
-				this.#chain.push(child);
-				const error = this.#format_illegal_import_chain(this.#chain);
-				this.#chain = []; // Reset the chain in case we want to reuse this guard
-				throw new Error(error);
-			}
-			this.assert_legal(child);
-		}
-		this.#chain.pop();
-	}
+/**
+ * Creates a guard that checks that no id imports a module that is not allowed to be imported into client-side code.
+ * @param {import('rollup').PluginContext} context
+ * @param {{ cwd: string, lib: string }} paths
+ */
+export function module_guard(context, { cwd, lib }) {
+	/** @type {Set<string>} */
+	const seen = new Set();
 
-	/**
-	 * `true` if the provided ID represents a server-only module, else `false`.
-	 * @param {string} module_id
-	 * @returns {boolean}
-	 */
-	#is_illegal(module_id) {
-		if (this.#is_kit_illegal(module_id) || this.#is_user_illegal(module_id)) return true;
-		return false;
-	}
+	const dirs = {
+		// ids will be posixified, so we need to posixify these, too
+		cwd: posixify(cwd),
+		node_modules: posixify(path.join(cwd, 'node_modules')),
+		server: posixify(path.join(lib, 'server'))
+	};
 
 	/**
-	 * `true` if the provided ID represents a Kit-defined server-only module, else `false`.
-	 * @param {string} module_id
-	 * @returns {boolean}
+	 * @param {string} id
+	 * @param {Array<{ id: string, dynamic: boolean }>} chain
 	 */
-	#is_kit_illegal(module_id) {
-		return ILLEGAL_IMPORTS.has(module_id);
-	}
+	function follow(id, chain) {
+		if (seen.has(id)) return;
+		seen.add(id);
 
-	/**
-	 * `true` if the provided ID represents a user-defined server-only module, else `false`.
-	 * @param {string} module_id
-	 * @returns {boolean}
-	 */
-	#is_user_illegal(module_id) {
-		if (module_id.startsWith(this.#server_dir)) return true;
+		if (is_illegal(id, dirs)) {
+			chain.shift(); // discard the entry point
+			id = normalize_id(id, lib, cwd);
 
-		// files outside the project root are ignored
-		if (!module_id.startsWith(CWD_ID)) return false;
+			const pyramid =
+				chain.map(({ id, dynamic }, i) => {
+					id = normalize_id(id, lib, cwd);
 
-		// so are files inside node_modules
-		if (module_id.startsWith(NODE_MODULES_ID)) return false;
+					return `${repeat(' ', i * 2)}- ${id} ${dynamic ? 'dynamically imports' : 'imports'}\n`;
+				}) + `${repeat(' ', chain.length)}- ${id}`;
 
-		return ILLEGAL_MODULE_NAME_PATTERN.test(path.basename(module_id));
-	}
+			const message = `Cannot import ${id} into client-side code:\n${pyramid}`;
 
-	/**
-	 * @param {string} str
-	 * @param {number} times
-	 */
-	#repeat(str, times) {
-		return new Array(times + 1).join(str);
-	}
+			throw new Error(message);
+		}
 
-	/**
-	 * Create a formatted error for an illegal import.
-	 * @param {Array<ImportGraph>} stack
-	 */
-	#format_illegal_import_chain(stack) {
-		const dev_virtual_prefix = '/@id/__x00__';
-		const prod_virtual_prefix = '\0';
+		const module = context.getModuleInfo(id);
 
-		stack = stack.map((graph) => {
-			if (graph.id.startsWith(dev_virtual_prefix)) {
-				return { ...graph, id: graph.id.replace(dev_virtual_prefix, '') };
-			}
-			if (graph.id.startsWith(prod_virtual_prefix)) {
-				return { ...graph, id: graph.id.replace(prod_virtual_prefix, '') };
-			}
-			if (graph.id.startsWith(this.#lib_dir)) {
-				return { ...graph, id: graph.id.replace(this.#lib_dir, '$lib') };
+		if (module) {
+			for (const child of module.importedIds) {
+				follow(child, [...chain, { id, dynamic: false }]);
 			}
 
-			return { ...graph, id: path.relative(process.cwd(), graph.id) };
-		});
-
-		const pyramid = stack
-			.map(
-				(file, i) =>
-					`${this.#repeat(' ', i * 2)}- ${file.id} ${
-						file.dynamic ? '(imported by parent dynamically)' : ''
-					}`
-			)
-			.join('\n');
-
-		return `Cannot import ${stack.at(-1)?.id} into public-facing code:\n${pyramid}`;
-	}
-}
-
-/** @implements {ImportGraph} */
-export class RollupImportGraph {
-	/** @type {(id: string) => import('rollup').ModuleInfo | null} */
-	#node_getter;
-
-	/** @type {import('rollup').ModuleInfo} */
-	#module_info;
-
-	/** @type {string} */
-	id;
-
-	/** @type {boolean} */
-	dynamic;
-
-	/** @type {Set<string>} */
-	#seen;
-
-	/**
-	 * @param {(id: string) => import('rollup').ModuleInfo | null} node_getter
-	 * @param {import('rollup').ModuleInfo} node
-	 */
-	constructor(node_getter, node) {
-		this.#node_getter = node_getter;
-		this.#module_info = node;
-		this.id = remove_query_from_id(normalizePath(node.id));
-		this.dynamic = false;
-		this.#seen = new Set();
-	}
-
-	/**
-	 * @param {(id: string) => import('rollup').ModuleInfo | null} node_getter
-	 * @param {import('rollup').ModuleInfo} node
-	 * @param {boolean} dynamic
-	 * @param {Set<string>} seen;
-	 * @returns {RollupImportGraph}
-	 */
-	static #new_internal(node_getter, node, dynamic, seen) {
-		const instance = new RollupImportGraph(node_getter, node);
-		instance.dynamic = dynamic;
-		instance.#seen = seen;
-		return instance;
-	}
-
-	get children() {
-		return this.#children();
+			for (const child of module.dynamicallyImportedIds) {
+				follow(child, [...chain, { id, dynamic: true }]);
+			}
+		}
 	}
 
-	*#children() {
-		if (this.#seen.has(this.id)) return;
-		this.#seen.add(this.id);
-		for (const id of this.#module_info.importedIds) {
-			const child = this.#node_getter(id);
-			if (child === null) return;
-			yield RollupImportGraph.#new_internal(this.#node_getter, child, false, this.#seen);
-		}
-		for (const id of this.#module_info.dynamicallyImportedIds) {
-			const child = this.#node_getter(id);
-			if (child === null) return;
-			yield RollupImportGraph.#new_internal(this.#node_getter, child, true, this.#seen);
+	return {
+		/** @param {string} id should be posixified */
+		check: (id) => {
+			follow(id, []);
 		}
-	}
+	};
 }
 
-/** @implements {ImportGraph} */
-export class ViteImportGraph {
-	/** @type {Set<string>} */
-	#module_types;
-
-	/** @type {import('vite').ModuleNode} */
-	#module_info;
-
-	/** @type {string} */
-	id;
-
-	/** @type {Set<string>} */
-	#seen;
-
-	/**
-	 * @param {Set<string>} module_types Module types to analyze, eg '.js', '.ts', etc.
-	 * @param {import('vite').ModuleNode} node
-	 */
-	constructor(module_types, node) {
-		this.#module_types = module_types;
-		this.#module_info = node;
-		this.id = remove_query_from_id(normalizePath(node.id ?? ''));
-		this.#seen = new Set();
-	}
-
-	/**
-	 * @param {Set<string>} module_types Module types to analyze, eg '.js', '.ts', etc.
-	 * @param {import('vite').ModuleNode} node
-	 * @param {Set<string>} seen
-	 * @returns {ViteImportGraph}
-	 */
-	static #new_internal(module_types, node, seen) {
-		const instance = new ViteImportGraph(module_types, node);
-		instance.#seen = seen;
-		return instance;
-	}
-
-	get dynamic() {
-		return false;
+/**
+ * Removes cwd/lib path from the start of the id
+ * @param {string} id
+ * @param {string} lib
+ * @param {string} cwd
+ */
+export function normalize_id(id, lib, cwd) {
+	if (id.startsWith(lib)) {
+		id = id.replace(lib, '$lib');
 	}
 
-	get children() {
-		return this.#children();
+	if (id.startsWith(cwd)) {
+		id = path.relative(cwd, id);
 	}
 
-	*#children() {
-		if (this.#seen.has(this.id)) return;
-		this.#seen.add(this.id);
-		for (const child of this.#module_info.importedModules) {
-			if (!this.#module_types.has(path.extname(this.id))) {
-				continue;
-			}
-			yield ViteImportGraph.#new_internal(this.#module_types, child, this.#seen);
-		}
-	}
-}
-
-/**
- * Throw an error if a private module is imported from a client-side node.
- * @param {(id: string) => import('rollup').ModuleInfo | null} node_getter
- * @param {import('rollup').ModuleInfo} node
- * @param {string} lib_dir
- * @returns {void}
- */
-export function prevent_illegal_rollup_imports(node_getter, node, lib_dir) {
-	const graph = new RollupImportGraph(node_getter, node);
-	const guard = new IllegalModuleGuard(lib_dir);
-	guard.assert_legal(graph);
+	return posixify(id);
 }
 
 /**
- * Throw an error if a private module is imported from a client-side node.
- * @param {import('vite').ModuleNode} node
- * @param {string} lib_dir
- * @param {Iterable<string>} module_types File extensions to analyze in addition to the defaults: `.ts`, `.js`, etc.
- * @returns {void}
+ * @param {string} str
+ * @param {number} times
  */
-export function prevent_illegal_vite_imports(node, lib_dir, module_types) {
-	const graph = new ViteImportGraph(get_module_types(module_types), node);
-	const guard = new IllegalModuleGuard(lib_dir);
-	guard.assert_legal(graph);
+function repeat(str, times) {
+	return new Array(times + 1).join(str);
 }
diff --git a/packages/kit/src/exports/vite/graph_analysis/index.spec.js b/packages/kit/src/exports/vite/graph_analysis/index.spec.js
index 1b00de5a8..2a3ee7738 100644
--- a/packages/kit/src/exports/vite/graph_analysis/index.spec.js
+++ b/packages/kit/src/exports/vite/graph_analysis/index.spec.js
@@ -1,191 +1,163 @@
-import { describe } from '../../../utils/unit_test.js';
+import { test } from 'uvu';
 import * as assert from 'uvu/assert';
-import { IllegalModuleGuard } from './index.js';
-import path from 'path';
-import { normalizePath } from 'vite';
-
-const CWD = process.cwd();
-const FAKE_LIB_DIR = normalizePath(path.join(CWD, 'lib'));
-const DEV_VIRTUAL_DYNAMIC_ID = '/@id/__x00__$env/dynamic/private';
-const PROD_VIRTUAL_DYNAMIC_ID = '\0$env/dynamic/private';
-const DEV_VIRTUAL_STATIC_ID = '/@id/__x00__$env/static/private';
-const PROD_VIRTUAL_STATIC_ID = '\0$env/static/private';
-const USER_SERVER_ID = normalizePath(path.join(FAKE_LIB_DIR, 'test.server.js'));
-const USER_SERVER_ID_NODE_MODULES = normalizePath(path.join(CWD, 'node_modules', 'test.server.js'));
-const USER_SERVER_ID_OUTSIDE_ROOT = normalizePath(path.join(CWD, '..', 'test.server.js'));
-const USER_SERVER_FOLDER_ID = normalizePath(path.join(FAKE_LIB_DIR, '/server/some/nested/path.js'));
-
-/**
- * @template {any} T
- * @param {Array<T>} arr
- * @returns {Generator<T>}
- */
-function* generator_from_array(arr) {
-	for (const item of arr) {
-		yield item;
-	}
-}
+import { module_guard } from './index.js';
 
 /**
- * @param {Array<import('./types').ImportGraph>} nodes_to_insert
- * @returns {import('./types').ImportGraph}
+ *
+ * @param {Record<string, { importedIds?: string[]; dynamicallyImportedIds?: string[] }>} graph
+ * @param {string} [expected_error]
  */
-function get_module_graph(...nodes_to_insert) {
-	return {
-		id: 'test.svelte',
-		dynamic: false,
-		children: generator_from_array([
-			{
-				id: 'fine.js',
-				dynamic: false,
-				children: generator_from_array([
-					{
-						id: 'also_fine.js',
-						dynamic: false,
-						children: generator_from_array([
-							{
-								id: 'erstwhile.css',
-								dynamic: false,
-								children: generator_from_array([])
-							},
-							{
-								id: 'gruntled.js',
-								dynamic: false,
-								children: generator_from_array([])
-							}
-						])
-					},
-					{
-						id: 'somewhat_neat.js',
-						dynamic: false,
-						children: generator_from_array([])
-					},
-					{
-						id: 'blah.ts',
-						dynamic: false,
-						children: generator_from_array([])
-					}
-				])
-			},
-			{
-				id: 'something.svelte',
-				dynamic: false,
-				children: generator_from_array(nodes_to_insert)
-			},
-			{
-				id: 'im_not_creative.hamburger',
-				dynamic: false,
-				children: generator_from_array([])
-			}
-		])
-	};
-}
-
-describe('IllegalImportGuard', (test) => {
-	const guard = new IllegalModuleGuard(FAKE_LIB_DIR);
-
-	test('assert succeeds for a graph with no illegal imports', () => {
-		assert.not.throws(() => guard.assert_legal(get_module_graph()));
+function check(graph, expected_error) {
+	// @ts-expect-error
+	const context = /** @type {import('rollup').PluginContext} */ ({
+		/** @param {string} id */
+		getModuleInfo(id) {
+			return {
+				importedIds: [],
+				dynamicallyImportedIds: [],
+				...graph[id]
+			};
+		}
 	});
 
-	test('assert throws an error when importing $env/static/private in dev', () => {
-		const module_graph = get_module_graph({
-			id: DEV_VIRTUAL_STATIC_ID,
-			dynamic: false,
-			children: generator_from_array([])
-		});
-		assert.throws(
-			() => guard.assert_legal(module_graph),
-			/.*Cannot import \$env\/static\/private into public-facing code:.*/gs
-		);
+	const guard = module_guard(context, {
+		cwd: '~',
+		lib: '~/src/lib'
 	});
 
-	test('assert throws an error when importing $env/static/private in prod', () => {
-		const module_graph = get_module_graph({
-			id: PROD_VIRTUAL_STATIC_ID,
-			dynamic: false,
-			children: generator_from_array([])
-		});
-		assert.throws(
-			() => guard.assert_legal(module_graph),
-			/.*Cannot import \$env\/static\/private into public-facing code:.*/gs
-		);
-	});
-
-	test('assert throws an error when importing $env/dynamic/private in dev', () => {
-		const module_graph = get_module_graph({
-			id: DEV_VIRTUAL_DYNAMIC_ID,
-			dynamic: false,
-			children: generator_from_array([])
-		});
-		assert.throws(
-			() => guard.assert_legal(module_graph),
-			/.*Cannot import \$env\/dynamic\/private into public-facing code:.*/gs
-		);
-	});
+	if (expected_error) {
+		try {
+			guard.check('~/src/entry');
+			throw new Error('Expected an error');
+		} catch (e) {
+			// @ts-expect-error
+			assert.equal(e.message, expected_error.replace(/^\t+/gm, ''));
+		}
+	} else {
+		guard.check('~/src/entry');
+	}
+}
 
-	test('assert throws an error when importing $env/dynamic/private in prod', () => {
-		const module_graph = get_module_graph({
-			id: PROD_VIRTUAL_DYNAMIC_ID,
-			dynamic: false,
-			children: generator_from_array([])
-		});
-		assert.throws(
-			() => guard.assert_legal(module_graph),
-			/.*Cannot import \$env\/dynamic\/private into public-facing code:.*/gs
-		);
-	});
+test('throws an error when importing $env/static/private', () => {
+	check(
+		{
+			'~/src/entry': {
+				importedIds: ['~/src/routes/+page.svelte']
+			},
+			'~/src/routes/+page.svelte': {
+				importedIds: ['\0$env/static/private']
+			}
+		},
+		`Cannot import \0$env/static/private into client-side code:
+		- src/routes/+page.svelte imports
+		 - \0$env/static/private`
+	);
+});
 
-	test('assert throws an error when importing a single server-only module', () => {
-		const module_graph = get_module_graph({
-			id: USER_SERVER_ID,
-			dynamic: false,
-			children: generator_from_array([])
-		});
+test('throws an error when dynamically importing $env/static/private', () => {
+	check(
+		{
+			'~/src/entry': {
+				importedIds: ['~/src/routes/+page.svelte']
+			},
+			'~/src/routes/+page.svelte': {
+				dynamicallyImportedIds: ['\0$env/static/private']
+			}
+		},
+		`Cannot import \0$env/static/private into client-side code:
+		- src/routes/+page.svelte dynamically imports
+		 - \0$env/static/private`
+	);
+});
 
-		assert.throws(
-			() => guard.assert_legal(module_graph),
-			/.*Cannot import \$lib\/test.server.js into public-facing code:.*/gs
-		);
-	});
+test('throws an error when importing $env/dynamic/private', () => {
+	check(
+		{
+			'~/src/entry': {
+				importedIds: ['~/src/routes/+page.svelte']
+			},
+			'~/src/routes/+page.svelte': {
+				importedIds: ['\0$env/dynamic/private']
+			}
+		},
+		`Cannot import \0$env/dynamic/private into client-side code:
+		- src/routes/+page.svelte imports
+		 - \0$env/dynamic/private`
+	);
+});
 
-	test('assert throws an error when importing a module in the server-only folder', () => {
-		const module_graph = get_module_graph({
-			id: USER_SERVER_FOLDER_ID,
-			dynamic: false,
-			children: generator_from_array([])
-		});
+test('throws an error when dynamically importing $env/dynamic/private', () => {
+	check(
+		{
+			'~/src/entry': {
+				importedIds: ['~/src/routes/+page.svelte']
+			},
+			'~/src/routes/+page.svelte': {
+				dynamicallyImportedIds: ['\0$env/dynamic/private']
+			}
+		},
+		`Cannot import \0$env/dynamic/private into client-side code:
+		- src/routes/+page.svelte dynamically imports
+		 - \0$env/dynamic/private`
+	);
+});
 
-		assert.throws(
-			() => guard.assert_legal(module_graph),
-			/.*Cannot import \$lib\/server\/some\/nested\/path.js into public-facing code:.*/gs
-		);
-	});
+test('throws an error when importing a .server.js module', () => {
+	check(
+		{
+			'~/src/entry': {
+				importedIds: ['~/src/routes/+page.svelte']
+			},
+			'~/src/routes/+page.svelte': {
+				importedIds: ['~/src/routes/illegal.server.js']
+			},
+			'~/src/routes/illegal.server.js': {}
+		},
+		`Cannot import src/routes/illegal.server.js into client-side code:
+		- src/routes/+page.svelte imports
+		 - src/routes/illegal.server.js`
+	);
+});
 
-	test('assert ignores illegal server-only modules in node_modules', () => {
-		const module_graph = get_module_graph({
-			id: USER_SERVER_ID_NODE_MODULES,
-			dynamic: false,
-			children: generator_from_array([])
-		});
+test('throws an error when importing a $lib/server/**/*.js module', () => {
+	check(
+		{
+			'~/src/entry': {
+				importedIds: ['~/src/routes/+page.svelte']
+			},
+			'~/src/routes/+page.svelte': {
+				importedIds: ['~/src/lib/server/some/module.js']
+			},
+			'~/src/lib/server/some/module.js': {}
+		},
+		`Cannot import $lib/server/some/module.js into client-side code:
+		- src/routes/+page.svelte imports
+		 - $lib/server/some/module.js`
+	);
+});
 
-		assert.not.throws(() => guard.assert_legal(module_graph));
+test('ignores .server.js files in node_modules', () => {
+	check({
+		'~/src/entry': {
+			importedIds: ['~/src/routes/+page.svelte']
+		},
+		'~/src/routes/+page.svelte': {
+			importedIds: ['~/node_modules/illegal.server.js']
+		},
+		'~/node_modules/illegal.server.js': {}
 	});
+});
 
-	test('assert ignores illegal server-only modules outside the project root', () => {
-		const module_graph = get_module_graph({
-			id: USER_SERVER_ID_OUTSIDE_ROOT,
-			dynamic: false,
-			children: generator_from_array([])
-		});
-
-		assert.not.throws(() => guard.assert_legal(module_graph));
+test('ignores .server.js files outside the project root', () => {
+	check({
+		'~/src/entry': {
+			importedIds: ['~/src/routes/+page.svelte']
+		},
+		'~/src/routes/+page.svelte': {
+			importedIds: ['/illegal.server.js']
+		},
+		'/illegal.server.js': {}
 	});
 });
 
-/*
-We don't have a great way to mock Vite and Rollup's implementations of module graphs, so unit testing
-ViteImportGraph and RollupImportGraph is kind of an exercise in "code coverage hubris" -- they're covered by
-the integration tests, where Vite and Rollup can provide a useful graph implementation. If, in the future, we can find
-a reason to unit test them, we can add those below.
-*/
+test.run();
diff --git a/packages/kit/src/exports/vite/graph_analysis/utils.js b/packages/kit/src/exports/vite/graph_analysis/utils.js
index f2caa3e04..bdfa7a42b 100644
--- a/packages/kit/src/exports/vite/graph_analysis/utils.js
+++ b/packages/kit/src/exports/vite/graph_analysis/utils.js
@@ -4,27 +4,3 @@ const query_pattern = /\?.*$/s;
 export function remove_query_from_id(path) {
 	return path.replace(query_pattern, '');
 }
-
-/**
- * Vite does some weird things with import trees in dev
- * for example, a Tailwind app.css will appear to import
- * every file in the project. This isn't a problem for
- * Rollup during build.
- * @param {Iterable<string>} config_module_types
- */
-export const get_module_types = (config_module_types) => {
-	return new Set([
-		'',
-		'.ts',
-		'.js',
-		'.svelte',
-		'.mts',
-		'.mjs',
-		'.cts',
-		'.cjs',
-		'.svelte.md',
-		'.svx',
-		'.md',
-		...config_module_types
-	]);
-};
diff --git a/packages/kit/src/exports/vite/graph_analysis/utils.spec.js b/packages/kit/src/exports/vite/graph_analysis/utils.spec.js
index 7aff1b999..8b1feb788 100644
--- a/packages/kit/src/exports/vite/graph_analysis/utils.spec.js
+++ b/packages/kit/src/exports/vite/graph_analysis/utils.spec.js
@@ -1,6 +1,6 @@
 import { describe } from '../../../utils/unit_test.js';
 import * as assert from 'uvu/assert';
-import { remove_query_from_id, get_module_types } from './utils.js';
+import { remove_query_from_id } from './utils.js';
 
 describe('remove_query_string_from_path', (test) => {
 	const module_ids = [
@@ -25,34 +25,3 @@ describe('remove_query_string_from_path', (test) => {
 		});
 	});
 });
-
-describe('get_module_types', (test) => {
-	const base_expected_extensions = [
-		'',
-		'.ts',
-		'.js',
-		'.svelte',
-		'.mts',
-		'.mjs',
-		'.cts',
-		'.cjs',
-		'.svelte.md',
-		'.svx',
-		'.md'
-	];
-
-	test('returns correct base extensions', () => {
-		const module_types = get_module_types([]);
-		base_expected_extensions.forEach((extension) => {
-			assert.equal(module_types.has(extension), true);
-		});
-	});
-
-	test('correctly extends base extensions', () => {
-		const additional_extensions = ['.foo', '.bar', '.baz'];
-		const module_types = get_module_types(additional_extensions);
-		base_expected_extensions.concat(additional_extensions).forEach((extension) => {
-			assert.equal(module_types.has(extension), true);
-		});
-	});
-});
diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js
index 941a87568..0a9214c09 100644
--- a/packages/kit/src/exports/vite/index.js
+++ b/packages/kit/src/exports/vite/index.js
@@ -14,10 +14,10 @@ import { generate_manifest } from '../../core/generate_manifest/index.js';
 import { runtime_directory, logger } from '../../core/utils.js';
 import { find_deps, get_default_build_config } from './build/utils.js';
 import { preview } from './preview/index.js';
-import { get_aliases, get_env } from './utils.js';
-import { prevent_illegal_rollup_imports } from './graph_analysis/index.js';
+import { get_config_aliases, get_app_aliases, get_env } from './utils.js';
 import { fileURLToPath } from 'node:url';
 import { create_static_module, create_dynamic_module } from '../../core/env.js';
+import { is_illegal, module_guard, normalize_id } from './graph_analysis/index.js';
 
 const cwd = process.cwd();
 
@@ -257,7 +257,7 @@ function kit() {
 				},
 				publicDir: svelte_config.kit.files.assets,
 				resolve: {
-					alias: get_aliases(svelte_config.kit)
+					alias: [...get_app_aliases(svelte_config.kit), ...get_config_aliases(svelte_config.kit)]
 				},
 				root: cwd,
 				server: {
@@ -292,7 +292,22 @@ function kit() {
 			if (id.startsWith('$env/')) return `\0${id}`;
 		},
 
-		async load(id) {
+		async load(id, options) {
+			if (options?.ssr === false) {
+				const normalized_cwd = vite.normalizePath(cwd);
+				const normalized_lib = vite.normalizePath(svelte_config.kit.files.lib);
+				if (
+					is_illegal(id, {
+						cwd: normalized_cwd,
+						node_modules: vite.normalizePath(path.join(cwd, 'node_modules')),
+						server: vite.normalizePath(path.join(normalized_lib, 'server'))
+					})
+				) {
+					const relative = normalize_id(id, normalized_lib, normalized_cwd);
+					throw new Error(`Cannot import ${relative} into client-side code`);
+				}
+			}
+
 			switch (id) {
 				case '\0$env/static/private':
 					return create_static_module('$env/static/private', env.private);
@@ -330,10 +345,11 @@ function kit() {
 			completed_build = false;
 
 			if (is_build) {
-				rimraf(paths.build_dir);
+				if (!vite_config.build.watch) {
+					rimraf(paths.build_dir);
+					rimraf(paths.output_dir);
+				}
 				mkdirp(paths.build_dir);
-
-				rimraf(paths.output_dir);
 				mkdirp(paths.output_dir);
 			}
 		},
@@ -350,20 +366,17 @@ function kit() {
 					return;
 				}
 
+				const guard = module_guard(this, {
+					cwd: vite.normalizePath(process.cwd()),
+					lib: vite.normalizePath(svelte_config.kit.files.lib)
+				});
+
 				manifest_data.nodes.forEach((_node, i) => {
 					const id = vite.normalizePath(
 						path.resolve(svelte_config.kit.outDir, `generated/nodes/${i}.js`)
 					);
 
-					const module_node = this.getModuleInfo(id);
-
-					if (module_node) {
-						prevent_illegal_rollup_imports(
-							this.getModuleInfo.bind(this),
-							module_node,
-							vite.normalizePath(svelte_config.kit.files.lib)
-						);
-					}
+					guard.check(id);
 				});
 
 				const verbose = vite_config.logLevel === 'info';
@@ -408,7 +421,7 @@ function kit() {
 					server
 				};
 
-				const manifest_path = `${paths.output_dir}/server/manifest.js`;
+				const manifest_path = `${paths.output_dir}/server/manifest-full.js`;
 				fs.writeFileSync(
 					manifest_path,
 					`export const manifest = ${generate_manifest({
@@ -431,6 +444,7 @@ function kit() {
 						script,
 						[
 							vite_config.build.outDir,
+							manifest_path,
 							results_path,
 							'' + verbose,
 							JSON.stringify({ ...env.private, ...env.public })
@@ -459,6 +473,16 @@ function kit() {
 					});
 				});
 
+				// generate a new manifest that doesn't include prerendered pages
+				fs.writeFileSync(
+					`${paths.output_dir}/server/manifest.js`,
+					`export const manifest = ${generate_manifest({
+						build_data,
+						relative_path: '.',
+						routes: manifest_data.routes.filter((route) => prerender_map.get(route.id) !== true)
+					})};\n`
+				);
+
 				if (options.service_worker_entry_file) {
 					if (svelte_config.kit.paths.assets) {
 						throw new Error('Cannot use service worker alongside config.kit.paths.assets');
diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js
index 2948346e0..a0c69ac9d 100644
--- a/packages/kit/src/exports/vite/preview/index.js
+++ b/packages/kit/src/exports/vite/preview/index.js
@@ -100,27 +100,24 @@ export async function preview(vite, vite_config, svelte_config) {
 
 				const { pathname } = new URL(/** @type {string} */ (req.url), 'http://dummy');
 
-				// only treat this as a page if it doesn't include an extension
-				if (pathname === '/' || /\/[^./]+\/?$/.test(pathname)) {
-					const file = join(
-						svelte_config.kit.outDir,
-						'output/prerendered/pages' +
-							pathname +
-							(pathname.endsWith('/') ? 'index.html' : '.html')
-					);
-
-					if (fs.existsSync(file)) {
-						res.writeHead(200, {
-							'content-type': 'text/html',
-							etag
-						});
-
-						fs.createReadStream(file).pipe(res);
-						return;
-					}
+				let filename = join(svelte_config.kit.outDir, 'output/prerendered/pages' + pathname);
+				let prerendered = is_file(filename);
+
+				if (!prerendered) {
+					filename += filename.endsWith('/') ? 'index.html' : '.html';
+					prerendered = is_file(filename);
 				}
 
-				next();
+				if (prerendered) {
+					res.writeHead(200, {
+						'content-type': 'text/html',
+						etag
+					});
+
+					fs.createReadStream(filename).pipe(res);
+				} else {
+					next();
+				}
 			})
 		);
 
@@ -187,3 +184,8 @@ function scoped(scope, handler) {
 		}
 	};
 }
+
+/** @param {string} path */
+function is_file(path) {
+	return fs.existsSync(path) && !fs.statSync(path).isDirectory();
+}
diff --git a/packages/kit/src/exports/vite/utils.js b/packages/kit/src/exports/vite/utils.js
index cfbafa7b6..9f729187b 100644
--- a/packages/kit/src/exports/vite/utils.js
+++ b/packages/kit/src/exports/vite/utils.js
@@ -96,15 +96,14 @@ function merge_into(a, b) {
 
 /**
  * Transforms kit.alias to a valid vite.resolve.alias array.
+ *
  * Related to tsconfig path alias creation.
  *
  * @param {import('types').ValidatedKitConfig} config
  * */
-export function get_aliases(config) {
+export function get_config_aliases(config) {
 	/** @type {import('vite').Alias[]} */
 	const alias = [
-		{ find: '__GENERATED__', replacement: path.posix.join(config.outDir, 'generated') },
-		{ find: '$app', replacement: `${runtime_directory}/app` },
 		// For now, we handle `$lib` specially here rather than make it a default value for
 		// `config.kit.alias` since it has special meaning for packaging, etc.
 		{ find: '$lib', replacement: config.files.lib }
@@ -135,6 +134,21 @@ export function get_aliases(config) {
 	return alias;
 }
 
+/**
+ * Returns Vite aliases for generated and runtime files.
+ *
+ * @param {import('types').ValidatedKitConfig} config
+ * */
+export function get_app_aliases(config) {
+	/** @type {import('vite').Alias[]} */
+	const alias = [
+		{ find: '__GENERATED__', replacement: path.posix.join(config.outDir, 'generated') },
+		{ find: '$app', replacement: `${runtime_directory}/app` }
+	];
+
+	return alias;
+}
+
 /**
  * @param {string} str
  */
diff --git a/packages/kit/src/exports/vite/utils.spec.js b/packages/kit/src/exports/vite/utils.spec.js
index f2160386d..554f7b883 100644
--- a/packages/kit/src/exports/vite/utils.spec.js
+++ b/packages/kit/src/exports/vite/utils.spec.js
@@ -3,7 +3,7 @@ import { test } from 'uvu';
 import * as assert from 'uvu/assert';
 import { validate_config } from '../../core/config/index.js';
 import { posixify } from '../../utils/filesystem.js';
-import { deep_merge, get_aliases, merge_vite_configs } from './utils.js';
+import { deep_merge, get_app_aliases, get_config_aliases, merge_vite_configs } from './utils.js';
 
 test('basic test no conflicts', async () => {
 	const merged = deep_merge(
@@ -211,7 +211,10 @@ test('transform kit.alias to resolve.alias', () => {
 		}
 	});
 
-	const transformed = get_aliases(config.kit).map((entry) => {
+	// combine aliases from config with generated and runtime aliases
+	const aliases = [...get_app_aliases(config.kit), ...get_config_aliases(config.kit)];
+
+	const transformed = aliases.map((entry) => {
 		const replacement = posixify(path.relative('.', entry.replacement));
 
 		return {
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index b622af0c4..e871bbf39 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: []
 	};
 
@@ -367,6 +373,8 @@ export function create_client({ target, base, trailing_slash }) {
 
 	/** @param {import('./types').NavigationFinished} result */
 	function initialize(result) {
+		if (__SVELTEKIT_DEV__ && document.querySelector('vite-error-overlay')) return;
+
 		current = result.state;
 
 		const style = document.querySelector('style[data-sveltekit]');
@@ -382,7 +390,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', {
@@ -479,6 +487,12 @@ export function create_client({ target, base, trailing_slash }) {
 			};
 
 			// TODO remove this for 1.0
+			Object.defineProperty(result.props.page, 'routeId', {
+				get() {
+					throw new Error('$page.routeId has been replaced by $page.route.id');
+				},
+				enumerable: false
+			});
 			/**
 			 * @param {string} property
 			 * @param {string} replacement
@@ -593,8 +607,8 @@ export function create_client({ target, base, trailing_slash }) {
 
 					// prerendered pages may be served from any origin, so `initial_fetch` urls shouldn't be resolved
 					return started
-						? subsequent_fetch(resolved, init)
-						: initial_fetch(requested, resolved, init);
+						? subsequent_fetch(requested, resolved, init)
+						: initial_fetch(requested, init);
 				},
 				setHeaders: () => {}, // noop
 				depends,
@@ -992,7 +1006,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 +1130,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 +1323,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,
@@ -1345,9 +1361,9 @@ export function create_client({ target, base, trailing_slash }) {
 
 			/** @param {Event} event */
 			const trigger_prefetch = (event) => {
-				const { url, options } = find_anchor(event);
-				if (url && options.prefetch) {
-					if (is_external_url(url)) return;
+				const { url, options, has } = find_anchor(event);
+				if (url && options.prefetch && !is_external_url(url)) {
+					if (options.reload || has.rel_external || has.target || has.download) return;
 					prefetch(url);
 				}
 			};
@@ -1379,7 +1395,7 @@ export function create_client({ target, base, trailing_slash }) {
 				if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
 				if (event.defaultPrevented) return;
 
-				const { a, url, options } = find_anchor(event);
+				const { a, url, options, has } = find_anchor(event);
 				if (!a || !url) return;
 
 				const is_svg_a_element = a instanceof SVGAElement;
@@ -1399,15 +1415,10 @@ export function create_client({ target, base, trailing_slash }) {
 				)
 					return;
 
-				if (a.hasAttribute('download')) return;
+				if (has.download) return;
 
 				// Ignore the following but fire beforeNavigate
-				const rel = (a.getAttribute('rel') || '').split(/\s+/);
-				if (
-					rel.includes('external') ||
-					options.reload ||
-					(is_svg_a_element ? a.target.baseVal : a.target)
-				) {
+				if (options.reload || has.rel_external || has.target) {
 					const navigation = before_navigate({ url, type: 'link' });
 					if (!navigation) {
 						event.preventDefault();
diff --git a/packages/kit/src/runtime/client/fetcher.js b/packages/kit/src/runtime/client/fetcher.js
index 42a7b577e..552bae15e 100644
--- a/packages/kit/src/runtime/client/fetcher.js
+++ b/packages/kit/src/runtime/client/fetcher.js
@@ -63,24 +63,17 @@ const cache = new Map();
  * Should be called on the initial run of load functions that hydrate the page.
  * Saves any requests with cache-control max-age to the cache.
  * @param {RequestInfo | URL} resource
- * @param {string} resolved
  * @param {RequestInit} [opts]
  */
-export function initial_fetch(resource, resolved, opts) {
-	const url = JSON.stringify(resource instanceof Request ? resource.url : resource);
-
-	let selector = `script[data-sveltekit-fetched][data-url=${url}]`;
-
-	if (opts?.body && (typeof opts.body === 'string' || ArrayBuffer.isView(opts.body))) {
-		selector += `[data-hash="${hash(opts.body)}"]`;
-	}
+export function initial_fetch(resource, opts) {
+	const selector = build_selector(resource, opts);
 
 	const script = document.querySelector(selector);
 	if (script?.textContent) {
 		const { body, ...init } = JSON.parse(script.textContent);
 
 		const ttl = script.getAttribute('data-ttl');
-		if (ttl) cache.set(resolved, { body, init, ttl: 1000 * Number(ttl) });
+		if (ttl) cache.set(selector, { body, init, ttl: 1000 * Number(ttl) });
 
 		return Promise.resolve(new Response(body, init));
 	}
@@ -90,18 +83,39 @@ export function initial_fetch(resource, resolved, opts) {
 
 /**
  * Tries to get the response from the cache, if max-age allows it, else does a fetch.
+ * @param {RequestInfo | URL} resource
  * @param {string} resolved
  * @param {RequestInit} [opts]
  */
-export function subsequent_fetch(resolved, opts) {
-	const cached = cache.get(resolved);
-	if (cached) {
-		if (performance.now() < cached.ttl) {
-			return new Response(cached.body, cached.init);
+export function subsequent_fetch(resource, resolved, opts) {
+	if (cache.size > 0) {
+		const selector = build_selector(resource, opts);
+		const cached = cache.get(selector);
+		if (cached) {
+			if (performance.now() < cached.ttl) {
+				return new Response(cached.body, cached.init);
+			}
+
+			cache.delete(selector);
 		}
-
-		cache.delete(resolved);
 	}
 
 	return native_fetch(resolved, opts);
 }
+
+/**
+ * Build the cache key for a given request
+ * @param {RequestInfo | URL} resource
+ * @param {RequestInit} [opts]
+ */
+function build_selector(resource, opts) {
+	const url = JSON.stringify(resource instanceof Request ? resource.url : resource);
+
+	let selector = `script[data-sveltekit-fetched][data-url=${url}]`;
+
+	if (opts?.body && (typeof opts.body === 'string' || ArrayBuffer.isView(opts.body))) {
+		selector += `[data-hash="${hash(opts.body)}"]`;
+	}
+
+	return selector;
+}
diff --git a/packages/kit/src/runtime/client/utils.js b/packages/kit/src/runtime/client/utils.js
index 25d183dd3..fca6edf23 100644
--- a/packages/kit/src/runtime/client/utils.js
+++ b/packages/kit/src/runtime/client/utils.js
@@ -58,7 +58,14 @@ export function find_anchor(event) {
 			noscroll,
 			prefetch,
 			reload
-		}
+		},
+		has: a
+			? {
+					rel_external: (a.getAttribute('rel') || '').split(/\s+/).includes('external'),
+					download: a.hasAttribute('download'),
+					target: !!(a instanceof SVGAElement ? a.target.baseVal : a.target)
+			  }
+			: {}
 	};
 }
 
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..899e5aa76 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;
@@ -103,6 +104,7 @@ export async function load_data({
 		data: server_data_node?.data ?? null,
 		route: event.route,
 		fetch: async (input, init) => {
+			const cloned_body = input instanceof Request && input.body ? input.clone().body : null;
 			const response = await event.fetch(input, init);
 
 			const url = new URL(input instanceof Request ? input.url : input, event.url);
@@ -149,7 +151,11 @@ export async function load_data({
 							fetched.push({
 								url: same_origin ? url.href.slice(event.url.origin.length) : url.href,
 								method: event.request.method,
-								request_body: /** @type {string | ArrayBufferView | undefined} */ (init?.body),
+								request_body: /** @type {string | ArrayBufferView | undefined} */ (
+									input instanceof Request && cloned_body
+										? await stream_to_string(cloned_body)
+										: init?.body
+								),
 								response_body: body,
 								response: response
 							});
@@ -233,3 +239,20 @@ export async function load_data({
 
 	return data ? unwrap_promises(data) : null;
 }
+
+/**
+ * @param {ReadableStream<Uint8Array>} stream
+ */
+async function stream_to_string(stream) {
+	let result = '';
+	const reader = stream.getReader();
+	const decoder = new TextDecoder();
+	while (true) {
+		const { done, value } = await reader.read();
+		if (done) {
+			break;
+		}
+		result += decoder.decode(value);
+	}
+	return result;
+}
diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js
index fdf762996..bd8d51dda 100644
--- a/packages/kit/src/utils/routing.js
+++ b/packages/kit/src/utils/routing.js
@@ -72,11 +72,6 @@ export function parse_route_id(id) {
 									return (
 										content // allow users to specify characters on the file system in an encoded manner
 											.normalize()
-											// We use [ and ] to denote parameters, so users must encode these on the file
-											// system to match against them. We don't decode all characters since others
-											// can already be epressed and so that '%' can be easily used directly in filenames
-											.replace(/%5[Bb]/g, '[')
-											.replace(/%5[Dd]/g, ']')
 											// '#', '/', and '?' can only appear in URL path segments in an encoded manner.
 											// They will not be touched by decodeURI so need to be encoded here, so
 											// that we can match against them.
diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js
index c3b4bd08f..213f07b52 100644
--- a/packages/kit/src/utils/routing.spec.js
+++ b/packages/kit/src/utils/routing.spec.js
@@ -67,6 +67,11 @@ const tests = {
 		pattern: /^\/@at-encoded\/([^/]+?)\/?$/,
 		names: ['id'],
 		types: [undefined]
+	},
+	'/%255bdoubly-encoded': {
+		pattern: /^\/%5bdoubly-encoded\/?$/,
+		names: [],
+		types: []
 	}
 };
 
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/app.html b/packages/kit/test/apps/basics/src/app.html
index bbc550f6a..dd9910661 100644
--- a/packages/kit/test/apps/basics/src/app.html
+++ b/packages/kit/test/apps/basics/src/app.html
@@ -5,9 +5,6 @@
 		<meta name="viewport" content="width=device-width" />
 		<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
 		<meta name="transform-page" content="__REPLACEME__" />
-		<!-- included to ensure Vite tranform middleware isn't run on static scripts
-			   https://github.com/sveltejs/kit/issues/5672 -->
-		<script src="%sveltekit.assets%/empty.js"></script>
 		%sveltekit.head%
 	</head>
 	<body>
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-cache-control/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/+page.svelte
new file mode 100644
index 000000000..1148676b0
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/+page.svelte
@@ -0,0 +1,1 @@
+<a href="/load/fetch-cache-control/load-data">load-data</a>
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data/+page.js b/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data/+page.js
new file mode 100644
index 000000000..193489d5b
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data/+page.js
@@ -0,0 +1,24 @@
+export async function load(event) {
+	const url = '/load/fetch-cache-control/load-data';
+
+	const res_fr = await event.fetch(url, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json'
+		},
+		body: JSON.stringify({ lang: `fr` })
+	});
+	const fr = await res_fr.json();
+
+	const res_hu = await event.fetch(url, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json'
+		},
+		body: JSON.stringify({ lang: `hu` })
+	});
+
+	const hu = await res_hu.json();
+
+	return { fr, hu };
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data/+page.svelte
new file mode 100644
index 000000000..8a9db1b9a
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data/+page.svelte
@@ -0,0 +1,16 @@
+<script>
+	export let data;
+	$: ({ fr, hu } = data);
+</script>
+
+<a href="/load/fetch-cache-control">fetch-cache-control</a>
+
+<h2>fr</h2>
+<div id="fr">
+	{JSON.stringify(fr)}
+</div>
+
+<h2>hu</h2>
+<div id="hu">
+	{JSON.stringify(hu)}
+</div>
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data/+server.js b/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data/+server.js
new file mode 100644
index 000000000..2fb805f66
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data/+server.js
@@ -0,0 +1,20 @@
+import { json } from '@sveltejs/kit';
+
+/** @type {import('./$types').RequestHandler} */
+export async function POST({ request, setHeaders }) {
+	setHeaders({
+		'cache-control': 'public, max-age=7'
+	});
+
+	try {
+		const { lang } = await request.json();
+		if (lang === 'fr') {
+			return json({ hi: 'bonjour' });
+		} else if (lang === 'hu') {
+			return json({ hi: 'szia' });
+		}
+	} catch (error) {}
+
+	// default to english
+	return json({ hi: 'hello' });
+}
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/src/routes/load/invalidation/depends/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/depends/+page.svelte
index 1ce33105c..e402d30d5 100644
--- a/packages/kit/test/apps/basics/src/routes/load/invalidation/depends/+page.svelte
+++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/depends/+page.svelte
@@ -9,7 +9,7 @@
 <button
 	type="button"
 	class="shared"
-	on:click={() => invalidate('invalidate-depends:shared')}
+	on:click={() => (window.promise = invalidate('invalidate-depends:shared'))}
 >
 	invalidate shared
 </button>
@@ -18,7 +18,7 @@
 <button
 	type="button"
 	class="server"
-	on:click={() => invalidate('invalidate-depends:server')}
+	on:click={() => (window.promise = invalidate('invalidate-depends:server'))}
 >
 	invalidate server
 </button>
diff --git a/packages/kit/test/apps/basics/src/routes/load/serialization-post-request/+page.js b/packages/kit/test/apps/basics/src/routes/load/serialization-post-request/+page.js
new file mode 100644
index 000000000..fb5d863be
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/serialization-post-request/+page.js
@@ -0,0 +1,20 @@
+/** @type {import('@sveltejs/kit').Load} */
+export async function load({ url, fetch }) {
+	/** @param {string} body */
+	async function post(body) {
+		const request = new Request(url.origin + '/load/serialization-post.json', {
+			method: 'POST',
+			headers: {
+				'content-type': 'text/plain'
+			},
+			body
+		});
+		const res = await fetch(request);
+
+		return await res.text();
+	}
+	const a = await post('x');
+	const b = await post('y');
+
+	return { a, b };
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/serialization-post-request/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/serialization-post-request/+page.svelte
new file mode 100644
index 000000000..53e2ed09b
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/serialization-post-request/+page.svelte
@@ -0,0 +1,7 @@
+<script>
+	/** @type {import('./$types').PageData} */
+	export let data;
+</script>
+
+<h1>a: {data.a}</h1>
+<h2>b: {data.b}</h2>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 9d82bfccf..8a04dd861 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');
@@ -467,6 +479,30 @@ test.describe('Load', () => {
 		expect(response.headers()['cache-control']).toBe('private, no-store');
 	});
 
+	test('cache with body hash', async ({ page, clicknav }) => {
+		// 1. go to the page (first load, we expect the right data)
+		await page.goto('/load/fetch-cache-control/load-data');
+		expect(await page.textContent('div#fr')).toBe(JSON.stringify({ hi: 'bonjour' }));
+		expect(await page.textContent('div#hu')).toBe(JSON.stringify({ hi: 'szia' }));
+
+		// 2. change to another route (client side)
+		await clicknav('[href="/load/fetch-cache-control"]');
+
+		// 3. come back to the original page (client side)
+		let did_request_data = false;
+		page.on('request', (request) => {
+			if (request.url().endsWith('fetch-cache-control/load-data')) {
+				did_request_data = true;
+			}
+		});
+		await clicknav('[href="/load/fetch-cache-control/load-data"]');
+
+		// 4. data should still be the same (and cached)
+		expect(await page.textContent('div#fr')).toBe(JSON.stringify({ hi: 'bonjour' }));
+		expect(await page.textContent('div#hu')).toBe(JSON.stringify({ hi: 'szia' }));
+		expect(did_request_data).toBe(false);
+	});
+
 	if (process.env.DEV) {
 		test('using window.fetch causes a warning', async ({ page }) => {
 			const port = 5173;
@@ -825,8 +861,8 @@ test.describe.serial('Invalidation', () => {
 		expect(server).toBeDefined();
 		expect(shared).toBeDefined();
 
-		await Promise.all([page.click('button.server'), page.waitForLoadState('networkidle')]);
-		await page.waitForTimeout(200);
+		await page.click('button.server');
+		await page.evaluate(() => window.promise);
 		const next_server = await page.textContent('p.server');
 		const next_shared = await page.textContent('p.shared');
 		expect(server).not.toBe(next_server);
@@ -840,8 +876,8 @@ test.describe.serial('Invalidation', () => {
 		expect(server).toBeDefined();
 		expect(shared).toBeDefined();
 
-		await Promise.all([page.click('button.shared'), page.waitForLoadState('networkidle')]);
-		await page.waitForTimeout(200);
+		await page.click('button.shared');
+		await page.evaluate(() => window.promise);
 		const next_server = await page.textContent('p.server');
 		const next_shared = await page.textContent('p.shared');
 		expect(server).toBe(next_server);
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index 7c7632120..df0270de7 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"]');
@@ -700,15 +708,13 @@ test.describe('Load', () => {
 		expect(await page.textContent('h1')).toBe('a: X');
 		expect(await page.textContent('h2')).toBe('b: Y');
 
-		const payload_a = '{"status":200,"statusText":"","headers":{},"body":"X"}';
-		const payload_b = '{"status":200,"statusText":"","headers":{},"body":"Y"}';
-
 		if (!javaScriptEnabled) {
+			const payload_a = '{"status":200,"statusText":"","headers":{},"body":"X"}';
+			const payload_b = '{"status":200,"statusText":"","headers":{},"body":"Y"}';
 			// by the time JS has run, hydration will have nuked these scripts
 			const script_contents_a = await page.innerHTML(
 				'script[data-sveltekit-fetched][data-url="/load/serialization-post.json"][data-hash="3t25"]'
 			);
-
 			const script_contents_b = await page.innerHTML(
 				'script[data-sveltekit-fetched][data-url="/load/serialization-post.json"][data-hash="3t24"]'
 			);
@@ -720,6 +726,28 @@ test.describe('Load', () => {
 		expect(requests.some((r) => r.endsWith('/load/serialization.json'))).toBe(false);
 	});
 
+	test('POST fetches with Request init are serialized', async ({ page, javaScriptEnabled }) => {
+		await page.goto('/load/serialization-post-request');
+
+		expect(await page.textContent('h1')).toBe('a: X');
+		expect(await page.textContent('h2')).toBe('b: Y');
+
+		if (!javaScriptEnabled) {
+			const payload_a = '{"status":200,"statusText":"","headers":{},"body":"X"}';
+			const payload_b = '{"status":200,"statusText":"","headers":{},"body":"Y"}';
+			// by the time JS has run, hydration will have nuked these scripts
+			const script_contents_a = await page.innerHTML(
+				'script[data-sveltekit-fetched][data-url="/load/serialization-post.json"][data-hash="3t25"]'
+			);
+			const script_contents_b = await page.innerHTML(
+				'script[data-sveltekit-fetched][data-url="/load/serialization-post.json"][data-hash="3t24"]'
+			);
+
+			expect(script_contents_a).toBe(payload_a);
+			expect(script_contents_b).toBe(payload_b);
+		}
+	});
+
 	test('json string is returned', async ({ page }) => {
 		await page.goto('/load/relay');
 		expect(await page.textContent('h1')).toBe('42');
@@ -1967,7 +1995,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 +2023,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 +2060,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');
+	});
 });
diff --git a/packages/kit/test/apps/dev-only/src/app.html b/packages/kit/test/apps/dev-only/src/app.html
index bbc550f6a..d169577ac 100644
--- a/packages/kit/test/apps/dev-only/src/app.html
+++ b/packages/kit/test/apps/dev-only/src/app.html
@@ -5,12 +5,9 @@
 		<meta name="viewport" content="width=device-width" />
 		<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
 		<meta name="transform-page" content="__REPLACEME__" />
-		<!-- included to ensure Vite tranform middleware isn't run on static scripts
-			   https://github.com/sveltejs/kit/issues/5672 -->
-		<script src="%sveltekit.assets%/empty.js"></script>
 		%sveltekit.head%
 	</head>
-	<body>
-		%sveltekit.body%
+	<body class="started">
+		<div>%sveltekit.body%</div>
 	</body>
 </html>
diff --git a/packages/kit/test/apps/dev-only/src/routes/env/dynamic-private-dynamic-import/+page.svelte b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/env/dynamic-private-dynamic-import/+page.svelte
similarity index 100%
rename from packages/kit/test/apps/dev-only/src/routes/env/dynamic-private-dynamic-import/+page.svelte
rename to packages/kit/test/apps/dev-only/src/routes/illegal-imports/env/dynamic-private-dynamic-import/+page.svelte
diff --git a/packages/kit/test/apps/dev-only/src/routes/env/dynamic-private/+page.svelte b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/env/dynamic-private/+page.svelte
similarity index 100%
rename from packages/kit/test/apps/dev-only/src/routes/env/dynamic-private/+page.svelte
rename to packages/kit/test/apps/dev-only/src/routes/illegal-imports/env/dynamic-private/+page.svelte
diff --git a/packages/kit/test/apps/dev-only/src/routes/env/static-private-dynamic-import/+page.svelte b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/env/static-private-dynamic-import/+page.svelte
similarity index 100%
rename from packages/kit/test/apps/dev-only/src/routes/env/static-private-dynamic-import/+page.svelte
rename to packages/kit/test/apps/dev-only/src/routes/illegal-imports/env/static-private-dynamic-import/+page.svelte
diff --git a/packages/kit/test/apps/dev-only/src/routes/env/static-private/+page.svelte b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/env/static-private/+page.svelte
similarity index 100%
rename from packages/kit/test/apps/dev-only/src/routes/env/static-private/+page.svelte
rename to packages/kit/test/apps/dev-only/src/routes/illegal-imports/env/static-private/+page.svelte
diff --git a/packages/kit/test/apps/dev-only/src/routes/server-only-folder/dynamic-import/+page.svelte b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-folder/dynamic-import/+page.svelte
similarity index 100%
rename from packages/kit/test/apps/dev-only/src/routes/server-only-folder/dynamic-import/+page.svelte
rename to packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-folder/dynamic-import/+page.svelte
diff --git a/packages/kit/test/apps/dev-only/src/routes/server-only-folder/static-import/+page.svelte b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-folder/static-import/+page.svelte
similarity index 100%
rename from packages/kit/test/apps/dev-only/src/routes/server-only-folder/static-import/+page.svelte
rename to packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-folder/static-import/+page.svelte
diff --git a/packages/kit/test/apps/dev-only/src/routes/server-only-modules/dynamic-import/+page.svelte b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-modules/dynamic-import/+page.svelte
similarity index 66%
rename from packages/kit/test/apps/dev-only/src/routes/server-only-modules/dynamic-import/+page.svelte
rename to packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-modules/dynamic-import/+page.svelte
index ea20469dc..5e763368e 100644
--- a/packages/kit/test/apps/dev-only/src/routes/server-only-modules/dynamic-import/+page.svelte
+++ b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-modules/dynamic-import/+page.svelte
@@ -1,5 +1,5 @@
 <script>
-	const mod = import('$lib/test.server.js');
+	const mod = import('../illegal.server.js');
 </script>
 
 {#await mod then resolved}
diff --git a/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-modules/illegal.server.js b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-modules/illegal.server.js
new file mode 100644
index 000000000..770268db5
--- /dev/null
+++ b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-modules/illegal.server.js
@@ -0,0 +1,1 @@
+export const should_explode = 'boom';
diff --git a/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-modules/static-import/+page.svelte b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-modules/static-import/+page.svelte
new file mode 100644
index 000000000..43bb66e92
--- /dev/null
+++ b/packages/kit/test/apps/dev-only/src/routes/illegal-imports/server-only-modules/static-import/+page.svelte
@@ -0,0 +1,5 @@
+<script>
+	import { should_explode } from '../illegal.server.js';
+</script>
+
+<p>{should_explode}</p>
diff --git a/packages/kit/test/apps/dev-only/src/routes/server-only-modules/static-import/+page.svelte b/packages/kit/test/apps/dev-only/src/routes/server-only-modules/static-import/+page.svelte
deleted file mode 100644
index e0e7bff39..000000000
--- a/packages/kit/test/apps/dev-only/src/routes/server-only-modules/static-import/+page.svelte
+++ /dev/null
@@ -1,5 +0,0 @@
-<script>
-	import { should_explode } from '$lib/test.server.js';
-</script>
-
-<p>{should_explode}</p>
diff --git a/packages/kit/test/apps/dev-only/svelte.config.js b/packages/kit/test/apps/dev-only/svelte.config.js
index bded48544..821c14379 100644
--- a/packages/kit/test/apps/dev-only/svelte.config.js
+++ b/packages/kit/test/apps/dev-only/svelte.config.js
@@ -1,4 +1,6 @@
 /** @type {import('@sveltejs/kit').Config} */
-const config = {};
+const config = {
+	kit: {}
+};
 
 export default config;
diff --git a/packages/kit/test/apps/dev-only/test/test.js b/packages/kit/test/apps/dev-only/test/test.js
index 7010d7a51..cc8f615f7 100644
--- a/packages/kit/test/apps/dev-only/test/test.js
+++ b/packages/kit/test/apps/dev-only/test/test.js
@@ -3,66 +3,78 @@ import { test } from '../../../utils.js';
 
 /** @typedef {import('@playwright/test').Response} Response */
 
-test.describe.configure({ mode: 'parallel' });
+test.describe.serial('Illegal imports', () => {
+	test.skip(({ javaScriptEnabled }) => !process.env.DEV || !javaScriptEnabled);
 
-test.describe('$env', () => {
-	test('$env/dynamic/private is not statically importable from the client', async ({ request }) => {
-		const resp = await request.get('/env/dynamic-private');
-		expect(await resp.text()).toMatch(
-			/.*Cannot import \$env\/dynamic\/private into public-facing code:.*/gs
+	test('$env/dynamic/private is not statically importable from the client', async ({ page }) => {
+		await page.goto('/illegal-imports/env/dynamic-private', {
+			wait_for_started: false
+		});
+		expect(await page.textContent('.message-body')).toBe(
+			'Cannot import \0$env/dynamic/private into client-side code'
 		);
 	});
 
-	test('$env/dynamic/private is not dynamically importable from the client', async ({
-		request
-	}) => {
-		const resp = await request.get('/env/dynamic-private-dynamic-import');
-		expect(await resp.text()).toMatch(
-			/.*Cannot import \$env\/dynamic\/private into public-facing code:.*/gs
+	test('$env/dynamic/private is not dynamically importable from the client', async ({ page }) => {
+		await page.goto('/illegal-imports/env/dynamic-private-dynamic-import', {
+			wait_for_started: false
+		});
+		expect(await page.textContent('.message-body')).toBe(
+			'Cannot import \0$env/dynamic/private into client-side code'
 		);
 	});
 
-	test('$env/static/private is not statically importable from the client', async ({ request }) => {
-		const resp = await request.get('/env/static-private');
-		expect(await resp.text()).toMatch(
-			/.*Cannot import \$env\/static\/private into public-facing code:.*/gs
+	test('$env/static/private is not statically importable from the client', async ({ page }) => {
+		await page.goto('/illegal-imports/env/static-private', {
+			wait_for_started: false
+		});
+		expect(await page.textContent('.message-body')).toBe(
+			'Cannot import \0$env/static/private into client-side code'
 		);
 	});
 
-	test('$env/static/private is not dynamically importable from the client', async ({ request }) => {
-		const resp = await request.get('/env/static-private-dynamic-import');
-		expect(await resp.text()).toMatch(
-			/.*Cannot import \$env\/static\/private into public-facing code:.*/gs
+	test('$env/static/private is not dynamically importable from the client', async ({ page }) => {
+		await page.goto('/illegal-imports/env/static-private-dynamic-import', {
+			wait_for_started: false
+		});
+		expect(await page.textContent('.message-body')).toBe(
+			'Cannot import \0$env/static/private into client-side code'
 		);
 	});
-});
 
-test.describe('server-only modules', () => {
-	test('server-only module is not statically importable from the client', async ({ request }) => {
-		const resp = await request.get('/server-only-modules/static-import');
-		expect(await resp.text()).toMatch(
-			/.*Cannot import \$lib\/test.server.js into public-facing code:.*/gs
+	test('server-only module is not statically importable from the client', async ({ page }) => {
+		await page.goto('/illegal-imports/server-only-modules/static-import', {
+			wait_for_started: false
+		});
+		expect(await page.textContent('.message-body')).toBe(
+			'Cannot import src/routes/illegal-imports/server-only-modules/illegal.server.js into client-side code'
 		);
 	});
-	test('server-only module is not dynamically importable from the client', async ({ request }) => {
-		const resp = await request.get('/server-only-modules/dynamic-import');
-		expect(await resp.text()).toMatch(
-			/.*Cannot import \$lib\/test.server.js into public-facing code:.*/gs
+
+	test('server-only module is not dynamically importable from the client', async ({ page }) => {
+		await page.goto('/illegal-imports/server-only-modules/dynamic-import', {
+			wait_for_started: false
+		});
+		expect(await page.textContent('.message-body')).toBe(
+			'Cannot import src/routes/illegal-imports/server-only-modules/illegal.server.js into client-side code'
 		);
 	});
-});
 
-test.describe('server-only folder', () => {
-	test('server-only folder is not statically importable from the client', async ({ request }) => {
-		const resp = await request.get('/server-only-folder/static-import');
-		expect(await resp.text()).toMatch(
-			/.*Cannot import \$lib\/server\/blah\/test.js into public-facing code:.*/gs
+	test('server-only folder is not statically importable from the client', async ({ page }) => {
+		await page.goto('/illegal-imports/server-only-folder/static-import', {
+			wait_for_started: false
+		});
+		expect(await page.textContent('.message-body')).toBe(
+			'Cannot import $lib/server/blah/test.js into client-side code'
 		);
 	});
-	test('server-only folder is not dynamically importable from the client', async ({ request }) => {
-		const resp = await request.get('/server-only-folder/dynamic-import');
-		expect(await resp.text()).toMatch(
-			/.*Cannot import \$lib\/server\/blah\/test.js into public-facing code:.*/gs
+
+	test('server-only folder is not dynamically importable from the client', async ({ page }) => {
+		await page.goto('/illegal-imports/server-only-folder/dynamic-import', {
+			wait_for_started: false
+		});
+		expect(await page.textContent('.message-body')).toBe(
+			'Cannot import $lib/server/blah/test.js into client-side code'
 		);
 	});
 });
diff --git a/packages/kit/test/build-errors/env.spec.js b/packages/kit/test/build-errors/env.spec.js
index 1dba0a958..c0fec16a4 100644
--- a/packages/kit/test/build-errors/env.spec.js
+++ b/packages/kit/test/build-errors/env.spec.js
@@ -11,7 +11,7 @@ test('$env/dynamic/private is not statically importable from the client', () =>
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Cannot import \$env\/dynamic\/private into public-facing code:.*/gs
+		/.*Cannot import \0\$env\/dynamic\/private into client-side code:.*/gs
 	);
 });
 
@@ -23,7 +23,7 @@ test('$env/dynamic/private is not dynamically importable from the client', () =>
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Cannot import \$env\/dynamic\/private into public-facing code:.*/gs
+		/.*Cannot import \0\$env\/dynamic\/private into client-side code:.*/gs
 	);
 });
 
@@ -35,7 +35,7 @@ test('$env/static/private is not statically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Cannot import \$env\/static\/private into public-facing code:.*/gs
+		/.*Cannot import \0\$env\/static\/private into client-side code:.*/gs
 	);
 });
 
@@ -47,7 +47,7 @@ test('$env/static/private is not dynamically importable from the client', () =>
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Cannot import \$env\/static\/private into public-facing code:.*/gs
+		/.*Cannot import \0\$env\/static\/private into client-side code:.*/gs
 	);
 });
 
diff --git a/packages/kit/test/build-errors/server-only.spec.js b/packages/kit/test/build-errors/server-only.spec.js
index f11b33beb..7e620820e 100644
--- a/packages/kit/test/build-errors/server-only.spec.js
+++ b/packages/kit/test/build-errors/server-only.spec.js
@@ -11,7 +11,7 @@ test('$lib/*.server.* is not statically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Cannot import \$lib\/test.server.js into public-facing code:.*/gs
+		/.*Cannot import \$lib\/test.server.js into client-side code:.*/gs
 	);
 });
 
@@ -23,7 +23,7 @@ test('$lib/*.server.* is not dynamically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Cannot import \$lib\/test.server.js into public-facing code:.*/gs
+		/.*Cannot import \$lib\/test.server.js into client-side code:.*/gs
 	);
 });
 
@@ -35,7 +35,7 @@ test('$lib/server/* is not statically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Cannot import \$lib\/server\/something\/test.js into public-facing code:.*/gs
+		/.*Cannot import \$lib\/server\/something\/test.js into client-side code:.*/gs
 	);
 });
 
@@ -47,7 +47,7 @@ test('$lib/server/* is not dynamically importable from the client', () => {
 				stdio: 'pipe',
 				timeout: 15000
 			}),
-		/.*Cannot import \$lib\/server\/something\/test.js into public-facing code:.*/gs
+		/.*Cannot import \$lib\/server\/something\/test.js into client-side code:.*/gs
 	);
 });
 
diff --git a/packages/kit/test/utils.d.ts b/packages/kit/test/utils.d.ts
index a1958487e..31d83058f 100644
--- a/packages/kit/test/utils.d.ts
+++ b/packages/kit/test/utils.d.ts
@@ -26,6 +26,12 @@ export const test: TestType<
 			 * `handleError` defines the shape
 			 */
 			read_errors(href: string): Record<string, any>;
+			page: PlaywrightTestArgs['page'] & {
+				goto: (
+					url: string,
+					opts?: Parameters<PlaywrightTestArgs['page']['goto']>[1] & { wait_for_started?: boolean }
+				) => ReturnType<PlaywrightTestArgs['page']['goto']>;
+			};
 		},
 	PlaywrightWorkerArgs & PlaywrightWorkerOptions
 >;
diff --git a/packages/kit/test/utils.js b/packages/kit/test/utils.js
index a60ec8e56..4bfc9ebdc 100644
--- a/packages/kit/test/utils.js
+++ b/packages/kit/test/utils.js
@@ -90,7 +90,7 @@ export const test = base.extend({
 			// @ts-expect-error
 			page[fn] = async function (...args) {
 				const res = await page_fn.call(page, ...args);
-				if (javaScriptEnabled) {
+				if (javaScriptEnabled && args[1]?.wait_for_started !== false) {
 					await page.waitForSelector('body.started', { timeout: 5000 });
 				}
 				return res;