Repository: sveltejs/kit
Pull request #6537 (2022-09-03 00:07:03)

add navigation (but not invalidation) types
A more conservative version of #6522.

There's some controversy about whether `invalidate(...)` and `invalidateAll()` should trigger `beforeNavigate` and `afterNavigate` callbacks and update the `navigating` store. There are good arguments on both sides.

I think it makes sense to merge the changes _without_ the invalidation stuff in the meantime, since it's a non-breaking change that will add a useful feature and close an issue.

### 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


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 021c3015af93e4d7396d2de68261502e12b3aaaf

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name          | Type  |
| ------------- | ----- |
| @sveltejs/kit | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/sveltejs/kit/new/gh-6517-without-invalidation?filename=.changeset/unlucky-baboons-buy.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A%22test-basics%22%3A%20patch%0A---%0A%0Aadd%20navigation%20(but%20not%20invalidation)%20types%0A)


netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *kit-demo* processing.


|  Name | Link |
|---------------------------------|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 021c3015af93e4d7396d2de68261502e12b3aaaf |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/kit-demo/deploys/63129aa95586ff0009a8393b |

Patch:
diff --git a/.changeset/little-beers-wink.md b/.changeset/little-beers-wink.md
new file mode 100644
index 000000000..71dab5697
--- /dev/null
+++ b/.changeset/little-beers-wink.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[feat] add `type` to navigation object
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index b8b7e03c9..1e61ce2cc 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -80,10 +80,10 @@ export function create_client({ target, base, trailing_slash }) {
 	};
 
 	const callbacks = {
-		/** @type {Array<(opts: { from: URL, to: URL | null, cancel: () => void }) => void>} */
+		/** @type {Array<(navigation: import('types').Navigation & { cancel: () => void }) => void>} */
 		before_navigate: [],
 
-		/** @type {Array<(opts: { from: URL | null, to: URL }) => void>} */
+		/** @type {Array<(navigation: import('types').Navigation) => void>} */
 		after_navigate: []
 	};
 
@@ -142,8 +142,10 @@ export function create_client({ target, base, trailing_slash }) {
 
 	function invalidate() {
 		if (!invalidating) {
+			const url = new URL(location.href);
+
 			invalidating = Promise.resolve().then(async () => {
-				await update(new URL(location.href), []);
+				await update(url, []);
 
 				invalidating = null;
 				force_invalidation = false;
@@ -177,7 +179,8 @@ export function create_client({ target, base, trailing_slash }) {
 				replaceState
 			},
 			accepted: () => {},
-			blocked: () => {}
+			blocked: () => {},
+			type: 'goto'
 		});
 	}
 
@@ -395,7 +398,8 @@ export function create_client({ target, base, trailing_slash }) {
 			});
 		}
 
-		const navigation = { from: null, to: new URL(location.href) };
+		/** @type {import('types').Navigation} */
+		const navigation = { from: null, to: new URL(location.href), type: 'load' };
 		callbacks.after_navigate.forEach((fn) => fn(navigation));
 
 		started = true;
@@ -977,21 +981,44 @@ export function create_client({ target, base, trailing_slash }) {
 	 *     replaceState: boolean;
 	 *     state: any;
 	 *   } | null;
+	 *   type: import('types').NavigationType;
+	 *   delta?: number;
 	 *   accepted: () => void;
 	 *   blocked: () => void;
 	 * }} opts
 	 */
