Repository: sveltejs/kit
Pull request #8399 (2023-01-08 22:03:03)

[fix] route invalidation comparison based on url and route, not on id
Reference issue: https://github.com/sveltejs/kit/issues/8398

This solves layouts *always* invalidating and rerunning `load` if they mention `event.route`, even if the route ID did not change, due to a wrong comparison in the code.

I have no idea how to write tests, but this should be simple enough.

### 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.
- [ ] 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
- [ ] 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: 0a9065981c3df85fb70be943daf434e6d737b716

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/Algoinde/kit/new/master?filename=.changeset/few-scissors-shout.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A---%0A%0AFix%20route%20invalidation%20comparison%20based%20on%20url%20and%20route%2C%20not%20on%20id%0A)



Patch:
diff --git a/.changeset/big-cobras-sip.md b/.changeset/big-cobras-sip.md
new file mode 100644
index 000000000..4436c6b4e
--- /dev/null
+++ b/.changeset/big-cobras-sip.md
@@ -0,0 +1,5 @@
+---
+'@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
new file mode 100644
index 000000000..28b778bfa
--- /dev/null
+++ b/.changeset/breezy-walls-provide.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+only prerender a given dependency once
diff --git a/.changeset/dry-poets-crash.md b/.changeset/dry-poets-crash.md
new file mode 100644
index 000000000..58eeacb84
--- /dev/null
+++ b/.changeset/dry-poets-crash.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+`updated.check()` type changed to Promise<boolean>
diff --git a/.changeset/stupid-actors-look.md b/.changeset/stupid-actors-look.md
new file mode 100644
index 000000000..b31e235a4
--- /dev/null
+++ b/.changeset/stupid-actors-look.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] quote 'script' in CSP directives
diff --git a/.changeset/sweet-hairs-roll.md b/.changeset/sweet-hairs-roll.md
new file mode 100644
index 000000000..fb969c331
--- /dev/null
+++ b/.changeset/sweet-hairs-roll.md
@@ -0,0 +1,5 @@
+---
+'@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
new file mode 100644
index 000000000..6cee5b7d2
--- /dev/null
+++ b/.changeset/wet-feet-lie.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] don't add nonce attribute to `<link>` elements
diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md
index a4f8d9561..834f4101c 100644
--- a/documentation/docs/20-core-concepts/20-load.md
+++ b/documentation/docs/20-core-concepts/20-load.md
@@ -521,7 +521,7 @@ export async function load({ fetch, depends }) {
 ```svelte
 /// file: src/routes/random-number/+page.svelte
 <script>
-	import { invalidateAll } from '$app/navigation';
+	import { invalidate, invalidateAll } from '$app/navigation';
 
 	/** @type {import('./$types').PageData} */
 	export let data;
diff --git a/documentation/docs/30-advanced/50-server-only-modules.md b/documentation/docs/30-advanced/50-server-only-modules.md
index c14a4f21d..e6c424003 100644
--- a/documentation/docs/30-advanced/50-server-only-modules.md
+++ b/documentation/docs/30-advanced/50-server-only-modules.md
@@ -51,4 +51,6 @@ Cannot import $lib/server/secrets.js into public-facing code:
 
 Even though the public-facing code — `src/routes/+page.svelte` — only uses the `add` export and not the secret `atlantisCoordinates` export, the secret code could end up in JavaScript that the browser downloads, and so the import chain is considered unsafe.
 
-This feature also works with dynamic imports, even interpolated ones like ``await import(`./${foo}.js`)``, with one small caveat: during development, if there are two or more dynamic imports between the public-facing code and the server-only module, the illegal import will not be detected the first time the code is loaded.
\ No newline at end of file
+This feature also works with dynamic imports, even interpolated ones like ``await import(`./${foo}.js`)``, with one small caveat: during development, if there are two or more dynamic imports between the public-facing code and the server-only module, the illegal import will not be detected the first time the code is loaded.
+
+> Unit testing frameworks like Vitest do not distinguish between server-only and public-facing code. For this reason, illegal import detection is disabled when running tests, as determined by `process.env.TEST === 'true'`.
\ No newline at end of file
diff --git a/packages/kit/src/core/prerender/prerender.js b/packages/kit/src/core/prerender/prerender.js
index aa3e732c6..0e6f86306 100644
--- a/packages/kit/src/core/prerender/prerender.js
+++ b/packages/kit/src/core/prerender/prerender.js
@@ -102,10 +102,20 @@ export async function prerender() {
 	/** @type {import('types').SSRManifest} */
 	const manifest = (await import(pathToFileURL(manifest_path).href)).manifest;
 
+	/** @type {Map<string, string>} */
+	const saved = new Map();
+
 	override({
 		building: true,
 		paths: config.paths,
-		read: (file) => readFileSync(join(config.files.assets, file))
+		read: (file) => {
+			// stuff we just wrote
+			const filepath = saved.get(file);
+			if (filepath) return readFileSync(filepath);
+
+			// stuff in `static`
+			return readFileSync(join(config.files.assets, file));
+		}
 	});
 
 	const server = new Server(manifest);
@@ -222,6 +232,7 @@ export async function prerender() {
 			}
 
 			const body = result.body ?? new Uint8Array(await result.response.arrayBuffer());
+
 			save(
 				'dependencies',
 				result.response,
@@ -351,6 +362,9 @@ export async function prerender() {
 		} else if (response_type !== OK) {
 			handle_http_error({ status: response.status, path: decoded, referrer, referenceType });
 		}
+
+		manifest.assets.add(file);
+		saved.set(file, dest);
 	}
 
 	for (const route of manifest._.routes) {
diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js
index 0acd4dabd..03956dd3b 100644
--- a/packages/kit/src/exports/vite/index.js
+++ b/packages/kit/src/exports/vite/index.js
@@ -301,7 +301,7 @@ function kit({ svelte_config }) {
 		},
 
 		async load(id, options) {
-			if (options?.ssr === false) {
+			if (options?.ssr === false && process.env.TEST !== 'true') {
 				const normalized_cwd = vite.normalizePath(cwd);
 				const normalized_lib = vite.normalizePath(svelte_config.kit.files.lib);
 				if (
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index 6ef4cc0ed..69fb5e4f1 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -709,7 +709,7 @@ export function create_client({ target, base }) {
 		let server_data = null;
 
 		const url_changed = current.url ? id !== current.url.pathname + current.url.search : false;
-		const route_changed = current.route ? id !== current.route.id : false;
+		const route_changed = current.route ? route.id !== current.route.id : false;
 
 		const invalid_server_nodes = loaders.reduce((acc, loader, i) => {
 			const previous = current.branch[i];
diff --git a/packages/kit/src/runtime/client/utils.js b/packages/kit/src/runtime/client/utils.js
index 8e1a04764..aa14bcce7 100644
--- a/packages/kit/src/runtime/client/utils.js
+++ b/packages/kit/src/runtime/client/utils.js
@@ -211,6 +211,7 @@ export function create_updated_store() {
 	/** @type {NodeJS.Timeout} */
 	let timeout;
 
+	/** @type {() => Promise<boolean>} */
 	async function check() {
 		if (DEV || !BROWSER) return false;
 
diff --git a/packages/kit/src/runtime/server/page/csp.js b/packages/kit/src/runtime/server/page/csp.js
index 282369140..f7b8eedd4 100644
--- a/packages/kit/src/runtime/server/page/csp.js
+++ b/packages/kit/src/runtime/server/page/csp.js
@@ -16,7 +16,8 @@ const quoted = new Set([
 	'none',
 	'strict-dynamic',
 	'report-sample',
-	'wasm-unsafe-eval'
+	'wasm-unsafe-eval',
+	'script'
 ]);
 
 const crypto_pattern = /^(nonce|sha\d\d\d)-/;
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 91337cab0..030b43561 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -211,22 +211,17 @@ export async function render_response({
 		const path = prefixed(dep);
 
 		if (resolve_opts.preload({ type: 'css', path })) {
-			const attributes = [];
-
-			if (csp.style_needs_nonce) {
-				attributes.push(`nonce="${csp.nonce}"`);
-			}
+			const attributes = ['rel="stylesheet"'];
 
 			if (inline_styles.has(dep)) {
 				// don't load stylesheets that are already inlined
 				// include them in disabled state so that Vite can detect them and doesn't try to add them
 				attributes.push('disabled', 'media="(max-width: 0)"');
 			} else {
-				const preload_atts = ['rel="preload"', 'as="style"'].concat(attributes);
+				const preload_atts = ['rel="preload"', 'as="style"'];
 				link_header_preloads.add(`<${encodeURI(path)}>; ${preload_atts.join(';')}; nopush`);
 			}
 
-			attributes.unshift('rel="stylesheet"');
 			head += `\n\t\t<link href="${path}" ${attributes.join(' ')}>`;
 		}
 	}
diff --git a/packages/kit/types/ambient.d.ts b/packages/kit/types/ambient.d.ts
index efcc1904d..a8bd4cf9f 100644
--- a/packages/kit/types/ambient.d.ts
+++ b/packages/kit/types/ambient.d.ts
@@ -320,7 +320,7 @@ declare module '$app/stores' {
 	/**
 	 *  A readable store whose initial value is `false`. If [`version.pollInterval`](https://kit.svelte.dev/docs/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update the store value to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling.
 	 */
-	export const updated: Readable<boolean> & { check(): boolean };
+	export const updated: Readable<boolean> & { check(): Promise<boolean> };
 
 	/**
 	 * A function that returns all of the contextual stores. On the server, this must be called during component initialization.


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/+layout.js b/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/+layout.js
index b37c81283..a144dc362 100644
--- a/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/+layout.js
+++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/+layout.js
@@ -1,4 +1,4 @@
 /** @type {import('./$types').LayoutLoad} */
 export function load({ route }) {
-	return { route };
+	return { route, random: Math.random() };
 }
diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/+layout.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/+layout.svelte
index 2f2c178ac..3346ea43f 100644
--- a/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/+layout.svelte
+++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/+layout.svelte
@@ -4,7 +4,11 @@
 </script>
 
 <h1>route.id: {data.route.id}</h1>
+<h2>random id: {data.random}</h2>
 <slot />
 
 <a href="/load/invalidation/route/shared/a">/load/invalidation/route/shared/a</a>
 <a href="/load/invalidation/route/shared/b">/load/invalidation/route/shared/b</a>
+
+<a href="/load/invalidation/route/shared/unchanged-x">/load/invalidation/route/shared/unchanged-x</a>
+<a href="/load/invalidation/route/shared/unchanged-y">/load/invalidation/route/shared/unchanged-y</a>
diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/[x]/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/[x]/+page.svelte
new file mode 100644
index 000000000..ae59943f0
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/route/shared/[x]/+page.svelte
@@ -0,0 +1,1 @@
+<p>[x]</p>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index d6bf2f9b1..0a8cc4c66 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -1069,6 +1069,16 @@ test.describe('Invalidation', () => {
 		expect(await page.textContent('h1')).toBe('route.id: /load/invalidation/route/shared/b');
 	});
 
+	test('route.id does not rerun layout if unchanged', async ({ page, clicknav }) => {
+		await page.goto('/load/invalidation/route/shared/unchanged-x');
+		expect(await page.textContent('h1')).toBe('route.id: /load/invalidation/route/shared/[x]');
+		const id = await page.textContent('h2');
+
+		await clicknav('[href="/load/invalidation/route/shared/unchanged-y"]');
+		expect(await page.textContent('h1')).toBe('route.id: /load/invalidation/route/shared/[x]');
+		expect(await page.textContent('h2')).toBe(id);
+	});
+
 	test('$page.url can safely be mutated', async ({ page }) => {
 		await page.goto('/load/mutated-url?q=initial');
 		await expect(page.getByText('initial')).toBeVisible();
diff --git a/packages/kit/test/apps/options/svelte.config.js b/packages/kit/test/apps/options/svelte.config.js
index bae1cda49..b41b487d6 100644
--- a/packages/kit/test/apps/options/svelte.config.js
+++ b/packages/kit/test/apps/options/svelte.config.js
@@ -5,7 +5,8 @@ const config = {
 		embedded: true,
 		csp: {
 			directives: {
-				'script-src': ['self']
+				'script-src': ['self'],
+				'require-trusted-types-for': ['script']
 			}
 		},
 		files: {
diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js
index 588a7cbe5..97fb3bc36 100644
--- a/packages/kit/test/apps/options/test/test.js
+++ b/packages/kit/test/apps/options/test/test.js
@@ -124,6 +124,13 @@ test.describe('CSP', () => {
 
 		await close();
 	});
+
+	test("quotes 'script'", async ({ page }) => {
+		const response = await page.goto(`/path-base`);
+		expect(response.headers()['content-security-policy']).toMatch(
+			/require-trusted-types-for 'script'/
+		);
+	});
 });
 
 test.describe('Custom extensions', () => {
diff --git a/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/also-not-buffered/+page.js b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/also-not-buffered/+page.js
new file mode 100644
index 000000000..de4123c2a
--- /dev/null
+++ b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/also-not-buffered/+page.js
@@ -0,0 +1,9 @@
+/** @type {import('@sveltejs/kit').Load} */
+export async function load({ fetch }) {
+	const url = '/fetch-endpoint/not-buffered.json';
+	const res = await fetch(url);
+
+	return {
+		headers: res.headers
+	};
+}
diff --git a/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/also-not-buffered/+page.svelte b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/also-not-buffered/+page.svelte
new file mode 100644
index 000000000..bf73bfc65
--- /dev/null
+++ b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/also-not-buffered/+page.svelte
@@ -0,0 +1,6 @@
+<script>
+	/** @type {import('./$types').PageData} */
+	export let data;
+</script>
+
+<h1>content-type: {data.headers.get('content-type')}</h1>
diff --git a/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.json/+server.js b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.json/+server.js
index 82d3cb7b8..692b07ced 100644
--- a/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.json/+server.js
+++ b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.json/+server.js
@@ -1,5 +1,10 @@
 import { json } from '@sveltejs/kit';
 
+let called = false;
+
 export async function GET() {
+	if (called) throw new Error('should only be called once');
+	called = true;
+
 	return json({ answer: 42 });
 }