<component :is> never reaches its handler. The static component pass claims the
component tag first, tries to load a file literally named component.stx, fails, and
emits an error string — including absolute server filesystem paths — into the rendered
page.
This takes out 26 of the 91 primitives in @stacksjs/components, because the whole
headless-primitive family is built on <component :is>.
Cause
packages/stx/src/process.ts:
1299: output = await processComponents(output, context, filePath, opts, dependencies)
1302: output = await processDynamicComponents(output, context, filePath, opts, dependencies)processComponents runs first. Its lowercase-tag scan
(component-renderer.ts:875-876) does not skip component — the tag is absent from the
htmlTags skip-set at component-renderer.ts:40-59 — so it resolves it as a component file
named component.stx, does not find one, and returns the error string from
utils.ts:874-878. processDynamicComponents at 1302 never sees the tag.
Repro
Minimal, and it isolates ordering as the cause — the same template renders correctly when the dynamic pass is called directly:
components/Card.stx = <article class="card"><slot /></article>A — through the live pipeline
processDirectives('<component :is="which" class="x">hello</component>',
{ which: 'Card' }, `${dir}/page.stx`, { componentsDir }, new Set())[Error loading component: ENOENT: no such file or directory, open 'component']
Searched paths:
- <tmp>/components/component.stx
- <tmp>/components/Component.stx
- …/packages/stx/src/components/component.stx
- …/packages/stx/src/components/Component.stxExpected <article class="card">hello</article>. The slot content is destroyed.
B — same template, dynamic pass called directly
processDynamicComponents('<component :is="which" class="x">hello</component>', { which: 'Card' }, …)
// -> <article class="card">hello</article> correctA vs B is the proof: 1299 running before 1302 is the cause.
Confirmed end to end in a real app
Not just at the unit level. Dropping <DialogPanel className="probe"><p>probe</p></DialogPanel>
into a page of a running stx app (bughq, resources/views/login.stx) and requesting it over
HTTP returns, in the served HTML:
Error loading component: ENOENT: no such file or directory, open 'component']
…
/Users/…/bughq/resources/views/layouts/d…
/Users/…/bughq/resources/views/component…So this is not a dev-only diagnostic — absolute server paths are emitted into the response
body. That is worth treating as information disclosure independently of the rendering bug:
whatever the fix for the ordering, the not-found path should not print filesystem locations
into a page. Suggest gating the "Searched paths" list behind debug.
Scope
26 of the 91 tag-addressable primitives are affected — every file under
packages/components/src/ui/ that uses the pattern:
$ grep -rl "<component" packages/components/src/ui/ | wc -l
26Whole families are dead on arrival: dialog/ (DialogPanel.stx:11, DialogTitle.stx:9,
DialogDescription.stx:9, DialogBackdrop.stx:8), plus all of dropdown/, listbox/,
combobox/, popover/, radio-group/, stepper/.
Self-closing is affected identically: <DialogBackdrop /> produces the same error block.
The other 65 primitives are fine — I verified <Badge>, <Button>, <TextInput> and
<Skeleton> render correctly from an app page, with kebab→camel prop mapping, :-bound
props, @-event forwarding and caller-scoped slot expressions all working. So this is a
sharp, contained defect rather than a broken plugin.
A pure reorder is not sufficient
Moving 1302 above 1299 fixes the family above but leaves two adjacent defects in
processDynamicComponents itself:
processDynamicComponents('<component :is="as" class="x">hello</component>', { as: 'div' }, …)
// -> [Error loading component: ENOENT: … open 'div'] + absolute pathsA plain HTML tag name in :is is treated as a component file rather than rendered as that
element — <component :is="'div'"> is the standard escape hatch for a
caller-chosen wrapper element, and it fails.
processDynamicComponents('<component :is="{{ as }}" class="c">x</component>', { as: 'div' }, …)
// -> emits a "dynamic component" comment rather than resolvingAn interpolated :is is not resolved either.
So the fix wants to be: skip component in the static pass (or run the dynamic pass first),
and have the dynamic pass fall back to emitting a plain element when :is resolves to an
HTML tag name, and resolve interpolated values. A test asserting <component :is> survives
the full processDirectives pipeline — not just the dynamic pass in isolation — would have
caught the ordering half, which is exactly the gap A/B above demonstrates.