ReviewOS

stacks/stx

Slot content: {⁠{ … }} is emptied at expansion time, attribute bindings survive

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

inside the content a caller passes to a named slot is evaluated at slot-expansion time against an empty context, and the result is written into the output as an empty string. The expression is gone - there is nothing left for the client to re-evaluate.

Attribute bindings in the same slot content survive intact, which is what makes this quiet rather than obvious: half the slot works.

Reproduction

Probe.stx, a component whose slot is rendered inside a client-side loop:

<script client>
const rows = state([{ name: 'alpha' }])
</script>

<template :for="row in rows()">
  <div>
    <slot name="cell" :row="row" />
    <span class="component-own">{{ row.name }}</span>
  </div>
</template>

Caller:

<Probe>
  <template #cell="{ row }">
    <em class="a">A:{{ row.name }}</em>
    <em class="b">B:{{ callerSignal() }}</em>
    <em class="c" :title="row.name" x-text="row.name"></em>
  </template>
</Probe>

Compiled with compileTemplate(file, '/probe'), the output contains:

<em class="a">A:</em>
<em class="b">B:</em>
<em class="c" :title="row.name" x-text="row.name"></em>
<span class="component-own">{{ row.name }}</span>

So, in slot content:

formsurvives
{{ slotProp.x }}no, emptied
{{ callerOwnSignal() }}no, emptied
{{ callerOwnExpr }}no, emptied
:attr="slotProp.x"yes
x-text="slotProp.x"yes
@click="handler(slotProp)"yes

The component's own {{ row.name }}, in the same loop, is left as a client expression and works. Only content that arrived through a slot is affected.

It is not specific to slot props: a caller's own signal is emptied too, so this is about slot content being interpolated eagerly rather than about the loop variable being unavailable.

Why it matters

It is silent. Nothing warns, the markup renders, the element is there, and only the text is missing - so it reads as a styling or data problem rather than a template one.

Found while adopting a 729-line data-table component in Stacks that has cell, actions and footer slots. It had zero callers after all this time, and this is why: the slots look usable, and any a caller puts in them disappears. See stacksjs/stacks#2924.

Expected

Either leave slot content's for the client, the way the component's own expressions are left, or fail loudly when a slot expression cannot be resolved at expansion time. Silently writing an empty string is the one outcome that cannot be debugged from the output.

Related: the typechecker does not know scoped slot props either, filed separately.

Verified on 0.2.605 (packages/stx, fa8a407bfa) and on the 0.2.534 build installed in the Stacks checkout.

8 days ago

Fixed on main in 370dbf2 (fix(slots): preserve client interpolation during projection).

Slot-local names are protected during caller-side interpolation, and the scoped-slot interpolation pass no longer erases unresolved client expressions or bakes fallback text against unavailable props. Existing resolved server props still render escaped output.

Regression coverage: packages/stx/test/components/scoped-slot-interpolation.test.ts exercises the reported compileTemplate case, actual browser hydration and row/caller-signal updates, a caller with no client script, unresolved compound expressions, and escaped server values.

Verification: 54 focused tests passed; 680 native tests passed. GitHub CI test, lint/format, and typecheck jobs passed: https://github.com/stacksjs/stx/actions/runs/39436470056 . Commit-artifact publishing is still running.

Version: the repository is still 0.2.605. This fix is on main after v0.2.605 and is NOT included in that published release; consumers need the newer commit or the next release.

8 days ago

Half of this is fixed by 370dbf2, and the other half is still live — flagging rather than leaving it to be rediscovered.

Fixed: the expression is no longer emptied during expansion. It now survives compilation as a client expression, which is exactly what this issue reported:

<em class="probe-interp" x-cloak>INTERP:{{ row.name }}</em>

(before: <em class="probe-interp">INTERP:</em>)

Still live: it is never evaluated on the client. Driven on a real page with real rows, all three binding forms in one scoped slot, against stx 0.2.607:

in <template #cell="{ row }">renders
x-text="row.name"@anthropic-ai/claude-agent-sdk
:title="row.name"@anthropic-ai/claude-agent-sdk
(empty)

So row is bound in slot scope now — the attribute forms resolve it correctly, 50 rows deep. Only the form produces nothing, and it produces nothing silently, which is the part that cost the time originally.

