Repository: sveltejs/kit (js)

Issue:
`cookies.delete` not working when using form `enhance`
### Describe the bug

Using `enhance` on a `<form>` and throwing `redirect` in the form action causes `cookies.delete` to not work in the page server load function.

### Reproduction

https://github.com/s3812497/sveltekit-enhance-bug

Reproduction:
1. Click the enhanced form button.
2. User is redirected.
3. Cookie is not deleted.

Expected:
1. Click the enhanced form button.
2. User is redirected.
3. Cookie is deleted in `+page.server`

### Logs

_No response_

### System Info

```shell
System:
    OS: macOS 12.5.1
    CPU: (8) arm64 Apple M1 Pro
    Memory: 1.56 GB / 32.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 16.17.0 - ~/.nvm/versions/node/v16.17.0/bin/node
    npm: 8.15.0 - ~/.nvm/versions/node/v16.17.0/bin/npm
  Browsers:
    Chrome: 104.0.5112.79
    Firefox: 104.0.1
    Firefox Developer Edition: 105.0
    Safari: 15.6.1
    Safari Technology Preview: 16.0
  npmPackages:
    @sveltejs/adapter-auto: next => 1.0.0-next.71 
    @sveltejs/kit: next => 1.0.0-next.472 
    svelte: ^3.44.0 => 3.50.0 
    vite: ^3.1.0 => 3.1.0
```


### Severity

serious, but I can work around it

### Additional Information

_No response_

Pull request description:
[fix] harmonize cookie path and add dev time warnings
- Warnings will be logged to the console if it's detected that there's likely misusage of the path parameter.
- More documentation
- Fix mismatch of cookie path between /foo and /foo/__data.json server-load calls by emulating browser behavior of the default path

Fixes #6609

### 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/eighty-turkeys-exercise.md b/.changeset/eighty-turkeys-exercise.md
new file mode 100644
index 000000000..7815fd251
--- /dev/null
+++ b/.changeset/eighty-turkeys-exercise.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] harmonize cookie path and add dev time warnings
diff --git a/.changeset/tidy-wombats-repeat.md b/.changeset/tidy-wombats-repeat.md
new file mode 100644
index 000000000..d236ecbbf
--- /dev/null
+++ b/.changeset/tidy-wombats-repeat.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[feat] add invalidateAll option to goto
diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md
index ca04ed73b..759f53499 100644
--- a/documentation/docs/20-core-concepts/20-load.md
+++ b/documentation/docs/20-core-concepts/20-load.md
@@ -266,6 +266,8 @@ export async function load({ cookies }) {
 }
 ```
 
+> When setting cookies, be aware of the `path` property. By default, the `path` of a cookie is the current pathname. If you for example set a cookie at page `admin/user`, the cookie will only be available within the `admin` pages by default. In most cases you likely want to set `path` to `'/'` to make the cookie available throughout your app.
+
 Both server-only and shared `load` functions have access to a `setHeaders` function that, when running on the server, can set headers for the response. (When running in the browser, `setHeaders` has no effect.) This is useful if you want the page to be cached, for example:
 
 ```js
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index 70287c910..eaa25bf00 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -164,13 +164,19 @@ export function create_client({ target, base, trailing_slash }) {
 
 	/**
 	 * @param {string | URL} url
-	 * @param {{ noscroll?: boolean; replaceState?: boolean; keepfocus?: boolean; state?: any }} opts
+	 * @param {{ noscroll?: boolean; replaceState?: boolean; keepfocus?: boolean; state?: any; invalidateAll?: boolean }} opts
 	 * @param {string[]} redirect_chain
 	 * @param {{}} [nav_token]
 	 */
 	async function goto(
 		url,
-		{ noscroll = false, replaceState = false, keepfocus = false, state = {} },
+		{
+			noscroll = false,
+			replaceState = false,
+			keepfocus = false,
+			state = {},
+			invalidateAll = false
+		},
 		redirect_chain,
 		nav_token
 	) {
@@ -188,7 +194,11 @@ export function create_client({ target, base, trailing_slash }) {
 				replaceState
 			},
 			nav_token,
-			accepted: () => {},
+			accepted: () => {
+				if (invalidateAll) {
+					force_invalidation = true;
+				}
+			},
 			blocked: () => {},
 			type: 'goto'
 		});
