diff --git a/.changeset/preload-redirect-cache.md b/.changeset/preload-redirect-cache.md
new file mode 100644
index 000000000000..fe08ba924b67
--- /dev/null
+++ b/.changeset/preload-redirect-cache.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+fix: don't cache preloaded redirect results, so navigation re-runs `load` instead of replaying a stale redirect
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index a0b858ba0ddb..20685fe0c3a7 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -622,8 +622,11 @@ async function _preload_data(intent) {
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
+ if (result.type === 'redirect' || (result.type === 'loaded' && result.state.error)) {
+ // Don't cache errors or redirects, because they might be transient.
+ // A cached redirect would be replayed without re-running `load`
+ // until a navigation commits, which can turn a resolvable
+ // redirect chain into a redirect loop
discard_load_cache();
}
return result;
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/dashboard/+page.server.js b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/dashboard/+page.server.js
new file mode 100644
index 000000000000..20425a680b33
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/dashboard/+page.server.js
@@ -0,0 +1,9 @@
+import { redirect } from '@sveltejs/kit';
+import { state } from '../state.js';
+
+export function load() {
+ if (!state.selected) {
+ redirect(303, '/data-sveltekit/preload-data/redirect-gate/select');
+ }
+ return {};
+}
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/dashboard/+page.svelte b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/dashboard/+page.svelte
new file mode 100644
index 000000000000..8b53980d76d2
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/dashboard/+page.svelte
@@ -0,0 +1 @@
+
dashboard
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/select/+page.server.js b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/select/+page.server.js
new file mode 100644
index 000000000000..e310689bf9d2
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/select/+page.server.js
@@ -0,0 +1,7 @@
+import { redirect } from '@sveltejs/kit';
+import { state } from '../state.js';
+
+export function load() {
+ state.selected = true;
+ redirect(303, '/data-sveltekit/preload-data/redirect-gate/dashboard');
+}
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/start/+page.server.js b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/start/+page.server.js
new file mode 100644
index 000000000000..8c5dd3542d24
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/start/+page.server.js
@@ -0,0 +1,6 @@
+import { state } from '../state.js';
+
+export function load() {
+ // reset so the test can run repeatedly against the same server
+ state.selected = false;
+}
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/start/+page.svelte b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/start/+page.svelte
new file mode 100644
index 000000000000..889614be7758
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/start/+page.svelte
@@ -0,0 +1,5 @@
+go to dashboard
diff --git a/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/state.js b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/state.js
new file mode 100644
index 000000000000..1cc97fae3184
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/data-sveltekit/preload-data/redirect-gate/state.js
@@ -0,0 +1 @@
+export const state = { selected: false };
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 8b5d6df581ee..46606fab0345 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -1071,6 +1071,24 @@ test.describe('data-sveltekit attributes', () => {
await expect(page).toHaveURL(offline_url);
});
+ test('preloaded redirect is not replayed on navigation', async ({ page }) => {
+ await page.goto('/data-sveltekit/preload-data/redirect-gate/start');
+
+ // preload /dashboard while its load redirects to /select
+ await page.locator('#gated').hover();
+ await page.locator('#gated').dispatchEvent('touchstart');
+ await Promise.all([
+ page.waitForTimeout(100), // wait for preloading to start
+ page.waitForLoadState('networkidle') // wait for preloading to finish
+ ]);
+
+ // navigating must re-run load rather than replay the cached redirect,
+ // otherwise the mutual redirect never resolves and loops
+ await page.locator('#gated').click();
+ await expect(page.locator('h1')).toHaveText('dashboard');
+ await expect(page).toHaveURL(/redirect-gate\/dashboard/);
+ });
+
test('data-sveltekit-preload-data error does not block user navigation', async ({
page,
context,