ReviewOS

stacks/stx

No way to share client-side logic between pages — imports in <script client> silently break directive binding

#1780
Closed glennmichael123 opened this 10 days ago ·
10 days ago

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:

  1. Auto-register a composables/ directory (mirroring stores/) so import { useThing } from '@composables' works out of the box — the transform is already there.
  2. Export registerComposablesClient(), matching registerStoresClient, so a project can populate window.__composables itself.
  3. Make directive evaluation see module-scope bindings from a bundled client script — the most general fix, probably the most invasive.
  4. 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).

Sign in to comment on this issue.