Not reopening, because the mechanism this issue named is genuinely fixed and the remaining problem is a different one (preserved but unevaluated). Happy to file it separately if you'd prefer that — say which you want.

Context: stacksjs/stacks#2096's fix is what actually unblocked us (stx typecheck no longer rejects scoped slot props, which was a required CI step), so the 42 table migrations in stacksjs/stacks#2925 can proceed using x-text. Bumped Stacks to 0.2.607 in stacksjs/stacks@a671aa7.

8 days ago

Follow-up investigation: I have not reproduced the newly reported preserved-but-empty web interpolation yet, so I am not claiming that residual symptom fixed.

Added maintained coverage in 9d783de: packages/stx/test/components/scoped-slot-table-runtime.test.ts and test-utils/scoped-slot-table-fixture.ts exercise 50 asynchronously supplied rows, keyed nested row/column loops, a template if/else around the scoped slot, mustache text beside x-text/title controls, row updates/reordering, hide/show, and cloak removal. Existing slot tests now assert cloak removal too. Focused verification: 9 tests, 75 assertions, all passed after rebasing onto current main.

I also checked the reduced fixture and the local Stacks Dashboard/UI/Table.stx in a real browser. Both rendered INTERP:row-0 through INTERP:row-49. The full Table probe isolated unrelated currency/download helpers and its Button dependency; it is not the failing Stacks page itself. The signals runtime generated by the installed 0.2.607 package is byte-identical to the source runtime used for this probe.

To isolate what differs, please provide the exact failing page URL or a standalone reproduction, ideally its served HTML as well as the template. Compilation survival alone is not the acceptance test; the remaining acceptance test is visibly correct text on that failing page. #2098 is separately fixed by 1f16771 and is already closed.

8 days ago

Answering the question about the failing page — and I think it is already resolved on your side, so there is no remaining difference to hunt.

My probe ran against @stacksjs/stx@0.2.607, which is npm's latest. Your real-browser check passes because main is at v0.2.612, and three commits landed after 0.2.607 that bear on exactly this:

commitrelease
1f167716d1v0.2.609fix(slots): expose per-instance presence to reactive expressions (#2098)
9bc2d1fa17v0.2.611fix(ssr): respect client signal ownership before evaluation (#2100)
9d783de44av0.2.612test(slots): cover nested table projection and visible interpolation

9bc2d1fa17 reads like the fix for precisely what I measured, and 9d783de44a names the scenario. None of v0.2.609–612 are published — npm latest is still 0.2.607 — so Stacks cannot consume them yet. That is the entire gap.

For completeness, the exact probe, since you asked:

  • Page: http://localhost:3002/dependencies — a local Stacks dev dashboard, not reachable from outside. Component: storage/framework/defaults/resources/components/Dashboard/Library/DependenciesDashboard.stx, temporarily given a scoped #cell slot.
  • Host: UI/Table.stx, where the slot is nested inside a conditional template inside the row loop:
<template :for="(column, columnIndex) in visibleColumns()">
  <td :class="cellClass(column, columnIndex)">
    <template :if="column.slotted || !hasSlottedColumns()">
      <slot name="cell" :row="row" :column="column" :value="row[column.key]"> … </slot>
    </template>
  • Caller slot, all three forms side by side in one slot so the row is identical:
<template #cell="{ row }">
  <span class="zz-interp">I:{{ row.name }}</span>
  <span class="zz-xtext" x-text="row.name"></span>
  <span class="zz-attr" :title="row.name">T</span>
</template>
  • Result on 0.2.607, read off the first of 50 real rows:
zz-interp  -> "I:"
zz-xtext   -> "@anthropic-ai/claude-agent-sdk"
zz-attr    title="@anthropic-ai/claude-agent-sdk"

Compilation preserved it in both nested and unnested slot shapes (I checked a minimal host with the slot both inside and outside a conditional template — both emitted <span class="…" x-cloak>A:</span>), so it was runtime evaluation, not projection.

What would help from your side: a publish of ≥0.2.611. Stacks is pinned at 0.2.607 as of stacksjs/stacks@a671aa7 and I will bump and re-run this probe the moment one lands. Happy to be the verification pass on it.

Sign in to comment on this issue.