ReviewOS

stacks/stx

`{⁠{ }}` interpolation inside `x-for` renders empty in components (works at view level)

#1748
Closed glennmichael123 opened this 14 days ago · 1 comment
14 days ago

Summary

{⁠{ loopVar.prop }} text interpolation inside an x-for loop renders empty when the loop lives inside a hydrated (kebab-tag) component. The same loop works at the view level, and x-text="loopVar.prop" works in both. The loop iterates the correct number of times — only the {⁠{ }} text nodes are blank. Attribute directives that use the loop variable (e.g. @click="pick(it.id)") also work; it's specifically {⁠{ }} text interpolation that doesn't receive the per-item scope inside a component.

Environment

  • @stacksjs/stx 0.2.70
  • bun-plugin-stx 0.2.70 (dev server via bun-plugin-stx/serve)
  • Bun 1.3.13, macOS (darwin)

Reproduction

ReproList.stx (a component, in your partialsDir):

<script client>
const items = state([])
onMount(() => {
  setTimeout(() => items.set([{ name: 'CompX' }, { name: 'CompY' }]), 60)
})
</script>

<ul id="repro-list">
  <li x-for="it in items" class="interp">{{ it.name || 'none' }}</li>
  <li x-for="it in items" class="xt" x-text="it.name || 'none'"></li>
</ul>

xfor-repro.stx (a view, in your patterns), doing the same loop inline plus the component:

@section('content')
<script client>
const viewItems = state([])
onMount(() => {
  setTimeout(() => viewItems.set([{ name: 'ViewX' }, { name: 'ViewY' }]), 60)
})
</script>

<ul id="view-interp"><li x-for="it in viewItems">{{ it.name || 'none' }}</li></ul>
<repro-list />
@endsection

Load the view.

Expected vs actual

wheredirectiveresult
view-level x-for{⁠{ it.name }}✅ ViewX, ViewY
component x-for{⁠{ it.name }}❌ empty, empty
component x-forx-text="it.name"✅ CompX, CompY

Rendered component HTML — the loop ran (2 <li class="interp">) but the {⁠{ }} nodes are blank:

<li class="interp"></li>
<li class="interp"></li>
<li class="xt">CompX</li>
<li class="xt">CompY</li>

Workaround

Use x-text (or bind via an attribute) for loop-variable values inside components: <span x-text="it.name"></span> instead of <span>{⁠{ it.name }}</span>.

In the served/inlined runtime, regex backslashes appear stripped from string-literal regexes. Two observed:

  • the x-for/:for trailing-() retry ships as new RegExp("(s*)s*$") instead of \(\s*\)\s*$, so the retry that recovers signal() list expressions never fires (bare signal refs are required in :for);
  • the inScope first-identifier regex ships as [A-Za-z_$][w$]* instead of [\w$]*, so the [STX] :for ... inScope=false root=<char> diagnostic only captures the first character of the identifier.

These may be unrelated to the interpolation-scope bug, but both point at string contents being mangled by the build.

14 days ago

Fixed in 4c5fb057f3 (on main). This was two independent bugs, both root-caused + verified (full suite 9239 pass / 0 fail).

Primary — {⁠{ }} emptied inside a component x-for

The server was emptying {⁠{ it.name }} before it reached the client (reproduced: the view kept it, the component shipped <li class="interp"></li>).

processExpressions only preserves {⁠{ }} over non-context vars when the template is detected as "uses signals". But renderComponentWithSlot strips the component's <script client> before the body's expressions run, leaving a bare x-for="it in items" — and usesSignalsInScript only recognised @-prefixed directives with a () call, so it returned false → {⁠{ it.name }} was evaluated server-side (loop var undefined → empty).

Fix: usesSignalsInScript now treats the unambiguously client-side :/x- directives (:for/x-for/:if/x-text/…) as reactive, guarded to attribute position so it can't false-match style="display:show".

Secondary — runtime regexes shipped with backslashes stripped

You were right that this is the same "string contents mangled by the build" class — and it was real. The runtime is emitted as a template literal, so every backslash must be doubled to survive; several regex literals used a single backslash, and the :for retry used new RegExp('...') (which loses a second layer to the browser's string-literal parse — worse than a literal, not better). Your two reported cases plus three more I found, all verified against the generated runtime:

  • :for/x-for trailing-() retry: new RegExp('\\(\\s*\\)\\s*$') → /\\(\\s*\\)\\s*$/ (shipped as (s*)s*$; both bare and items() call forms now recover)
  • inScope first-ident diag: [\\w$]* (shipped [w$]* → captured one char)
  • @click writeback guard ^[a-zA-Z_$]\\w*\\s*= (shipped w*s*= → spaced assignments like open = !open never matched, so the writeback silently skipped)
  • useCookie name-escaping char-class + \\$& replacement (an unescaped . could false-match a different cookie)
  • four {⁠{ }} head-interpolation regexes (\\s → literal s)

Tests

  • new test/components/interpolation-in-loop.test.ts (server preserves the component-loop {⁠{ }}; usesSignalsInScript recognises bare x-for/:for, still rejects plain-server / @foreach)
  • test/signals/use-cookie.test.ts: the old assertion pinned the bug ('\$&') — corrected to the shipped '\\$&' plus a discriminating behavioural test (a . must not false-match aXbc for the name a.bc)

The client-side interpolation then runs the same processElement text path that already worked at view level (and x-text already worked in components), so the round-trip is covered.

Sign in to comment on this issue.