ReviewOS

stacks/stx

Scoped slot props only arrive when the child's loop variable, the prop and the parent's name are all the same

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

What happens

A scoped slot prop only reaches the slot content when three names match: the child's loop variable, the slot prop and the name the parent destructures. Otherwise the parent reads undefined.

  • The child binds the prop to a loop variable with another name (<li :for="item in items()"> <slot name="actions" :row="item" />). The parent's #actions="{ row }" gets row === undefined.
  • The child binds :row="row" and the parent renames it in the destructure (#actions="{ row: entry }"). entry is undefined.

It only works when the child's loop variable is itself called row, the prop is :row="row" and the parent reads row. So the slot content seems to resolve row against the child's loop scope by name, and never through the slot props.

Found while moving dashboard tables onto a shared Table component in stacksjs/stacks#2925. Table happens to name its loop variable row, which is the only reason its #cell="{ row }" slot works.

Repro (stx 0.2.661, stx dev slot-scope.stx)

components/BoxItem.stx:

<script client>
const items = useReactiveProp('items', [])
</script>

<ul>
  <li :for="item in items()">
    <span>{{ item.name }}</span>
    <slot name="actions" :row="item" />
  </li>
</ul>

components/BoxRow.stx is the same file with the loop variable renamed: :for="row in items()", and :row="row".

slot-scope.stx:

<script client>
const items = state([{ id: 1, name: 'first' }, { id: 2, name: 'second' }])
</script>

<h2>1: child passes :row="item", parent reads row</h2>
<BoxItem :items="items()">
  <template #actions="{ row }">
    <code>typeof row=[{{ typeof row }}]</code>
  </template>
</BoxItem>

<h2>2: same child, parent reads the child's loop variable item, which it never declared</h2>
<BoxItem :items="items()">
  <template #actions="{ row }">
    <code>item.name=[{{ typeof item === 'undefined' ? 'undefined' : item.name }}]</code>
  </template>
</BoxItem>

<h2>3 (control): child passes :row="row", parent reads row</h2>
<BoxRow :items="items()">
  <template #actions="{ row }">
    <code>row.name=[{{ row ? row.name : 'undefined' }}]</code>
  </template>
</BoxRow>

<h2>4: child passes :row="row", parent destructures it under another name</h2>
<BoxRow :items="items()">
  <template #actions="{ row: entry }">
    <code>entry.name=[{{ typeof entry === 'undefined' ? 'undefined' : entry.name }}]</code>
  </template>
</BoxRow>

Rendered text:

1: child passes :row="item", parent reads row
first typeof row=[undefined]
second typeof row=[undefined]
2: same child, parent reads the child's loop variable item, which it never declared
first item.name=[undefined]
second item.name=[undefined]
3 (control): child passes :row="row", parent reads row
first row.name=[first]
second row.name=[second]
4: child passes :row="row", parent destructures it under another name
first entry.name=[undefined]
second entry.name=[undefined]

Expected

Cases 1 and 4 should print each row's name, as case 3 does. A slot prop's value is the evaluated expression (item), exposed under the prop's name (row), and the parent's destructure decides what it is called locally.

Case 2 shows this is not a simple leak of the child's scope into the parent. The child's own variable item is not visible either. Only row resolves, and only when the child happens to have a row in scope.

8 days ago

Fixed on main in e20c927, with follow-ons 7db570f / 35836c2 and native template support in 2c76a92. Pushed cohort: 1d67b36.

Version status: unreleased after STX v0.2.665. v0.2.665 does not contain this fix; use these main commits until the next patch is published.

Reactive slot outlets now evaluate the child's prop expressions and bind the resulting object to the caller's actual binding pattern. Child loop names, prop names and caller aliases no longer need to match. Unrelated child locals remain private. This also covers object/nested/default bindings, caller-variable shadowing, events/attributes, bare-text projections, reactive replacement and removed-row cleanup. Native template projections remain transparent rather than adding layout wrappers, and generated component loops retain their key.

Maintained regression: scoped-slot-prop-bindings.test.ts, covering both browser build modes and normal/minified native bundles. Keyed native controls are also checked in template-for-fragments.test.ts and shared-runtime-screen.test.ts.

Verification: 3,132 passed, 0 failed across the components/native/shared-components/scoped-slot-typecheck regression cohort; separate imported-HUD protocol checks: 2 passed, 0 failed. Repository lint (0 errors; existing warnings), format check and typecheck passed. Closing as fixed; no published-package claim yet.

8 days ago

Release follow-up: the fix is now verified in a clean npm installation of @stacksjs/stx 0.2.674 (including a frozen install). The public installed native compiler passes both normal/minified probes for __stx_row="eyJraW5kIjoiY29tbWVudCIsImF0IjoiU2F0IE9jdCAyNCAyMDI2IDIzOjIyOjQxIEdNVCswMDAwIChDb29yZGluYXRlZCBVbml2ZXJzYWwgVGltZSkiLCJjb21tZW50Ijp7ImlkIjoxNDEwNjcsImF1dGhvciI6ImdsZW5ubWljaGFlbDEyMyIsIndoZW4iOiI4IGRheXMgYWdvIiwiY3JlYXRlZEF0IjoiU2F0IE9jdCAyNCAyMDI2IDIzOjIyOjQxIEdNVCswMDAwIChDb29yZGluYXRlZCBVbml2ZXJzYWwgVGltZSkiLCJlZGl0ZWQiOmZhbHNlLCJib2R5SHRtbCI6IjxwPlJlbGVhc2UgZm9sbG93LXVwOiB0aGUgZml4IGlzIG5vdyB2ZXJpZmllZCBpbiBhIGNsZWFuIG5wbSBpbnN0YWxsYXRpb24gb2YgPHN0cm9uZz48YSBocmVmPVwiL3N0YWNrc2pzXCI+QHN0YWNrc2pzPC9hPi9zdHggMC4yLjY3NDwvc3Ryb25nPiAoaW5jbHVkaW5nIGEgZnJvemVuIGluc3RhbGwpLiBUaGUgcHVibGljIGluc3RhbGxlZCBuYXRpdmUgY29tcGlsZXIgcGFzc2VzIGJvdGggbm9ybWFsL21pbmlmaWVkIHByb2JlcyBmb3IgPGNvZGU+OnJvdz1cIml0ZW1cIjwvY29kZT4gd2l0aCBjYWxsZXIgZGVzdHJ1Y3R1cmluZyA8Y29kZT57IHJvdzogZW50cnksIGluZGV4OiBwb3NpdGlvbiB9PC9jb2RlPiwgY2FsbGVyLXZzLWNoaWxkIHNjb3BlLCBrZXllZCByZWFjdGl2ZSB1cGRhdGVzLCBldmVudCBwYXlsb2FkcyBhbmQgcmVtb3ZhbC4gQ3VycmVudC1tYWluIGJyb3dzZXIvbmF0aXZlIHNjb3BlZC1zbG90IHJlZ3Jlc3Npb25zIGFsc28gcGFzcy48L3A+PHA+VGhlIG9sZGVyIGNsb3NlIGNvbW1lbnQncyDigJx1bnJlbGVhc2VkIGFmdGVyIHYwLjIuNjY14oCdIGRlc2NyaWJlcyB0aGUgc3RhdGUgYXQgbGFuZGluZzsgaXQgaXMgbm8gbG9uZ2VyIHRoZSBjdXJyZW50IHBhY2thZ2Ugc3RhdHVzLiB2MC4yLjY2NiBpcyBub3QgcmVhZGFibGUgZnJvbSBucG0sIHNvIHVzZSB0aGUgdmVyaWZpZWQgcHVibGlzaGVkIHYwLjIuNjc0IHJhdGhlciB0aGFuIHdhaXRpbmcgb24gdGhhdCBvbGRlciB0YWcuPC9wPiIsInJlYWN0aW9ucyI6W10sInJlYWN0aW9uRmllbGRzIjpbeyJuYW1lIjoib3duZXIiLCJ2YWx1ZSI6InN0YWNrcyJ9LHsibmFtZSI6InJlcG8iLCJ2YWx1ZSI6InN0eCJ9LHsibmFtZSI6Im51bWJlciIsInZhbHVlIjoyMTEyfSx7Im5hbWUiOiJjb21tZW50X2lkIiwidmFsdWUiOjE0MTA2N31dLCJwaWNraW5nIjpmYWxzZSwicGlja2VySHJlZiI6Ij9yZWFjdD0xNDEwNjcjcmVhY3Rpb25zLTE0MTA2NyJ9LCJldmVudCI6bnVsbH0=" with caller destructuring { row: entry, index: position }, caller-vs-child scope, keyed reactive updates, event payloads and removal. Current-main browser/native scoped-slot regressions also pass.

The older close comment's “unreleased after v0.2.665” describes the state at landing; it is no longer the current package status. v0.2.666 is not readable from npm, so use the verified published v0.2.674 rather than waiting on that older tag.

Sign in to comment on this issue.