Repository: sveltejs/kit (JavaScript)

Issue:
fetch POST caching missing body
### Describe the bug

When I do a POST that receives a `data-ttl`, it's cached.
Unfortunately, the cache key is computed only with the `url`.

Therefore, when I do a POST with another body, the fetch will not fire and the result will come from the cache.

### Reproduction

The PR is coming with a test.
Here was the failing test before the fix: https://github.com/jycouet/kit/tree/fix/fetch-cache-control/packages/kit/test/apps/basics/src/routes/load/fetch-cache-control/load-data

### Logs

_No response_

### System Info

```Shell
System:
    OS: Linux 5.10 Ubuntu 22.04.1 LTS 22.04.1 LTS (Jammy Jellyfish)
    CPU: (8) x64 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz
    Memory: 3.32 GB / 7.69 GB
    Container: Yes
    Shell: 5.1.16 - /bin/bash
  Binaries:
    Node: 18.12.1 - ~/.volta/tools/image/node/18.12.1/bin/node
    Yarn: 1.22.19 - ~/.volta/tools/image/yarn/1.22.19/bin/yarn
    npm: 8.19.2 - ~/.volta/tools/image/node/18.12.1/bin/npm
  npmPackages:
    svelte: ^3.52.0 => 3.52.0
```


### Severity

serious, but I can work around it

### Additional Information

This use case is quite common in GraphQL context. 
Especially with services like stellate that are doing caching.

It makes it impossible to use SvelteKit with these GraphQL endpoints.

PR is following 👍


Discussion:

Patch:
diff --git a/.changeset/curvy-years-sin.md b/.changeset/curvy-years-sin.md
new file mode 100644
index 000000000000..4d51c5960cbd
--- /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/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index 96d65b89a479..b0b300c18ad5 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -593,8 +593,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,
diff --git a/packages/kit/src/runtime/client/fetcher.js b/packages/kit/src/runtime/client/fetcher.js
index 42a7b577e1b9..552bae15eadd 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;
+}


Test patch:
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 000000000000..1148676b0b33
--- /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 000000000000..193489d5bfbf
--- /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 000000000000..8a9db1b9a70b
--- /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 000000000000..2fb805f66703
--- /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/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 9d82bfccff96..acf123fd8242 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -467,6 +467,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;