ReviewOS

stacks/stx

Scoped slots (`<slot :prop="value" />`) drop their children and emit a literal <slot> — 9 components render empty

#1975
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Summary

A component whose template uses a scoped slot — <slot :name="value" /> rather than <slot /> — silently drops everything the caller passed into it, and emits a literal, malformed <slot … / /> element into the HTML.

Nine shipped components in @stacksjs/components use this form, so nine components render as empty shells. <Table> is the clearest case: it renders <table> with nothing in it, whatever you put inside.

Minimal reproduction

Two components, identical except for the slot:

<!-- plain-slot.stx -->
<script server>
export const tone = $props.tone || 'a'
</script>
<div data-plain=""><slot /></div>
<!-- scoped-slot.stx -->
<script server>
export const tone = $props.tone || 'a'
</script>
<div data-scoped=""><slot :tone="tone" /></div>

Called the same way:

<PlainSlot tone="x">PLAIN-CHILD-RENDERED</PlainSlot>
<ScopedSlot tone="x">SCOPED-CHILD-RENDERED</ScopedSlot>

Rendered output:

<div data-plain="x">PLAIN-CHILD-RENDERED</div>

<div data-scoped="x"><slot tone="x" / /></div>

The binding was evaluated — :tone="tone" became tone="x" — but the slot was never substituted, the children are gone, and the emitted tag has a stray / before the self-closing />.

Impact: <Table> renders nothing

packages/components/src/ui/table/Table.stx ends with:

<table class="">
  <slot :striped="striped" :hoverable="hoverable" />
</table>

So this:

<Table striped="true">
  <TableHead><TableRow><TableHeader>Date</TableHeader><TableHeader>Session</TableHeader></TableRow></TableHead>
  <TableBody><TableRow><TableCell>Mon</TableCell><TableCell>Intervals</TableCell></TableRow></TableBody>
</Table>

produces:

<div class="overflow-x-auto">
  <table class="min-w-full divide-y divide-line">
    <slot striped="true" hoverable="true" / />
  </table>
</div>

document.querySelectorAll('table')[0].rows.length → 0.

Dropping the <Table> wrapper and using a plain <table> with the same children works — 2 rows, 2 cells each — which confirms the sub-components are fine and the wrapper's slot is what fails.

The same thing removes every <DropdownItem>'s content: two items in, two literal <slot / /> out, no labels.

Affected components

Every file matching <slot : in packages/components/src:

FileWhat breaks
ui/table/Table.stxthe whole Table/TableHead/TableBody/TableRow/TableHeader/TableCell family
ui/dropdown/DropdownItem.stxmenu item labels
ui/listbox/ListboxOption.stxoption labels
ui/combobox/ComboboxOption.stxoption labels
ui/radio-group/RadioGroupOption.stxoption labels
ui/stepper/Stepper.stx, ui/stepper/StepperStep.stxstep content
ui/notification/Notification.stxbody content
ui/virtual-list/VirtualList.stxevery row

Expected

Either the renderer substitutes scoped slots (exposing the bound values to the slotted content), or — if scoped slots are not a supported feature — the nine components should use a plain <slot /> and pass their values another way, so that at minimum children are not discarded and no literal <slot> reaches the browser.

Silently emitting <slot … / /> is the worst of the three outcomes: nothing renders and nothing says why.

Environment

  • stx / @stacksjs/components 0.2.339
  • stacks 0.75.31, Bun 1.3.x, macOS
  • Found while migrating hq.training onto the component library.
9 days ago

Re-tested on @stacksjs/components 0.2.343 (from 0.2.339). Still reproduces, and I can now name the discriminator precisely — it is narrower and more actionable than "children are dropped".

A plain <slot /> works. A scoped <slot :prop="…" /> drops everything.

Rendered output for a scoped one is the literal <slot / /> — note the stray / where the binding was stripped.

That is the whole rule, and it predicts every case I tested:

slotresult
Dropdown, DropdownButton, DropdownItemsplainchildren render ✅
DropdownItem<slot :disabled="disabled" />empty ❌
Listbox, RadioGroup, Combobox (containers)plainchildren render ✅
ListboxOption, RadioGroupOption, ComboboxOptionscopedempty ❌
Table<slot :striped="striped" :hoverable="hoverable" />0 rows ❌
Tabsplainrenders ✅

Exactly 9 of 51 components are affected

combobox/ComboboxOption.stx     radio-group/RadioGroupOption.stx
dropdown/DropdownItem.stx       stepper/Stepper.stx
listbox/ListboxOption.stx       stepper/StepperStep.stx
notification/Notification.stx   table/Table.stx
virtual-list/VirtualList.stx

42 others use a plain <slot /> and are fine.

Why that matters for prioritising this

The containers were fixed at some point between 0.2.339 and 0.2.343 — <Dropdown> now passes its children through, where before the whole subtree vanished. So this is already most of the way there. What remains is that every one of these families is broken at the leaf, which is indistinguishable from the whole family being broken: a <Dropdown> that renders three empty menu items is not usable, and <Table> renders zero rows because its own slot is the scoped one.

So fixing scoped slots unblocks the menu family, the table family, the stepper and notifications in one go — nine files, one bug.

Reproducing

<Dropdown>
  <DropdownButton>Actions</DropdownButton>
  <DropdownItems>
    <DropdownItem>Edit</DropdownItem>
  </DropdownItems>
</Dropdown>

The item renders with role="menuitem" and <slot / /> inside it; textContent is "".

<Table>
  <TableBody><TableRow><TableCell>Ava</TableCell></TableRow></TableBody>
</Table>

Renders <table class="min-w-full divide-y divide-line"><slot hoverable="true" / /></table> — 0 rows, 0 cells.

Measured in a browser against a real Crosswind build, stacks 0.75.35.

Sign in to comment on this issue.