`navigating` stays set after an `invalidateAll()` aborts an in-flight `goto()`
Describe the bug
invalidate() or invalidateAll() can run while a client-side navigation is still loading. That navigation is then aborted, but navigating is never reset. It keeps holding the aborted navigation until another navigation completes, so anything that shows a loading state from navigating stays in that state.
From packages/kit/src/runtime/client/client.js in 2.70.3:
navigate()setsstores.navigating/navigating.currentwhen the navigation starts (:1758)._invalidate()takes a new token (:425,const nav_token = (token = {})).- When the navigation's load returns,
navigate()sees that the token has changed (:1809). It callsnav.reject(new Error('navigation aborted'))and returns. - The only reset of
navigatingfor a navigation happens at the end of one that completes (:2050), and that early return skips it.:1988is a second early return on the same token check.
Reproduction
A fresh npx sv create --template minimal --types ts app, with @sveltejs/kit 2.70.3 and svelte 5.57.0.
src/routes/+page.ts:
export async function load({ url }) {
if (url.searchParams.has('slow')) await new Promise((r) => setTimeout(r, 1000));
return {};
}src/routes/+page.svelte:
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { navigating } from '$app/state';
function go() {
goto('?slow=1').catch(() => {});
setTimeout(() => invalidateAll(), 100);
}
function goPlain() {
goto('?slow=1').catch(() => {});
}
</script>
<button id="go" onclick={go}>go</button>
<button id="plain" onclick={goPlain}>go without invalidate</button>
<p id="state">navigating: {navigating.to ? 'yes' : 'no'}</p>Clicking each button in Chromium (driven by Playwright, reading #state 200 ms after the click and again 3 s later) gives:
| button | 200 ms after the click | 3 s later | URL afterwards |
|---|---|---|---|
| go without invalidate (control) | navigating: yes |
navigating: no |
?slow=1 |
go (invalidateAll 100 ms after goto) |
navigating: yes |
navigating: yes |
unchanged |
The aborted navigation's URL is never applied, which seems right. The problem is that navigating stays set indefinitely, until some later navigation completes.
We met this in an app where a search box calls goto on a debounce while a form's invalidateAll, after a save, lands inside the search's load. The app's loading overlay reads navigating, so it stayed up for good.
Expected behaviour
navigating goes back to null when a navigation is aborted, just as it does when one completes. Alternatively, whatever superseded the navigation could take the store over.
Workaround
Treat a navigation as over once its complete promise settles, either way:
let ended = $state.raw(null); // raw: the store holds the object itself, and a proxy is never identical to it
$effect(() => {
const n = $navigating;
if (n) n.complete.then(() => (ended = n), () => (ended = n));
});
const isNavigating = $derived($navigating !== null && $navigating !== ended);Related
#13778 (aborted navigations fail silently) is the same abort path, seen from the side of whoever called goto.
System Info
@sveltejs/kit 2.70.3, svelte 5.57.0, the Vite version from the minimal template, Chromium via Playwright, macOS. By reading the code, the same early return exists in 2.59.1, but I have not run it there.
Severity
annoying, but we have a workaround
Source: sveltejs/kit