Summary
There is currently no way to share client-side logic between .stx pages. Any mechanism that moves code out of a page's own inline <script client> silently breaks directive binding.
Why it matters
In a dashboard of ~34 pages, the same ~15-line block (a typed-confirmation gate for destructive operations) is copy-pasted into 11 of them. That duplication is how two pages ended up shipping delete buttons with no confirmation at all — there was no single place to get it right. Extracting it into a composable is the obvious fix and isn't currently possible.
What I tried
Each verified in a headless browser against a working control page on the same commit.
1. Local module import into <script client>
// page.stx
<script client>
import { useOpConfirm } from '../../src/use-op-confirm.ts'
const { pending, typed, canRun, askOp, runOp, cancelOp } = useOpConfirm({ endpoint: '/api/…' })
</script>
…
<button @click="askOp('backup:run', 'backup', 'run a backup')">Back up</button>
<div @show="pending() !== null">…</div>Bundles without error, page renders 200, useOpConfirm is present in the output — but clicking the button does nothing. @click never fires and @show never becomes true. client-script-bundler.ts runs the script through Bun.build, which puts it in module scope, and directives evaluate in the page scope where those names no longer exist.
2. Same logic moved into an @included partial as <script client>
The partial's script is inlined into the including page's output, but the behaviour is identical — bindings never fire.
3. import { … } from '@composables'
This is the one that looks right: store-imports.ts:47 rewrites it to const { … } = window.__composables, a plain destructure in the page's own scope — exactly where directives can see it. But nothing populates window.__composables. There's no composablesDir config, no registerComposablesClient() (the analogue of registerStoresClient), and no auto-discovery. The transform exists with no supported way to feed it.
The failure mode is the concerning part
It fails closed and silent: no console error, no build warning, no runtime exception. The page renders, the button is there, the click just does nothing. On a destructive-action confirm that's benign; on something that should fire, it's a silent regression that only a click-through would catch.
Suggested fix
Any one of these would unblock it:
- Auto-register a
composables/directory (mirroringstores/) soimport { useThing } from '@composables'works out of the box — the transform is already there. - Export
registerComposablesClient(), matchingregisterStoresClient, so a project can populatewindow.__composablesitself. - Make directive evaluation see module-scope bindings from a bundled client script — the most general fix, probably the most invasive.
- Failing all of those: error at build time when a
<script client>contains user imports, rather than emitting a page whose directives silently don't work.
Happy to send a PR for (1) or (2) if you'll say which you'd prefer.
Versions
stx 0.2.82, Bun 1.4.0-canary. Reproduced in stacksjs/ts-cloud packages/ui (34 stx pages, stx dev).