:if on a wrapper removes a child component's markup but still runs its onMount, so a component cannot use being mounted as its "open" state.
Measured on stx 0.2.368.
Repro
Three files. The child logs when it mounts:
<!-- components/gate-child.stx -->
<script server>
export const which = $props.which || 'unknown'
</script>
<script client>
const which = {{ which }} ?? 'unknown'
onMount(() => { console.log('[gate] mounted:', which) })
onDestroy(() => { console.log('[gate] destroyed:', which) })
</script>
<div class="gate-child">{{ which }}</div><!-- components/gate-local.stx — a partial with its own signal -->
<script client>
const gateLocalOpen = state(false)
</script>
<div :if="gateLocalOpen">
<GateChild which="partial-local-signal" />
</div><!-- views/gate.stx -->
<script client>
const gateHostOpen = state(false)
</script>
<div>
<!-- A: directly in the view, gated by the view's own signal -->
<div :if="gateHostOpen">
<GateChild which="view-direct" />
</div>
<!-- B: in a partial, gated by a signal the partial declares -->
@include('gate-local')
</div>Both signals are false and never set. Load the page:
[gate] mounted: view-direct
[gate] mounted: partial-local-signaldocument.querySelectorAll('.gate-child').length // 0So the :if does what it says to the markup — nothing is rendered — while the component's <script client> lifecycle runs anyway. onDestroy does not fire either, so the component is mounted and stays mounted, invisibly.
Why it matters
A component whose job starts at mount has no way to be conditionally active. Ours is a modal shell: it moves focus into its panel, traps Tab, locks body scroll and registers an Escape handler in onMount, and releases all of that in onDestroy. Gating it from outside is the documented way to avoid :open as a prop, which has its own problem in a partial (#1994).
With the lifecycle running regardless, a page with three of these behind :if locks the document's scroll before anyone has opened anything, and Escape reaches whichever instance registered last rather than the one on screen. The symptom is a page you cannot scroll with no dialog on it.
The workaround is to go back to a bound :open prop and have the component do nothing while it is false — which means the component is always mounted, which is what gating from outside was for.
What would fix it
:if should govern the child component's lifecycle, not only its output: no mount while the condition is false, onDestroy when it becomes false.
If that is a larger change than it sounds, a documented way for a component to ask "am I actually rendered?" — or for :if to defer the mount — would be enough to build on.
Note on a wrong turn, in case it narrows anything
I first thought this was about partials included from a layout specifically, because in our app the symptom appeared on pages whose layout includes the partial and not on a page whose view includes one. The minimal repro above does not support that: case A, a component directly in a view, mounts just the same. Whatever makes the app's two cases differ is probably timing rather than placement, and the mount-regardless behaviour is the part that reproduces cleanly.