ReviewOS

stacks/stx

SSG inlines a months-old signals runtime — generateSignalsRuntime() is never called (a throwing generator still builds 24/24 pages)

#1814
Closed glennmichael123 opened this 11 days ago ·
11 days ago

Summary

In a Stacks app building via ./buddy build (stx SSG, 24 pages), the signals runtime inlined into every page is months older than the installed stx, and generateSignalsRuntime() is never called during the build.

I can make the installed generator throw and the build still succeeds, emitting a full runtime. So the runtime that ships is not produced by the stx the build resolves.

Nothing warns. The page looks fine, the build reports success, and every runtime fix silently fails to reach the browser.

Environment

  • App: Stacks app, ./buddy build → Build complete! (SSG) Pages generated: 24
  • bun.lock: @stacksjs/stx@0.2.153; bun 1.4.0 (same version runs the build)
  • node_modules/@stacksjs/stx symlinked to a local fork checkout (raw ln -s)
  • Both published 0.2.153 and the fork produce a byte-identical runtime, so this is not fork-specific — I originally hit it while checking whether a fork fix landed

Symptom

generateSignalsRuntime()      len 158407   setSelectionRange x2   has `function isDerived`
runtime inlined in the page   len 156578   setSelectionRange x0   no  `function isDerived`
identical prefix                    10677 bytes, then diverges

Same generator lineage (identical preamble), older vintage. The emitted copy predates both isDerived and the @model caret guard.

Three proofs it isn't the resolved stx