-	async function navigate({ url, scroll, keepfocus, redirect_chain, details, accepted, blocked }) {
-		const from = current.url;
+	async function navigate({
+		url,
+		scroll,
+		keepfocus,
+		redirect_chain,
+		details,
+		type,
+		delta,
+		accepted,
+		blocked
+	}) {
 		let should_block = false;
 
+		/** @type {import('types').Navigation} */
 		const navigation = {
-			from,
+			from: current.url,
 			to: url,
-			cancel: () => (should_block = true)
+			type
+		};
+
+		if (delta !== undefined) {
+			navigation.delta = delta;
+		}
+
+		const cancellable = {
+			...navigation,
+			cancel: () => {
+				should_block = true;
+			}
 		};
 
-		callbacks.before_navigate.forEach((fn) => fn(navigation));
+		callbacks.before_navigate.forEach((fn) => fn(cancellable));
 
 		if (should_block) {
 			blocked();
@@ -1003,10 +1030,7 @@ export function create_client({ target, base, trailing_slash }) {
 		accepted();
 
 		if (started) {
-			stores.navigating.set({
-				from: current.url,
-				to: url
-			});
+			stores.navigating.set(navigation);
 		}
 
 		await update(
@@ -1018,9 +1042,7 @@ export function create_client({ target, base, trailing_slash }) {
 				details
 			},
 			() => {
-				const navigation = { from, to: url };
 				callbacks.after_navigate.forEach((fn) => fn(navigation));
-
 				stores.navigating.set(null);
 			}
 		);
@@ -1129,9 +1151,11 @@ export function create_client({ target, base, trailing_slash }) {
 			addEventListener('beforeunload', (e) => {
 				let should_block = false;
 
+				/** @type {import('types').Navigation & { cancel: () => void }} */
 				const navigation = {
 					from: current.url,
 					to: null,
+					type: 'unload',
 					cancel: () => (should_block = true)
 				};
 
@@ -1244,7 +1268,8 @@ export function create_client({ target, base, trailing_slash }) {
 						replaceState: url.href === location.href
 					},
 					accepted: () => event.preventDefault(),
-					blocked: () => event.preventDefault()
+					blocked: () => event.preventDefault(),
+					type: 'link'
 				});
 			});
 
@@ -1254,6 +1279,8 @@ export function create_client({ target, base, trailing_slash }) {
 					// with history.go, which means we end up back here, hence this check
 					if (event.state[INDEX_KEY] === current_history_index) return;
 
+					const delta = event.state[INDEX_KEY] - current_history_index;
+
 					navigate({
 						url: new URL(location.href),
 						scroll: scroll_positions[event.state[INDEX_KEY]],
@@ -1264,9 +1291,10 @@ export function create_client({ target, base, trailing_slash }) {
 							current_history_index = event.state[INDEX_KEY];
 						},
 						blocked: () => {
-							const delta = current_history_index - event.state[INDEX_KEY];
-							history.go(delta);
-						}
+							history.go(-delta);
+						},
+						type: 'popstate',
+						delta
 					});
 				}
 			});
