What happens
A component that stays on the page during in-app navigation, such as a layout's nav or a modal the layout mounts, is torn down and set up again on every navigation, but its markup stays bound to the first instance. From the first navigation on:
- state the new instance sets never reaches the screen
- the first instance's
onDestroyhas already run, so whatever it was listening to is gone too
The component is effectively frozen.
Minimal repro (stx 0.2.286)
<!-- components/Repro.stx, included by the layout, so it stays on screen across navigation -->
<script client>
const open = state(false)
function show() {
open.set(true)
}
// The usual shape: listen while mounted, stop on destroy.
onMount(() => globalThis.addEventListener('repro:open', show))
onDestroy(() => globalThis.removeEventListener('repro:open', show))
</script>
<div>
<span>{{ open() ? 'open' : 'closed' }}</span>
<span :if="open()">IF</span>
</div>- Load any page with that layout, then run
dispatchEvent(new Event('repro:open')). The component showsopenandIF, which is correct. - Reload, run
await navigate('/other-page'), then dispatch the same event. It staysclosed, andIFstays hidden.
What the counters show
With counters in setup, onMount and onDestroy, starting from a full load:
| setups | mounts | destroys | |
|---|---|---|---|
| full load | 1 | 1 | 0 |
| after 1st navigation | 2 | 2 | 2 |
| after 2nd | 3 | 3 | 3 |
| after 3rd | 4 | 4 | 4 |
Every navigation re-runs the whole component. Toggling from a click handler bound at first render changes the first instance's signal (checked by keeping references to each state()), and that is the only one the text and :if bindings read. Anything registered without an onDestroy counterpart accumulates: a document click listener added in onMount fired 2, 3, 4, then 5 times per click after 1 to 4 navigations.
Why, as far as I can tell
- The router re-runs
<script data-stx-scoped data-stx-run="always">on every navigation (runsAlwaysindist/chunk-bf8wf3r2.js), including those of layout components that are outside the swapped region. The fragment carries the layout's scripts (extractContainerContent). - Binding skips an element that is already bound:
if (!scopeVars || root.__stx_disposers) returnindist/signals.js. The component's root element never left the page, so it keeps its first bindings, while setup,onMountandonDestroyall run for the new instance.
Impact
In WildLoop:
- The sign-in sheet listens for an "open" event in
onMount. After one navigation it could never open again. - The iOS app (Craft) always does one in-app navigation at launch, so there it was broken from the first screen, and it looked like a Craft bug.
- The nav's account state froze after the first navigation in the same way.
We worked around it by driving that state from a shared store (useStore) instead of component signals.
Expected
Either components whose root stays on the page are not re-run on navigation, or they are re-run and their existing bindings are disposed and rebound to the new instance. The first seems closest to what a persistent layout component means.
Related: #1828 (runsAlways dedup), #1957 (shared imports inlined per component).