ReviewOS

stacks/stx

<component :is> is claimed by the static component pass and never resolves — 26 of 91 library primitives render an error string with absolute server paths

#1817
Closed glennmichael123 opened this 14 days ago · 2 comments
14 days ago

<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.stx

Expected <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>   correct

A 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
26

Whole 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 paths

A 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 resolving

An 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.

14 days ago

Fixed in 9680549872.

Your A-vs-B repro nailed it — pass ordering was the cause. Two changes, and both matter:

  • component joins the static scan's skip-set. This is the part that matters if you're already running it: a stray <component> can no longer leak absolute server filesystem paths into a response.
  • The dynamic pass now runs before the static one. Skipping the tag alone would have left a dynamically-chosen component's children unprocessed — there's a test covering that (<component :is="Wrap"> where Wrap contains <Inner />).

One scope note: a literal is="Card" (no colon) is still not a binding — only :is and v-bind:is are. Vue accepts the literal form and stx could too, but that's new API rather than this defect, so the test pins the current contract plus the property that actually matters: the unsupported form degrades to inert markup rather than an error string carrying server paths.

Also worth flagging while I was in there: v-bind:is reaches the dynamic pass, but v-bind: also trips the signals-syntax detector, so that template takes a different route entirely. Pre-existing and out of scope here — say the word if you want it filed.

14 days ago

The ordering half of this is confirmed fixed — no more [Error loading component] and no leaked absolute paths. Thanks.

The other half from the "A pure reorder is not sufficient" section is still open and is the one the library depends on: interpolated :is="{⁠{ as }}" emits a dynamic component comment and drops the whole subtree including <slot />. All 26 affected primitives use that form (grep -rl 'is="{⁠{' packages/components/src/ui/ | wc -l → 26), so they remain unusable — now silently rather than loudly.

Filed with measurements as #1826 rather than reopening here, since it is a distinct defect.

Sign in to comment on this issue.