diff --git a/packages/kit/types/ambient.d.ts b/packages/kit/types/ambient.d.ts
index 5f1b79da0..cd9a044e3 100644
--- a/packages/kit/types/ambient.d.ts
+++ b/packages/kit/types/ambient.d.ts
@@ -95,6 +95,8 @@ declare module '$app/environment' {
  * ```
  */
 declare module '$app/navigation' {
+	import { Navigation } 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.
 	 * This is generally discouraged, since it breaks user expectations.
@@ -158,17 +160,23 @@ declare module '$app/navigation' {
 	export function prefetchRoutes(routes?: string[]): Promise<void>;
 
 	/**
-	 * A navigation interceptor that triggers before we navigate to a new URL (internal or external) whether by clicking a link, calling `goto`, or using the browser back/forward controls.
-	 * This is helpful if we want to conditionally prevent a navigation from completing or lookup the upcoming url.
+	 * A navigation interceptor that triggers before we navigate to a new URL, whether by clicking a link, calling `goto(...)`, or using the browser back/forward controls.
+	 * Calling `cancel()` will prevent the navigation from completing.
+	 *
+	 * When navigating to an external URL, `navigation.to` will be `null`.
+	 *
+	 * `beforeNavigate` must be called during a component initialization. It remains active as long as the component is mounted.
 	 */
 	export function beforeNavigate(
-		fn: (navigation: { from: URL; to: URL | null; cancel: () => void }) => void
+		callback: (navigation: Navigation & { cancel: () => void }) => void
 	): void;
 
 	/**
-	 * A lifecycle function that runs when the page mounts, and also whenever SvelteKit navigates to a new URL but stays on this component.
+	 * 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(fn: (navigation: { from: URL | null; to: URL }) => void): void;
+	export function afterNavigate(callback: (navigation: Navigation) => void): void;
 }
 
 /**
@@ -210,7 +218,7 @@ declare module '$app/stores' {
 	export const page: Readable<Page>;
 	/**
 	 * A readable store.
-	 * When navigating starts, its value is `{ from: URL, to: URL }`,
+	 * When navigating starts, its value is a `Navigation` object with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties.
 	 * When navigating finishes, its value reverts to `null`.
 	 */
 	export const navigating: Readable<Navigation | null>;
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index cbbd7fb73..7eb746c2d 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -219,9 +219,13 @@ export interface LoadEvent<
 	depends: (...deps: string[]) => void;
 }
 
+export type NavigationType = 'load' | 'unload' | 'link' | 'goto' | 'popstate';
+
 export interface Navigation {
-	from: URL;
-	to: URL;
+	from: URL | null;
+	to: URL | null;
+	type: NavigationType;
+	delta?: number;
 }
 
 export interface Page<Params extends Record<string, string> = Record<string, string>> {


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/store/navigating/+layout.svelte b/packages/kit/test/apps/basics/src/routes/store/navigating/+layout.svelte
index 4ce91af0c..706de9b32 100644
--- a/packages/kit/test/apps/basics/src/routes/store/navigating/+layout.svelte
+++ b/packages/kit/test/apps/basics/src/routes/store/navigating/+layout.svelte
@@ -10,7 +10,9 @@
 
 <div id="nav-status">
 	{#if $navigating}
-		<p id="navigating">navigating from {$navigating.from.pathname} to {$navigating.to.pathname}</p>
+		<p id="navigating">
+			navigating from {$navigating.from.pathname} to {$navigating.to.pathname} ({$navigating.type})
+		</p>
 	{:else}
 		<p id="not-navigating">not currently navigating</p>
 	{/if}
diff --git a/packages/kit/test/apps/basics/src/routes/store/navigating/a/+page.js b/packages/kit/test/apps/basics/src/routes/store/navigating/a/+page.js
new file mode 100644
index 000000000..ebd7ec42f
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/store/navigating/a/+page.js
@@ -0,0 +1,4 @@
+/** @type {import('./$types').PageLoad} */
+export async function load() {
+	await new Promise((f) => setTimeout(f, 50));
+}
diff --git a/packages/kit/test/apps/basics/src/routes/store/navigating/b/+page.js b/packages/kit/test/apps/basics/src/routes/store/navigating/b/+page.js
index 7b1c80573..ebd7ec42f 100644
--- a/packages/kit/test/apps/basics/src/routes/store/navigating/b/+page.js
+++ b/packages/kit/test/apps/basics/src/routes/store/navigating/b/+page.js
@@ -1,5 +1,4 @@
-/** @type {import('@sveltejs/kit').Load} */
+/** @type {import('./$types').PageLoad} */
 export async function load() {
 	await new Promise((f) => setTimeout(f, 50));
-	return {};
 }
diff --git a/packages/kit/test/apps/basics/src/routes/store/navigating/c/+page.js b/packages/kit/test/apps/basics/src/routes/store/navigating/c/+page.js
index dbc433ad4..3b4c7fb0d 100644
--- a/packages/kit/test/apps/basics/src/routes/store/navigating/c/+page.js
+++ b/packages/kit/test/apps/basics/src/routes/store/navigating/c/+page.js
@@ -1,4 +1,4 @@
-/** @type {import('@sveltejs/kit').Load} */
+/** @type {import('./$types').PageLoad} */
 export async function load() {
 	await new Promise((f) => setTimeout(f, 1000));
 	return {};
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index 6e9eb5652..789ffd4e9 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -1135,7 +1135,7 @@ test.describe('$app/stores', () => {
 		expect(JSON.parse(await page.textContent('#store-data'))).toEqual(stuff3);
 	});
 
-	test('navigating store contains from and to', async ({ app, page, javaScriptEnabled }) => {
+	test('navigating store contains from, to and type', async ({ app, page, javaScriptEnabled }) => {
 		await page.goto('/store/navigating/a');
 
 		expect(await page.textContent('#nav-status')).toBe('not currently navigating');
@@ -1148,10 +1148,16 @@ test.describe('$app/stores', () => {
 				page.textContent('#navigating')
 			]);
 
-			expect(res[1]).toBe('navigating from /store/navigating/a to /store/navigating/b');
+			expect(res[1]).toBe('navigating from /store/navigating/a to /store/navigating/b (link)');
 
 			await page.waitForSelector('#not-navigating');
 			expect(await page.textContent('#nav-status')).toBe('not currently navigating');
+
+			page.goBack();
+			await page.waitForSelector('#navigating');
+			expect(await page.textContent('#navigating')).toBe(
+				'navigating from /store/navigating/b to /store/navigating/a (popstate)'
+			);
 		}
 	});