ReviewOS

stacks/stx

devtools: ship Stacks DevTools browser extension (component tree, signal/scope inspector, query timeline)

#1747
Closed glennmichael123 opened this 15 days ago · 6 comments
15 days ago

Problem

Debugging a Stacks app today involves:

  • console.log from <script client> to verify scope state
  • document.querySelectorAll('[data-stx-scope]') from console to find scoped elements
  • window.stx._scopes inspection to see registered scopes
  • Reading the rendered HTML to understand which :if branches are active

None of this is visual. None of it is real-time. None of it surfaces the reactive graph (which signals depend on which derived values, which effects subscribe to which signals).

Compared to Vue DevTools / React DevTools, this is a generation behind on tooling.

Proposed: Stacks DevTools browser extension

A Chrome/Firefox extension with these panes:

Component tree

Visual tree of mounted components matching the DOM structure:

- BenchHeader (scope: stx_scope_BenchHeader_8)
  - isAuthenticated: true (derived)
  - notifications: { unreadCount: 3, items: [...] } (store)
- ArticleView (scope: stx_scope_ArticleView_9)
  - reviewsStore.current: { id: 16, title: "...", ... }
  - hasReviewerName: true (derived)
- Comments (scope: stx_scope_Comments_10)
  - commentsLoaded: true
  - comments: [{ id: 1, body: "..." }]

Click a node to highlight in the DOM, see all scope vars + reactive derivations.

Signal / scope inspector

For any selected scope, show:

  • All registered signals + current values
  • All derived signals + their dependency graph (which signals they read)
  • All effects + which signals they subscribe to + how often they've fired
Scope: stx_scope_Comments_10

Signals:
  commentsLoaded: true (set 3x: initial, fetchById:start, loadComments:done)
  comments: [...] (set 2x: initial, loadComments:done)
  commentDraft: "" (set 0x)

Derived:
  isAuthenticated → reads: authStore.token

Effects:
  bindIfChain[composer/sign-in] → subscribes: isAuthenticated (re-ran 4x)
  bindIfChain[loading/empty/list] → subscribes: commentsLoaded, comments (re-ran 8x)

Conditional rendering trace

Show every :if / :else-if / :else decision + current pick:

<DIV :if="isAuthenticated()" → pick: 0 (current: true)
<DIV :else                    → pick: not active
<UL :if="!commentsLoaded()"  → pick: not active
<DIV :else-if="comments().length === 0" → pick: not active
<UL :else                     → pick: 2 (current: shown)

Query / fetch timeline

When P1.4's useQuery lands, show:

GET /api/judges/1 (loading, 234ms)
GET /api/comments?reviewId=16 (cached, served from store)
POST /api/reviews/16/like (mutation, 67ms, optimistic+confirmed)

SSR vs Hydration diff viewer

Highlight any HTML element where the SSR-rendered content differs from what the client runtime would have rendered. (Pairs with P1.1's SSR branch elimination — if pre-evaluation gets a different answer than runtime would have, that's a hydration mismatch worth flagging.)

Prior art

  • Vue DevTools — component tree, props/data/computed inspector, event timeline, performance profiler, Vuex/Pinia state, router history
  • React DevTools — component tree, props/state/hooks inspector, profiler
  • Redux DevTools — state-tree time travel
  • TanStack Query DevTools — query cache inspector, mutation timeline
  • Svelte DevTools — component tree + stores
  • Solid DevTools — signals graph

Every framework has at least the first two panes. Stacks has none.

