Skip to content

Commit 4562275

Browse files
fix: prevent navigation when preloading fails due to network error (#11944)
fixes #9508 Adds a preload tokens set, which is updated with the corresponding pending preloads. This set is checked when an error occurs during a navigation to check if this is a preload navigation, and if yes, don't trigger the error --------- Co-authored-by: Simon Holthausen <[email protected]>
1 parent b94010d commit 4562275

File tree

8 files changed

+162
-16
lines changed

8 files changed

+162
-16
lines changed

.changeset/good-roses-design.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sveltejs/kit": patch
3+
---
4+
5+
fix: prevent navigation when `data-sveltekit-preload-data` fails to fetch due to network error

packages/kit/src/runtime/client/client.js

+67-16
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ const invalidated = [];
172172
*/
173173
const components = [];
174174

175-
/** @type {{id: string, promise: Promise<import('./types.js').NavigationResult>} | null} */
175+
/** @type {{id: string, token: {}, promise: Promise<import('./types.js').NavigationResult>} | null} */
176176
let load_cache = null;
177177

178178
/** @type {Array<(navigation: import('@sveltejs/kit').BeforeNavigate) => void>} */
@@ -219,6 +219,14 @@ let page;
219219
/** @type {{}} */
220220
let token;
221221

222+
/**
223+
* A set of tokens which are associated to current preloads.
224+
* If a preload becomes a real navigation, it's removed from the set.
225+
* If a preload token is in the set and the preload errors, the error
226+
* handling logic (for example reloading) is skipped.
227+
*/
228+
const preload_tokens = new Set();
229+
222230
/** @type {Promise<void> | null} */
223231
let pending_invalidate;
224232

@@ -375,16 +383,26 @@ async function _goto(url, options, redirect_count, nav_token) {
375383

376384
/** @param {import('./types.js').NavigationIntent} intent */
377385
async function _preload_data(intent) {
378-
load_cache = {
379-
id: intent.id,
380-
promise: load_route(intent).then((result) => {
381-
if (result.type === 'loaded' && result.state.error) {
382-
// Don't cache errors, because they might be transient
383-
load_cache = null;
384-
}
385-
return result;
386-
})
387-
};
386+
// Reuse the existing pending preload if it's for the same navigation.
387+
// Prevents an edge case where same preload is triggered multiple times,
388+
// then a later one is becoming the real navigation and the preload tokens
389+
// get out of sync.
390+
if (intent.id !== load_cache?.id) {
391+
const preload = {};
392+
preload_tokens.add(preload);
393+
load_cache = {
394+
id: intent.id,
395+
token: preload,
396+
promise: load_route({ ...intent, preload }).then((result) => {
397+
preload_tokens.delete(preload);
398+
if (result.type === 'loaded' && result.state.error) {
399+
// Don't cache errors, because they might be transient
400+
load_cache = null;
401+
}
402+
return result;
403+
})
404+
};
405+
}
388406

389407
return load_cache.promise;
390408
}
@@ -803,11 +821,31 @@ function diff_search_params(old_url, new_url) {
803821
}
804822

805823
/**
806-
* @param {import('./types.js').NavigationIntent} intent
824+
* @param {Omit<import('./types.js').NavigationFinished['state'], 'branch'> & { error: App.Error }} opts
825+
* @returns {import('./types.js').NavigationFinished}
826+
*/
827+
function preload_error({ error, url, route, params }) {
828+
return {
829+
type: 'loaded',
830+
state: {
831+
error,
832+
url,
833+
route,
834+
params,
835+
branch: []
836+
},
837+
props: { page, constructors: [] }
838+
};
839+
}
840+
841+
/**
842+
* @param {import('./types.js').NavigationIntent & { preload?: {} }} intent
807843
* @returns {Promise<import('./types.js').NavigationResult>}
808844
*/
809-
async function load_route({ id, invalidating, url, params, route }) {
845+
async function load_route({ id, invalidating, url, params, route, preload }) {
810846
if (load_cache?.id === id) {
847+
// the preload becomes the real navigation
848+
preload_tokens.delete(load_cache.token);
811849
return load_cache.promise;
812850
}
813851

@@ -855,9 +893,15 @@ async function load_route({ id, invalidating, url, params, route }) {
855893
try {
856894
server_data = await load_data(url, invalid_server_nodes);
857895
} catch (error) {
896+
const handled_error = await handle_error(error, { url, params, route: { id } });
897+
898+
if (preload_tokens.has(preload)) {
899+
return preload_error({ error: handled_error, url, params, route });
900+
}
901+
858902
return load_root_error_page({
859903
status: get_status(error),
860-
error: await handle_error(error, { url, params, route: { id: route.id } }),
904+
error: handled_error,
861905
url,
862906
route
863907
});
@@ -940,6 +984,15 @@ async function load_route({ id, invalidating, url, params, route }) {
940984
};
941985
}
942986

987+
if (preload_tokens.has(preload)) {
988+
return preload_error({
989+
error: await handle_error(err, { params, url, route: { id: route.id } }),
990+
url,
991+
params,
992+
route
993+
});
994+
}
995+
943996
let status = get_status(err);
944997
/** @type {App.Error} */
945998
let error;
@@ -972,8 +1025,6 @@ async function load_route({ id, invalidating, url, params, route }) {
9721025
route
9731026
});
9741027
} else {
975-
// if we get here, it's because the root `load` function failed,
976-
// and we need to fall back to the server
9771028
return await server_fallback(url, { id: route.id }, error, status);
9781029
}
9791030
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function load({ url }) {
2+
return {
3+
url: url.toString()
4+
};
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<a id="one" href="/data-sveltekit/preload-data/offline/target" data-sveltekit-preload-data>target</a
2+
>
3+
4+
<a
5+
id="slow-navigation"
6+
href="/data-sveltekit/preload-data/offline/slow-navigation"
7+
data-sveltekit-preload-data>slow-navigation</a
8+
>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export async function load() {
2+
return new Promise((resolve) => {
3+
setTimeout(resolve, 1000);
4+
});
5+
}

packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/slow-navigation/+page.svelte

Whitespace-only changes.

packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/offline/target/+page.svelte

Whitespace-only changes.

packages/kit/test/apps/basics/test/client.test.js

+72
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,78 @@ test.describe('data-sveltekit attributes', () => {
665665
expect(requests.length).toBe(0);
666666
});
667667

668+
test('data-sveltekit-preload-data network failure does not trigger navigation', async ({
669+
page,
670+
context,
671+
browserName
672+
}) => {
673+
await page.goto('/data-sveltekit/preload-data/offline');
674+
675+
await context.setOffline(true);
676+
677+
await page.locator('#one').dispatchEvent('mousemove');
678+
await Promise.all([
679+
page.waitForTimeout(100), // wait for preloading to start
680+
page.waitForLoadState('networkidle') // wait for preloading to finish
681+
]);
682+
683+
let offline_url = /\/data-sveltekit\/preload-data\/offline/;
684+
if (browserName === 'chromium') {
685+
// it's chrome-error://chromewebdata/ on ubuntu but not on windows
686+
offline_url = /chrome-error:\/\/chromewebdata\/|\/data-sveltekit\/preload-data\/offline/;
687+
}
688+
expect(page).toHaveURL(offline_url);
689+
});
690+
691+
test('data-sveltekit-preload-data error does not block user navigation', async ({
692+
page,
693+
context,
694+
browserName
695+
}) => {
696+
await page.goto('/data-sveltekit/preload-data/offline');
697+
698+
await context.setOffline(true);
699+
700+
await page.locator('#one').dispatchEvent('mousemove');
701+
await Promise.all([
702+
page.waitForTimeout(100), // wait for preloading to start
703+
page.waitForLoadState('networkidle') // wait for preloading to finish
704+
]);
705+
706+
expect(page).toHaveURL('/data-sveltekit/preload-data/offline');
707+
708+
await page.locator('#one').dispatchEvent('click');
709+
await page.waitForTimeout(100); // wait for navigation to start
710+
await page.waitForLoadState('networkidle');
711+
712+
let offline_url = /\/data-sveltekit\/preload-data\/offline/;
713+
if (browserName === 'chromium') {
714+
// it's chrome-error://chromewebdata/ on ubuntu but not on windows
715+
offline_url = /chrome-error:\/\/chromewebdata\/|\/data-sveltekit\/preload-data\/offline/;
716+
}
717+
expect(page).toHaveURL(offline_url);
718+
});
719+
720+
test('data-sveltekit-preload does not abort ongoing navigation', async ({
721+
page,
722+
browserName
723+
}) => {
724+
await page.goto('/data-sveltekit/preload-data/offline');
725+
726+
await page.locator('#slow-navigation').dispatchEvent('click');
727+
await page.waitForTimeout(100); // wait for navigation to start
728+
await page.locator('#slow-navigation').dispatchEvent('mousemove');
729+
await Promise.all([
730+
page.waitForTimeout(100), // wait for preloading to start
731+
page.waitForLoadState('networkidle') // wait for preloading to finish
732+
]);
733+
734+
expect(page).toHaveURL(
735+
'/data-sveltekit/preload-data/offline/slow-navigation' ||
736+
(browserName === 'chromium' && 'chrome-error://chromewebdata/')
737+
);
738+
});
739+
668740
test('data-sveltekit-reload', async ({ baseURL, page, clicknav }) => {
669741
/** @type {string[]} */
670742
const requests = [];

0 commit comments

Comments
 (0)