Repository: sveltejs/kit (js)

Issue:
API endpoint, called from the server, with cache-header cannot be cleared
### Describe the bug

This is similar to, but different than https://github.com/sveltejs/kit/issues/7784 

I have an api endpoint that looks like this 

```js
export async function GET({ url, setHeaders, request }) {
	console.log('ENDPOINT', x++);

	const search = url.searchParams.get('search') || '';

	setHeaders({
		'cache-control': 'max-age=60',
		Vary: 'todos-cache'
	});
	const todos = await getTodos(search);
	return json(todos);
}
```

This endpoint is called from a SHARED loader. So the first time I view a page, the fetch is called from SvelteKit's internals, on the server. With the cache-control header, as above, these results are cached for 60 seconds. Even a call to invalidate to the api endpoint url does not clear that cached entry. Even adding a depends call in the shared loader, and called invalidate on that does not clear the cached entry. Even manually passing a different value of the header on each call (to test) does not clear the cached entry.

Is this by design? Note that I'm not talking about browser caching. I can already modify the header I'm Vary'ing on (todos-cache) on the client, and successfully clear the cache, which works.

The only workaround I've found is to just not add the cache-control header at all, depending on whether the loader is running client-side, or server-side (which I can surmise by snooping around the headers).

Again, mainly trying to surmise if this is by design. If it is, by all means close this. If not, I'd be happy to whip up a minimal repro. 

### Reproduction

