Skip to content

Frontend: hard-won details

The level-3 ledger: technical findings underneath Frontend for agents and Design choices, each paid for with a real debugging loop while building the reference notebooks. Some are transient (pinned to beta versions — noted); all are worth keeping so nobody pays twice. Format: symptom → cause → rule.

Versions these were established against: solid-js@2.0.0-beta.15, @solidjs/web@2.0.0-beta.15, vite-plugin-solid@3.0.0-next.5, Vite 6, @babel/core 7.29.

SolidJS 2.0 (beta) semantics

  • Owned-scope writes throw in dev. Setting a signal from inside a memo compute, a component body — or the synchronous prologue of an async generator consumed by a memo — raises REACTIVE_WRITE_IN_OWNED_SCOPE. Internal bookkeeping signals (a cell's progress/settled/partial) opt in with createSignal(v, { ownedWrite: true }); otherwise defer the write (queueMicrotask) or move it to an event handler. The solid-cells probe notes predate this enforcement.
  • createEffect(source, handler): the handler is untracked — for reads too. Writes belong in the handler (that's the design), but reading a signal inside the handler warns STRICT_READ_UNTRACKED (at 60 Hz, spectacularly) and can miss updates. Consume the value the source computed: createEffect(() => frames.at(i.get()), (frame) => draw(frame)), not createEffect(i.get, () => draw(frames.at(i.get()))).
  • Same-tick reads after writes lie — always, not just in tools and tests. A signal write is a transaction: set stages the value, the commit happens at the next microtask, and the reactive graph is the only reader of your writes. Code Solid did not call — an event handler, a chrome.* callback, a timer, a socket, an agent tool's run, a rAF tick; any imperative boundary (getObserver() === null) — reads the last committed value: x.set(v); x.get() returns the pre-write value, and a memo over x is exactly as stale, which is why making the raw value fresh (a liveSignal) can never fix a derived read. Reads inside the graph (memo computes, effect computes, JSX, cell deps) always see a consistent staged snapshot — this is why pure-dataflow code never meets the bug. Cures, in order: don't read back — branch on the local you computed or the setter's return (it returns the written value); where a flow genuinely must observe its own writes, flush() (exported from solid-js, dev and prod) commits synchronously, and flush(fn) also runs effect handlers before returning, so effect-driven surfaces are already repainted. control(), durableSignal(), and createStore all share these semantics; the aiui primitives shout when a boundary read would return a pre-write value. The whole contract is pinned by packages/aiui-viz/src/solid-semantics.test.ts; Solid 1.x had read-your-own-writes behind the identical API, which is why 1.x-trained intuition (and priors) keep regenerating the bug. Machine state under the mode engine is exempt by construction — solidModeEngine commits every dispatch under flush and keeps state a plain frozen object, so reading it after a dispatch is never stale, from any scope; putting modal state there is the structural fix for this bug class.
  • Gone from 1.x: onMount (use ref callbacks — they run when the element exists), classList (compute class strings). render and the JSX type moved to @solidjs/web (jsxImportSource likewise). <Show>'s non-keyed function child receives an accessor; keyed receives the value.
  • Ref callbacks run with no owner. getOwner() inside a ref={…} returns null — the ref is not a reactive scope — so an onCleanup registered from a ref, or from a microtask the ref queues, silently never runs (NO_OWNER_CLEANUP in dev; the observer/listener leaks on dispose). Capture getOwner() in the component body and re-enter with runWithOwner(owner, …) around the deferred setup. Living reference: aiui-slides' setupObserver (packages/aiui-slides/src/deck.tsx).
  • Reading a pending async value in JSX outside a Loading boundary defers the ROOT mount. A direct call on a not-yet-settled cell (or any async memo) suspends; with no boundary above it, Solid holds the entire document's first paint until the async settles — the symptom is a blank page plus an ASYNC_OUTSIDE_LOADING_BOUNDARY warning, easily misread as a render bug. <CellView> provides the boundary (plus state chrome); a value that must render quietly with no chrome reads cell.latest() — non-throwing, non-suspending, undefined until first settle.
  • <Index> is also gone; <Repeat count={n}>{(i) => …} is 2.0's position-keyed list — and the distinction is load-bearing, not cosmetic. Reference-keyed <For> over freshly-computed row objects re-creates the DOM node on every recompute, which detaches a node mid-interaction: the intent client's press-and-hold cap was re-created the instant its own lit state flipped, and the pointerup died with the detached node (event delegation only delivers to connected nodes). Render recomputed projections position-keyed — the node persists and its attributes update in place. Regression: packages/aiui-intent-client/src/ui/panel.test.tsx ("survives its own press").
  • A disabled button swallows pointer events. A press-and-hold gesture whose down-command becomes unavailable mid-hold (pressing it is exactly what made it unavailable) must keep the button enabled across the whole gesture, or the pointerup lands on a disabled element and the hold wedges. The bar projection encodes the rule: a hold cap stays enabled while either half of its gesture applies (packages/aiui-viz/src/modal/bar.ts).
  • An uncaught effect-phase throw permanently halts the document's reactive system (since 2.0.0-beta.32; recoverable only via resetErrorHalt). Symptom: a page that looks alive but stops responding entirely — one console error, no crash. Only SYNCHRONOUS throws in the handler do this; an async rejection is an unhandled rejection, not a halt (still .catch() it). The rule has two complementary halves, one per owner kind:
    • Component trees: an error boundary at the mount seam. aiui-viz's PageBoundary wraps each mounted SitePage (the gallery shell, the scaffold's main.tsx) and each landing-card preview, so in a multi-app document one page's fault renders a contained fault card — with a reset, and self-recovery when the faulting source changes — while every sibling keeps flowing. Don't blanket-wrap individual effects: for pure-reactive effects a throw is a bug you want loud, and in dev the fault card names it.
    • Durable graphs: bridgeEffect at every foreign crossing. Effects in hotCellGraph roots outlive any mount, so no boundary can contain them — and their handlers are exactly where real throws come from (engine setters, worker postMessage, chrome.* after "extension context invalidated", socket sends, lost GL contexts). bridgeEffect(compute, handler, { name, scope }) hardens the crossing: a failure is recorded to the bridge registry (surfaced in the report tool's bridges section) and the console, and the feed stays live for the next change. The airlock rule: a foreign system's failure is a fact to surface, never permission to halt the graph. Living reference: morphogen's sim-params feeds (demos/morphogen/src/model/graph.ts); containment proof: packages/aiui-viz/src/page-boundary.test.tsx.
  • Errored memos read as undefined and drop their previous value; without an error boundary the failure is near-silent (isPending stays true, error dumped globally). This is why cells cache their own last value and wrap state derivation in createErrorBoundary — and why refetch() must route through the boundary's reset() to re-run a failed compute.
  • A callable's identity can't live on .name. Cells are functions; Function.name is read-only, the assignment fails, and reads leak the internal closure's inferred name (every attribution stamp briefly read "read"). Use a differently-named property (cellName).

Theming under prefers-color-scheme

  • Figure colors and panel-chart colors are different species. A color painting a self-contained dark canvas (and the legend chips that key it) must be a constant across modes; the "same" color used as a chart mark on a panel surface must be per-mode. Identical hex in dark mode, they diverge in light. Validate a figure palette against BOTH the figure surface and the opposite-mode panel where its legend chips appear.
  • A module-level mode() signal makes the system theme fully reactive — chart-option memos that read it re-render on a live OS theme flip, no reload. Put the matchMedia listener inside durable() so HMR re-evaluation doesn't stack listeners.
  • as const palette objects over-narrow. Literal-union types from as const fail Record<Mode, Palette> indexed access ("'#2f6fce' not assignable to '#4a86dd'"); type palettes with an explicit interface instead.

Mosaic / DuckDB-WASM (@uwdata/vgplot 0.28.1; the exact @duckdb/duckdb-wasm pin lives in pnpm-workspace.yaml — 1.33.1-dev61.0 today; live compatibility table: duckdb-mosaic.md)

Found building the seismos notebook (full detail: demos/seismos/src/NOTES.md):

  • A custom MosaicClient defaults to being served from the pre-aggregation index, which applies interval clauses but silently DROPS point clauses — a categorical cross-filter that looks wired but does nothing. Override get filterStable() { return false } on clients that must see every clause.
  • A 2-D region clause needs scales metadata to propagate through a Selection; when driving filters programmatically (agent tools), two 1-D clauseIntervals (lon + lat) behave where one 2-D clause won't.
  • intervalXY on a raster mark resolves the brush field to NULL — pass xfield/yfield explicitly.
  • A mosaic-inputs Menu bound to a Selection is write-only (no back-sync); reset the <select> yourself when its clause clears elsewhere.
  • highlight({ by }) over a crossfilter Selection is a silent no-op for the chart's own clause — the crossfilter resolver unconditionally drops every clause whose clients contain the mark being tested, which is exactly the clause toggleY publishes (clients: plot.markSet). The marimo-style gray-out of unselected categories therefore needs the toggle/legend to publish into its OWN non-cross Selection (categorySelection() in aiui-viz/mosaic-selection), include-relayed into the crossfilter at construction (Selection.crossfilter({ include: [origin] })), with highlight({ by: origin }) beside the toggle.
  • The include relay is one-way (origin → includer): resetting the crossfilter never reaches the origin, whose Highlight then keeps the chart grayed over an unfiltered page. Whole-state clears reset every dim-target Selection (resetSelectionDimTargets); per-component clears reset the clause subset on the producer's OWN selection (clearSelectionFor / the clear-selection action / the inspector's ✕).
  • A category origin must be intersect, never single: adoption follows each component publish with a headless retraction from the dimension's source, and single resolution lets that different-source, null-predicate update displace the component clause — the origin goes empty (chart un-grays) while the relayed copy keeps filtering (measured live).
  • mosaic's Toggle implements no reset() (Interval1D/2D, Region, Menu, Search do): an external clear strands its internal value, so the next click reads as a deselect. The facet binding nulls it when the clause vanishes (mosaic-facet.ts).
  • Pin @duckdb/duckdb-wasm to the exact version @uwdata/mosaic-core depends on, so one deduped copy exists; build DuckDB from locally ?url-imported wasm/worker assets (no CDN) — it survives a hosting prefix and works offline.

Found building the wine demo (embedding-atlas integration; full detail: docs/embedding-view.md):

  • A fully occluded Chrome window freezes the whole Mosaic pipeline while document.visibilityState still reads "visible": Chrome pauses the rendering steps (rAF, ResizeObserver delivery, screenshots), and coordinator-driven updates ride them — views stay blank, menus never populate, custom clients never get results, and nothing errors. Bites hardest when driving the session browser from an agent while its window sits behind other apps; verify in a headless instance instead (headless never occludes). Layout still computes under occlusion (clientWidth is live), which is why size-dependent mounts should measure directly rather than wait for a first ResizeObserver delivery.
  • embedding-atlas declares non-optional peers on @uwdata/mosaic-spec and @uwdata/vgplot that pnpm auto-installs at the NEWEST release — which can be broken upstream (mosaic-spec 0.30.0 demanded an unpublished vgplot ^0.30.0). Pin both at the app's mosaic line in the consumer's own dependencies.

Biome (lint) specifics

  • useAnchorContent rejects icon-only anchors even with aria-label + an aria-hidden SVG — it wants real content; add a visually-hidden <span class="sr-only">.
  • Solid's innerHTML prop is NOT covered by noDangerouslySetInnerHtml (a React-only rule) — and a suppression comment for it errors as unused. KaTeX-via-innerHTML needs no suppression.
  • noShadowRestrictedNames forbids a binding named Math — name a math component TeX.
  • noUselessEmptyExport's autofix deletes the export {}; AND any comment attached to it — a trailing marker comment (a scenery fence, a directive) sitting directly above the export vanishes with it. Keep load-bearing trailing comments standalone, separated by a blank line. (Comment-only .ts files are fine: this repo does not enable isolatedModules-style checks that would demand a real export.)

Babel / compile-time instrumentation

  • JSX visitors in other plugins never fire under babel-preset-solid. The Solid compiler visits each outermost JSXElement, compiles the whole subtree internally, and replaces it — the shared traversal never descends into JSX children. Do all JSX stamping in Program.enter with an explicit path.traverse, which runs before any replacement (@locator/babel-jsx is built the same way). Diagnostic corollary: a plugin can be instantiated and its Program visitor run while its JSX visitors never do — when verifying a babel plugin, instrument the traversal, not the instantiation.
  • vite-plugin-solid's babel option only sees .jsx/.tsx. Plain .ts model files — where the dataflow lives — are never processed by its babel pass. Ship instrumentation as a standalone Vite plugin (enforce: "pre", own @babel/core pass, dev-only, content-sniff to skip files with nothing to stamp): it covers all extensions and decouples from Solid entirely.
  • Enable the jsx parser plugin only for *.jsx/*.tsx. In plain .ts, jsx parsing makes <T>expr type assertions ambiguous.
  • The compiler must run under Vitest too, not just Vite. Anything that tests compiled behavior (control names, lifted descriptions) needs the same plugin in vitest.config.ts — the template ships it wired; a test project without it sees anonymous cells and undescribed controls and fails mysteriously.
  • Explicit names must be compile-time string literals — dynamic registration passes a prebuilt spec. An inline control({ name: expr }) whose name is not a literal is a code-framed compile error (the name is a durable key, a tool identity, and a grep target). Legitimate dynamic registration — a library minting controls from data, like the mode engine's agent bridge — builds the options object first (const spec = {…}; control(spec)): the compiler deliberately leaves a non-literal options expression alone, and the runtime missing-name guard is the backstop (packages/aiui-viz/src/mode-solid.ts is the documented shape).
  • Babel string literals escape non-ASCII (κ\u03BA in output). Harmless at runtime, but string-matching tests over transformed output should use ASCII fixtures.

Vite HMR routing

  • import.meta.hot.accept(dep, cb) works only in a module that directly imports dep. Registered anywhere else it silently does nothing and the update full-reloads.
  • Every import path to a changed module must reach an acceptor. One stray secondary import (the sim engine importing a single shader constant) routes the update around your handler and up to the root → full reload. Sever secondary paths (pass values through constructors) or accept on each.
  • A self-accepting module absorbs propagation for un-handled updates beneath it — the dataflow module's bare hot.accept() is why edits to shared model code hot-apply instead of bubbling past main to a reload.
  • Comment-only edits can produce byte-identical output, so solid-refresh swaps nothing — no remount side effects run. Don't interpret a no-op hot update as evidence the mechanism works.
  • Worker files and durable-root modules force full reloads by construction (Vite can't hot-swap a live worker; the durable wiring module is everything's ancestor). Practical rule when driving a live experiment: don't edit *.worker.ts or store.ts mid-run.
  • HMR ordering hazard for adopted resources: the replacement component's setup can run before the old component's cleanup. Cleanup of a shared durable resource must be conditional ("still mine?") or unnecessary by design — never take the canvas back from your successor.
  • Never optimizeDeps.include a workspace-linked package. The dep-optimizer cache is keyed by the lockfile, not file contents; the linked package is served stale after every rebuild. (Related: the dep scanner cannot see through virtual modules — a plugin-injected bare import is discovered at request time, costing one reload on cold start; acceptable, or pre-bundle for registry consumers only.)
  • Dev SPA fallback ≠ static-host SPA fallback. Vite's dev server rewrites unknown paths to index.html for free, so client-side routes "just work" locally — then 404 on S3/CloudFront, which resolves neither folder indexes nor extensionless keys through a REST origin. Ship explicit per-route objects at publish time, and upload extensionless copies with --content-type text/html (mime guessing gives octet-stream). The gallery's publish.sh is the worked example.
  • Routers dispose component trees, not module singletons. Anything durable-registry-held (a rAF loop, a WebGL engine, a worker) keeps running when its route unmounts — by design. Route changes need an explicit pause-not-destroy seam (park the loops, keep the state); destroying would throw away exactly the accrued state the durable model exists to protect.
  • vite preview resolves the config with command: "serve" (plus an isPreview flag — key environment-dependent base on command === "build" || isPreview, never on command alone), and apply: "serve" plugins are active under preview too. The failure mode is maximally confusing: with the wrong base, the SPA fallback serves index.html for every asset URL — status 200, content HTML — while requests carrying Sec-Fetch-Dest: script (real browsers) correctly refuse the fallback and 404.
  • Source-first workspace packages that ship JSX work end-to-end — and for a specific reason worth knowing: vite-plugin-solid compiles JSX for any served .tsx and only disables solid-refresh for ids containing /node_modules/. pnpm workspace links resolve to the real packages/<pkg>/src/ path, so a linked component library (aiui-viz) gets both JSX compilation and HMR component boundaries in every consumer. Verified live: editing the library's cell-view.tsx hot-updates a running app with no reload, sim state intact.
  • import.meta.env.* is substituted when a package is built. Prebuilt library code can never read its consumer's env; the general rule: runtime configuration for prebuilt code must travel through runtime channels (injected globals, plugin-generated modules).

Ecosystem status (transient — recheck before relying on it)

  • LocatorJS on Solid 2.0: @locator/babel-jsx works but emits file::<element-index> only; @locator/runtime ships precompiled Solid 1.x (solid-js/web import) and crashes the dep optimizer; solid-devtools pins solid-js ^1.9. Hence the in-repo source locator (data-source-loc with real line:col, dependency-free bar @babel/core), now owned by the dev source processor (packages/aiui-source-processor, the aiui() plugin).
  • The 2.0 toolchain that works together (beta.32 line, upgraded 2026-08-10): solid-js@next + @solidjs/web@next + vite-plugin-solid@next. TypeScript ≥ 5.x with jsx: "preserve", jsxImportSource: "@solidjs/web". Since vite-plugin-solid@3.0.0-next.13 the default JSX compiler is the native @dom-expressions/compiler (exact-pinned by the plugin; compiler: 'babel' remains as an escape hatch), and solid-refresh is dead — the HMR runtime moved into core as the dev-only solid-js/refresh entry, with granular per-component refresh on by default. The in-repo source locator is unaffected by the native default by design (its own enforce: "pre" Babel pass, not the plugin's babel option) — verified: data-source-loc stamps and HMR hot-swap both work under the native compiler.
  • The JSX compiler and runtime move in lockstep — fresh installs break otherwise. Symptom: "claimElement" is not exported by @solidjs/web/… (or scope) at dev/build time: the newer compiler emits helpers the older runtime doesn't ship, and the plugin's babel-preset-solid range floats to the newest beta on any fresh lockfile (first paid for in fai-design's styleguide). Same trap one layer down: solid-js depends on @solidjs/signals via a beta-crossing caret. Rule: pin babel-preset-solid AND @solidjs/signals to the same beta as the solid catalog via pnpm overrides (root pnpm-workspace.yaml and the create-aiui app template), and bump the catalog + overrides + template together. Newer plugin versions also ratchet their own peer floors (next.24 requires runtime ≥ beta.32), so pnpm now refuses the worst mixed states outright.
  • beta.32 scheduler semantics — async pendingness holds effects. While a question is pending without a containing boundary (e.g. a downstream memo threw NotReadyError and awaits a commit), the scheduler defers effect propagation — user AND render tiers — until the graph settles, and writes made from inside the in-flight run are staged into the pending question's transaction (invisible even to pull reads). Consequence for cells: stream: "commit" (the production mode) is unaffected — every yield commits and the world flows — but latest-mode side-channel partials coalesce to the settle value whenever a downstream consumer is pending on the cell. Probed 2026-08-10; pinned by the two latest-mode tests in aiui-viz's cell.test.ts.
  • beta.32 dev lint STRICT_READ_UNTRACKED: reading a reactive value directly in a <Show> children callback body (Show invokes it untracked) now warns "will not update". Move the read into a tracked JSX expression container — wrap in a fragment: {(v) => <>{v()(...)}</>} (see demos/gallery/src/site/Landing.tsx).

Testing cells and controls headless

  • Solid must resolve as ONE browser/dev build under Vitest, or reactivity silently dies. A package whose vitest config lacks resolve.conditions: ["browser", "development", …] + server.deps.inline: [/solid-js/, /@solidjs\//, /@habemus-papadum\//] gets a node-resolved SECOND solid instance: signals write to one runtime, memos track in the other — get() reads fresh values while cells never recompute (or a dispatch "works" while writes commit into a graph nobody reads and the DOM never updates), and nothing errors. The recipe includes a never-matching external regex (/^never-external-solid-js$/) — vite-plugin-solid force-externalizes solid-js unless the user config already lists a matching external, so without it inline silently loses. Every test config in this repo carries the recipe (aiui-viz's vite.config.ts has the full story; aiui-intent-client's copied it); copy it into any NEW package that tests Solid before debugging "my cell doesn't update".

  • Cells must be created inside cellHarness's setup callback — created outside any owner they throw NO_OWNER_BOUNDARY (Solid 2.0 requires an owner for the underlying memo). The harness exists precisely to own them; build the graph in the callback, return what the test needs.

  • jsdom has no Worker. Don't mock the module — parameterize the seam: buildGraph(worker = realWorker) and hand tests a ~30-line stub speaking the same run/cancel → progress/partial/done protocol over the pure layer-1 functions (demos/walkthrough/src/model/graph.test.ts is the worked example).

  • Controls are module-global state; tests must reset between cases — but modules are imported once, so a reset that unregisters leaves every later test with an empty registry. resetControlSurface therefore restores initial values and clears dependency edges while keeping registrations; call it in afterEach.

Workers, GPU, and long computation

  • Cancellation needs a macrotask. await Promise.resolve() between chunks never yields to message delivery; await new Promise(r => setTimeout(r, 0)) does. A worker that only micro-yields is uncancellable in practice.
  • import type from a mixed barrel is safe in workers. Under verbatimModuleSyntax, type-only imports are fully erased — a worker importing protocol types from a barrel that also re-exports JSX components never drags the component code (or solid-js) into its bundle.
  • Don't emit the final value as both a partial and done — it double-records in any accumulating consumer (aztec's frame ring collected 65 frames for 64 steps). Gate the last partial or let done carry it, not both.
  • Browsers cap live WebGL contexts per tab (~8–16, oldest silently destroyed). This is what forces the hibernate policy (read state back to CPU, release the context) in any multi-page design that keeps several GPU notebooks warm.
  • Readback and screenshot disagree diagnostically — a blank canvas with healthy readback is a compositing problem; blank both is a produce/draw problem. (The full decision table lives in the retired observable web workers notes, readable in the repo's git history.)

Driving a live app from an agent

  • After calling a tool that mutates state, await a task boundary before reading (report()) — see the batching entry above; same-tick probes produce convincing false alarms ("seek is broken" cost an hour; it wasn't).
  • The demo apps' own tool surfaces (window.__morpho, window.__aztec) are the intended verification instrument: discover with .tools, act with .call, observe with .report(), and map anything on screen to source and dataflow with .call("locate", { selector }).