ReviewOS

stacks/stx

Dev build aborts entirely when one view fails to parse (incl. unused default-fallback views); error names no file

#1810
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Summary

A single unparseable .stx in any scanned route root throws an uncaught BuildMessage that kills ./buddy dev on startup — and it names no file, so there's nothing to go on. The failure is not isolated to the bad view, and it can be triggered by a framework-default fallback view the app never references.

What I saw

[buddy] uncaughtException: BuildMessage: Unexpected ===

No path, no line. I traced it to a corrupted framework-default view:

storage/framework/defaults/resources/views/blog/category.stx:145
<li class=": ? '' 'active'  === cat.slug categorySlug">

The app does not use this view. It's only in the build because the file-router scans the defaults dir as a fallback root (packages/router/src/file-router.ts — the pagesDirs stack; packages/bun-plugin/src/serve.ts then builds every discovered view). So one broken optional view — shipped by the framework itself — takes down the entire dev server, with an error that points nowhere.

Impact

  • One malformed/optional view aborts the whole build.
  • The terse Unexpected === (no file, no line) makes it very hard to locate — I had to binary-search the scanned views by hand.
  • Apps inherit build failures from framework-default views they never opted into.

Asks

  1. Isolate per-view compile failures — skip + warn and continue building the rest, rather than aborting everything. At minimum for default-fallback roots.
  2. Name the offending file (and line) in the error message.
  3. Reconsider auto-routing framework-default demo views (cart, checkout/*, dashboard/*, orders/*, blog demos) into apps that didn't opt in — a real app gets phantom routes and inherits the framework's view bugs.

Repro

Drop any malformed .stx into a scanned views root:

<li class=": ? ''  === a b">x</li>

./buddy dev → uncaught BuildMessage: Unexpected ===, no file named, whole dev server down.

9 days ago

Asks 1 and 2 landed in f539929dfe. Ask 3 I've deliberately left — reasoning below.

Why it named no file

The location was never missing. Bun computes it and puts it on BuildMessage.position; .message and .toString() omit it. And a BuildMessage is not an instanceof Error and carries no .stack — so the error instanceof Error ? error.message : String(error) idiom, which this codebase uses at about a dozen catch sites, takes the String() branch and reduces the whole thing to exactly the string you saw.

Verified against real Bun rather than a mock, and pinned that way:

naive     : BuildMessage: Unexpected ===
formatted : /app/views/blog/category.stx:1:17: Unexpected ===
                const y = a === === b

Two related traps the same helper handles: Bun.build doesn't throw a BuildMessage at all — it throws an AggregateError whose own message is just Bundle failed, with the real cause one level down in .errors. And Bun.Transpiler always reports position.file as a synthetic input.ts, while the client-script bundler's entrypoint is a temp file under .stx/bundle-tmp — so even when a path was reported it pointed at a generated file rather than your view. The real path is threaded from the caller now.

Isolation

serve-multi.ts compiled each file inside a loop whose only try/catch sat outside it. The markdown branch had its own inner catch and continued; the .stx branch didn't, so a throw took down every remaining view and the dev server never started.

That's sharper than it looks, because every if (!result.success) branch after a Bun.build in this repo is dead code — Bun.build throws by default on Bun ≥ 1.2, so those handlers never run and the result.logs they'd have printed (which do carry position) are never read. The try/catch is the only thing that can catch it.

Ask 3 — not changed, and I'd argue it shouldn't be here

Multi-root inheritance is the documented purpose of the pagesDirs stack, not an accident: packages/router/test/layered-roots.test.ts explicitly pins that defaults-only views become real routes, and the commit that introduced it (fdc35cbb93) states the requirement outright — apps want to inherit a stack of default views and override only the ones they care about. Changing it would break apps relying on it today, and it's a routing-policy decision rather than a bug, so it wants its own issue and its own call. Leaving this one open for it.

Two things found on the way that are worth their own tickets if you want them — say the word and I'll file:

  • generateRouteTypes applies no component/layout/partial filter, so route-types.d.ts lists things routes.ts correctly omits and that 404 at runtime. Three sources of truth, three answers; docs/ARCHITECTURE.md:506 describes the filtering as universal, which is only true of the manifest.
  • site-builder/build.ts and bun-plugin/watch.ts compile all entrypoints in one Bun.build, so one bad page still takes the batch down there. Isolating those means per-entrypoint builds, which is a real perf trade — didn't want to make that call unilaterally.

One caveat

The crash doesn't reproduce against this repo's own dev servers. Three fixtures across both serve paths — including your exact <li> line, and a <script client> variant — started cleanly and returned 200 for the malformed view. So the throwing call site is upstream of stx (a build step ./buddy dev runs). The fixes here stand on their own — the loop really was un-isolated, and the position really was being discarded at every catch site — but I can't claim they're provably the whole of your crash. If you can get me the stack with these changes in, the message should now name the file.

9 days ago

Follow-up — a forensic detail that should let you find the actual throw site quickly, since it isn't in stx.

Unexpected === rather than Unexpected : is a fingerprint. Transpiling the corrupted value in every wrapping I could think of:

what the transpiler receivedBun's message
the raw attribute value, : ? '' 'active' === cat.slug categorySlugUnexpected :
the minimal repro's raw valueUnexpected :
with the mustache placeholdered, : ? '' "__STX_EXPR_0__" === a bUnexpected :
wrapped as an object literalUnexpected :
a suffix starting at the mustache, === cat.slug categorySlugUnexpected ===

So the call that threw received a suffix of that line beginning at or just after a quote — not the raw attribute value, not the whole line, not the file. Anything still carrying the leading : ? '' produces a different message.

Two things follow:

  1. It rules out transpileTypeScript (stx's utils.ts), which substitutes → "__STX_EXPR_n__" before transpiling and therefore shifts the error to Unexpected :. Whatever threw did not do that substitution.
  2. It rules out Bun.build. Bun.build throws AggregateError: Bundle failed, routes to unhandledRejection rather than uncaughtException, and Bun's own printer emits the real file:line:col. Only Bun.Transpiler.transformSync()/.scan() on an in-memory string, thrown synchronously with nothing on the stack to catch it, produces your exact line — and its position.file is the synthetic "input.ts", never a real path, which is precisely why no file was named.

Also worth knowing: a transformSync throw inside a Bun.serve fetch handler yields a 500 plus Bun's formatted trace, not a process-level uncaughtException. So this can't be a per-request render failure — it's eager, startup-time work.

One correction to the issue's premise while I'm here: packages/bun-plugin/src/serve.ts does not build every discovered view at startup — it renders per request. The eager all-routes loop is in the other dev server (dev-server/serve-app.ts), and that one already isolates per view.

Net: look for a Bun.Transpiler call on a quote-delimited substring of an attribute, on the Stacks side, outside a try/catch. The stx-side fixes still stand on their own — the serve-multi loop really was un-isolated, and the position really was discarded at every catch site — but I'd rather be straight that they're not proven to be your crash.

If it helps narrow further: two stx catch sites swallow a BuildMessage completely today — variable-extractor.ts has a bare catch {} with no error binding, and utils.ts's transpile catch only logs under STX_DEBUG_TS. Neither can produce your uncaught throw, but if you hit a silent malformed-view symptom later, those are where it would hide. Happy to make them report if you want that.

9 days ago

Asks 1 and 2 are now complete, in f539929dfe and 8d89cb1ffe. Closing on those; ask 3 wants its own issue and its own decision — reasoning below.

Isolation

Both non-isolated compile loops are fixed:

  • serve-multi.ts — the .stx branch had no inner try/catch while the markdown branch beside it did, so a throw took down every remaining view and the dev server never started.
  • site-builder/build.ts — compiled all entrypoints in one Bun.build, which is all-or-nothing.

For the site builder I didn't want to pay a build-per-page on every run just to get isolation, so the batch runs first and the per-entrypoint pass happens only on failure. Healthy builds pay nothing; a broken build pays one extra pass and returns a precise list. Every page failing is still a hard error — that's a real build failure, not one bad view.

Naming the file

formatBuildFailure recovers the location Bun computes and leaves off .message, unwraps the AggregateError that Bun.build actually throws (whose own message is just Bundle failed), and prefers a caller-supplied path over Bun.Transpiler's synthetic input.ts.

One thing worth knowing for anything else you touch here: every if (!result.success) branch after a Bun.build in this repo is dead code. Bun.build throws by default on Bun ≥ 1.2, so those handlers never run and the result.logs they'd have printed — which do carry position — are never read.

Ask 3 — not doing it here

Multi-root inheritance is the documented purpose of the pagesDirs stack, packages/router/test/layered-roots.test.ts pins that defaults-only views become real routes, and the commit that introduced it (fdc35cbb93) states the requirement outright. Changing it would break apps relying on it, and it's a routing-policy decision rather than a bug — so it deserves its own issue where that trade-off is the whole subject, not a sub-item of a build-isolation report.

If you want it, the shape I'd suggest is a per-root descriptor (there's already precedent: pluginPageDirs carries { dir, prefix }), letting a root be resolvable-but-not-enumerated. Say the word and I'll file it with that design.

Still true

Your original crash does not reproduce against this repo's own dev servers, so the throwing call site is upstream of stx. The fingerprint in my earlier comment (Unexpected === means the transpiler received a suffix starting at the mustache) should locate it quickly — and with these changes the message will now name a file when it does fire.

Sign in to comment on this issue.