Repository: sveltejs/kit (JavaScript)

Issue:
data-sveltekit-preload-data="hover" results in unexpected hard navigation when network is sketchy / offline
### Describe the bug

When using `data-sveltekit-preload-data="hover"`, you can invoke an unexpected hard navigation when the network is unavailable. When you're offline, and hover over a link, SvelteKit tries to preload the data/code, that fails (because you're offline, of course), and then it tries to load the error page (which also fails), and the eventual fallback is a hard navigation to the link's `href`, which I would not expect to happen while you are just hovering over a link.

This requires a certain combination of circumstances:

- root layout has a load function (this does not happen without that)
- you are using `data-sveltekit-preload-data="hover"` either on the link or set globally on the `body` (it's still a problem with `"tap"`, but it's way more obviously a bug with `"hover"`, because you're not clicking on the link)
- the link goes to a different page route
- you are offline or under some other adverse network situation, such that the preload requests and the subsequent requests for the error page all fail

What appears to be happening stepping through the code in `client/client.js`, it looks like when you try to preload the data on a link, SvelteKit is eventually doing `load_route()`, which sends you via a `catch` block to `load_root_error_page()`, which has [a conditional when your root layout has a load function](https://github.com/sveltejs/kit/blob/c0612a8f846f3eba8bf2d23f30d713e1ae14dd7b/packages/kit/src/runtime/client/client.js#L984-L1007) that lands you in `native_navigation`.

So, this is easiest to reproduce when offline, but it could happen in other adverse network situations.

### Reproduction

https://github.com/leereamsnyder/sveltekit-offline-preload-bug

This is a skeleton SvelteKit project, with [the addition of a simple layout load fn](https://github.com/leereamsnyder/sveltekit-offline-preload-bug/commit/b1f7c562913b29aa4c489ab1b0468b7c0e239827).

The readme has repro steps, copied here:

1. `npm install`
1. `npm run dev`
1. Open up http://localhost:5173 in Chrome (this is most straightforward in Chrome)
1. In the command palette (CMD + SHIFT + P), choose **Go Offline**, or select **Offline** from the throttling selector in the **Network** panel, typically next to **Disable cache**
1. Hover over the **Sverdle** link in the nav. In a flash, SvelteKit will do a hard navigation to`http://localhost:5173/sverdle`, which will fail because you're offline

### Logs

_No response_

### System Info

```Shell
System:
    OS: macOS 13.2
    CPU: (10) arm64 Apple M1 Pro
    Memory: 112.14 MB / 16.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 18.13.0 - /opt/homebrew/bin/node
    Yarn: 1.22.19 - /opt/homebrew/bin/yarn
    npm: 8.19.3 - /opt/homebrew/bin/npm
  Browsers:
    Chrome: 111.0.5563.110
    Safari: 16.3
    Safari Technology Preview: 16.4
  npmPackages:
    @sveltejs/adapter-auto: ^2.0.0 => 2.0.0 
    @sveltejs/kit: ^1.5.0 => 1.13.0 
    svelte: ^3.54.0 => 3.57.0 
    vite: ^4.2.0 => 4.2.1
```


### Severity

serious, but I can work around it

### Additional Information

Just to be clear, I don't expect SvelteKit to, ya know, _work_ when the network is unavailable.

It's the unexpected hard navigation that happens when you just hover over a link that's the problem.

This is not a throrough or ironclad workaround, but to the extent that the browser will tell you that you're offline with built-in APIs, you can mitigate this by turning preload behavior `"off"` when detected. I have a component like this floating around that changes the `data-sveltekit-preload-data` on the `<body>`:

```js
import { onMount } from 'svelte'

  onMount(() => {
    const originalPreloadData = document.body.getAttribute('data-sveltekit-preload-data')

    // this is not often accurate!
    if (navigator.onLine === false) {
      document.body.setAttribute('data-sveltekit-preload-data', 'off')
    }

    window.addEventListener('offline', () => {
      document.body.setAttribute('data-sveltekit-preload-data', 'off')
    })

    window.addEventListener('online', () => {
      document.body.setAttribute('data-sveltekit-preload-data', originalPreloadData)
    })
  })
```


Discussion:
I am experiencing a similar thing. I have 
- a prerendered +layout.server.js file that is loading data
- a data-sveltekit-preload-data="hover" attribute on the body tag
- a page transition handled by the +layout.svelte file on route change

Everything works fine in dev mode. Also npm run preview works great. But once deployed with Firebase, the route change happens when I'm just hovering over a link! And it's also happening when I'm online - not just when offline. 

Deleting data-sveltekit-preload-data="hover" from the body tag solves the issue with the navigation on hover. But the app ist still performing a full page reload, thus my page transition doesn't work anymore.
I'm facing this exact same problem without having to deal with that last point.
> you are offline or under some other adverse network situation, such that the preload requests and the subsequent requests for the error page all fail

I built my SvelteKit app, and I ran a local preview using that `vite preview` and it works fine. Furthermore, I even throttled the network speed to GPRS in Firefox, and it's still not forcefully switching routes.

But, when I deploy my app to Firebase, it's the exact same behavior in the deployed application as described above.
To deploy, I didn't use any adapters, just regular `firebase deploy` command because Firebase has experimental support for deploying SvelteKit apps now.
We have also seen this problem on our site where hovering over a link sometimes randomly triggers a navigation. But our site is deployed on Vercel, not Firebase.
Regardless of network, when a link would error (403), just hovering over it redirects the user to the error page. We'd expect the error to be silent, rather than redirecting to the error page!
This is also happening to me when I use cloudflare DDoS protection. I think it is because the network request is initially not a success because it hits the DDoS protection so it does a hard navigation.
I have a similar issue where hard-navigation occurs when `__data.json?x-sveltekit-invalidated=01` results in a 403 for whatever reason (deployed to cloudfront/s3)



Patch:
diff --git a/.changeset/good-roses-design.md b/.changeset/good-roses-design.md
new file mode 100644
index 000000000000..b2055ea2c9a3
--- /dev/null
+++ b/.changeset/good-roses-design.md
@@ -0,0 +1,5 @@
+---
+"@sveltejs/kit": patch
+---
+
+fix: prevent navigation when `data-sveltekit-preload-data` fails to fetch due to network error
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index dd400677931f..69e3a61b44ab 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -172,7 +172,7 @@ const invalidated = [];
  */
 const components = [];
 
-/** @type {{id: string, promise: Promise<import('./types.js').NavigationResult>} | null} */
+/** @type {{id: string, token: {}, promise: Promise<import('./types.js').NavigationResult>} | null} */
 let load_cache = null;
 
 /** @type {Array<(navigation: import('@sveltejs/kit').BeforeNavigate) => void>} */
@@ -219,6 +219,14 @@ let page;
 /** @type {{}} */
 let token;
 
+/**
+ * A set of tokens which are associated to current preloads.
+ * If a preload becomes a real navigation, it's removed from the set.
+ * If a preload token is in the set and the preload errors, the error
+ * handling logic (for example reloading) is skipped.
+ */
+const preload_tokens = new Set();
+
 /** @type {Promise<void> | null} */
 let pending_invalidate;
 
@@ -375,16 +383,26 @@ async function _goto(url, options, redirect_count, nav_token) {
 
 /** @param {import('./types.js').NavigationIntent} intent */
 async function _preload_data(intent) {
-	load_cache = {
-		id: intent.id,
-		promise: load_route(intent).then((result) => {
-			if (result.type === 'loaded' && result.state.error) {
-				// Don't cache errors, because they might be transient
-				load_cache = null;
-			}
-			return result;
-		})
-	};
+	// Reuse the existing pending preload if it's for the same navigation.
+	// Prevents an edge case where same preload is triggered multiple times,
+	// then a later one is becoming the real navigation and the preload tokens
+	// get out of sync.
+	if (intent.id !== load_cache?.id) {
+		const preload = {};
+		preload_tokens.add(preload);
+		load_cache = {
+			id: intent.id,
+			token: preload,
+			promise: load_route({ ...intent, preload }).then((result) => {
+				preload_tokens.delete(preload);
+				if (result.type === 'loaded' && result.state.error) {
+					// Don't cache errors, because they might be transient
+					load_cache = null;
+				}
+				return result;
+			})
+		};
+	}
 
 	return load_cache.promise;
 }
@@ -803,11 +821,31 @@ function diff_search_params(old_url, new_url) {
 }
 
 /**
- * @param {import('./types.js').NavigationIntent} intent
+ * @param {Omit<import('./types.js').NavigationFinished['state'], 'branch'> & { error: App.Error }} opts
+ * @returns {import('./types.js').NavigationFinished}
+ */
+function preload_error({ error, url, route, params }) {
+	return {
+		type: 'loaded',
+		state: {
+			error,
+			url,
+			route,
+			params,
+			branch: []
+		},
+		props: { page, constructors: [] }
+	};
+}
+
+/**
+ * @param {import('./types.js').NavigationIntent & { preload?: {} }} intent
  * @returns {Promise<import('./types.js').NavigationResult>}
  */
-async function load_route({ id, invalidating, url, params, route }) {
+async function load_route({ id, invalidating, url, params, route, preload }) {
 	if (load_cache?.id === id) {
+		// the preload becomes the real navigation
+		preload_tokens.delete(load_cache.token);
 		return load_cache.promise;
 	}
 
@@ -855,9 +893,15 @@ async function load_route({ id, invalidating, url, params, route }) {
 		try {
 			server_data = await load_data(url, invalid_server_nodes);
 		} catch (error) {
+			const handled_error = await handle_error(error, { url, params, route: { id } });
+
+			if (preload_tokens.has(preload)) {
+				return preload_error({ error: handled_error, url, params, route });
+			}
+
 			return load_root_error_page({
 				status: get_status(error),
-				error: await handle_error(error, { url, params, route: { id: route.id } }),
+				error: handled_error,
 				url,
 				route
 			});
@@ -940,6 +984,15 @@ async function load_route({ id, invalidating, url, params, route }) {
 					};
 				}
 
+				if (preload_tokens.has(preload)) {
+					return preload_error({
+						error: await handle_error(err, { params, url, route: { id: route.id } }),
+						url,
+						params,
+						route
+					});
+				}
+
 				let status = get_status(err);
 				/** @type {App.Error} */
 				let error;
@@ -972,8 +1025,6 @@ async function load_route({ id, invalidating, url, params, route }) {
 						route
 					});
 				} else {
-					// if we get here, it's because the root `load` function failed,
-					// and we need to fall back to the server
 					return await server_fallback(url, { id: route.id }, error, status);
 				}
 			}


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/+layout.server.js b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/+layout.server.js
new file mode 100644
index 000000000000..9d06c5313fd0
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/+layout.server.js
@@ -0,0 +1,5 @@
+export function load({ url }) {
+	return {
+		url: url.toString()
+	};
+}
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/+page.svelte b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/+page.svelte
new file mode 100644
index 000000000000..15e990e00a5c
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/+page.svelte
@@ -0,0 +1,8 @@
+<a id="one" href="/data-sveltekit/preload-data/offline/target" data-sveltekit-preload-data>target</a
+>
+
+<a
+	id="slow-navigation"
+	href="/data-sveltekit/preload-data/offline/slow-navigation"
+	data-sveltekit-preload-data>slow-navigation</a
+>
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/slow-navigation/+page.server.js b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/slow-navigation/+page.server.js
new file mode 100644
index 000000000000..e7f852a46324
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/slow-navigation/+page.server.js
@@ -0,0 +1,5 @@
+export async function load() {
+	return new Promise((resolve) => {
+		setTimeout(resolve, 1000);
+	});
+}
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/slow-navigation/+page.svelte b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/slow-navigation/+page.svelte
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/target/+page.svelte b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/target/+page.svelte
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 2ae2b2818b74..4df2ba593000 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -665,6 +665,78 @@ test.describe('data-sveltekit attributes', () => {
 		expect(requests.length).toBe(0);
 	});
 
+	test('data-sveltekit-preload-data network failure does not trigger navigation', async ({
+		page,
+		context,
+		browserName
+	}) => {
+		await page.goto('/data-sveltekit/preload-data/offline');
+
+		await context.setOffline(true);
+
+		await page.locator('#one').dispatchEvent('mousemove');
+		await Promise.all([
+			page.waitForTimeout(100), // wait for preloading to start
+			page.waitForLoadState('networkidle') // wait for preloading to finish
+		]);
+
+		let offline_url = /\/data-sveltekit\/preload-data\/offline/;
+		if (browserName === 'chromium') {
+			// it's chrome-error://chromewebdata/ on ubuntu but not on windows
+			offline_url = /chrome-error:\/\/chromewebdata\/|\/data-sveltekit\/preload-data\/offline/;
+		}
+		expect(page).toHaveURL(offline_url);
+	});
+
+	test('data-sveltekit-preload-data error does not block user navigation', async ({
+		page,
+		context,
+		browserName
+	}) => {
+		await page.goto('/data-sveltekit/preload-data/offline');
+
+		await context.setOffline(true);
+
+		await page.locator('#one').dispatchEvent('mousemove');
+		await Promise.all([
+			page.waitForTimeout(100), // wait for preloading to start
+			page.waitForLoadState('networkidle') // wait for preloading to finish
+		]);
+
+		expect(page).toHaveURL('/data-sveltekit/preload-data/offline');
+
+		await page.locator('#one').dispatchEvent('click');
+		await page.waitForTimeout(100); // wait for navigation to start
+		await page.waitForLoadState('networkidle');
+
+		let offline_url = /\/data-sveltekit\/preload-data\/offline/;
+		if (browserName === 'chromium') {
+			// it's chrome-error://chromewebdata/ on ubuntu but not on windows
+			offline_url = /chrome-error:\/\/chromewebdata\/|\/data-sveltekit\/preload-data\/offline/;
+		}
+		expect(page).toHaveURL(offline_url);
+	});
+
+	test('data-sveltekit-preload does not abort ongoing navigation', async ({
+		page,
+		browserName
+	}) => {
+		await page.goto('/data-sveltekit/preload-data/offline');
+
+		await page.locator('#slow-navigation').dispatchEvent('click');
+		await page.waitForTimeout(100); // wait for navigation to start
+		await page.locator('#slow-navigation').dispatchEvent('mousemove');
+		await Promise.all([
+			page.waitForTimeout(100), // wait for preloading to start
+			page.waitForLoadState('networkidle') // wait for preloading to finish
+		]);
+
+		expect(page).toHaveURL(
+			'/data-sveltekit/preload-data/offline/slow-navigation' ||
+				(browserName === 'chromium' && 'chrome-error://chromewebdata/')
+		);
+	});
+
 	test('data-sveltekit-reload', async ({ baseURL, page, clicknav }) => {
 		/** @type {string[]} */
 		const requests = [];