ReviewOS

stacks/stx

22 browser-only composables (useClipboard, useGeolocation, …) are exported but never reach the client runtime; calling one kills the whole client block with a ReferenceError

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

Summary

composables/ exports 36 composables. 29 of them never reach the client runtime, and 22 of those wrap APIs that exist only in a browser — clipboard, geolocation, battery, fullscreen, notifications, speech, wake lock, the observers, and more.

They are exported from composables/index.ts, they type-check, they autocomplete, and they are unreachable from the only environment where their underlying API exists.

Calling one from a <script client> block throws ReferenceError and kills the entire client block, so the page stops hydrating and renders raw to the user.

Reproduction

<script client>
const kind = state('start')
const c = useClipboard()
kind.set('resolved:' + typeof c)
</script>
<main><p id="probe">{{ kind() }}</p></main>

Rendered output:

rendered : 
error    : ReferenceError: useClipboard is not defined

The literal in the DOM is the important part: one unresolvable name does not degrade that one call, it takes down every binding in the block.

The 22

Diffing composables/*.ts against the use* keys actually present on window.stx in a live page:

composables/ files : 36
on window.stx      : 35   (a mostly disjoint set — see below)

Browser-only, exported, unreachable:

useBattery, useBroadcastChannel, useClipboard, useDeviceOrientation, useEventSource, useEyeDropper, useFullscreen, useGeolocation, useIdle, useIntersectionObserver, useKeyboard, useMouse, useMutationObserver, useNetwork, useNotification, usePermissions, useResizeObserver, useShare, useSpeech, useTextSelection, useWakeLock, useWindow

Also missing but not browser-gated: useChannel, useMeta, useRouter, useStorage, useTimer, useUtilities, useWebsocket.

Root cause

There are two parallel, hand-maintained implementations of the composable surface with no shared source:

  • src/composables/*.ts — a module tree, importable server-side, 36 entries
  • src/signals.ts:5458 — the client runtime's window.stx = { … } literal, with its own inline reimplementations, 35 use* entries

A composable added to the first does not appear in the second. useClipboard has existed in composables/use-clipboard.ts with full JSDoc and a ClipboardRef interface the whole time; it has simply never been reachable from a browser.

The name is also in neither STX_AUTO_IMPORTS (client-script.ts:308) nor the runtime, which is why it degrades to a bare ReferenceError rather than the undefined is not a function that #1804 describes. Those are the two halves of the same drift.

A second defect in the canonical implementation

Worth fixing at the same time, because it will bite whoever wires this up first. composables/use-clipboard.ts exposes copied as a getter plus a subscribe(), and auto-resets the flag on a timer:

copiedTimeout = setTimeout(() => { copied = false }, timeout)

There is no notify() in that callback. subscribe() fires on copy and never on the reset. So the intended "Copied!" → "Copy" affordance cannot be built from the public surface: a subscriber mirroring copied into a signal latches at true forever. The timer's only purpose is to flip a flag nobody can observe flipping.

(onDestroy is also absent here, so copiedTimeout outlives the component. The deprecated browser-composables.ts copy does clear it.)

Suggested fix

  1. Generate the client runtime's composable surface from composables/, or add a test asserting the two sets agree. One hand-maintained list mirroring another is the actual bug; useClipboard is a symptom.
  2. Until then, at minimum port the 22 browser-only ones — they are the set for which "server-side only" is a contradiction.
  3. Make an unresolvable auto-import name a build-time error. Silently emitting a call to an undefined binding turns a typo into a blank page.
  4. Call notify() in the reset timeout, and register onDestroy to clear it.

Point 3 stands on its own regardless of the rest: today, one bad name in a client block costs the whole page, at runtime, in production.

Found while replacing hand-rolled navigator.clipboard calls in a production stx app with the framework's own composable. Related: #1804, #1797.

10 days ago

Two of the three asks are done; the third is blocked on something the issue's framing doesn't surface.

Landed

Point 3 — the escalation from "one call fails" to "the page renders raw " (acbaebf022). This turned out to be a separate defect and the one doing the real damage: the DOMContentLoaded page-setup loop invoked window[setupName]() with no try/catch, unlike the two sibling setup call sites which both wrap it. A throw — from any cause, not just a missing name — escaped the forEach and aborted everything after it: the remaining roots, the mount queue, and the binding pass. That is why one bad name cost the whole document. Contained now; 4 of 5 tests fail without it.

A compile-time diagnostic (2e5630511d), as a warning rather than the build-time error you asked for. Your repro now emits:

[stx] /app/pages/copy.stx: "useClipboard" is exported by @stacksjs/stx/composables but is
not available in the browser — it is a server-only module export, so this call throws
ReferenceError at runtime. Import it explicitly (import { useClipboard } from
'@stacksjs/stx/composables') so the bundler inlines it, or use a runtime equivalent.

Not a hard failure on purpose: #1810 is a report of the dev server aborting on one bad view with no filename, and answering one complaint by adding a second way to abort the build seemed like a poor trade — especially now the runtime failure is contained and names the identifier. Say the word if you'd rather it were fatal under debug: true.

It's restricted to names with a known cause rather than a general "looks like a hook" rule, because a warning people learn to ignore is worse than none.

Point 4 — the clipboard defect (acbaebf022). Your read was exactly right, and the consequence is worth stating: the reset callback never called notify(), so a subscriber mirroring copied saw the true and never the false. The "Copied!" → "Copy" affordance was impossible to build from the public surface — the timer's only job was to flip a flag nobody could observe flipping.

I added stop() rather than relying on onDestroy alone, because signals-api's mountCallbacks/destroyCallbacks arrays are written and never drained — so the onDestroy you correctly identified as missing would have been dead code on the module path. Filed separately as #1811.

Counts

The three-way diff, computed against the real runtime surface: composables/ exports 149 functions and 120 are unreachable, not 22/29. The issue counted files; counting exported functions is the number that matters to an author.

Not landed, and why

Making the 120 reachable is not a wiring job, which is the part I'd push back on. Those exports are pre-unification *Ref shapes — getter plus subscribe() plus a manual stop() — not signals. Bundling them onto window.stx would make the names resolve and still leave templates dead, because a *Ref doesn't drive a re-render. useClipboard().copied in a template would never update.

So porting is blocked on converting them to the Signal contract — the work #1710/#1726/#1797 did for the 29 that did make it across — not on transport. Your point 1 (generate one surface from the other, or test that the two sets agree) is right, but it can only be applied once both sides share a contract; today they are genuinely two different products, which is why no single source exists.

Leaving this open for that. Happy to take the conversion in batches if you want to prioritise a particular family (clipboard / geolocation / observers look like the useful ones).

10 days ago

All four points are now addressed. Closing.

Point 1 — one source, not two

c139a0c47a bundles the real modules and merges them onto window.stx, rather than hand-porting them into the runtime's template literal. Your suggestion was "generate the client surface from composables/, or test that the two sets agree" — this is the first, and it means there is genuinely one implementation rather than two that can drift.

Your repro now works:

data-stx-framework-composables injected: true
function useClipboard present:          true

Demand-driven: a page calling useClipboard ships ~2.8KB; a page calling none ships nothing. One wrinkle worth recording — scanning the rendered page matched these names inside the injected runtime's own 150KB of source, so every page looked like it needed the bundle. The scan strips stx-owned script blocks first.

Skip, never clobber: a name the runtime already provides wins. Those are the signal-based versions #1710/#1726/#1797 unified, and shadowing them with the module versions would be a regression rather than a fix.

Point 3 — build-time diagnostic

Landed earlier (2e5630511d) and now moved, because it became inaccurate the moment these started bundling. "Server-only export" was the right warning when nothing shipped them; now only a genuine bundling failure leaves a name unresolvable, so that's what it reports.

It stayed a warning rather than a hard error deliberately — see #1810, which is a report of the dev server aborting on one bad view. Adding a second way to abort a build would have answered one complaint by creating another.

Point 4 — the clipboard defect

Fixed in acbaebf022, and your read was exactly right. The consequence is worth stating plainly: with no notify() on the reset, a subscriber mirroring copied saw the true and never the false, so the standard "Copied!" → "Copy" affordance was impossible to build from the public surface. The timer's only job was flipping a flag nobody could observe flipping.

onDestroy is registered and now actually fires — the queues it pushes into were never drained, which I filed and fixed as #1811. stop() is exposed too, since it's still the right call outside a component scope.

Point 2 and the honest caveat

The 120 are reachable, but they are pre-unification shapes — a getter plus subscribe() plus a manual stop(), not signals. So useClipboard().copy(text) works and subscribe() works, but reading .copied in a template does not re-render on its own.

That's why I'd call this a bridge rather than the end state. Converting them to the Signal contract is the real fix, and it's mechanical but large (37 files). Worth its own issue — happy to file it, or to take it in batches if you want to prioritise a family (clipboard / geolocation / observers look like the useful ones).

Counts

For the record: composables/ exports 149 functions and 120 were unreachable, not 22/29. The issue counted files; counting exported functions against the real runtime surface is the number that matters to an author.

Sign in to comment on this issue.