ReviewOS

stacks/stx

Auto-mount detection is a hand-maintained regex that has drifted: 35 of 71 runtime globals unmatched, so a useQuery/useStore-only client block never mounts

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

The usesSignals test that decides whether a <script client> block gets wrapped in stx.mount() is a hand-maintained regex alternation. It has drifted from the runtime globals list, so a client block built out of the newer reactive composables is classified as "not using signals", skips mounting, and falls through to the legacy IIFE path with no reactivity.

This is the same drift class that #1804 just eliminated for STX_AUTO_IMPORTS — by deriving it from STX_RUNTIME_GLOBALS instead of maintaining a literal. The header comment at client-script.ts:306-327 documents that decision. This regex, ~1000 lines further down in the same file, was left hand-maintained.

The code

packages/stx/src/client-script.ts:1332:

const usesSignals = /\b(state|derived|effect|ref|reactive|computed|watch|watchEffect|useLocalStorage|useSessionStorage|useRef|useEventListener|useFetch|useDebounce|useDebouncedValue|useThrottle|useInterval|useTimeout|useToggle|useCounter|useClickOutside|useFocus|useAsync|useColorMode|useDark|useWebSocket|useRoute|useSearchParams|onMount|onDestroy|onMounted|onBeforeUnmount|onUnmounted|batch|untrack|peek)\s*(?:<[^>]*>)?\s*\(/.test(scriptContent)

When usesSignals is false the block skips the stx.mount() wrapper (client-script.ts:1353-1354). The fallback at 1358-1367 only rescues it if a top-level declaration name happens to also appear in the template.

The gap

36 names in the regex; 71 in STX_RUNTIME_GLOBALS. 35 runtime globals are missing:

defineEmits, defineExpose, definePageMeta, defineProps, defineSlots, defineStore,
goBack, goForward, inject, isDerived, isSignal, navigate, nextTick, onBeforeMount,
provide, registerStoresClient, setRouteParams, useCookie, useHead, useId,
useMediaQuery, useMutation, useOptimistic, usePreferredContrast, usePreferredDark,
usePreferredLight, usePreferredReducedMotion, useQuery, useReactiveProp,
useScrollLock, useSeoMeta, useSlots, useStore, watchMultiple, withDefaults

Some of those are compile-time only (defineProps, defineEmits, defineSlots, defineExpose, withDefaults, definePageMeta) and are reasonably excluded. But the reactive ones are not:

useStore, defineStore, useQuery, useMutation, useOptimistic, useCookie, useMediaQuery, useScrollLock, usePreferredDark, usePreferredLight, usePreferredContrast, usePreferredReducedMotion, useReactiveProp, watchMultiple

Every one of those returns or drives a signal, and every one of them fails the test.

Repro

A client block that is entirely reasonable and entirely broken:

<script client>
const { data, loading } = useQuery('/api/issues')
</script>

<main>
  <p :if="loading()">Loading…</p>
  <ul :else>
    <li :for="issue in data()">{{ issue.title }}</li>
  </ul>
</main>

useQuery is not in the alternation, no other listed name appears, so usesSignals is false, stx.mount() is skipped, and the bindings never hydrate. The page renders the loading branch forever. Same for a block whose only call is useStore('session').

Adding a single unrelated state(0) makes it work, which is a confusing thing to discover.

Why it is easy to miss

It is latent in any app whose client blocks also call state()/effect() — which is most of them today, since the newer composables are the ones apps are being encouraged to move to. It surfaces exactly when someone follows that advice and writes a block that is purely useQuery/useStore. The failure mode is a page stuck on its skeleton with no console error, which is a hard thing to trace back to a regex in the compiler.

Fix

Derive it, the way STX_AUTO_IMPORTS now is. Something like a STX_REACTIVE_GLOBALS subset exported from runtime-globals.ts — the compile-time-only names are a small, stable, explicitly-listed exclusion — with the regex built from it at module load. That way adding a composable to the runtime cannot silently fail to mount.

A test asserting that every name in STX_RUNTIME_GLOBALS, minus the declared compile-time set, is matched by usesSignals would pin it permanently.

  • #1804 — the same drift, fixed for the auto-import list by deriving from the runtime.
  • #1818 — other defects in useFetch/useQuery found in the same audit.
9 days ago

Fixed in 44a7c7b393, exactly as you framed it — derived from STX_RUNTIME_GLOBALS rather than hand-maintained, the same fix #1804 used one file away.

The one judgement call: I excluded the compile-time macros you flagged (defineProps, defineEmits, defineSlots, defineExpose, withDefaults, definePageMeta) via a named COMPILE_TIME_ONLY_GLOBALS constant rather than a filter buried in the regex — calling one of those genuinely doesn't make a block reactive, and naming the set makes the exclusion reviewable.

Fixed alongside #1820, since the two decisions had drifted apart from each other as well as from the globals list, and now share a source so they can't disagree again.

Sign in to comment on this issue.