ReviewOS

stacks/stx

Props passed component-to-component are silently dropped

#1937
Closed chris opened this 10 days ago ·
10 days ago

Passing a prop from one component to another silently delivers nothing. Every prop: arrays, booleans, and plain string attributes alike. Page-to-component passing works correctly, which is what makes this so easy to miss.

Reproduce

<!-- resources/components/PropProbe.stx -->
<script server>
import { defineProps, withDefaults } from 'stx'
interface P { items?: any[], flag?: boolean, text?: string }
const { items, flag, text } = withDefaults(defineProps<P>(), { items: [], flag: false, text: '' })
const count = Array.isArray(items) ? items.length : -1
</script>
<template>
  <p data-probe="items= flag= text=function Ca(){return new Sa}"></p>
</template>
<!-- resources/components/PropWrapper.stx -->
<script server>
import { defineProps, withDefaults } from 'stx'
const { seed } = withDefaults(defineProps<{ seed?: number }>(), { seed: 0 })
const built = [{ a: seed }, { a: seed + 1 }]
</script>
<template>
  <PropProbe :items="built" :flag="true" text="from-wrapper" />
</template>
<!-- resources/views/prop-probe.stx -->
<script server>
const rows = [{ a: 1 }, { a: 2 }, { a: 3 }]
const yes = true
</script>
<template>
  <div>
    <PropProbe :items="rows" :flag="yes" text="hello" />
    <PropWrapper :seed="5" />
  </div>
</template>

Rendered:

data-probe="items=3 flag=true text=hello"      <- from the page: correct
data-probe="items=0 flag=false text="          <- from a component: everything lost

The nested component renders with its defaults. Nothing warns, no error appears in the console or the log, and the page returns 200.

Why it is worth fixing rather than documenting

A shared wrapper component is the normal way to avoid repeating chrome, and this makes that pattern quietly wrong. In our case a ChartFrame component held the title, the empty state and the legend for eight chart components; the legend simply never rendered, on every chart, and the charts looked fine because the plot itself is passed through a slot rather than a prop.

That mattered more than it sounds: the legend is the secondary encoding that makes our categorical palette legal for colour-vision deficiency. So the failure mode was an accessibility regression that looked like a working page.

Contrast with the @foreach diagnostic, which is excellent: when a server loop reads something non-iterable, stx prints a specific message naming the variable and the file. A prop arriving as its default could warn the same way when the parent passed something and the child received nothing.

Environment

@stacksjs/stx as resolved by stacks@0.70.371, Bun 1.3.14, macOS, buddy dev serve path.

Found while building a chart component library in a Stacks app on 2026-08-14.

10 days ago

Fixed in 31b8d28.

The trigger was narrower than it looked, and worth recording because the workaround is a one-line edit if you hit this on a released version: it was the import { defineProps } from 'stx' line in the wrapper's <script server>. Removing that import makes the same component work — defineProps is injected by the engine, so the import was never needed.

Mechanism. An import of an engine-provided name was rewritten into a re-export (module.exports.defineProps = defineProps), which put the engine's per-component binding into that component's context. A child inherits its parent's context, and context keys are appended to the script's parameter list after the engine bindings — so the later parameter won. The child's defineProps was the parent's, read the parent's props, and found none of its own.

That is why only defineProps did it. state, withDefaults and defineEmits leak identically and are harmless, because they are not bound per component.

You were right that the static attribute is the tell. text="from-wrapper" cannot depend on any context, so its loss said the props were dropped wholesale rather than mis-evaluated — that is the observation that separated "tag parsed wrong" from "wrong props object handed over", and it is pinned as its own assertion in the regression test.

The fix, in variable-extractor.ts:

  • Engine bindings are no longer re-exported. They are already in scope as parameters, so nothing needs them in the context; publishing them is what made a parent's binding reachable from a child. Aliases included.
  • defineProps and $props join props in the set a context value may not shadow. props was already excluded on exactly this reasoning; the other two were missed. Deliberately not the whole engine set — state is a plausible name for a US state, and a page that puts one in context should keep it.

Regression test at packages/stx/test/component-nested-props.test.ts, covering stx and @stacksjs/stx, the aliased form, and a non-engine import (defineStore) that must keep resolving through the import as before.

On the diagnostic you asked for — a warning when a parent passed something and the child received nothing — I did not add one. With this fixed I could not construct a case where it would fire that is not itself a bug worth fixing outright, and a warning that never fires is worse than none. If you hit a remaining case where props still arrive as defaults, please reopen with it; that would be the evidence the check has something real to say.

Sign in to comment on this issue.