Impact

  • Debug velocity: 10× improvement on hard-to-trace state bugs. The [stx-debug] probes I added manually this week to diagnose #1737 would have been automatic.
  • Adopter confidence: "I can see what my framework is doing" is a huge factor in enterprise adoption.
  • Performance profiling: the query timeline + effect run counts surface perf issues (like P1.4's chain over-subscription) that are otherwise invisible.

Phasing

  • Phase 1: Console-only API — window.__stxDevtools exposing the component tree + scope inspector as JS API. Adopters use it from console, no extension.
  • Phase 2: Chrome extension with component tree pane
  • Phase 3: Signal/scope inspector
  • Phase 4: Conditional rendering trace
  • Phase 5: Query timeline (depends on P1.4)
  • Phase 6: SSR/hydration diff viewer (depends on P1.1)

This is genuinely XL effort. Probably a year of dedicated work. But it's transformative for adopter DX.

Happy to PR Phase 1 — the runtime already exposes window.stx._scopes and findElementScope; just need to wrap as a documented API.

14 days ago

Plan committed (docs/proposals/devtools.md), and Phase 1 is shipped in 05f055163b (on main).

window.__stxDevtools — a read-only, console-usable inspector wired to the live runtime, no manual registerComponent:

__stxDevtools.tree()          // component tree from [data-stx-scope], nested by DOM ancestry
__stxDevtools.scope('Comments_10')   // → { signals, derived, stores, methods, values } with current values
__stxDevtools.inspect($0)     // nearest enclosing scope for a DOM element
__stxDevtools.stores()        // registered store ids

Signal/derived values are read via peek, so inspecting never subscribes the active effect — it can't pollute the reactive graph (pinned by a test: an effect that inspects a signal does not re-run when that signal later .set()s). Internals (__*, $el, $refs, $props) aren't surfaced. This retires the manual [stx-debug] probing the issue calls out.

This is the in-repo protocol the browser extension (Phases 4–6) will consume. Phase 2 — the live dependency graph (which effects read which signals — already tracked internally, just not exposed) + set/run counters — is the next increment, dev-gated. Tests: test/devtools/stx-devtools.test.ts. Full suite 9300 pass / 0 fail.

14 days ago

Update — Phase 2 shipped: reactivity instrumentation (graph + counters)

Building on Phase 1's read-only window.__stxDevtools (now version: 2):

  • Opt-in, zero prod overhead — enable() / disable() / tracking(). OFF by default even in the dev runtime; the increments in signal.set and runEffect sit behind a single __stxDevtoolsTracking branch, so an un-enabled runtime pays nothing in the hot path (addresses the issue's "overhead + memory" concern).
  • Counters — stats() → global { signalSets, effectRuns } since the last resetStats(); per-signal _setCount; per-effect _runCount + stable _stxId. (the "set 3x / re-ran 8x" data.)
  • Reactive graph — graph() walks the scope registry and returns, per scope, each signal/derived with its current value (read via peek so inspecting can't pollute the graph), its setCount, and its subscribers count — the dependency edges, i.e. "which effects read this signal". Signals/derived now expose _effects (their subscriber set).

Tests (+4): tracking toggles; counts while enabled + resetStats zeroes; counts nothing while disabled (proving the gate); graph() reports value/setCount/subscribers. Full suite 9360/0, lint + typecheck clean.

Remaining in-repo: Phase 3 — structured :if decision trace (bindIfChain already computes the picked branch) + a query timeline (instrument useQuery/useFetch/useMutation). Phases 4–6 (the browser-extension UI) are the separate packages/devtools-extension effort that consumes this protocol.

14 days ago

Update — Phase 3 shipped: :if trace + query timeline (in-repo protocol complete)

Both feeds are bounded ring buffers, recorded only while Phase 2 tracking is enabled:

  • :if decision trace — __stxDevtools.ifTrace() → per reactive @if/:if/v-if chain evaluation, { scopeId, branches, picked, pickedAttr, prev }. Emitted from bindIfChain right after it computes the pick (the structured form of its existing diagnostic).
  • Query timeline — __stxDevtools.queries() → per useFetch/useQuery/useMutation request, { source, url, method, status, ok, ms } (+ error on failure). All three now route their fetch through one __stxFetch wrapper that times + records it.
  • resetStats() clears both buffers.

Tests (+3): ifTrace records a reactive chain's branches+pick; queries records a useFetch entry; neither records while disabled. Full suite 9363/0.


The in-repo DevTools protocol is now complete (window.__stxDevtools, version 2):

API
Phase 1 — introspectiontree() · scope(id) · inspect(el) · stores()
Phase 2 — instrumentationenable()/disable()/tracking() · stats() · resetStats() · graph()
Phase 3 — tracesifTrace() · queries()

All read-only/opt-in and zero-overhead when disabled. What remains is Phases 4–6: the browser-extension UI — a separate packages/devtools-extension that consumes this protocol (the issue's "year-long part"). The runtime side is ready for it.

14 days ago

Update — packages/devtools-extension scaffolded (Phases 4–6 begun)

The browser-extension that consumes the Phase 1–3 window.__stxDevtools protocol now exists, wired and tested end-to-end (distinct from packages/devtools, the server dashboard):

panel (panel.ts/controller) ─▶ background relay ─▶ content-script ─▶ page bridge (inject) ─▶ window.__stxDevtools
        ◀──────────────────── responses, rendered as tree/graph/timeline ────────────────────
PieceStatus
protocol.ts — request/response contract + pure handleDevtoolsRequest✅ tested
inject.ts — page-world bridge over postMessage✅ tested
content-script.ts — page↔extension relay✅ wired
relay.ts / background.ts — MV3 port pairing by tab id✅ routing tested
build.ts — bun run build → loadable dist/✅ tested
render.ts — tree / graph / queries / ifTrace / stats → HTML (XSS-escaped)✅ tested
panel-controller.ts — view → request → render orchestration✅ tested
Rich interactive UI + live refresh🚧 browser-only polish

31 package tests, all green. The whole pipeline is verified except the final browser-only UI polish (live auto-refresh, interactive graph viz), which is best built with the extension loaded in a real browser. The hard contracts — protocol, transport, build, rendering — are settled and tested.

14 days ago

Update — panel UI built + visually iterated

The extension panel is now functional and polished. Since driving an extension's devtools panel headlessly isn't practical, I added a preview harness (bun run preview in packages/devtools-extension) that renders every view with mock data into a standalone dark-theme page — openable in any browser, or screenshot headlessly. I iterated the visuals against real screenshots.

Views (all from the tested pure renderers):

  • Tree — nested scopes, each id click-to-drill into its scope inspector.
  • Graph — per-scope signal tables with type pills (signal/derived) and a subscriber bar (width ∝ dependents), so "what's heavily depended on" reads at a glance.
  • Queries — timeline with color-coded status pills (green 2xx, red failures w/ the error).
  • If Trace — each chain's picked branch as a green pill (#0 :if), red none when unmatched.
  • Stats (counters) and Scope (signals/derived/values/methods).

Plus: a live toggle that polls the current view (~1s), and the MV3 transport (page bridge → content-script → background relay) — all unit-tested.

To use: bun run build in the package → load dist/ unpacked in Chrome (Developer mode); the Stacks panel appears in devtools on any page running the stx signals runtime. Or bun run preview to eyeball the UI without installing.

41 package tests, all green. The whole extension — protocol, transport, build, every render view, controller, live refresh, drill-down — is now built and tested; what's left is optional feature depth (tree expand/collapse, filtering, value editing).

14 days ago

Closing — Stacks DevTools shipped (protocol + extension)

Both halves are built, tested, and verified against the real runtime.

In-page protocol (window.__stxDevtools, v4 — in the stx runtime, ships with every app)

  • tree / scope(id) — component scopes and their signals.
  • stores / store(id) — defineStore state, getters (derived), and actions.
  • graph — every signal/derived with value, set-count, and subscriber count.
  • mutations — chronological state-change log (prev → next, attributed to scope or store).
  • queries — useFetch/useQuery/useMutation timeline.
  • ifTrace — which @if/:if branch each chain picked.
  • stats + enable/disable/tracking/resetStats — counters, all gated behind __stxDevtoolsTracking (off by default → zero production overhead).

Browser extension (packages/devtools-extension/, MV3)

  • Page bridge → content-script → background relay (paired by tabId) → DevTools panel; pure protocol handler + pure renderers.
  • Panel views for every protocol method, click-to-drill into scopes/stores, graph filter, live refresh, color-coded query/branch pills + subscriber bars.
  • bun run build → loadable dist/; bun run preview renders the UI standalone (used to iterate visuals via screenshots).

Verification

  • Unit tests for protocol routing, every renderer, the panel controller, the relays, and the build.
  • A real runtime ↔ bridge integration test (test/integration.test.ts) boots the actual signals runtime and drives protocol requests through the bridge over a real message event — proving the extension's contract matches the runtime, not just mocks.

Deferred

  • Chrome Web Store publishing (icons, listing, CI package step) — packaging/submission that needs maintainer assets/accounts; tracked in #1754.

Full suite green. Closing as the protocol + extension are complete; store publishing continues in #1754.

Sign in to comment on this issue.