Describe the bug
When a link is data-preloaded (data-sveltekit-preload-data, on hover or tap) and the target route's load returns a redirect(...), the client stores that redirect result in its preload cache (load_cache). On the subsequent client-side navigation the cached redirect is replayed without re-running the load, so the redirect decision is never re-evaluated against current server state.
If the redirect target itself redirects back — two routes gating on opposite conditions — the client's redirect-follow recursion never commits a page, and load_cache is only cleared on a committed navigation. So the stale redirect is replayed every cycle until the 20-redirect limit throws Error: Redirect loop. This loops even when a fresh evaluation would resolve (i.e. after the gating condition has changed), because the cached leg never re-fetches.
Reproduction
Two routes gating on opposite states of a server-side flag, plus a preloaded link into one:
src/lib/state.js
export const state = { selected: false };
src/routes/dashboard/+page.server.js
import { redirect } from '@sveltejs/kit';
import { state } from '$lib/state.js';
export function load() {
if (!state.selected) redirect(303, '/select');
return {};
}
src/routes/select/+page.server.js
import { redirect } from '@sveltejs/kit';
import { state } from '$lib/state.js';
export function load() {
state.selected = true; // "select", then go to the gated page
redirect(303, '/dashboard');
}
src/routes/start/+page.svelte
<a href="/dashboard" data-sveltekit-preload-data="hover">go to dashboard</a>
Steps:
- Visit
/start.
- Hover or tap the link. This preloads
/dashboard/__data.json while selected is false, so /dashboard's load redirects to /select, and that redirect result is cached for /dashboard.
- Click the link.
Expected: /dashboard → /select (sets selected = true) → /dashboard renders. One bounce, resolves.
Actual: /dashboard replays the cached redirect to /select; /select redirects back to /dashboard; the cached /dashboard redirect is replayed again — never re-fetched, so it never observes selected === true — and it loops until Error: Redirect loop.
Without the preload (data-sveltekit-preload-data="off", or a full-page navigation) it resolves in one bounce — confirming the cached redirect is the cause, not the mutual redirect itself. A hard refresh also "fixes" it, because a fresh document load starts with an empty client cache.
Root cause (packages/kit/src/runtime/client/client.js, verified on 2.70.0)
_preload_data caches the load_route result and only discards it when it is a loaded result with an error (// Don't cache errors, because they might be transient). A redirect result is kept.
load_route short-circuits on a cache hit: if (load_cache?.id === id) { … return load_cache.promise; } — returning the cached redirect with no fetch.
- In
navigate, the redirect branch (redirect_count < 20) recurses and returns before the load_cache = null on the commit path — so a redirect-only chain never clears the cache.
Expected behaviour
A preloaded redirect result shouldn't be replayed as a navigation decision. The most consistent fix looks like discarding redirect results in _preload_data the same way errors are already discarded (a redirect is equally transient / server-state-dependent, and isn't renderable data), or otherwise re-evaluating rather than replaying it on consumption.
Severity / scope
Any app with two routes gating on opposite conditions (e.g. an onboarding/auth gate: a page requires X, the gate requires not-X) plus a preloaded link into one of them can hit an unrecoverable redirect loop until a hard refresh. Reproduces through @sveltejs/kit@2.70.0.
Related: #16192 (query caches a redirect response) — same theme in a different subsystem.
System Info
@sveltejs/kit: reproduced on 2.61.1 and verified still present through 2.70.0
Describe the bug
When a link is data-preloaded (
data-sveltekit-preload-data, on hover or tap) and the target route'sloadreturns aredirect(...), the client stores that redirect result in its preload cache (load_cache). On the subsequent client-side navigation the cached redirect is replayed without re-running the load, so the redirect decision is never re-evaluated against current server state.If the redirect target itself redirects back — two routes gating on opposite conditions — the client's redirect-follow recursion never commits a page, and
load_cacheis only cleared on a committed navigation. So the stale redirect is replayed every cycle until the 20-redirect limit throwsError: Redirect loop. This loops even when a fresh evaluation would resolve (i.e. after the gating condition has changed), because the cached leg never re-fetches.Reproduction
Two routes gating on opposite states of a server-side flag, plus a preloaded link into one:
src/lib/state.jssrc/routes/dashboard/+page.server.jssrc/routes/select/+page.server.jssrc/routes/start/+page.svelteSteps:
/start./dashboard/__data.jsonwhileselectedisfalse, so/dashboard's load redirects to/select, and that redirect result is cached for/dashboard.Expected:
/dashboard→/select(setsselected = true) →/dashboardrenders. One bounce, resolves.Actual:
/dashboardreplays the cached redirect to/select;/selectredirects back to/dashboard; the cached/dashboardredirect is replayed again — never re-fetched, so it never observesselected === true— and it loops untilError: Redirect loop.Without the preload (
data-sveltekit-preload-data="off", or a full-page navigation) it resolves in one bounce — confirming the cached redirect is the cause, not the mutual redirect itself. A hard refresh also "fixes" it, because a fresh document load starts with an empty client cache.Root cause (
packages/kit/src/runtime/client/client.js, verified on 2.70.0)_preload_datacaches theload_routeresult and only discards it when it is aloadedresult with an error (// Don't cache errors, because they might be transient). Aredirectresult is kept.load_routeshort-circuits on a cache hit:if (load_cache?.id === id) { … return load_cache.promise; }— returning the cached redirect with no fetch.navigate, theredirectbranch (redirect_count < 20) recurses and returns before theload_cache = nullon the commit path — so a redirect-only chain never clears the cache.Expected behaviour
A preloaded
redirectresult shouldn't be replayed as a navigation decision. The most consistent fix looks like discardingredirectresults in_preload_datathe same way errors are already discarded (a redirect is equally transient / server-state-dependent, and isn't renderable data), or otherwise re-evaluating rather than replaying it on consumption.Severity / scope
Any app with two routes gating on opposite conditions (e.g. an onboarding/auth gate: a page requires X, the gate requires not-X) plus a preloaded link into one of them can hit an unrecoverable redirect loop until a hard refresh. Reproduces through
@sveltejs/kit@2.70.0.Related: #16192 (
querycaches a redirect response) — same theme in a different subsystem.System Info
@sveltejs/kit: reproduced on 2.61.1 and verified still present through 2.70.0