ReviewOS

stacks/stx

Multiple bugs in signals/directives/router — silent @show failures, re-execution on navigation, API inconsistencies

#1668
Closed glennmichael123 opened this 15 days ago · 3 comments
15 days ago

Summary

While building an app with locally-linked @stacksjs/stx, I ran into several bugs and DX issues across signals, directives, the client-side router, and the script-setup parser. Filing them together so you can triage as a batch; happy to split into separate issues if preferred.

Version tested: current main of stacksjs/stx (locally linked).

#SeverityAreaTitle
1🔴 Criticalsignals.ts:bindShow@show/:show silently throws and corrupts sibling processing on call expressions
2🟠 HighRouter / layout @includeRe-executed setup scripts on client-side navigation cause Identifier 'X' has already been declared
3🟠 HighRouter fragment swapComponent <script setup> blocks with import break on navigation (Cannot use import statement outside a module)
4🟡 MediumAPI design@ prefix overloaded for both events AND directives (@show, @if, @for) — inconsistent with Alpine/Vue
5🟡 Mediumsignals.ts:bindShow@show complex path lacks the retry-without-unwrap fallback that :text/:class have
6🟡 MediumDocumentation/DXTemplate auto-unwrap vs. script call-syntax isn't discoverable; silent failure mode
7🟢 Lowsignal-processing.ts:extractExportsHand-rolled tokenizer doesn't handle regex literals / destructuring params
8🟢 LowDebug loggingNoisy console.log statements left in hot paths

Bug 1 — @show silently corrupts sibling rendering 🔴

File: packages/stx/src/signals.ts (bindShow, around L1568–1608)

Symptom

@show=\"!foo()\" (any call expression) silently fails to evaluate AND all sibling elements after it stop rendering. No console error. Page looks half-rendered with no clue why.

Root cause

effect(() => {
  try {
    var scopeValues = {};
    for (var k in capturedScope) {
      var v = capturedScope[k];
      scopeValues[k] = (v && v._isSignal) ? v() : v;   // ← unwraps signals to values
    }
    var fn = new Function(...Object.keys(scopeValues), 'return ' + expr);
    var value = fn(...Object.values(scopeValues));     // ← expr evaluated with VALUES
    // ... set display
  }
  catch (e) {
    if (!(e instanceof ReferenceError) && !(e instanceof TypeError))
      console.warn('[STX] Show expression error:', expr, e);
    // ← TypeError silently swallowed
  }
});

For @show=\"!recording()\":

  1. recording is unwrapped to its boolean value (false)
  2. The expression !recording() becomes !false() — calling boolean as function
  3. TypeError: recording is not a function thrown
  4. Caught and silently ignored
  5. The effect's initial subscription setup is incomplete — subsequent sibling element processing gets corrupted

Reproduction

<div>A</div>
<div @show=\"!flag()\">B</div>
<div>C</div>
<div>D</div>

With const flag = state(false): A renders, B–D vanish entirely from the visible page.

Suggested fix

Mirror the retry-without-unwrap fallback that evalAttrExpr (L1388–1399) has:

effect(() => {
  let value
  try {
    const unwrapScope = createAutoUnwrapProxy(capturedScope)
    const fn = new Function(...Object.keys(capturedScope), 'return ' + expr)
    value = fn(...Object.values(unwrapScope))
  } catch (e1) {
    try {
      const fn = new Function(...Object.keys(capturedScope), 'return ' + expr)
      value = fn(...Object.values(capturedScope))
    } catch (e2) {
      console.warn('[STX] @show expression failed:', expr, e2)
      return
    }
  }
  el.style.display = value ? originalDisplay : 'none'
})

Bug 2 — Setup scripts re-execute on navigation, redeclaration errors 🟠

File: Client-side router (fragment swap) + layout @include

Symptom

Uncaught SyntaxError: Failed to execute 'appendChild' on 'Node': Identifier 'dark' has already been declared

…when navigating between pages via <StxLink>.

Root cause

The router re-executes all <script> tags in the swapped-in fragment, including those from @include'd layout partials (theme.stx, seed.stx, etc.). Top-level const/function declarations throw on re-execution because the identifier already exists in global scope from the initial page load.

Reproduction

<!-- theme.stx -->
<script client>
  const dark = useDark()
</script>

Initial load works. Navigate to any other page → redeclaration error.

Forced workaround

// Users have to write:
if (typeof dark === 'undefined') var dark = useDark()
// Or wrap entire script in: if (!window.tb) { ... }

Expected

Either:

  • Detect unchanged-layout scripts and skip re-execution
  • Auto-wrap fragment scripts in IIFEs
  • Track executed scripts by source-hash / element id

Bug 3 — Component <script setup> imports break on navigation 🟠

Symptom

Uncaught SyntaxError: Failed to execute 'appendChild' on 'Node':
Cannot use import statement outside a module

