Skip to content

navigating stays set after an invalidateAll() aborts an in-flight goto() #17114

Description

@HVossi92

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() sets stores.navigating / navigating.current when 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 calls nav.reject(new Error('navigation aborted')) and returns.
  • The only reset of navigating for a navigation happens at the end of one that completes (:2050), and that early return skips it. :1988 is 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions