ReviewOS

stacks/stx

0.2.369 resolves a `<script server>` relative import one level above the app root, then renders a visible error banner while the build reports success (13/46 pages)

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

Summary

Upgrading an app from stx 0.2.231 → 0.2.369 makes <script server> relative imports resolve to the wrong directory. The modules exist; the specifiers are the ones the app has always used. stx then replaces the whole component with a visible red error banner, and the build still reports success — so this ships.

In our app, 13 of 46 built pages came out as a banner with their content gone, including every signed-in dashboard page and the public homepage. The banner prints an absolute filesystem path to the visitor.

stx: a <script server> imports a module that does not resolve, so this component rendered nothing.
/…/status/resources/views/layouts/default.stx
ResolveMessage: Cannot find module '/…/hq-apps/app/lib/display'
      from '/…/status/node_modules/@stacksjs/stx/dist/chunk-wph1s6mm.js'

Note the resolved path: hq-apps/app/lib/display. The app root is hq-apps/status/, and the file is at hq-apps/status/app/lib/display.ts. It landed one level above the app root.

What changed

Same checkout, same source, only the dependency versions differ (@stacksjs/stx, bun-plugin-stx, @stacksjs/components all moved together):

pages with the error bannervisual:validate
0.2.2310 / 46passes
0.2.36913 / 46fails

Evidence

Every failing page, with the specifier it used and where 0.2.369 resolved it. Paths are shortened at hq-apps/; the app root is hq-apps/status/.

view (dir depth under app root)specifiercorrect target0.2.369 resolved to
resources/views/dashboard/settings/billing.stx (4)../../../../app/lib/teamContextstatus/app/lib/teamContexthq-apps/app/lib/teamContext
resources/views/dashboard/settings/cloud.stx (4)../../../../app/lib/teamContextsamehq-apps/app/lib/teamContext
resources/views/dashboard/settings/notifications.stx (4)../../../../app/lib/teamContextsamehq-apps/app/lib/teamContext
resources/views/dashboard/settings/security.stx (4)../../../../app/lib/teamContextsamehq-apps/app/lib/teamContext
resources/views/dashboard/settings/team.stx (4)../../../../app/lib/displaystatus/app/lib/displayhq-apps/app/lib/display
resources/views/dashboard/monitors/index.stx (4)../../../../app/lib/displaysamehq-apps/app/lib/display
resources/views/dashboard/monitors/new.stx (4)../../../../app/lib/teamContextsamehq-apps/app/lib/teamContext
resources/views/dashboard/incidents/index.stx (4)../../../../app/lib/displaysamehq-apps/app/lib/display
resources/views/dashboard/servers/index.stx (4)../../../../app/lib/displaysamehq-apps/app/lib/display
resources/views/dashboard/maintenance/index.stx (4)../../../../app/lib/teamContextsamehq-apps/app/lib/teamContext
resources/views/dashboard/status-pages/index.stx (4)../../../../app/lib/teamContextsamehq-apps/app/lib/teamContext
resources/views/dashboard/status-reports/index.stx (4)../../../../app/lib/teamContextsamehq-apps/app/lib/teamContext
resources/views/index.stx (2)../../../app/lib/authRequest— see belowhq-apps/app/lib/authRequest

And the pages at the same import style that do still resolve correctly at 0.2.369:

view (dir depth)specifierresult
resources/views/dashboard/index.stx (3)../../../app/lib/displayresolves
resources/views/dashboard/onboarding.stx (3)../../../app/lib/teamContextresolves

So a specifier with four ../ from a four-deep view directory lands one level too high, while a specifier with three ../ from a three-deep directory is fine. I could not pin the mechanism down further from outside — compileTemplate() returns serverScriptContent unevaluated, so the resolution happens in a later stage I could not isolate into a standalone repro.

Two separate behaviour changes may be tangled here, because resources/views/index.stx is a different case from the rest: its specifier has one more ../ than its own depth warrants, so page-relative resolution should send it above the app root. It resolved fine at 0.2.231 and fails at 0.2.369. That suggests 0.2.231 tolerated excess ../ (anchoring at the app root) and 0.2.369 does not — worth deciding deliberately either way, since the stricter behaviour is arguably correct for that one page and is a breaking change for anyone relying on the old leniency.

