ReviewOS

stacks/stx

Top-level functions in <script data-stx-scoped> are global in dev but not in the built app: inline onclick throws ReferenceError only in production

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

Summary

A top-level function foo() {} declared in <script data-stx-scoped> is reachable as a global in buddy dev, and is not reachable in the built/packaged app. Inline onclick="foo()" handlers in the same template therefore work in development and throw ReferenceError: Can't find variable: foo in production.

Because a packaged desktop app has no console open, this presents as "the button does nothing" with no diagnostic at all.

Reproduction

A view with a scoped block and a template that calls into it from markup:

<script data-stx-scoped>
async function doCleanSelected() { /* ... */ }
</script>

<button onclick='doCleanSelected()'>Clean Selected</button>

Serve it with buddy dev → the button works; typeof window.doCleanSelected === 'function'.

Build it (buddy build:views, packaged via build:desktop / build:dmg) and open the app → clicking throws:

error: ReferenceError: Can't find variable: doCleanSelected  @http://127.0.0.1:50438/app:1

Same source file, same block, opposite behaviour.

What differs between the two outputs

Fetching the same route from each:

devbuilt
function doCleanSelected inline in the HTMLyesyes
_stx/chunk.*.js referenced by the page06

So it is not simply "dev inlines, build extracts" — the declaration is present inline in both. The difference appears to be in how the built page's runtime/router executes data-stx-scoped blocks: in the built app the block's top-level declarations do not land on window, whereas in dev they do. The error's location (/app:1, the SPA shell rather than the route's own URL) suggests the router re-executing the block in a scope on navigation.

I have not tried to pin down the exact mechanism inside the runtime — the observable contract is what matters here, and it differs between the two modes.

Why this is worth fixing rather than documenting

The failure is invisible in every environment a developer actually tests in. It only appears in the packaged artifact, where there is no console, so the symptom is a dead button rather than an error. In our case (stacksjs/system-cleaner) four handlers — doCleanSelected, doEmptyTrash, doCleanDir, scanCleanupSizes — were dead in every shipped build while working perfectly in dev.

Either behaviour alone would be fine; it is the divergence that is the bug. If scoping is intended, dev should scope too, so the breakage shows up immediately.

Workaround

Assign explicitly at the end of the block:

window.doCleanSelected = doCleanSelected

Worth noting this is what the framework's own shipped public/disk-panel.js does (window.startDiskScan = function () {...}), which is why that panel's buttons kept working while the scoped-block ones did not.

Possible directions

  • Hoist top-level function declarations in a data-stx-scoped block to window, matching dev.
  • Or scope consistently in dev, and emit a build-time diagnostic when a template's inline onclick/onchange names an identifier that the scoped block declares but does not export — that would have turned this into a build error instead of a silent production failure.

Versions

  • @stacksjs/stx 0.2.281
  • Stacks 0.74.32, Bun 1.4.3
  • macOS 26.6.2, arm64
9 days ago

Fixed in 0d85f78. The mechanism turned out to be pinnable, and it explains the one detail the report flagged as unexplained — why these particular handlers died.

The mechanism

execScripts in the router wraps a re-executed script in a bare block, so that two pages both declaring const items cannot collide across a navigation:

ns.textContent = (hasImport||alreadyScoped) ? text : '{'+text+'}'

That wrap is invisible for a plain function declaration and not invisible for anything else. Annex B.3.3 hoists a block-level FunctionDeclaration's binding out to the enclosing var scope in sloppy mode — but it predates async functions, generators and classes and was never extended to them. Those are purely block-scoped, as are const and let.

Running exactly what the router emits, in a real browser:

declarationreachable as a global after the block wrap
function doEmptyTrash(){}✅ function
async function doCleanSelected(){}❌ undefined
const scanCleanupSizes = async () => {}❌ undefined
class Scanner {}❌ undefined

Identical in V8 and in JSC, which is what the packaged WebKit app runs.

So the divergence is exactly as described, and its cause is the block: a page served whole has its script parsed by the browser, a built SPA reaches the route as a fragment and the router re-runs it. This is also why all four of your dead handlers were async function — a plain function in the same block kept working, which is what made the block look innocent.

Your dev-vs-built table was the thing that made this findable, and the /app:1 location was right: the router re-executing the block is the step, just not in a scope — in a block, which is subtler.

The fix

The wrap stays (it is load-bearing for the collision it prevents); what it would have hidden is re-published. Deliberately narrow:

  • Only values that are functions, which is what an inline handler calls. Assigning every top-level const would write over window.name and window.top, which a lexical global shadows rather than overwrites.
  • Each name guarded on its own, so one unmatched name cannot take the rest down.
  • Column-anchored, since a declaration nested inside a function is indented and is not in scope at the end of the block anyway.

This is backwards compatible: it only makes more names reachable, and it restores what the browser would have published had it parsed the script itself.

On the workaround

window.doCleanSelected = doCleanSelected is no longer needed, and the observation that the framework's own public/disk-panel.js does exactly that was a good catch — it is the same workaround, and it is why that panel survived.

A note on testing

spa-harness cannot cover this and I did not pretend otherwise. It runs collected scripts through new Function, whose body is a function scope that nothing reaches the global object from, whatever the declaration kind — so it returns the same answer before and after the fix. The regression test asserts against the generated runtime, and the engine rule itself is asserted against the real global through indirect eval.

Sign in to comment on this issue.