Each is self-verifying — I confirmed the patch was still applied to dist/ after the build, so none of these are "the file got rebuilt underneath me".

  1. Identifier marker. Renamed __stx_model_bound → __stx_model_bound_MK1 in packages/stx/dist/signals.js. generateSignalsRuntime() returns the renamed identifier; the built page contains the original.

  2. Tag marker. Added data-forkmark to the injected tag in dist/runtime-injection.js (and every bundled copy). The page still emits a plain <script data-stx-scoped data-stx-runtime> — marker absent.

  3. Throwing generator. Patched function generateSignalsRuntime() { to throw new Error("FORK_GEN_CALLED"). Build: succeeded, 24/24 pages, Failed: 0, runtime still inlined. The generator is simply never invoked.

Ruled out (verified, not assumed)

  • Duplicate/nested copies — find for @stacksjs/stx (following symlinks) returns exactly one entry in the app tree
  • Global install — ~/.bun/install/global/.../stx is 0.2.153 and has the fix
  • bun install cache — a stale stx@0.2.95 exists but stat shows links=1, i.e. hardlinked into no node_modules
  • App caches — dist/ and the SSG cache dir deleted and confirmed absent before every build; builds report Cached: 0
  • stx package-local cache — packages/stx/.stx/cache/ moved aside entirely; output unchanged
  • Stale dev servers — killed all of the app's long-running dev processes; unchanged
  • CLI bundling — @stacksjs/buddy/dist/cli.js is a 6.6 KB launcher, no inlined runtime
  • bun binary — the build runs a different bun than my shell; both resolve @stacksjs/stx to the same path and produce the same 158407-char runtime
  • Grep across the app tree for a 64-char fingerprint of the emitted runtime: no file on disk contains it, yet it appears in the output

Dating clue

That fingerprint does match packages/stx/.stx/cache/*.html in an stx checkout — rendered HTML from June 13 – July 16. Moving that cache away didn't change the output, so it isn't the source, but it dates the emitted runtime to roughly that era.

Possible lead

runtime-injection.ts:118 returns the template untouched if it already contains data-stx-runtime / window.stx.state / STX Signals Runtime:

if (template.includes('data-stx-runtime') || template.includes('window.stx.state') || template.includes('STX Signals Runtime'))
  return template

If some earlier stage injects a runtime, this guard would make the real injector a silent no-op — matching every observation (injector loads, generator never runs, output carries an old runtime). I could not find what that earlier stage is.

Why this matters

This is the failure mode from #1792: correct, installed, loaded code that never reaches the output, with no diagnostic anywhere. Concretely it means a runtime fix can be released, installed, and verified present in dist/ while every built page keeps the old behaviour — which is exactly how I lost time chasing a caret fix that was already published in 0.2.153.

Even without a root cause, a cheap guard would have caught it: have the SSG assert that the runtime it emits is the one generateSignalsRuntime() returns (or log the length / a short hash at build time) and warn on mismatch.

11 days ago

Diagnosed. Your three proofs are all correct and they all have the same cause — and it is not that the SSG resolves a different stx.

The mechanism

bun run build bundles. Five separate dist entries each end up with their own inlined copy of the signals runtime generator:

$ grep -l "signals runtime loading" dist/*.js
dist/craft.js
dist/cli.js
dist/pwa.js
dist/ssg.js
dist/signals.js

getCachedSignalsRuntime does await import('./signals'), but an SSG build runs through dist/ssg.js, which carries its own bundled copy of that module. So:

  1. Renamed identifier — you renamed it in dist/signals.js; the page was generated from dist/ssg.js's copy. Both statements are true simultaneously.
  2. data-forkmark — dist/ssg.js has its own bundled runtime-injection too.
  3. Throwing generator — the function you patched genuinely is never called on that path. dist/ssg.js calls its own.

So "the runtime that ships is not produced by the stx the build resolves" is right about the file and wrong about the package. It is the same package, a different copy inside it.

The good news for the release

After a real bun run build, all five copies are the same vintage — checked against three markers that landed today:

dist/signals.js      isDerived=1 setSelectionRange=2 watchMultiple=1
dist/ssg.js          isDerived=1 setSelectionRange=2 watchMultiple=1
dist/cli.js          isDerived=1 setSelectionRange=4 watchMultiple=1
dist/pwa.js          isDerived=1 setSelectionRange=2 watchMultiple=1
dist/craft.js        isDerived=1 setSelectionRange=2 watchMultiple=1

(cli.js has 4 because it bundles both the dev and production runtimes.)

So a published build does carry runtime fixes into SSG, and this is not release-blocking. Your fork's dist/ was simply patched by hand without a rebuild, leaving dist/ssg.js at its previous vintage — which is exactly the "months older, identical preamble then diverges at 10,677 bytes" signature you measured.

The real defect

Five independently-bundled copies of one generator that can drift, with nothing checking they agree. Hand-patching dist/signals.js — a completely reasonable thing to do when verifying a fix — silently does nothing for the SSG, CLI, PWA and Craft paths, and gives you a build that succeeds while shipping stale code. That is worth a guard regardless of your specific cause.

Two suggestions, and I'm happy to do either:

  • A build-time assertion that the runtime section is byte-identical across all bundled entries. Cheap, and it converts this from a multi-hour forensic exercise into a failed build.
  • Teach stx doctor about it. It already diagnoses runtime staleness (pantry dist freshness, node_modules symlink, runtime length) but compares one copy; comparing all five is a small extension and exactly its remit.

Worth noting your instinct to verify with a throwing generator was the right move — it is what makes the diagnosis unambiguous. The trap was that dist/signals.js looks like the only place that function lives.

11 days ago

Guard landed in 9b618cc873.

packages/stx/test/runtime-copies-agree.test.ts asserts every bundled entry carries the same recent runtime markers, so a partial rebuild fails a test naming the entry instead of shipping quietly:

ssg.js is missing "setSelectionRange"

Verified by mutation rather than by construction — I removed a marker from dist/ssg.js and confirmed it fails, then restored it. (Two earlier attempts at that mutation didn't fire, because replacing setSelectionRange with setSelectionRangeXX leaves the original as a prefix. Worth knowing if you extend it.)

Markers rather than a byte-for-byte comparison, because the entries legitimately differ — cli.js bundles the dev runtime too — so equality would be false precision.

Closing: the copies are consistent after a real bun run build, so there was no code defect and nothing blocking a release; what was missing was anything that would tell you when they diverged. That now exists.

The stx doctor extension you'd also get value from is a bigger piece (it inspects an installed package rather than this repo's dist/), so I've left it — happy to do it if you want it, but it's a different check from the one that would have saved you here.

11 days ago

Reopening: the partial-rebuild explanation is now excluded, and the symptom is unchanged.

First, a correction to my original report — my throw-test had a hole. It patched the literal function generateSignalsRuntime() {, which only signals.js contains; ssg.js minifies the generator to tu, so that entry was never actually tested. The test below covers every entry and doesn't depend on matching a name.

After a full bun run build

All entries agree and the new guard passes:

signals.js  ssr=2  isDerived=1
ssg.js      ssr=2  isDerived=1
cli.js      ssr=4  isDerived=1
craft.js    ssr=2  isDerived=1
pwa.js      ssr=2  isDerived=1

bun test test/runtime-copies-agree.test.ts  ->  2 pass, 0 fail

The app still emits the old runtime:

emitted in page   len 156578   setSelectionRange x0   no `isDerived`
generateSignalsRuntime()  len 158688   setSelectionRange x2
identical prefix  10677 bytes, then diverges

156578 is byte-stable across every attempt I've made — many rebuilds, cache clears, killing all dev servers, and now two full clean fork builds.

Per-entry tag test

Rebuilt clean, then renamed the runtime's own identifier distinctly in each entry:

signals.js -> __stx_model_bound_SIGNALS
ssg.js     -> __stx_model_bound_SSG
cli.js     -> __stx_model_bound_CLI
craft.js   -> __stx_model_bound_CRAFT
pwa.js     -> __stx_model_bound_PWA

Built the app, then confirmed the tags were still applied in dist/ afterwards (so this isn't a rebuild racing the test). The page contains:

2  __stx_model_bound      <- untagged

No entry's runtime reaches the page.

The injected tag isn't ours either

Separately I added data-forkmark to the <script data-stx-scoped data-stx-runtime> construction in runtime-injection.js and every bundled copy. The page still emits a plain <script data-stx-scoped data-stx-runtime>. So whatever injects it is producing both the tag and the runtime.

Where it isn't

Cross-checked with grep -rla and rg -uu over node_modules, storage, app, config, resources, bootstrap, public:

  • __stx_model_bound appears in no file in the app tree — the only hit is the SSG cache the build itself writes (deleted before every build; builds report Cached: 0)
  • same for the literal data-stx-scoped data-stx-runtime
  • node_modules contains exactly three symlinks (@stacksjs/stx, bun-plugin-stx, stx-router) — no other stx anywhere in the resolution path, verified with find following symlinks
  • ruled out earlier and re-checked: global install (0.2.153, has the fix), pantry (no stx), the stale stx@0.2.95 in bun's cache (links=1, hardlinked nowhere), packages/stx/.stx/cache/ (moved aside, no change), long-running dev servers (killed, no change), @stacksjs/buddy/dist/cli.js (6.6 KB launcher), bun-plugin/dist/* (no runtime markers in any chunk)

The question

Given the string is assembled at runtime rather than stored, grep can't find the emitter — only the identifier, which should still appear literally in whatever generator source produces it, and doesn't.

So: is there a second injection path that doesn't go through these entries — something that composes the runtime plus the data-stx-runtime script tag independently? That would explain all of it, including why a throwing/tagged generator changes nothing.

Happy to run any probe you want against the app; it reproduces on every build.

11 days ago

Resolved — no stx defect. Your original close was right, and my reopen was wrong. Sorry for the noise.

There is no second injection path. runtime-injection.ts:156 is the only site in packages/stx/src, packages/router/src or packages/bun-plugin/src that inlines a full runtime; every other data-stx-runtime site emits an external src= reference (serve/compile modes) or only finds/moves/strips an existing tag. SSG never sets buildMode, so it lands on 156.

The reason a throwing generator, per-entry identifier tags, and a data-forkmark on the tag all changed nothing is simpler than a second emitter: none of my checkout's code ran during the build.

./buddy build                              # no target -> views
  -> Action.BuildViews
  -> @stacksjs/actions/dist/build/views.js # imports only @stacksjs/cli + @stacksjs/path
     projectPath('build.ts') absent ->
     runCommand("bunx --bun @stacksjs/stx build --pages resources/views --out dist")

bunx provisions its own install of the published package under /private/tmp/bunx-501-@stacksjs/stx@latest/ and runs that. My fork is linked in with a raw ln -s, and bunx only prefers node_modules/.bin when that entry is executable — bun build emits dist/cli.js as mode 644, so test -x failed and it fell through to the registry. No patch to my dist/ could ever appear in the output.

Byte-identity, which is what settles it. The comparison has to be run under the bunx binary, because ~/.pantry/global/bin/bunx is 1.3.1 while ~/.pantry/global/bin/bun alongside it is 1.4.0, and the two minifiers differ by 12 bytes on this input:

under bun 1.3.1 (= the bunx binary):
  EMITTED (dist/composer.html)  len 156578   isDerived false   setSelectionRange 0
  registry @stacksjs/stx@0.2.153 generateSignalsRuntime()
                                len 156578   IDENTICAL: true
  my fork                       len 158688   isDerived true    setSelectionRange 2

Two corrections to my earlier comments, both my error:

  1. I claimed ~/.bun/install/global/node_modules/@stacksjs/stx "is 0.2.153 and DOES have the fix". It is a symlink to my fork (-> …/Documents/Projects/stx/packages/stx), so I was measuring the fork twice and mistook it for a published copy. Published 0.2.153 does not contain the @model caret guard or isDerived. My statement that #1799 was already released was wrong.

  2. My throw-test was reported as covering the generator; it only patched the literal function generateSignalsRuntime() {, which appears solely in signals.js. ssg.js minifies it to tu. I flagged that hole in the reopen, but it is worth restating since it is what kept me pointed at stx instead of at the render path.

Fixed on my side with a build.ts in the app, which takes the other branch and runs the installed CLI directly — that also makes builds use the locked dependency instead of the newest published release.


One thing here may still be worth your attention, though it is arguably a Stacks issue rather than an stx one: the fallback shells out to bunx --bun @stacksjs/stx, i.e. @latest from the registry. For any app without a build.ts, static builds are rendered by whatever stx npm currently serves — not the version in package.json/bun.lock. A project pinned to 0.2.140 gets its pages built by 0.2.153 the day it ships, and nothing in the output says so. Happy to file that against stacksjs/stacks instead if you'd rather.

A second-order trap I hit while verifying, which is squarely stx: packages/stx/src/ssg.ts BuildCache.getHash keys on `${filePath}:${stats.mtimeMs}` plus dependency mtimes — nothing about stx's own identity. build-cache.json stores full rendered HTML per route, so after upgrading stx (or fixing a render path like this one) an unchanged .stx mtime re-serves HTML containing the old runtime. I had to rm -rf storage/framework/stx/ssg-cache to see my fix at all. Mixing the runtime version (or a hash of the generated runtime) into the cache key would make an upgrade self-invalidating.

Sign in to comment on this issue.