ReviewOS

stacks/stx

fix(devtools): the store panel is never populated — `defineStore` doesn't call `registerStore`, and the runtime console.logs 11 lines unconditionally in production

#1873
Closed glennmichael123 opened this 14 days ago · 2 comments
14 days ago

Summary

Two related problems in the store runtime's debug story.

  1. devtools.ts implements a full store panel — registerStore, recordStoreMutation, timeTravel, a selectedStore UI state — and the signals runtime never calls any of it. devToolsState.stores is therefore always empty, so the store tree, the mutation timeline and time travel are all permanently dead.
  2. In place of that, the runtime emits unconditional console.log — nine [stx:store] lines including one on every single useStore() call, plus two more in the DOMContentLoaded path. All eleven ship in dist and fire in production.

So the framework produces log noise for end users and no usable debug surface for developers.

Impact (real app)

analyticshq debugged its store initialization by rendering the SPA in a headless browser and reading console output — resources/stores/session.ts:43:

Verified in a headless browser, not by reading the bundle.

The findings had to be written down as comments so they would not be re-discovered. resources/stores/session.ts:36-43 records the failure class a store panel would have made obvious in one glance:

That failure is silent in the worst way: the ReferenceError is thrown inside the store IIFE, so defineStore never completes and useStore('session') reports "Store not found" somewhere else entirely.

Roughly 24 lines of that store file are comments preserving debugging results. Meanwhile the app's production console carries a [stx:store] useStore: session line for each of the six useStore('session') call sites (account.stx:14, dashboard.stx:790, register.stx:13, pricing.stx:21, login.stx:13, sites/new.stx:17), plus the registration and persistence lines, on every page load.

Evidence in stx

The devtools store API has no caller. packages/stx/src/devtools.ts:348-374:

export function registerStore(id, initialState, getters = {}, actions = []): void {
  if (!devToolsState.enabled) return
  …
  devToolsState.stores.set(id, info)          // :371 — the only writer
  recordEvent({ type: 'store:registered', … })
}
$ grep -n "registerStore" packages/stx/src/signals.ts
(no match)

defineStore lives at signals.ts:5753-5949 and never touches devtools. The consequence is at devtools.ts:702-705:

function timeTravel(historyIndex: number, storeId: string): void {
  const store = devToolsState.stores.get(storeId)
  if (!store) return                          // ← always taken

The runtime does maintain its own registry (window.stx._stores / window.__STX_STORES__, signals.ts:5945-5947), and devtools.ts:730 reads __STX_STORES__ — but only inside timeTravel, which has already returned by then.

Unconditional logging. packages/stx/src/signals.ts:

:5754  console.log('[stx:store] defineStore called:', id, …)
:5757  console.log('[stx:store] returning existing store:', id)
:5834  console.log('[stx:store] setup complete:', id, …)
:5883  console.log('[stx:store] hydrating from SSR:', id, …)
:5904  console.log('[stx:store] restoring persisted state:', id, …)
:5913  console.log('[stx:store] no persisted state found:', id, …)
:5930  console.log('[stx:store] persisting:', id, …)
:5948  console.log('[stx:store] registered:', id, …)
:5971  console.log('[stx:store] useStore:', id)          ← every store access
:6420  console.log('[DOMContentLoaded] processElement on:', el.tagName, …)
:6523  console.log('[stx] running global mountCallbacks:', mountCallbacks.length)

None is gated on a debug flag. Verified in the published build:

$ grep -o "console.log(.\[stx:store\]" node_modules/@stacksjs/stx/dist/signals.js | wc -l
9
$ grep -c "\[DOMContentLoaded\] processElement on:" node_modules/@stacksjs/stx/dist/signals.js
1
$ grep -c "running global mountCallbacks" node_modules/@stacksjs/stx/dist/signals.js
1

This is the same class as #1786 (the :class binding's debug log), in a different file and at 11× the volume.

Prior art

  • Vue Devtools Pinia panel: state tree, mutation timeline, time travel — populated automatically by defineStore, with no logging.
  • Redux DevTools: every dispatched action recorded, replayable.
  • TanStack Query Devtools: query cache, fetch status, staleness.

Ask

  1. Call registerStore(id, initialState, getters, actions) from defineStore (signals.ts:5753) and recordStoreMutation from each signal setter in the store's returned object, so the panel #1747 shipped has data.
  2. Gate every [stx:store] / [DOMContentLoaded] / mountCallbacks console.log behind the existing devtools-enabled flag, and strip them in production builds. console.log on every useStore() is a per-call cost in a hot path.
  3. Surface a real error when a store IIFE throws: today a ReferenceError inside the shared bundle aborts every defineStore and the developer sees Store not found from an unrelated useStore elsewhere (signals.ts:5966-5969). Detect that the bundle failed to execute and say so.

Related: #1747 (the devtools extension this would populate), #1754, #1786 (the same class of shipped debug log).

14 days ago

Claiming this one — working on it now.

14 days ago

Correction on the second half of this report, since I filed it: "the runtime console.logs 11 lines unconditionally in production" does not reproduce.

generateSignalsRuntime() already strips every console.log site through stripConsoleLog (added for #1668 bug 8), and debug defaults to false (config.ts:27). Measured on the current tree:

runtimeconsole.log sitesbytes
generateSignalsRuntimeDev()40321,721
generateSignalsRuntime()0163,137

The lines people see in a terminal come from the dev runtime, which keeps them deliberately. I mistook dev-server and test output for production behaviour when filing.

There was a real defect underneath, now fixed in 0bdffb0: stripping happened inside the try, and the catch returned generateSignalsRuntimeDev() — the raw, unstripped source. So any failure inside Bun.Transpiler shipped all 40 log sites to production silently. Stripping now happens before the try and the fallback returns the stripped source.

The store-registration half (fixed in adb25f7) was accurate, and turned up something worse than filed: two modules install window.__STX_DEVTOOLS__ with incompatible shapes, so createStore's unguarded .onStoreChange(...) was a TypeError on every store write whenever the panel-owning global loaded last.

Sign in to comment on this issue.