ReviewOS

stacks/stx

SPA navigation re-runs components that stay on the page, but their markup stays bound to the first instance: state set after the first navigation never renders

#1958
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

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 onDestroy has 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>
  1. Load any page with that layout, then run dispatchEvent(new Event('repro:open')). The component shows open and IF, which is correct.
  2. Reload, run await navigate('/other-page'), then dispatch the same event. It stays closed, and IF stays hidden.

What the counters show

With counters in setup, onMount and onDestroy, starting from a full load:

setupsmountsdestroys
full load110
after 1st navigation222
after 2nd333
after 3rd444

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 (runsAlways in dist/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) return in dist/signals.js. The component's root element never left the page, so it keeps its first bindings, while setup, onMount and onDestroy all 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).

9 days ago

Fixed in e9a59e7. A layout component that stays on screen no longer re-runs its setup on every SPA navigation. Before, each navigation created another copy of it with no markup. Covered for both fragment swaps and full-page responses. Closing this; reopen if the freeze still shows up.

9 days ago

Correction to my close above: e9a59e7 fixed only the <Component /> shape. The shape this issue describes, a component the layout pulls in through @include, still froze on main after one navigation: the text stayed closed, the :if stayed hidden, and each navigation added another click listener (1, 2, 3, 4 hits per click). A layout component without signals and teardown at a layout change were also still broken.

The rest landed in 761206b..6fbf599:

  • f18eb1d: an @include's onMount and onDestroy attach to its own scope, not the global queues every navigation drains
  • 1918ef9: an onDestroy registered inside onMount stays with its component
  • b16fc15: an @include's script names the root it binds, so a layout partial is not run again
  • 2e5474f: fragments leave out the layout chrome's component scripts, including components with no scope root
  • dc70d73: a layout change destroys the outgoing layout's components
  • b612f62: a partial whose root is a component keeps no owner stamp, so its script still runs on every visit inside the container
  • abee130: a whole-document navigation no longer sets a layout island up before its trigger
  • 1bc5486, 6fbf599: docs

packages/stx/test/router/persistent-layout-component.test.ts drives the issue's component through the real runtime and router, in both the fragment and the whole-document serving modes. 29 of its 68 cases fail on 761206b and all pass now; the full suite is 12997 pass, 0 fail. Still open by design: an onDestroy registered after an await or in a timer goes to the global queue (documented in docs/guide/components.md). Not released yet.

Sign in to comment on this issue.