…when navigating to a page that uses components with import in <script setup> (e.g. <Hero>, <FeaturedTrailCard> with import { defineProps } from 'stx').

Root cause

Component setup scripts like:

<script setup>
  import { defineProps, withDefaults } from 'stx'
</script>

are compiled away on initial load, but on fragment-swap navigation the router appends the raw HTML including these scripts as plain <script> tags (no type=\"module\") → import can't work outside modules.

Expected

  • Strip/transform component setup scripts before fragment-swapping
  • OR add type=\"module\" when re-injecting
  • OR pre-compile so no raw import ships

Impact

Any page using component composition breaks on navigation. In my case, had to rewrite the home page as fully self-contained (no <Hero>, <UserStatsCard>, etc.) to work around it.


Bug 4 — @ prefix overloaded for events AND directives 🟡

STX accepts @show, :show, and x-show as equivalent (see signals.ts:1354). The @ prefix is simultaneously used for event listeners (@click, @submit) AND directives (@show, @if, @for, @bind).

Convention elsewhere:

  • @ (Vue v-on: / Alpine x-on:) → events only
  • : (Vue v-bind:) → bindings/directives
  • x- (Alpine) → directives

STX's current state is inconsistent and unpredictable — @foo could be either an event or a directive.

Suggested

  • Deprecate @show/@if/@for/@bind with console warning
  • Canonical directive prefix: : (with x- Alpine-compatible alias)
  • Canonical event prefix: @

Bug 5 — @show missing retry fallback that :text/:class have 🟡

Compare evalAttrExpr (L1374 — used by :text, :class) vs bindShow (L1568).

evalAttrExpr has a two-stage fallback:

  1. Try with auto-unwrap proxy
  2. If that throws, retry without unwrapping

This lets :text=\"foo().bar\" work. bindShow only has stage 1 → call expressions die silently. Inconsistent DX.

(Related to Bug 1 — same fix addresses both.)


Bug 6 — Auto-unwrap semantics not discoverable 🟡

STX is signal-style in script code (SolidJS-like — call the getter):

const flag = state(false)
if (flag()) { ... }

…but proxy-unwrapped in templates (Vue-like — bare name):

<div :if=\"flag\">   <!-- works -->
<div :if=\"flag()\">  <!-- fails -->

This mental-model flip between script and template is never explicitly documented, and the failure mode (@show/:show) is silent — users can ship broken code without noticing.

Suggested

  • Document explicitly: "In scripts, call signals (flag()). In templates, use bare names (flag)."
  • Make @show/:show fail loudly on TypeError instead of silently
  • Dev-mode lint that flags @show=\"foo()\" patterns

Bug 7 — extractExports brittle hand-rolled tokenizer 🟢

File: packages/stx/src/signal-processing.ts:388–512

The function uses a character-by-character parser to find top-level declarations and track brace depth. It handles strings, template literals, comments, brace depth — but does not handle:

  • Regex literals (/pattern{3}/ would incorrectly increment depth)
  • Destructuring parameters (({a, b}) => — the { counts toward depth)
  • Division-vs-regex ambiguity

Not triggered in my project (no top-level regex), but a landmine for any user who writes regex or complex destructuring at setup-script top level.

Suggested

Replace with AST parser (OXC or Bun's built-in):

import { parseSync } from 'oxc-parser'

export function extractExports(code: string): string {
  const { program } = parseSync('setup.ts', code)
  return program.body
    .filter(n => ['VariableDeclaration', 'FunctionDeclaration'].includes(n.type))
    .flatMap(n => n.type === 'FunctionDeclaration' ? [n.id.name] : n.declarations.map(d => d.id.name))
    .join(', ')
}

Bug 8 — Noisy production-mode console.log 🟢

File: packages/stx/src/signals.ts (many locations)

Every navigation produces ~15 lines of [stx], [DOMContentLoaded], [stx:load], [router], [processElement], [stx] bindIf … logs. Examples:

console.log('[stx] signals runtime loading');
console.log('[stx] entering IIFE');
console.log('[DOMContentLoaded] data-stx:', setupName, ...);
console.log('[stx] bindIf INSERTING element for :if=' + expr);

Clutters production DevTools and adds measurable hot-path overhead.

Suggested

Gate behind a DEBUG flag or proper logger module with levels.


  1. Bug 1 — users silently lose half the page; no reasonable workaround
  2. Bug 2 — breaks every layout with a stateful partial
  3. Bug 3 — breaks any page using component composition via navigation
  4. Bug 4 + Bug 6 — API clarification + docs
  5. Bug 5 — ship alongside Bug 1
  6. Bug 7 — correctness time-bomb
  7. Bug 8 — cleanup pass

Happy to open PRs for any of these if useful.

14 days ago

Triage notes against current main:

Confirmed fixed:

  • Bug 1 (@show silent corruption on call expressions) — bindShow (packages/stx/src/signals.ts:1729-1784) now uses createAutoUnwrapProxy for the complex path and has the retry-without-unwrap fallback that evalAttrExpr already had. @show="!recording()" evaluates correctly without taking down sibling rendering.
  • Bug 2 (SPA nav: Identifier 'X' has already been declared) — packages/bun-plugin/src/serve.ts:1874 now prepends a <script data-stx-page>if(window.stx)window.stx._latestSetup=null;</script> to every SPA fragment before the new page's setup scripts, so re-execution doesn't collide with the previous page's bindings.
  • Bug 5 (@show complex path lacks retry-without-unwrap fallback) — same fix as Bug 1.

Still needs verification / separate tickets:

  • Bug 3 (component <script setup> with import breaks on nav) — script extraction code path not directly verified; worth a focused reproducer.
  • Bug 4 (@ prefix overload for events vs directives) — design decision; :/x-/@ convention is documented in CLAUDE.md ("Prefix Convention") but the overload of @show/@if/@for alongside event handlers remains.
  • Bug 6 (auto-unwrap vs call-syntax discoverability) — docs/DX issue; partially addressed by the new "Reach for stx primitives before vanilla JS" section in CLAUDE.md but the template-vs-script unwrap rule isn't called out explicitly.
  • Bug 7 (extractExports tokenizer doesn't handle regex literals / destructuring params) — signal-processing.ts was significantly rewritten but the specific failure modes aren't covered by tests; worth a focused reproducer.
  • Bug 8 (noisy console.log in hot paths) — still present (e.g. [stx] bindIf SKIPPED, [stx:store] defineStore called).

Leaving this open as the umbrella ticket. If you want, I can split bugs 3 / 7 / 8 into their own issues so they're independently trackable.

14 days ago

Follow-up after a deeper sweep — commit 752c900149:

Now fixed:

  • Bug 3 (component composition API imports break on SPA nav) — SIGNAL_API_RE in packages/stx/src/signal-processing.ts now lists defineProps/withDefaults/defineEmits/defineExpose. Scripts using only the composition API are merged into __stx_setup_XXX(), their from 'stx' imports are stripped, and the symbols are destructured from window.stx inside the setup wrapper. Regression tests in packages/stx/test/edge-cases/issue-fixes.test.ts cover both <script client> and bare <script setup> forms, plus the defineEmits / defineExpose paths.

  • Bug 6 (template/script unwrap discoverability) — new "Signals: scripts call, templates don't" section in CLAUDE.md documents the rule explicitly: flag() in scripts, bare flag in templates. Covers the silent-failure mode (:if="flag()" → false() → TypeError swallowed by directive binders), and the function-reference exception (#1695 handles bare-id function refs in event handlers).

  • Bug 7 (extractExports regex/destructuring landmines) — added position-aware regex-literal skipping. Tokenizer tracks lastSig and only treats / as regex when it follows expression-position tokens (= / ( / , / etc.); otherwise it's division. The skipper respects character classes, regex flags, and bails on newlines. Five new tests cover apostrophe-in-regex, brace-in-regex, backtick-in-regex, division-vs-regex disambiguation, and destructured arrow params.

  • Bug 8 (noisy console.log in prod) — generateSignalsRuntime() now runs a balanced-paren matcher over the source before minification, replacing every console.log(...) with 0. Prod runtime: 0 console.log, 34 console.warn, 6 console.error. Dev runtime (generateSignalsRuntimeDev()) still keeps all 44 informational logs for debugging. ~70KB smaller prod bundle; quiet DevTools across SPA navs. Four regression tests verify the strip + that the result still parses as valid JS.

Still open (intentionally):

  • Bug 4 (@ prefix overload for events AND directives) — design call, not a bug. Deprecating @show/@if/@for is a breaking change and needs a separate roadmap decision. Documented in CLAUDE.md § "Prefix Convention" but not deprecated.

Closing this umbrella ticket once you've had a chance to review. If Bug 4 should become its own ticket, happy to file it.

14 days ago

Bug 4 confirmed as by-design — @show/@if/@for stay alongside :show/:if/:for and x-show/x-if/x-for. Closing this umbrella.

Final tally:

  • ✅ Bug 1, 5 (@show silent corruption + retry fallback) — fixed in 1899048537
  • ✅ Bug 2 (Identifier already declared on SPA nav) — fixed via _latestSetup=null clear in bun-plugin/src/serve.ts:1874
  • ✅ Bug 3 (composition API imports break on nav) — fixed in 752c900149
  • 🟢 Bug 4 (@ prefix overload) — by design, not changing
  • ✅ Bug 6 (unwrap discoverability) — documented in 752c900149
  • ✅ Bug 7 (extractExports regex landmines) — fixed in 752c900149
  • ✅ Bug 8 (prod console.log noise) — stripped in 752c900149

Sign in to comment on this issue.