ReviewOS

stacks/stx

Component prop bindings (:prop="expr") never write their attribute inside an @include'd partial

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

Summary

A component prop binding — :prop="expr" on a component tag — never writes its attribute when the component sits in an @included partial. The server emits data-stx-parent-bindings="open" on the component root, but no open attribute ever appears, so useReactiveProp has nothing to observe and the component stays at its initial prop value for the life of the page.

Plain directives on plain elements in the same partial, on the same signal are reactive. It is specifically the component-prop path.

Measurement

resources/components/workout-panel.stx in hq.training — an @included partial, included once from resources/layouts/app.stx — with two bindings added side by side on the same signal:

<p id="wp-probe" :text="wpOpen ? 'PANEL-SIGNAL-TRUE' : 'PANEL-SIGNAL-FALSE'"></p>
<Drawer :open="wpOpen()" position="left" title="Probe" @close="wpStore.close()">…</Drawer>

One page load, before and after flipping the signal:

beforeafter
#wp-probe textContentPANEL-SIGNAL-FALSEPANEL-SIGNAL-TRUE ✅
Drawer root open attributenullnull ❌
Drawer visiblenono ❌

Setting the attribute by hand makes everything work correctly — panel shows, body scroll locks (overflow: hidden), focus moves into the panel:

document.querySelector('[data-stx-scope*="drawer"]').setAttribute('open', 'true')

So the component is fine. The binding is what does not arrive.

The rendered root

data-stx-parent-bindings = "open"
data-stx-parent-events   = "close"
data-stx-scope           = "stx_drawer_2_9am83y"
data-stx-props           = {"data-stx-scope":"stx_scope_workout_panel_63","position":"right",
                            "size":"lg","panelClass":"…","backdropClass":"…"}

Two things stand out:

  1. open is listed in data-stx-parent-bindings but appears in neither data-stx-props nor as an attribute. Every statically-passed prop made it; only the bound one did not.
  2. The partial's own scope id leaked in as a prop — "data-stx-scope":"stx_scope_workout_panel_63". That looks like the prop extraction taking an attribute belonging to the partial's scope wrapper, which suggests the binding machinery has the wrong idea about which scope owns the component.

Where it works

The same <Drawer :open="…"> in a top-level view works, both with a local state() and with a useStore() signal — attribute written, drawer opens. The difference is the partial, not the kind of signal:

LocationSignalattribute written
viewstate()yes
viewstore signalyes
@included partialstate()no
@included partialstore signalno

What I could not reduce

I could not build a standalone minimal partial, because a newly added .stx partial's <script client> did not execute at all in my checkout — its :text directive was stripped from the element and nothing ever set a value, even after clearing storage/framework/stx/bundle-cache/ and restarting the dev server with the file already on disk. Long-established partials in the same project are fine. I am reporting that only as an observation that got in the way; it may be unrelated, and it is why the evidence above is from the real file rather than a fixture.

Possibly related, printed on every dev boot of this project:

[stx:bundler] dropped every binding for workout-detail.ts (61b374780515); serving the unbundled source and not caching this result

Impact

This blocks adopting any stateful component in a partial. Partials are where an app puts the things that exist once and are driven from everywhere — a slide-over, a confirm dialog, a toast host — which is exactly the population that needs a bound open.

Environment

  • @stacksjs/components 0.2.339, stacks 0.75.34, Bun 1.3.x, macOS
  • Found while migrating hq.training onto the component library (#201).
11 days ago

Correction and a much wider scope

I filed this as "component prop bindings do not arrive in a partial". Further measurement shows that is a symptom of something broader, and I want to correct the record rather than leave a narrow report standing.

In an @included partial, most of the partial's own <script client> scope is not wired to its template. Only a store signal, written as an expression, drives a directive. Everything else silently renders nothing.

Four bindings in one file — resources/components/workout-panel.stx, included from resources/layouts/app.stx — measured in the same page load, after the signal was set to a truthy value:

bindingrenders
:text="wpOpen ? 'STORE-TRUE' : 'STORE-FALSE'" — store signal, expression"STORE-TRUE" ✅
:text="wpOpen" — same store signal, bare name"" ❌
:text="probeLocal" — local state('LOCAL-INITIAL'), bare name"" ❌
:text="probeLocal ? 'A' : 'B'" — same local state, expression"" ❌

probeLocal holds a non-empty string, so the empty results are not a falsy value rendering as "".

So a locally-declared state() in a partial drives nothing at all, and even a store signal only works inside an expression.

A component in a partial renders fine — it is only the bound prop that fails

This part corrects the practical conclusion I drew. With a component mounted inside a :if gate in the same partial:

<div :if="wpOpen" id="probe-gate">
  <Dialog open="true" @close="…">
    <DialogTitle>PROBE-SLOT-CONTENT</DialogTitle>
  </Dialog>
</div>

Measured after opening: the gate mounts, the Dialog renders at display: block, the slot content is in the DOM, and the nested DialogTitle renders. Static props, slots and nested components all work.

Only :open="expr" fails — and it fails even in the one form that works for plain directives (a store signal called as an expression).

And a component appears to break the directives after it

An A/B in the same file, changing nothing else:

  • with the <Dialog> block present: :text="wpOpen ? 'STORE-TRUE' : 'STORE-FALSE'" renders ""
  • with the <Dialog> block removed: the same binding renders "STORE-FALSE" → "STORE-TRUE"

Removing only the component's usage restores it; leaving the import in place makes no difference. So a component in a partial seems to take the surrounding template's directive processing with it.

Why this matters

I originally concluded from the narrow version of this bug that an app cannot put a reusable modal component in a partial. That conclusion was wrong — a component works there, gated by :if, with static props and slots. What an app cannot do is drive one through a bound prop, and it cannot rely on locally-declared state in a partial at all.

The second of those is the one I would prioritise: a partial that declares state() and binds it looks completely correct, throws nothing, logs nothing, and renders nothing.

Environment

  • @stacksjs/components 0.2.339, stacks 0.75.34, Bun 1.3.x, macOS

Sign in to comment on this issue.