Why this is worse than a resolution bug

  1. The build reports success. Total: 45 pages / Success: 45 / Failed: 0, while 13 of them contain nothing but the banner. A page carrying the error is a rendered page as far as the builder is concerned.
  2. The error is user-facing. It renders into the served HTML, in monospace, including an absolute filesystem path from the build machine.
  3. Nothing in the log names an import. What actually caught it for us was an unrelated responsive gate: the banner's long unwrapped path overflows a 390px viewport, so it surfaced as /dashboard/monitors at mobile: 176px horizontal overflow. Without that check we would have deployed it.

A hard build failure naming the file and the specifier would be strictly better than a banner — related to #2004, which made the same point about unresolved components. The same reasoning applies to unresolved modules.

Reproduction

In an app whose views sit four levels under the app root and import a local module with a matching ../../../../ specifier from <script server>:

app/lib/display.ts                              # exists
resources/views/dashboard/monitors/index.stx    # <script server> imports '../../../../app/lib/display'
  • bun add @stacksjs/stx@0.2.231 bun-plugin-stx@0.2.231 → build, page renders
  • bun add @stacksjs/stx@0.2.369 bun-plugin-stx@0.2.369 → build, page is a banner reading Cannot find module '<one level above app root>/app/lib/display'

Environment

  • @stacksjs/stx 0.2.369 (broken) / 0.2.231 (good)
  • bun-plugin-stx 0.2.369, @stacksjs/components 0.2.369
  • stacks 0.75.37, Bun 1.4.3-canary.1, macOS (also reproduced on ubuntu-latest in CI)
  • Registered via bunfig.toml → [serve.static] plugins = [ "bun-plugin-stx" ]
9 days ago

Fixed. Three separate causes sat behind this report, which is why it presented as unrepeatable.

The resolution itself — fixed in 0.2.374

A view that @extends a layout has its top-level scripts salvaged into the layout's content section, and #1698 tagged those with the view's path so their relative imports keep resolving against the view. The tagging only ever covered the scripts the salvage moved. A script written inside an @section is never moved: the section body is lifted into sections, substituted into the layout at @yield, and the combined document is extracted with the layout's path.

So the import resolved against the layouts directory, off by the gap between the view's depth and the layout's. That is the whole of your table: your layout sits three levels down, so views three levels down happened to be correct and views four levels down reached one directory above the app root. Same specifier shape, same app, different answers — exactly as you measured.

It also explains the build/serve split you noticed: the dev and production servers extract the view's script against the view before any layout is involved, so the specifier that satisfied the build was the wrong one for the servers.

Your reading of resources/views/index.stx as a separate case was right, and it is not affected: that one resolves from its own directory, where three ../ genuinely does land above the app root.

The build reporting success — fixed in the same release

Your point that a hard failure beats a banner is now the default. ssg.failOnServerScriptError (set false for the old behaviour) fails the build, records the route, fires onError and exits non-zero — the same path failOnIncludeError uses for #1921, and the same argument #2004 made for components. buildViews refuses to write the file too.

The message names the script's own file rather than the route, because a view's script is extracted inside its layout and one broken partial shows up on every page that uses it. It also drops the imported from …/@stacksjs/stx/dist/chunk-….js tail you quoted: that referrer is always stx's own module, so the one path in the message that looked like an answer pointed into the framework.

The banner reaching your visitors — fixed in 0.2.379, and a different bug

This part was not the boundary being misdesigned. It is gated !isProduction() && !isTest(), which should have hidden it on your deploy. In every published copy of stx, env.js read:

export function isProduction() { return !1; }
export function isTest() { return !1; }

The package is built by transforming each file with Bun.Transpiler, which constant-folds process.env.NODE_ENV === 'production' against the environment it is itself running in — and the release machine does not set NODE_ENV. So both gates were welded false in every installed copy, isDevelopment() welded true, and the development-only diagnostic shipped to end users regardless of what you set. Thanks for including the detail that the path was visible to visitors; that is what made it findable.

Verified against the published packages

Reproduced your recipe on the published tarballs rather than on a working tree:

view 4 deep, layout 3 deep, import inside @section
published 0.2.369Success: 1, Failed: 0 — page is nothing but the banner
published 0.2.380renders correctly
published 0.2.380, import genuinely brokenSuccess: 0, Failed: 1, page not written

And with a deliberately broken script under NODE_ENV=production on 0.2.380, the served page carries no banner; unset, it still does.

Regression tests: test/edge-cases/extends-relative-imports.test.ts covers both script placements plus the specifier that only ever worked from the layout, test/ssg/server-script-failure.test.ts and test/ssg/build-views-server-script-failure.test.ts cover the build gate, and test/env-gates-survive-the-build.test.ts runs the build's own transform, since the source was always correct and only the built output was wrong.

Sign in to comment on this issue.