ReviewOS

stacks/stx

A component receiving a parent reactive binding is served as its raw template by `buddy serve` (`class="{⁠{ buttonClasses }}"` in production) while `build` renders it correctly

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

Summary

A component that receives a reactive binding from its parent is served as its own un-evaluated template by buddy serve, while bun run build renders the same component correctly at the same commit. The served page ships the component's internal expressions verbatim:

<div data-stx-parent-bindings="loading" :loading="loading()" data-stx-scope="stx_button_2_1tf9xn"
     data-stx-props="{&quot;type&quot;:&quot;submit&quot;,&quot;fullWidth&quot;:&quot;true&quot;,&quot;className&quot;:&quot;btn-accent&quot;}">
  <button type="submit" class="{{ buttonClasses }}" :disabled="isDisabled()"
          aria-busy="{{ loading ? 'true' : 'false' }}" :aria-busy="isLoading() ? 'true' : 'false'">

class="{{ buttonClasses }}" reaches the browser as that literal string. It is not a hydration race — reading the DOM on the deployed page well after load gives:

{ "classAttr": "{{ buttonClasses }}", "background": "rgba(0, 0, 0, 0)",
  "padding": "0px", "borderRadius": "0px", "width": 51.6 }

The sign-in button of a production app was a bare run of unstyled text for as long as this was deployed.

The two paths disagree

Same commit, same page, same component:

<button> as emitted
bun run build → dist/login.htmlclass="inline-flex items-center justify-center font-semibold rounded-control transition-all … bg-accent-solid hover:bg-accent-solid-hover text-accent-ink shadow-md … w-full btn-accent"
buddy serve (production)class="{{ buttonClasses }}"

The build output is correct, so nothing that inspects dist/ can detect this.

Scope: components with a parent reactive binding

Within the same app and the same deploy:

  • <Breadcrumb> — static props only — renders correctly on four live public pages: <nav class="flex items-center flex-wrap gap-2 text-sm shell subnav" aria-label="Breadcrumb">. No data-stx-scope wrapper, no raw expressions.
  • <Button> — passed :loading="loading()" from the page — is wrapped in data-stx-scope / data-stx-props / data-stx-parent-bindings="loading" and its template is emitted raw.

The wrapper names the binding that distinguishes them (data-stx-parent-bindings="loading"), which suggests receiving a parent reactive binding is what switches the component into a client-hydrated mode whose hydration then never resolves the template under buddy serve. I have verified the two outcomes above directly; the mechanism is inference from the markup, not something I could isolate further from outside.

Affected in this app: 26 <Button> call sites across 9 files, every one of them passing :loading or :disabled.

Why nothing caught it

Worth stating, because the silence is the dangerous part. All of these were clean at the commit that shipped the broken page:

  • stx typecheck — 0 errors, 0 warnings across 62 script blocks and 615 template expressions
  • tsc --noEmit — 0
  • lint — 0 errors, 0 warnings over 155 files
  • test suite — 185 pass
  • bun run build — 33/33 pages
  • release:validate — passes, because dist/ is genuinely correct