n/a, for now (if this is not by design I'd be happy to make one).

### Logs

```Shell
n/a
```


### System Info

```Shell
System:
    OS: macOS 13.0.1
    CPU: (16) x64 Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
    Memory: 151.88 MB / 16.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 16.14.2 - /usr/local/Cellar/nvm/0.38.0/versions/node/v16.14.2/bin/node
    Yarn: 1.22.17 - /usr/local/Cellar/nvm/0.38.0/versions/node/v16.11.1/bin/yarn
    npm: 8.18.0 - ~/Documents/node_modules/.bin/npm
  Browsers:
    Chrome: 108.0.5359.124
    Firefox: 108.0.1
  npmPackages:
    @sveltejs/adapter-auto: ^1.0.0 => 1.0.0 
    @sveltejs/kit: ^1.0.1 => 1.0.1 
    svelte: ^3.54.0 => 3.55.0 
    vite: ^4.0.0 => 4.0.3
```


### Severity

annoyance

### Additional Information

_No response_

Pull request description:
[fix] skip inline cache when vary header is present
fixes #8286

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- [x] This message body should clearly illustrate what problems it solves.
- [x] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [x] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running `pnpm changeset` and following the prompts. All changesets should be `patch` until SvelteKit 1.0


Patch:
diff --git a/.changeset/big-cobras-sip.md b/.changeset/big-cobras-sip.md
deleted file mode 100644
index 4436c6b4e..000000000
--- a/.changeset/big-cobras-sip.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-Disable illegal import detection when running unit tests
diff --git a/.changeset/breezy-walls-provide.md b/.changeset/breezy-walls-provide.md
deleted file mode 100644
index 28b778bfa..000000000
--- a/.changeset/breezy-walls-provide.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-only prerender a given dependency once
diff --git a/.changeset/dry-poets-crash.md b/.changeset/dry-poets-crash.md
deleted file mode 100644
index 58eeacb84..000000000
--- a/.changeset/dry-poets-crash.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-`updated.check()` type changed to Promise<boolean>
diff --git a/.changeset/hot-walls-mix.md b/.changeset/hot-walls-mix.md
new file mode 100644
index 000000000..7a80e4ec0
--- /dev/null
+++ b/.changeset/hot-walls-mix.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Skip inline cache when vary header is present
diff --git a/.changeset/stupid-actors-look.md b/.changeset/stupid-actors-look.md
deleted file mode 100644
index b31e235a4..000000000
--- a/.changeset/stupid-actors-look.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-[fix] quote 'script' in CSP directives
diff --git a/.changeset/sweet-hairs-roll.md b/.changeset/sweet-hairs-roll.md
deleted file mode 100644
index fb969c331..000000000
--- a/.changeset/sweet-hairs-roll.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-fix route id comparison for load change detection
diff --git a/.changeset/wet-feet-lie.md b/.changeset/wet-feet-lie.md
deleted file mode 100644
index 6cee5b7d2..000000000
--- a/.changeset/wet-feet-lie.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-[fix] don't add nonce attribute to `<link>` elements
diff --git a/documentation/docs/10-getting-started/40-web-standards.md b/documentation/docs/10-getting-started/40-web-standards.md
index 58fd9fbaf..a12f534cf 100644
--- a/documentation/docs/10-getting-started/40-web-standards.md
+++ b/documentation/docs/10-getting-started/40-web-standards.md
@@ -12,7 +12,7 @@ In particular, you'll get comfortable with the following:
 
 SvelteKit uses [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) for getting data from the network. It's available in [hooks](/docs/hooks) and [server routes](/docs/routing#server) as well as in the browser.
 
-> A special version of `fetch` is available in [`load`](/docs/load) functions for invoking endpoints directly during server-side rendering, without making an HTTP call, while preserving credentials. (To make credentialled fetches in server-side code outside `load`, you must explicitly pass `cookie` and/or `authorization` headers.) It also allows you to make relative requests, whereas server-side `fetch` normally requires a fully qualified URL.
+> A special version of `fetch` is available in [`load`](/docs/load) functions, [server hooks](/docs/hooks#server-hooks) and [API routes](/docs/routing#server) for invoking endpoints directly during server-side rendering, without making an HTTP call, while preserving credentials. (To make credentialled fetches in server-side code outside `load`, you must explicitly pass `cookie` and/or `authorization` headers.) It also allows you to make relative requests, whereas server-side `fetch` normally requires a fully qualified URL.
 
 Besides `fetch` itself, the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) includes the following interfaces:
 
diff --git a/documentation/docs/30-advanced/25-errors.md b/documentation/docs/30-advanced/25-errors.md
index 40ac2269e..249aa8dd2 100644
--- a/documentation/docs/30-advanced/25-errors.md
+++ b/documentation/docs/30-advanced/25-errors.md
@@ -106,6 +106,8 @@ export function handleError({ error, event }) {
 }
 ```
 
+> Make sure that `handleError` _never_ throws an error
+
 ## Responses
 
 If an error occurs inside `handle` or inside a [`+server.js`](/docs/routing#server) request handler, SvelteKit will respond with either a fallback error page or a JSON representation of the error object, depending on the request's `Accept` headers.
diff --git a/documentation/docs/60-appendix/10-migrating.md b/documentation/docs/60-appendix/10-migrating.md
index 759bec196..2e04f115c 100644
--- a/documentation/docs/60-appendix/10-migrating.md
+++ b/documentation/docs/60-appendix/10-migrating.md
@@ -150,7 +150,7 @@ See [the FAQ](/faq#integrations) for detailed information about integrations.
 
 ### HTML minifier
 
-Sapper includes `html-minifier` by default. SvelteKit does not include this, but it can be added as a [hook](/docs/hooks#server-hooks-handle):
+Sapper includes `html-minifier` by default. SvelteKit does not include this, but you can add it as a prod dependency and then use it through a [hook](/docs/hooks#server-hooks-handle):
 
 ```js
 // @filename: ambient.d.ts
diff --git a/package.json b/package.json
index 1b4e7d3ac..18698ed23 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
 		"@rollup/plugin-json": "^6.0.0",
 		"@rollup/plugin-node-resolve": "^15.0.1",
 		"@svitejs/changesets-changelog-github-compact": "^1.0.0",
-		"playwright": "^1.28.1",
+		"playwright": "^1.29.2",
 		"prettier": "^2.8.0",
 		"rollup": "^3.7.0",
 		"svelte": "^3.55.0",
diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md
index 3a5baa878..d7d9d110a 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,31 @@
 # @sveltejs/kit
 
+## 1.0.9
+
+### Patch Changes
+
+- Add `assetFileNames` to worker rollup options ([#8384](https://github.com/sveltejs/kit/pull/8384))
+
+## 1.0.8
+
+### Patch Changes
+
+- Disable illegal import detection when running unit tests ([#8365](https://github.com/sveltejs/kit/pull/8365))
+
+- only prerender a given dependency once ([#8376](https://github.com/sveltejs/kit/pull/8376))
+
+- Error in dev mode if global `fetch` is used with relative URL ([#8370](https://github.com/sveltejs/kit/pull/8370))
+
+- `updated.check()` type changed to Promise<boolean> ([#8400](https://github.com/sveltejs/kit/pull/8400))
+
+- Don't strip body in no-cors mode on the server ([#8412](https://github.com/sveltejs/kit/pull/8412))
+
+- [fix] quote 'script' in CSP directives ([#8372](https://github.com/sveltejs/kit/pull/8372))
+
+- fix route id comparison for load change detection ([#8399](https://github.com/sveltejs/kit/pull/8399))
+
+- [fix] don't add nonce attribute to `<link>` elements ([#8369](https://github.com/sveltejs/kit/pull/8369))
+
 ## 1.0.7
 
 ### Patch Changes
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 18e143eff..2e246b3bc 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/kit",
-	"version": "1.0.7",
+	"version": "1.0.9",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
@@ -25,7 +25,7 @@
 		"undici": "5.14.0"
 	},
 	"devDependencies": {
-		"@playwright/test": "^1.28.1",
+		"@playwright/test": "^1.29.2",
 		"@types/connect": "^3.4.35",
 		"@types/marked": "^4.0.7",
 		"@types/mime": "^3.0.1",
diff --git a/packages/kit/src/exports/vite/build/utils.js b/packages/kit/src/exports/vite/build/utils.js
index 1198053c5..c5bf6e448 100644
--- a/packages/kit/src/exports/vite/build/utils.js
+++ b/packages/kit/src/exports/vite/build/utils.js
@@ -189,6 +189,7 @@ export function get_build_compile_config({ config, input, ssr, outDir }) {
 				output: {
 					entryFileNames: `${prefix}/workers/[name]-[hash].js`,
 					chunkFileNames: `${prefix}/workers/chunks/[name]-[hash].js`,
+					assetFileNames: `${prefix}/workers/assets/[name]-[hash][extname]`,
 					hoistTransitiveImports: false
 				}
 			}
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index 571db3d22..f1021713c 100644
--- a/packages/kit/src/exports/vite/dev/index.js
+++ b/packages/kit/src/exports/vite/dev/index.js
@@ -29,6 +29,17 @@ export async function dev(vite, vite_config, svelte_config) {
 		installPolyfills();
 	}
 
+	const fetch = globalThis.fetch;
+	globalThis.fetch = (info, init) => {
+		if (typeof info === 'string' && !/^\w+:\/\//.test(info)) {
+			throw new Error(
+				`Cannot use relative URL (${info}) with global fetch — use \`event.fetch\` instead: https://kit.svelte.dev/docs/web-standards#fetch-apis`
+			);
+		}
+
+		return fetch(info, init);
+	};
+
 	sync.init(svelte_config, vite_config.mode);
 
 	/** @type {import('types').Respond} */
diff --git a/packages/kit/src/runtime/server/fetch.js b/packages/kit/src/runtime/server/fetch.js
index 89f21b5db..8ff06eec8 100644
--- a/packages/kit/src/runtime/server/fetch.js
+++ b/packages/kit/src/runtime/server/fetch.js
@@ -63,17 +63,7 @@ export function create_fetch({ event, options, state, get_cookie_header }) {
 						if (cookie) request.headers.set('cookie', cookie);
 					}
 
-					let response = await fetch(request);
-
-					if (mode === 'no-cors') {
-						response = new Response('', {
-							status: response.status,
-							statusText: response.statusText,
-							headers: response.headers
-						});
-					}
-
-					return response;
+					return fetch(request);
 				}
 
 				/** @type {Response} */
diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js
index f5f20c44f..7b12a46fe 100644
--- a/packages/kit/src/runtime/server/page/load_data.js
+++ b/packages/kit/src/runtime/server/page/load_data.js
@@ -107,129 +107,150 @@ export async function load_data({
 		params: event.params,
 		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);
+		fetch: create_universal_fetch(event, state, fetched, csr, resolve_opts),
+		setHeaders: event.setHeaders,
+		depends: () => {},
+		parent
+	});
 
-			const url = new URL(input instanceof Request ? input.url : input, event.url);
-			const same_origin = url.origin === event.url.origin;
+	const data = result ? await unwrap_promises(result) : null;
+	validate_load_response(data, /** @type {string} */ (event.route.id));
+	return data;
+}
+
+/**
+ * @param {Pick<import('types').RequestEvent, 'fetch' | 'url' | 'request' | 'route'>} event
+ * @param {import("types").SSRState} state
+ * @param {import("./types").Fetched[]} fetched
+ * @param {boolean} csr
+ * @param {Pick<Required<import("types").ResolveOptions>, 'filterSerializedResponseHeaders'>} resolve_opts
+ */
+export function create_universal_fetch(event, state, fetched, csr, resolve_opts) {
+	/**
+	 * @param {URL | RequestInfo} input
+	 * @param {RequestInit} [init]
+	 */
+	return async (input, init) => {
+		const cloned_body = input instanceof Request && input.body ? input.clone().body : null;
+		let response = await event.fetch(input, init);
 
-			/** @type {import('types').PrerenderDependency} */
-			let dependency;
+		const url = new URL(input instanceof Request ? input.url : input, event.url);
+		const same_origin = url.origin === event.url.origin;
 
-			if (same_origin) {
-				if (state.prerendering) {
-					dependency = { response, body: null };
-					state.prerendering.dependencies.set(url.pathname, dependency);
-				}
+		/** @type {import('types').PrerenderDependency} */
+		let dependency;
+
+		if (same_origin) {
+			if (state.prerendering) {
+				dependency = { response, body: null };
+				state.prerendering.dependencies.set(url.pathname, dependency);
+			}
+		} else {
+			// simulate CORS errors and "no access to body in no-cors mode" server-side for consistency with client-side behaviour
+			const mode = input instanceof Request ? input.mode : init?.mode ?? 'cors';
+			if (mode === 'no-cors') {
+				response = new Response('', {
+					status: response.status,
+					statusText: response.statusText,
+					headers: response.headers
+				});
 			} else {
-				// simulate CORS errors server-side for consistency with client-side behaviour
-				const mode = input instanceof Request ? input.mode : init?.mode ?? 'cors';
-				if (mode !== 'no-cors') {
-					const acao = response.headers.get('access-control-allow-origin');
-					if (!acao || (acao !== event.url.origin && acao !== '*')) {
-						throw new Error(
-							`CORS error: ${
-								acao ? 'Incorrect' : 'No'
-							} 'Access-Control-Allow-Origin' header is present on the requested resource`
-						);
-					}
+				const acao = response.headers.get('access-control-allow-origin');
+				if (!acao || (acao !== event.url.origin && acao !== '*')) {
+					throw new Error(
+						`CORS error: ${
+							acao ? 'Incorrect' : 'No'
+						} 'Access-Control-Allow-Origin' header is present on the requested resource`
+					);
 				}
 			}
+		}
 
-			const proxy = new Proxy(response, {
-				get(response, key, _receiver) {
-					async function text() {
-						const body = await response.text();
-
-						if (!body || typeof body === 'string') {
-							const status_number = Number(response.status);
-							if (isNaN(status_number)) {
-								throw new Error(
-									`response.status is not a number. value: "${
-										response.status
-									}" type: ${typeof response.status}`
-								);
-							}
-
-							fetched.push({
-								url: same_origin ? url.href.slice(event.url.origin.length) : url.href,
-								method: event.request.method,
-								request_body: /** @type {string | ArrayBufferView | undefined} */ (
-									input instanceof Request && cloned_body
-										? await stream_to_string(cloned_body)
-										: init?.body
-								),
-								response_body: body,
-								response: response
-							});
-						}
+		const proxy = new Proxy(response, {
+			get(response, key, _receiver) {
+				async function text() {
+					const body = await response.text();
 
-						if (dependency) {
-							dependency.body = body;
+					if (!body || typeof body === 'string') {
+						const status_number = Number(response.status);
+						if (isNaN(status_number)) {
+							throw new Error(
+								`response.status is not a number. value: "${
+									response.status
+								}" type: ${typeof response.status}`
+							);
 						}
 
-						return body;
+						fetched.push({
+							url: same_origin ? url.href.slice(event.url.origin.length) : url.href,
+							method: event.request.method,
+							request_body: /** @type {string | ArrayBufferView | undefined} */ (
+								input instanceof Request && cloned_body
+									? await stream_to_string(cloned_body)
+									: init?.body
+							),
+							response_body: body,
+							response: response
+						});
 					}
 
-					if (key === 'arrayBuffer') {
-						return async () => {
-							const buffer = await response.arrayBuffer();
+					if (dependency) {
+						dependency.body = body;
+					}
 
-							if (dependency) {
-								dependency.body = new Uint8Array(buffer);
-							}
+					return body;
+				}
 
-							// TODO should buffer be inlined into the page (albeit base64'd)?
-							// any conditions in which it shouldn't be?
+				if (key === 'arrayBuffer') {
+					return async () => {
+						const buffer = await response.arrayBuffer();
 
-							return buffer;
-						};
-					}
+						if (dependency) {
+							dependency.body = new Uint8Array(buffer);
+						}
 
-					if (key === 'text') {
-						return text;
-					}
+						// TODO should buffer be inlined into the page (albeit base64'd)?
+						// any conditions in which it shouldn't be?
 
-					if (key === 'json') {
-						return async () => {
-							return JSON.parse(await text());
-						};
-					}
+						return buffer;
+					};
+				}
 
-					return Reflect.get(response, key, response);
+				if (key === 'text') {
+					return text;
+				}
+
+				if (key === 'json') {
+					return async () => {
+						return JSON.parse(await text());
+					};
 				}
-			});
-
-			if (csr) {
-				// ensure that excluded headers can't be read
-				const get = response.headers.get;
-				response.headers.get = (key) => {
-					const lower = key.toLowerCase();
-					const value = get.call(response.headers, lower);
-					if (value && !lower.startsWith('x-sveltekit-')) {
-						const included = resolve_opts.filterSerializedResponseHeaders(lower, value);
-						if (!included) {
-							throw new Error(
-								`Failed to get response header "${lower}" — it must be included by the \`filterSerializedResponseHeaders\` option: https://kit.svelte.dev/docs/hooks#server-hooks-handle (at ${event.route.id})`
-							);
-						}
-					}
 
-					return value;
-				};
+				return Reflect.get(response, key, response);
 			}
+		});
 
-			return proxy;
-		},
-		setHeaders: event.setHeaders,
-		depends: () => {},
-		parent
-	});
+		if (csr) {
+			// ensure that excluded headers can't be read
+			const get = response.headers.get;
+			response.headers.get = (key) => {
+				const lower = key.toLowerCase();
+				const value = get.call(response.headers, lower);
+				if (value && !lower.startsWith('x-sveltekit-')) {
+					const included = resolve_opts.filterSerializedResponseHeaders(lower, value);
+					if (!included) {
+						throw new Error(
+							`Failed to get response header "${lower}" — it must be included by the \`filterSerializedResponseHeaders\` option: https://kit.svelte.dev/docs/hooks#server-hooks-handle (at ${event.route.id})`
+						);
+					}
+				}
 
-	const data = result ? await unwrap_promises(result) : null;
-	validate_load_response(data, /** @type {string} */ (event.route.id));
-	return data;
+				return value;
+			};
+		}
+
+		return proxy;
+	};
 }
 
 /**
diff --git a/packages/kit/src/runtime/server/page/load_data.spec.js b/packages/kit/src/runtime/server/page/load_data.spec.js
new file mode 100644
index 000000000..493cbcfc5
--- /dev/null
+++ b/packages/kit/src/runtime/server/page/load_data.spec.js
@@ -0,0 +1,73 @@
+import { test } from 'uvu';
+import * as assert from 'uvu/assert';
+import { create_universal_fetch } from './load_data.js';
+
+/**
+ * @param {Partial<Pick<import('types').RequestEvent, 'fetch' | 'url' | 'request' | 'route'>>} event
+ */
+function create_fetch(event) {
+	event.fetch = event.fetch || (async () => new Response('foo'));
+	event.request = event.request || new Request('doesnt:matter');
+	event.route = event.route || { id: 'foo' };
+	event.url = event.url || new URL('https://domain-a.com');
+	return create_universal_fetch(
+		/** @type {Pick<import('types').RequestEvent, 'fetch' | 'url' | 'request' | 'route'>} */ (
+			event
+		),
+		{ getClientAddress: () => '' },
+		[],
+		true,
+		{
+			filterSerializedResponseHeaders: () => false
+		}
+	);
+}
+
+test('sets body to empty when mode is no-cors', async () => {
+	const fetch = create_fetch({});
+	const response = await fetch('https://domain-b.com', { mode: 'no-cors' });
+	const text = await response.text();
+	assert.equal(text, '');
+});
+
+test('keeps body when mode isnt no-cors on same domain', async () => {
+	const fetch = create_fetch({});
+	const response = await fetch('https://domain-a.com');
+	const text = await response.text();
+	assert.equal(text, 'foo');
+});
+
+test('succeeds when acao header present on cors', async () => {
+	const fetch = create_fetch({
+		fetch: async () => new Response('foo', { headers: { 'access-control-allow-origin': '*' } })
+	});
+	const response = await fetch('https://domain-a.com');
+	const text = await response.text();
+	assert.equal(text, 'foo');
+});
+
+test('errors when no acao header present on cors', async () => {
+	const fetch = create_fetch({});
+	try {
+		const response = await fetch('https://domain-b.com');
+		await response.text();
+		assert.unreachable('should have thrown cors error');
+	} catch (e) {
+		assert.ok(e instanceof Error);
+		assert.match(
+			e.message,
+			/CORS error: No 'Access-Control-Allow-Origin' header is present on the requested resource/
+		);
+	}
+});
+
+test('errors when trying to access non-serialized request headers on the server', async () => {
+	const fetch = create_fetch({});
+	const response = await fetch('https://domain-a.com');
+	assert.throws(
+		() => response.headers.get('content-type'),
+		/Failed to get response header "content-type" — it must be included by the `filterSerializedResponseHeaders` option/
+	);
+});
+
+test.run();
diff --git a/packages/kit/src/runtime/server/page/serialize_data.js b/packages/kit/src/runtime/server/page/serialize_data.js
index 3b278657b..5b275b0c6 100644
--- a/packages/kit/src/runtime/server/page/serialize_data.js
+++ b/packages/kit/src/runtime/server/page/serialize_data.js
@@ -46,6 +46,7 @@ export function serialize_data(fetched, filter, prerendering = false) {
 
 	let cache_control = null;
 	let age = null;
+	let vary = false;
 
 	for (const [key, value] of fetched.response.headers) {
 		if (filter(key, value)) {
@@ -54,6 +55,7 @@ export function serialize_data(fetched, filter, prerendering = false) {
 
 		if (key === 'cache-control') cache_control = value;
 		if (key === 'age') age = value;
+		if (key === 'vary') vary = true;
 	}
 
 	const payload = {
@@ -75,7 +77,11 @@ export function serialize_data(fetched, filter, prerendering = false) {
 		attrs.push(`data-hash=${escape_html_attr(hash(fetched.request_body))}`);
 	}
 
-	if (!prerendering && fetched.method === 'GET' && cache_control) {
+	// Compute the time the response should be cached, taking into account max-age and age.
+	// Do not cache at all if a vary header is present, as this indicates that the cache is
+	// likely to get busted. It would also mean we'd have to add more logic to computing the
+	// selector on the client which results in more code for 99% of people for the 1% who use vary.
+	if (!prerendering && fetched.method === 'GET' && cache_control && !vary) {
 		const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control);
 		if (match) {
 			const ttl = +match[1] - +(age ?? '0');
diff --git a/packages/kit/src/runtime/server/page/serialize_data.spec.js b/packages/kit/src/runtime/server/page/serialize_data.spec.js
index 57ef9ee14..186833710 100644
--- a/packages/kit/src/runtime/server/page/serialize_data.spec.js
+++ b/packages/kit/src/runtime/server/page/serialize_data.spec.js
@@ -61,4 +61,46 @@ test('escapes the attribute values', () => {
 	);
 });
 
+test('computes ttl using cache-control and age headers', () => {
+	const raw = 'an "attr" & a \ud800';
+	const escaped = 'an &quot;attr&quot; &amp; a &#55296;';
+	const response_body = '';
+	assert.equal(
+		serialize_data(
+			{
+				url: raw,
+				method: 'GET',
+				request_body: null,
+				response_body,
+				response: new Response(response_body, {
+					headers: { 'cache-control': 'max-age=10', age: '1' }
+				})
+			},
+			() => false
+		),
+		`<script type="application/json" data-sveltekit-fetched data-url="${escaped}" data-ttl="9">{"status":200,"statusText":"","headers":{},"body":\"\"}</script>`
+	);
+});
+
+test('doesnt compute ttl when vary header is present', () => {
+	const raw = 'an "attr" & a \ud800';
+	const escaped = 'an &quot;attr&quot; &amp; a &#55296;';
+	const response_body = '';
+	assert.equal(
+		serialize_data(
+			{
+				url: raw,
+				method: 'GET',
+				request_body: null,
+				response_body,
+				response: new Response(response_body, {
+					headers: { 'cache-control': 'max-age=10', vary: 'accept-encoding' }
+				})
+			},
+			() => false
+		),
+		`<script type="application/json" data-sveltekit-fetched data-url="${escaped}">{"status":200,"statusText":"","headers":{},"body":\"\"}</script>`
+	);
+});
+
 test.run();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0ced942b4..6ce043ba1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,7 +9,7 @@ importers:
       '@rollup/plugin-json': ^6.0.0
       '@rollup/plugin-node-resolve': ^15.0.1
       '@svitejs/changesets-changelog-github-compact': ^1.0.0
-      playwright: ^1.28.1
+      playwright: ^1.29.2
       prettier: ^2.8.0
       rollup: ^3.7.0
       svelte: ^3.55.0
@@ -22,7 +22,7 @@ importers:
       '@rollup/plugin-json': 6.0.0_rollup@3.7.0
       '@rollup/plugin-node-resolve': 15.0.1_rollup@3.7.0
       '@svitejs/changesets-changelog-github-compact': 1.0.0
-      playwright: 1.28.1
+      playwright: 1.29.2
       prettier: 2.8.0
       rollup: 3.7.0
       svelte: 3.55.0
@@ -218,7 +218,7 @@ importers:
       kleur: 4.1.5
       prompts: 2.4.2
     devDependencies:
-      '@playwright/test': 1.28.1
+      '@playwright/test': 1.29.2
       '@types/gitignore-parser': 0.0.0
       '@types/prettier': 2.7.1
       '@types/prompts': 2.4.1
@@ -257,7 +257,7 @@ importers:
 
   packages/kit:
     specifiers:
-      '@playwright/test': ^1.28.1
+      '@playwright/test': ^1.29.2
       '@sveltejs/vite-plugin-svelte': ^2.0.0
       '@types/connect': ^3.4.35
       '@types/cookie': ^0.5.1
@@ -299,7 +299,7 @@ importers:
       tiny-glob: 0.2.9
       undici: 5.14.0
     devDependencies:
-      '@playwright/test': 1.28.1
+      '@playwright/test': 1.29.2
       '@types/connect': 3.4.35
       '@types/marked': 4.0.7
       '@types/mime': 3.0.1
@@ -1239,13 +1239,13 @@ packages:
       fastq: 1.13.0
     dev: true
 
-  /@playwright/test/1.28.1:
-    resolution: {integrity: sha512-xN6spdqrNlwSn9KabIhqfZR7IWjPpFK1835tFNgjrlysaSezuX8PYUwaz38V/yI8TJLG9PkAMEXoHRXYXlpTPQ==}
+  /@playwright/test/1.29.2:
+    resolution: {integrity: sha512-+3/GPwOgcoF0xLz/opTnahel1/y42PdcgZ4hs+BZGIUjtmEFSXGg+nFoaH3NSmuc7a6GSFwXDJ5L7VXpqzigNg==}
     engines: {node: '>=14'}
     hasBin: true
     dependencies:
       '@types/node': 18.11.11
-      playwright-core: 1.28.1
+      playwright-core: 1.29.2
     dev: true
 
   /@polka/url/1.0.0-next.21:
@@ -3457,19 +3457,19 @@ packages:
       find-up: 4.1.0
     dev: true
 
-  /playwright-core/1.28.1:
-    resolution: {integrity: sha512-3PixLnGPno0E8rSBJjtwqTwJe3Yw72QwBBBxNoukIj3lEeBNXwbNiKrNuB1oyQgTBw5QHUhNO3SteEtHaMK6ag==}
+  /playwright-core/1.29.2:
+    resolution: {integrity: sha512-94QXm4PMgFoHAhlCuoWyaBYKb92yOcGVHdQLoxQ7Wjlc7Flg4aC/jbFW7xMR52OfXMVkWicue4WXE7QEegbIRA==}
     engines: {node: '>=14'}
     hasBin: true
     dev: true
 
-  /playwright/1.28.1:
-    resolution: {integrity: sha512-92Sz6XBlfHlb9tK5UCDzIFAuIkHHpemA9zwUaqvo+w7sFMSmVMGmvKcbptof/eJObq63PGnMhM75x7qxhTR78Q==}
+  /playwright/1.29.2:
+    resolution: {integrity: sha512-hKBYJUtdmYzcjdhYDkP9WGtORwwZBBKAW8+Lz7sr0ZMxtJr04ASXVzH5eBWtDkdb0c3LLFsehfPBTRfvlfKJOA==}
     engines: {node: '>=14'}
     hasBin: true
     requiresBuild: true
     dependencies:
-      playwright-core: 1.28.1
+      playwright-core: 1.29.2
     dev: true
 
   /polka/1.0.0-next.22:


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/load/cors/+page.js b/packages/kit/test/apps/basics/src/routes/load/cors/+page.js
deleted file mode 100644
index 9fbd10aed..000000000
--- a/packages/kit/test/apps/basics/src/routes/load/cors/+page.js
+++ /dev/null
@@ -1,8 +0,0 @@
-/** @type {import('./$types').PageLoad} */
-export async function load({ fetch, url }) {
-	const res = await fetch(`http://localhost:${url.searchParams.get('port')}`);
-
-	return {
-		text: await res.text()
-	};
-}
diff --git a/packages/kit/test/apps/basics/src/routes/load/cors/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/cors/+page.svelte
deleted file mode 100644
index a5063216b..000000000
--- a/packages/kit/test/apps/basics/src/routes/load/cors/+page.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-<script>
-	/** @type {import('./$types').PageData} */
-	export let data;
-</script>
-
-<h1>{@html data.text}</h1>
diff --git a/packages/kit/test/apps/basics/src/routes/load/cors/no-cors/+page.js b/packages/kit/test/apps/basics/src/routes/load/cors/no-cors/+page.js
deleted file mode 100644
index 70614cbbc..000000000
--- a/packages/kit/test/apps/basics/src/routes/load/cors/no-cors/+page.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/** @type {import('./$types').PageLoad} */
-export async function load({ fetch, url }) {
-	const res = await fetch(`http://localhost:${url.searchParams.get('port')}`, {
-		mode: 'no-cors'
-	});
-
-	return {
-		text: await res.text()
-	};
-}
diff --git a/packages/kit/test/apps/basics/src/routes/load/cors/no-cors/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/cors/no-cors/+page.svelte
deleted file mode 100644
index d689a601e..000000000
--- a/packages/kit/test/apps/basics/src/routes/load/cors/no-cors/+page.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-<script>
-	/** @type {import('./$types').PageData} */
-	export let data;
-</script>
-
-<h1>result: {@html data.text}</h1>
diff --git a/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.server.js b/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.server.js
deleted file mode 100644
index 7a412012c..000000000
--- a/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.server.js
+++ /dev/null
@@ -1,8 +0,0 @@
-/** @type {import('./$types').PageServerLoad} */
-export async function load({ fetch, url }) {
-	const res = await fetch(`http://localhost:${url.searchParams.get('port')}`);
-
-	return {
-		text: await res.text()
-	};
-}
diff --git a/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.svelte
deleted file mode 100644
index a5063216b..000000000
--- a/packages/kit/test/apps/basics/src/routes/load/cors/server-only/+page.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-<script>
-	/** @type {import('./$types').PageData} */
-	export let data;
-</script>
-
-<h1>{@html data.text}</h1>
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers-invalid-access/+page.js b/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers-invalid-access/+page.js
deleted file mode 100644
index eda2a2da8..000000000
--- a/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers-invalid-access/+page.js
+++ /dev/null
@@ -1,8 +0,0 @@
-/** @type {import('./$types').PageLoad} */
-export async function load({ fetch }) {
-	let res = await fetch('./irrelevant', {
-		method: 'GET'
-	});
-
-	res.headers.get('content-type');
-}
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers-invalid-access/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers-invalid-access/+page.svelte
deleted file mode 100644
index e69de29bb..000000000
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index 0a8476d16..b6a8c844c 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -838,8 +838,7 @@ test.describe('Load', () => {
 		baseURL,
 		page,
 		clicknav,
-		javaScriptEnabled,
-		browserName
+		javaScriptEnabled
 	}) => {
 		await page.goto('/load');
 		await clicknav('[href="/load/fetch-request-headers"]');
@@ -856,8 +855,8 @@ test.describe('Load', () => {
 				referer: `${baseURL}/load`,
 				// these headers aren't particularly useful, but they allow us to verify
 				// that page headers are being forwarded
-				'sec-fetch-dest': browserName === 'webkit' ? undefined : 'empty',
-				'sec-fetch-mode': browserName === 'webkit' ? undefined : 'cors',
+				'sec-fetch-dest': 'empty',
+				'sec-fetch-mode': 'cors',
 				connection: 'keep-alive'
 			});
 		} else {
@@ -868,17 +867,6 @@ test.describe('Load', () => {
 		}
 	});
 
-	test('errors when trying to access non-serialized request headers on the server', async ({
-		page,
-		read_errors
-	}) => {
-		await page.goto('/load/fetch-request-headers-invalid-access');
-
-		expect(read_errors(`/load/fetch-request-headers-invalid-access`).message).toContain(
-			'Failed to get response header "content-type" — it must be included by the `filterSerializedResponseHeaders` option'
-		);
-	});
-
 	test('exposes rawBody as a DataView to endpoints', async ({ page, clicknav }) => {
 		await page.goto('/load/raw-body');
 		await clicknav('[href="/load/raw-body/dataview"]');
@@ -952,37 +940,6 @@ test.describe('Load', () => {
 
 		expect(await page.textContent('h1')).toBe('true');
 	});
-
-	test('CORS errors are simulated server-side for shared load functions', async ({
-		page,
-		read_errors
-	}) => {
-		const { port, close } = await start_server(async (req, res) => {
-			res.end('hello');
-		});
-
-		await page.goto(`/load/cors?port=${port}`);
-		expect(await page.textContent('h1')).toBe('500');
-		expect(read_errors(`/load/cors`).message).toContain(
-			`CORS error: No 'Access-Control-Allow-Origin' header is present on the requested resource`
-		);
-
-		await page.goto(`/load/cors/no-cors?port=${port}`);
-		expect(await page.textContent('h1')).toBe('result: ');
-
-		await close();
-	});
-
-	test('CORS errors are skipped for server-only load functions', async ({ page }) => {
-		const { port, close } = await start_server(async (req, res) => {
-			res.end('hello');
-		});
-
-		await page.goto(`/load/cors/server-only?port=${port}`);
-		expect(await page.textContent('h1')).toBe('hello');
-
-		await close();
-	});
 });
 
 test.describe('Nested layouts', () => {
@@ -1297,7 +1254,7 @@ test.describe('Redirects', () => {
 	test('prevents redirect loops', async ({ baseURL, page, javaScriptEnabled, browserName }) => {
 		await page.goto('/redirect');
 
-		await page.click('[href="/redirect/loopy/a"]');
+		await page.locator('[href="/redirect/loopy/a"]').click();
 
 		if (javaScriptEnabled) {
 			await page.waitForSelector('#message');
@@ -1490,8 +1447,7 @@ test.describe('Routing', () => {
 
 	test('does not attempt client-side navigation to server routes', async ({ page }) => {
 		await page.goto('/routing');
-		await page.click('[href="/routing/ambiguous/ok.json"]');
-		await page.waitForLoadState('networkidle');
+		await page.locator('[href="/routing/ambiguous/ok.json"]').click();
 		expect(await page.textContent('body')).toBe('ok');
 	});
 
@@ -1560,7 +1516,7 @@ test.describe('Routing', () => {
 	}) => {
 		await page.goto('/routing/hashes/a');
 
-		await page.click('[href="#hash-target"]');
+		await page.locator('[href="#hash-target"]').click();
 		await clicknav('[href="/routing/hashes/b"]');
 
 		await page.goBack();
@@ -1624,6 +1580,7 @@ test.describe('Routing', () => {
 			await page.goto(`/routing?port=${port}`);
 			await Promise.all([
 				page.click(`[href="http://localhost:${port}"]`),
+				// assert that the app can visit a URL not owned by the app without crashing
 				page.waitForURL(`http://localhost:${port}/`)
 			]);
 		} finally {
@@ -1656,7 +1613,7 @@ test.describe('Routing', () => {
 		expect(await page.textContent('h1')).toBe('xyz/abc');
 		expect(await page.textContent('h2')).toBe('xyz/abc');
 
-		await page.click('[href="/routing/rest/xyz/abc/qwe/deep.json"]');
+		await page.locator('[href="/routing/rest/xyz/abc/qwe/deep.json"]').click();
 		expect(await page.textContent('body')).toBe('xyz/abc/qwe');
 	});
 
@@ -1711,7 +1668,7 @@ test.describe('Routing', () => {
 		clicknav
 	}) => {
 		await page.goto('/routing/cancellation');
-		await page.click('[href="/routing/cancellation/a"]');
+		await page.locator('[href="/routing/cancellation/a"]').click();
 		await clicknav('[href="/routing/cancellation/b"]');
 
 		expect(await page.url()).toBe(`${baseURL}/routing/cancellation/b`);
@@ -1892,10 +1849,10 @@ test.describe('Actions', () => {
 		expect(await page.textContent('pre')).toBe(JSON.stringify(null));
 
 		if (javaScriptEnabled) {
-			await page.click('button.increment-success');
+			await page.locator('button.increment-success').click();
 			await expect(page.locator('pre')).toHaveText(JSON.stringify({ count: 0 }));
 
-			await page.click('button.increment-invalid');
+			await page.locator('button.increment-invalid').click();
 			await expect(page.locator('pre')).toHaveText(JSON.stringify({ count: 1 }));
 		}
 	});
@@ -1909,10 +1866,10 @@ test.describe('Actions', () => {
 		expect(await page.textContent('pre')).toBe(JSON.stringify(null));
 
 		if (javaScriptEnabled) {
-			await page.click('button.increment-success');
+			await page.locator('button.increment-success').click();
 			await expect(page.locator('pre')).toHaveText(JSON.stringify({ count: 0 }));
 
-			await page.click('button.invalidateAll');
+			await page.locator('button.invalidateAll').click();
 			await page.waitForTimeout(500);
 			await expect(page.locator('pre')).toHaveText(JSON.stringify({ count: 0 }));
 			await app.goto('/actions/enhance');
@@ -1928,7 +1885,7 @@ test.describe('Actions', () => {
 		expect(await page.textContent('pre')).toBe(JSON.stringify(null));
 
 		if (javaScriptEnabled) {
-			await page.click('button.redirect');
+			await page.locator('button.redirect').click();
 			await expect(page.locator('footer')).toHaveText('Custom layout');
 		}
 	});
@@ -1938,7 +1895,7 @@ test.describe('Actions', () => {
 		expect(await page.textContent('pre')).toBe(JSON.stringify(null));
 
 		if (javaScriptEnabled) {
-			await page.click('button.error');
+			await page.locator('button.error').click();
 			await expect(page.locator('p')).toHaveText(
 				'This is your custom error page saying: "Unexpected Form Error"'
 			);
@@ -2103,11 +2060,11 @@ test.describe.serial('Cookies API', () => {
 		let span = page.locator('#cookie-value');
 		expect(await span.innerText()).toContain('undefined');
 
-		await page.click('button#teapot');
+		await page.locator('button#teapot').click();
 		await expect(page.locator('#cookie-value')).toHaveText('teapot');
 
 		// setting a different value...
-		await page.click('button#janeAusten');
+		await page.locator('button#janeAusten').click();
 		await expect(page.locator('#cookie-value')).toHaveText('Jane Austen');
 	});