@@ -1212,7 +1222,7 @@ export function create_client({ target, base, trailing_slash }) {
 					post_update();
 				}
 			} else if (result.type === 'redirect') {
-				goto(result.location, {}, []);
+				goto(result.location, { invalidateAll: true }, []);
 			} else {
 				/** @type {Record<string, any>} */
 				const props = {
diff --git a/packages/kit/src/runtime/server/cookie.js b/packages/kit/src/runtime/server/cookie.js
index 12148144d..62992d127 100644
--- a/packages/kit/src/runtime/server/cookie.js
+++ b/packages/kit/src/runtime/server/cookie.js
@@ -1,10 +1,20 @@
 import { parse, serialize } from 'cookie';
+import { DATA_SUFFIX } from '../../constants.js';
+import { normalize_path } from '../../utils/url.js';
+
+/**
+ * Tracks all cookies set during dev mode so we can emit warnings
+ * when we detect that there's likely cookie misusage due to wrong paths
+ *
+ * @type {Record<string, Set<string>>} */
+const cookie_paths = {};
 
 /**
  * @param {Request} request
  * @param {URL} url
+ * @param {Pick<import('types').SSROptions, 'dev' | 'trailing_slash'>} options
  */
-export function get_cookies(request, url) {
+export function get_cookies(request, url, options) {
 	const header = request.headers.get('cookie') ?? '';
 
 	const initial_cookies = parse(header);
@@ -42,7 +52,17 @@ export function get_cookies(request, url) {
 
 			const decode = opts?.decode || decodeURIComponent;
 			const req_cookies = parse(header, { decode });
-			return req_cookies[name]; // the decoded string or undefined
+			const cookie = req_cookies[name]; // the decoded string or undefined
+
+			if (!options.dev || cookie) {
+				return cookie;
+			}
+
+			if (c || cookie_paths[name]?.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 '/'?`
+				);
+			}
 		},
 
 		/**
@@ -51,14 +71,45 @@ export function get_cookies(request, url) {
 		 * @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 '/'
+					url.pathname.endsWith(DATA_SUFFIX)
+						? url.pathname.slice(0, -DATA_SUFFIX.length)
+						: 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('/') || '/';
+			}
+
 			new_cookies[name] = {
 				name,
 				value,
 				options: {
 					...defaults,
-					...opts
+					...opts,
+					path
 				}
 			};
+
+			if (options.dev) {
+				cookie_paths[name] = cookie_paths[name] || new Set();
+				if (!value) {
+					if (!cookie_paths[name].has(path) && cookie_paths[name].size > 0) {
+						console.warn(
+							`Trying to delete cookie '${name}' at path '${path}', but a cookie with that name only exists at a different path.`
+						);
+					}
+					cookie_paths[name].delete(path);
+				} else {
+					// We could also emit a warning here if the cookie already exists at a different path,
+					// but that's more likely a false positive because it's valid to set the same name at different paths
+					cookie_paths[name].add(path);
+				}
+			}
 		},
 
 		/**
@@ -66,15 +117,10 @@ export function get_cookies(request, url) {
 		 * @param {import('cookie').CookieSerializeOptions} opts
 		 */
 		delete(name, opts = {}) {
-			new_cookies[name] = {
-				name,
-				value: '',
-				options: {
-					...defaults,
-					...opts,
-					maxAge: 0
-				}
-			};
+			cookies.set(name, '', {
+				...opts,
+				maxAge: 0
+			});
 		},
 
 		/**
diff --git a/packages/kit/src/runtime/server/cookie.spec.js b/packages/kit/src/runtime/server/cookie.spec.js
index 9696e9152..34ca3d0e8 100644
--- a/packages/kit/src/runtime/server/cookie.spec.js
+++ b/packages/kit/src/runtime/server/cookie.spec.js
@@ -42,15 +42,15 @@ paths.negative.forEach(([path, constraint]) => {
 	});
 });
 
-/** @param {boolean} localhost */
-const cookies_setup = (localhost = false) => {
-	const url = new URL(localhost ? 'http://localhost:1234' : 'https://example.com');
+/** @param {string} href */
+const cookies_setup = (href = 'https://example.com') => {
+	const url = new URL(href);
 	const request = new Request(url, {
 		headers: new Headers({
 			cookie: 'a=b;'
 		})
 	});
-	return get_cookies(request, url);
+	return get_cookies(request, url, { dev: false, trailing_slash: 'ignore' });
 };
 
 test('a cookie should not be present after it is deleted', () => {
@@ -67,12 +67,22 @@ test('default values when set is called', () => {
 	const opts = new_cookies['a']?.options;
 	assert.equal(opts?.secure, true);
 	assert.equal(opts?.httpOnly, true);
-	assert.equal(opts?.path, undefined);
+	assert.equal(opts?.path, '/');
+	assert.equal(opts?.sameSite, 'lax');
+});
+
+test('default values when set is called on sub path', () => {
+	const { cookies, new_cookies } = cookies_setup('https://example.com/foo/bar');
+	cookies.set('a', 'b');
+	const opts = new_cookies['a']?.options;
+	assert.equal(opts?.secure, true);
+	assert.equal(opts?.httpOnly, true);
+	assert.equal(opts?.path, '/foo');
 	assert.equal(opts?.sameSite, 'lax');
 });
 
 test('default values when on localhost', () => {
-	const { cookies, new_cookies } = cookies_setup(true);
+	const { cookies, new_cookies } = cookies_setup('http://localhost:1234');
 	cookies.set('a', 'b');
 	const opts = new_cookies['a']?.options;
 	assert.equal(opts?.secure, false);
@@ -94,7 +104,7 @@ test('default values when delete is called', () => {
 	const opts = new_cookies['a']?.options;
 	assert.equal(opts?.secure, true);
 	assert.equal(opts?.httpOnly, true);
-	assert.equal(opts?.path, undefined);
+	assert.equal(opts?.path, '/');
 	assert.equal(opts?.sameSite, 'lax');
 	assert.equal(opts?.maxAge, 0);
 });
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index b3936a34b..206a61efe 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -96,7 +96,7 @@ export async function respond(request, options, state) {
 	/** @type {Record<string, string>} */
 	const headers = {};
 
-	const { cookies, new_cookies, get_cookie_header } = get_cookies(request, url);
+	const { cookies, new_cookies, get_cookie_header } = get_cookies(request, url, options);
 
 	if (state.prerendering) disable_search(url);
 
diff --git a/packages/kit/types/ambient.d.ts b/packages/kit/types/ambient.d.ts
index f51b42ff3..786b07acb 100644
--- a/packages/kit/types/ambient.d.ts
+++ b/packages/kit/types/ambient.d.ts
@@ -182,14 +182,32 @@ declare module '$app/navigation' {
 	 * Returns a Promise that resolves when SvelteKit navigates (or fails to navigate, in which case the promise rejects) to the specified `url`.
 	 *
 	 * @param url Where to navigate to. Note that if you've set [`config.kit.paths.base`](https://kit.svelte.dev/docs/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app.
-	 * @param opts.replaceState If `true`, will replace the current `history` entry rather than creating a new one with `pushState`
-	 * @param opts.noscroll If `true`, the browser will maintain its scroll position rather than scrolling to the top of the page after navigation
-	 * @param opts.keepfocus If `true`, the currently focused element will retain focus after navigation. Otherwise, focus will be reset to the body
-	 * @param opts.state The state of the new/updated history entry
+	 * @param opts Options related to the navigation
 	 */
 	export function goto(
 		url: string | URL,
-		opts?: { replaceState?: boolean; noscroll?: boolean; keepfocus?: boolean; state?: any }
+		opts?: {
+			/**
+			 * If `true`, will replace the current `history` entry rather than creating a new one with `pushState`
+			 */
+			replaceState?: boolean;
+			/**
+			 * If `true`, the browser will maintain its scroll position rather than scrolling to the top of the page after navigation
+			 */
+			noscroll?: boolean;
+			/**
+			 * If `true`, the currently focused element will retain focus after navigation. Otherwise, focus will be reset to the body
+			 */
+			keepfocus?: boolean;
+			/**
+			 * The state of the new/updated history entry
+			 */
+			state?: any;
+			/**
+			 * If `true`, all `load` functions of the page will be rerun. See https://kit.svelte.dev/docs/load#invalidation for more info on invalidation.
+			 */
+			invalidateAll?: boolean;
+		}
 	): Promise<void>;
 	/**
 	 * Causes any `load` functions belonging to the currently active page to re-run if they depend on the `url` in question, via `fetch` or `depends`. Returns a `Promise` that resolves when the page is subsequently updated.
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index 7c995d2c1..68aca0775 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -161,6 +161,8 @@ export interface Cookies {
 
 	/**
 	 * Deletes a cookie by setting its value to an empty string and setting the expiry date in the past.
+	 *
+	 * By default, the `path` of a cookie is the 'directory' of the current pathname. In most cases you should explicitly set `path: '/'` to make the cookie available throughout your app.
 	 */
 	delete(name: string, opts?: import('cookie').CookieSerializeOptions): void;
 


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/forced/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/forced/+page.svelte
index b66f248de..f7ebb7f7a 100644
--- a/packages/kit/test/apps/basics/src/routes/load/invalidation/forced/+page.svelte
+++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/forced/+page.svelte
@@ -1,5 +1,5 @@
 <script>
-	import { invalidateAll } from '$app/navigation';
+	import { invalidateAll, goto } from '$app/navigation';
 
 	/** @type {import('./$types').PageData} */
 	export let data;
@@ -7,4 +7,9 @@
 
 <h1>a: {data.a}, b: {data.b}</h1>
 
-<button on:click={invalidateAll}>invalidate</button>
+<button class="invalidateall" on:click={invalidateAll}>invalidate</button>
+
+<button
+	class="goto"
+	on:click={() => goto('/load/invalidation/forced?test', { invalidateAll: true })}>goto</button
+>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index f0dae6abe..67e1be088 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -755,17 +755,32 @@ test.describe.serial('Invalidation', () => {
 		await page.goto('/load/invalidation/forced');
 		expect(await page.textContent('h1')).toBe('a: 0, b: 1');
 
-		await page.click('button');
+		await page.click('button.invalidateall');
 		await page.waitForLoadState('networkidle');
 		await page.waitForTimeout(200); // apparently necessary
 		expect(await page.textContent('h1')).toBe('a: 2, b: 3');
 
-		await page.click('button');
+		await page.click('button.invalidateall');
 		await page.waitForLoadState('networkidle');
 		await page.waitForTimeout(200);
 		expect(await page.textContent('h1')).toBe('a: 4, b: 5');
 	});
 
+	test('server-only load functions are re-run following goto with forced invalidation', async ({
+		page,
+		request
+	}) => {
+		await request.get('/load/invalidation/forced/reset');
+
+		await page.goto('/load/invalidation/forced');
+		expect(await page.textContent('h1')).toBe('a: 0, b: 1');
+
+		await page.click('button.goto');
+		await page.waitForLoadState('networkidle');
+		await page.waitForTimeout(200); // apparently necessary
+		expect(await page.textContent('h1')).toBe('a: 2, b: 3');
+	});
+
 	test('multiple invalidations run concurrently', async ({ page, request }) => {
 		await page.goto('/load/invalidation/multiple');
 		expect(await page.textContent('p')).toBe('layout: 0, page: 0');