The one existing check for this class of problem looks for {{ via document.body.innerText, and every leftover here is in an attribute, which innerText cannot contain.

A served attribute value never legitimately contains {{, so emitting one is always a bug. If the renderer cannot evaluate a component, failing loudly would be far better than serving its template — this is the same argument as #2004 and #2035, now with a third shape.

Environment

  • @stacksjs/components 0.2.369, @stacksjs/stx 0.2.369, bun-plugin-stx 0.2.369
  • stacks 0.74.66, Bun 1.4.3-canary.1
  • Served by bun node_modules/@stacksjs/buddy/dist/cli.js serve, which routes to bun-plugin-stx/serve against resources/views (not dist/)
9 days ago

Narrower diagnosis: it is the root element's attributes, and the server script is not the problem

Still reproduces on 0.2.378 (latest), so nothing since has fixed it.

My original report guessed the component "never renders". That is wrong, and the correct version is much narrower. Rendering <Button :loading="loading()"> through buddy serve and dumping the region:

<button type="submit" class="" aria-busy="false">
  <svg __stx_show="ZmFsc2U=" x-cloak class="animate-spin undefined" …>

Look at the two class attributes in the same component:

elementattributeresult
root <button>class=""unsubstituted
inner <svg>class=""substituted → animate-spin undefined

The inner one resolved. So the <script server> block did run and its exports are in scope — spinnerClasses came out as a real string. Only the root element's attributes are left raw. (The undefined inside that class is a separate, smaller bug.)

That also explains the one case that works: <Breadcrumb> renders <nav class="flex items-center flex-wrap gap-2 text-sm" aria-label="Breadcrumb"> correctly on the same deploy, and <EmptyState> renders <div class="flex flex-col items-center text-center gap-3 px-6 py-12 bg-panel rounded-panel ring-1 ring-line"> correctly. Neither has signal scripts, so neither takes the scope-wrapper path.

A guess at where to look

emitClientReactiveAttrs splices the caller's reactive bindings into the root via findRenderedComponentRoot(output).insertPos, and the scope wrapper in utils.ts wraps result when hasSignalScripts. Both of those operate on the root tag specifically, which is also the one tag whose never gets substituted. I have not isolated which of the two does it, but the overlap seems unlikely to be a coincidence.

Blast radius

Static-analysing the 105 components in @stacksjs/components for the signature — has signal scripts and has in its root element's attributes:

  • 51 at risk, including Button, Badge, Card, Dialog, Form, Checkbox, Radio, Listbox, Combobox, Pagination, Sidebar, Login, Signup
  • 34 have root expressions but no signal scripts, so they render fine (Breadcrumb, EmptyState, SectionCard, Spinner, Skeleton, …)
  • 20 have no expressions in root attributes

That is roughly half the library unusable in production while being perfectly fine in bun run build.

Repro harness

Anything that inspects dist/ cannot see this, which is why it reached production. This boots the real serve path, fetches, and reports expressions that survived into the HTML:

const proc = Bun.spawn(['bun', `${appDir}/node_modules/@stacksjs/buddy/dist/cli.js`, 'serve', '--port', String(port)], { cwd: appDir })
// …wait for the port…
const html = await (await fetch(`http://127.0.0.1:${port}${path}`)).text()
for (const m of html.matchAll(/\s([a-zA-Z_:@][\w:.-]*)\s*=\s*"([^"]*\{\{[^"]*)"/g))
  console.log(`${m[1]}="${m[2]}"`)

Against a page using <Button :loading="…"> it prints class="" and aria-busy="false". Against the same page with the component reverted to a plain <button>, nothing.

Worth noting the attribute-only scope: the existing unresolved-expression check reads document.body.innerText, which by construction cannot contain an attribute value, so it reports clean on a page that is visibly broken.

9 days ago

Fixed in 0.2.379

Verified against a served page, not the changelog — the release notes for 0.2.379/0.2.380 mention a component behind a false :if no longer mounts, which is a different case, so this looks fixed incidentally rather than deliberately. Worth knowing in case the fix is not covered by a test.

Same reproduction as the report: <Button :loading="loading()" fullWidth className="btn-accent"> on a real login page, rendered through buddy serve.

Before (0.2.378 and earlier):

<button type="submit" class="" aria-busy="false">

Now (0.2.379):

<button type="submit"
  class="inline-flex items-center justify-center font-semibold rounded-control transition-all duration-200
         focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-panel focus:ring-accent
         disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none
         bg-accent-solid hover:bg-accent-solid-hover text-accent-ink shadow-md hover:shadow-lg
         px-4 py-2 text-base gap-2 w-full btn-accent"
  :disabled="isDisabled()" aria-busy="false" :aria-busy="isLoading() ? 'true' : 'false'" @click="onClick($event)">

The root element's attributes are substituted now, which was the whole defect. :disabled, :aria-busy and @click correctly remain as client directives, and the served markup is byte-for-byte what bun run build produced all along.

Checked across five instances on four pages (two on /login, one each on /register, /forgot-password, /reset-password): zero unresolved expressions in attributes. The only left anywhere on those pages is in element text, which is ordinary client-rendered output.

All 26 <Button> call sites that had to be reverted are restored and shipping again.

Happy for this to be closed. One suggestion if it is not already covered: a regression test asserting that a component with a parent reactive binding has its root element's attributes substituted would pin this, since the inner elements were always fine and that is what made the bug hard to see.

9 days ago

Closing as not reproducible on current main. I want to be straight about what that does and does not establish, because I could not find the cause.

What I verified

Rendering <Button :loading="loading()"> from a page that owns the signal — the exact shape in the report, with type="submit", fullWidth and className — through both paths at the same commit, with NODE_ENV=production:

buildModeclass as emitteddata-stx-parent-bindings
serveinline-flex items-center justify-center font-semibold rounded-control …present
compileinline-flex items-center justify-center font-semibold rounded-control …present

No raw on either path.

The inferred mechanism is disproven

The report reasons that receiving a parent reactive binding is what switches the component into a client-hydrated mode whose template never resolves under serve — on the evidence that <Breadcrumb> (static props) renders correctly while <Button> (passed :loading) does not, and that the wrapper names the binding.

That inference does not hold: data-stx-parent-bindings is present on both paths, and the template resolves on both. The wrapper is not what distinguishes a working render from a broken one, so whatever separated Breadcrumb from Button in that deploy was something else.

That was careful reasoning from the markup — it was the only evidence available from outside — and it happens to be wrong, which is worth recording so nobody re-derives it.

My own leading theory is also weaker than I thought

I had assumed a stale .stx/cache surviving the 0.2.231 → 0.2.369 upgrade: serve reads that cache, build does not, which would explain the two paths disagreeing at one commit. But checkCache has carried an engine-fingerprint guard since v0.2.339 —

// Rendered by another stx: its output is not this one's to serve.
if (meta.engine !== cacheEngineFingerprint())
  return null

— and 0.2.369 is past that, so an entry written by 0.2.231 would have been rejected rather than served. The theory needs the guard to have been absent or bypassed, and I have no evidence for either.

So

I cannot reproduce it, the stated mechanism is disproven, and my own replacement theory does not survive checking. Nothing I changed is a known fix for it, and I would rather say that than claim one.

A great deal has moved since 0.2.369 — this is 0.2.395 — so it may well be gone. If it recurs, the two things that would make it tractable are the serving app's .stx/ directory state at the time, and whether rm -rf .stx before buddy serve changes the output. Reopen with either and I will take it again.

Sign in to comment on this issue.