Skip to content

Svelte 4 + SvelteKit: old page stays mounted after navigating from inside an open modal with out:|global

SvelteKit 2.70 with Svelte 4.2.20. A link inside an open modal sheet (a <dialog> opened with showModal(), mounted as {#if open}<Sheet> with in:fade|global / out:fade|global) does a client-side navigation to another route. The URL changes and the new route's DOM appears, but the old page never unmounts: it stays visible over the new route, keeps polling and re-rendering, and its <dialog> still matches :modal with document.body overflow still 'hidden'. No console error, no pageerror. It reproduces with prefers-reduced-motion (zero-duration transitions) and with View Transitions skipped, so it isn't a stuck document.startViewTransition. The same navigation from the same page works once the sheet is closed first (e.g. a tab bar link). I suspected the View Transition hook and the transition duration first; neither was the cause.

1 solution
ranked by outcome — not votes
Accepted

Root cause: Svelte 4 never releases an outro group when one of its outros is cancelled by a re-intro.

In svelte/src/runtime/internal/transitions.js (4.2.x), create_out_transition increments group.r and decrements it only when the animation loop reaches end_time. Its end(reset) method just sets running = false; it never decrements group.r. SvelteKit swaps routes with a svelte:component switch that waits on that outro group (group_outros(); transition_out(old.$$.fragment, 1, 0, () => destroy_component(old))).

With out:…|global, the page teardown creates an outro for the sheet. The leaving page is still alive while its outro group runs. If anything makes the reactive dependencies of an enclosing {#if} dirty (a polling store, the $page update from the navigation itself, a prop change), the compiled update calls transition_in(if_block, 1). That re-runs the sheet's i(), which calls dialog_outro.end(1). The outro is cancelled, group.r never reaches 0, and the callback that destroys the old page never runs. This happens at zero duration too, because the re-intro lands in the same flush, before the first animation frame.

Fix: don't use |global on outros for components that can be open when the page navigates. Make them local:

<dialog
  in:fade|global={{ duration: 250 }}
  out:fade={{ duration: 150 }}   <!-- local: no |global -->
>
  <div in:panel_in|global out:panel_out>…</div>
</dialog>

A local outro still plays when the parent's own {#if open} closes the modal, because local = 1 passes through component boundaries (transition_out(child.$$.fragment, local)). Page teardown reaches nested blocks via transition_out(if_block) with no local, so no outro is created, nothing can be cancelled, and the old route is destroyed at once. Intros can stay |global, since an intro never joins an outro group.

To diagnose: after navigation, document.querySelectorAll('dialog') still shows open=true and :modal, and an element only the old route renders is still in the DOM.