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).
| # | Severity | Area | Title |
|---|---|---|---|
| 1 | 🔴 Critical | signals.ts:bindShow | @show/:show silently throws and corrupts sibling processing on call expressions |
| 2 | 🟠 High | Router / layout @include | Re-executed setup scripts on client-side navigation cause Identifier 'X' has already been declared |
| 3 | 🟠 High | Router fragment swap | Component <script setup> blocks with import break on navigation (Cannot use import statement outside a module) |
| 4 | 🟡 Medium | API design | @ prefix overloaded for both events AND directives (@show, @if, @for) — inconsistent with Alpine/Vue |
| 5 | 🟡 Medium | signals.ts:bindShow | @show complex path lacks the retry-without-unwrap fallback that :text/:class have |
| 6 | 🟡 Medium | Documentation/DX | Template auto-unwrap vs. script call-syntax isn't discoverable; silent failure mode |
| 7 | 🟢 Low | signal-processing.ts:extractExports | Hand-rolled tokenizer doesn't handle regex literals / destructuring params |
| 8 | 🟢 Low | Debug logging | Noisy 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()\":
recordingis unwrapped to its boolean value (false)- The expression
!recording()becomes!false()— calling boolean as function TypeError: recording is not a functionthrown- Caught and silently ignored
- 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
importships
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:
@(Vuev-on:/ Alpinex-on:) → events only:(Vuev-bind:) → bindings/directivesx-(Alpine) → directives
STX's current state is inconsistent and unpredictable — @foo could be either an event or a directive.
Suggested
- Deprecate
@show/@if/@for/@bindwith console warning - Canonical directive prefix:
:(withx-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:
- Try with auto-unwrap proxy
- 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/:showfail 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.
Recommended fix priority
- Bug 1 — users silently lose half the page; no reasonable workaround
- Bug 2 — breaks every layout with a stateful partial
- Bug 3 — breaks any page using component composition via navigation
- Bug 4 + Bug 6 — API clarification + docs
- Bug 5 — ship alongside Bug 1
- Bug 7 — correctness time-bomb
- Bug 8 — cleanup pass
Happy to open PRs for any of these if useful.