App architecture overview#
This page is the big-picture mental model for the Cellucid web app: how the app boots, how data moves through the system, and where performance-sensitive boundaries are.
If you are new to the codebase, start here before diving into any single module.
At a glance#
Audience
Wet lab / non-technical: read “Big picture” + “What is state?” + “How to report a bug”.
Computational users: read “Boot sequence” + “Data sources” + “State and persistence”.
Developers: read the whole page, then continue to State (DataState) and events.
Time
20–40 minutes
Prerequisites
None (links point to code files in this workspace)
Big picture (boot → state → UI → rendering)#
cellucid/index.html
├─ defines: sidebar DOM + <canvas id="glcanvas">, CSP, deferred (disabled) controls
├─ loads: ui/core/*-init.js, code-load-watch.js, file-drop-guard.js (classic, pre-module)
└─ runs: assets/js/app/main.js (ES module)
main.js (orchestrator)
├─ createViewer() → rendering/viewer.js (WebGL2 renderer)
├─ createDataState() → app/state/core/data-state.js (typed arrays + state managers)
├─ initUI() → app/ui/core/ui-coordinator.js (DOM + modules)
├─ data sources → data/data-source-manager.js (sample catalog, files, server, links, viewer)
├─ data loaders → data/data-loaders.js (AnnData adapters over the reader worker)
├─ sessions → app/session/session-serializer.js (.cellucid-session, format 4)
├─ histories → app/history/dataset-history.js (a history per dataset shown)
├─ state domains → app/state/domains/index.js (installStateDomains)
├─ analysis → app/analysis/* (compute backends + plots + UI)
└─ admits deferred controls once their listeners exist
DataState (state coordinator)
├─ emits events: visibility/field/highlight/page/view-representation/mapping/vectorFields changes
├─ owns: typed arrays + per-view contexts
└─ calls viewer: updateColors/updateTransparency/updatePositions/etc
the one-way rule
───────────────
control ──dispatch──▶ action ──handler──▶ DataState ──explicit method──▶ viewer ──▶ GPU
▲ │ (app/actions/) │
│ └──recorded──▶ history │ (app/history/)
└────────────────event──────────────────────┘
A control dispatches the action it stands for and calls no mutating state
method; a UI module never reaches past DataState into the viewer, and the
viewer never reads the DOM. These shortcuts have been the source of
regressions.
Design goal: keep “hot” work (per-frame render and per-point math) inside the renderer and state managers, not in main.js or UI modules.
Boot sequence (what happens on page load)#
The high-level startup flow is:
cellucid/index.htmldeclares its import map (every module at the URL naming its bytes), runs its classic bootstraps (ui/core/preconnect-init.js,ga-init.js,theme-init.js,viewport-init.js,code-load-watch.js,file-drop-guard.js), then loadsassets/js/app/main.jsas an ES module. The parser stops at the style gate (<script id="cellucid-style-gate">, CELLUCID_STYLE_GATE, straight after the stylesheet link) until the stylesheet and all it imports are in, so no engine parses<body>or fires DOMContentLoaded, the boot’s cue, in a page without its styles (WebKit does not hold DOMContentLoaded for a stylesheet by itself).main.jsmarks the rootdata-code-startedonce its whole static graph has run; an uncaught error before that mark, a module that did not download, or a stylesheet that did not, is the code-load watch’s Cellucid could not load card.main.jscreates a WebGL2 viewer:createViewer({ canvas, labelLayer, viewTitleLayer }). The full signature also acceptsonViewFocus; the bootstrap does not pass it. If the canvas yields nowebgl2context this throws immediately and the startup-failure card replaces the app. There is no software renderer.main.jscreates the app state:createDataState({ viewer, labelLayer }).main.jsinitializes notifications and the optional analytics entry point (app/optional-analytics.js): it queues events while the analytics modules load by dynamic import, is never awaited, never throws, and stays inert when a privacy blocker refuses them. Then it sets up theDataSourceManager.The app decides what to load first — it resolves the startup selection (source, dataset id, load method) but loads nothing yet:
URL params (
?remote=…,?data=gh:…,?dataset=…) may override the default demo.A viewer page a host program opened (
?viewer=true&viewerId=…&viewerToken=…, Pythonshow_anndata()or Rcellucid_view()) registers thejupyterdata source and selects the dataset its server serves.
The app publishes the empty runtime and initializes the UI coordinator (
initUI), which wires the sidebar modules to state/viewer.Only once their listeners exist are the deferred controls (data sources, renderer toggles, benchmark) enabled, and the live-region line under the Open data heading (
#data-source-status) switches from “Starting Cellucid…” to “Data sources are ready…”. The render loop starts.The startup selection loads as the first ordinary dataset change (
ui.loadStartupDataset→reloadActiveDatasetInPlace), under the same transaction owner as every later change, so any choice the user makes while it loads replaces it:the AnnData file opened in the reader worker, then the identity, obs and var inventories and the graph description the AnnData adapter builds from it
points buffers (embedding positions, colors, outlier quantiles, etc.)
the dataset’s history, made once the dataset is published; the starting view its catalog names, when it names one, put back without a step; then the first tracker starts. A dataset the person chose (a link, a file, a sample picked; not the startup’s default sample) is recorded in My datasets A failure of this load that nothing superseded is the terminal startup failure; otherwise startup publishes
readywhen it settles.
Important
Step 7 is an invariant, not a nicety. Any control whose handler is wired after
an await in this bootstrap must be authored disabled in index.html and
registered with ui/core/deferred-control-readiness.js. A control that is
clickable before its listener exists silently discards the click, which is
indistinguishable from a bug to the user.
Data sources (how the frontend “gets data”)#
Cellucid supports multiple data sources through a single coordinator:
cellucid/assets/js/data/data-source-manager.js(DataSourceManager)Registers sources and manages dataset switching.
Produces a dataset base URL (
baseUrl) used by the low-level loaders.
Common sources:
local-demo: the sample dataset list, read from a dataset catalog (
datasets.jsonand the AnnData files it lists) at the sample catalog URL (configured via<meta name="cellucid-sample-catalog" ...>or?sampleCatalog=...; in productionhttps://theislab.github.io/cellucid-datasets/anndata/)local-user: Open… → File… or Folder… (and drag and drop) for one file or folder of any format in
data/input-formats.js(AnnData.h5adand Zarr, MuData, Loom, 10x.h5andouts/, R objects, MatrixMarket folders)remote: connects to a
cellucid serveserver; the browser’s AnnData reader reads the file or store it names in place with range requestslink: data opened from a link through a provider of
data/sources/registry.js(GitHub, CELLxGENE, GEO, Zenodo, Hugging Face, Figshare, Dropbox, Google Drive, OneDrive) or any web address of a file, a store folder or a dataset catalog folder (sharing; no server)jupyter: the viewer a host program opened (Python notebook or script, R), with its command/event channel (
data/viewer-channel.js)
Deep dive: Data loading pipeline and caching.
What is “state” in Cellucid?#
Cellucid has many interacting features. The app treats state as first-class:
DataState is the core coordinator#
DataState (in cellucid/assets/js/app/state/core/data-state.js) owns:
core typed arrays (positions, colors, visibility/transparency)
field registries (rename/delete/user-defined)
highlight pages/groups and per-point highlight overlay arrays
per-view contexts (live view + snapshot views)
the representation (embedding) each view shows, the data mapping, and vector-field availability
loaded obs/var field data, released least recently used once inactive fields pass their memory budget (
field/residency.js,data/memory-budget.js)
DataState emits events so UI/analysis modules can stay decoupled:
visibility:changedfield:changedhighlight:changedpage:changedview:representation-changedmapping:changed/representations:changedvectorFields:changed
Deep dive: State (DataState) and events.
Viewer state is separate#
The WebGL viewer (cellucid/assets/js/rendering/viewer.js) owns GPU resources and the render loop.
It is updated by the state layer using explicit methods like:
viewer.setData(...)viewer.updateColors(...)viewer.updateTransparency(...)viewer.updatePositions(...)
Deep dive: Rendering pipeline (WebGL) and performance notes.
Actions, state domains and the history#
Every state change a person, an agent, a Python or R host or a replay can make
is an action of one catalogue (app/actions/), dispatched through one
dispatcher that checks its parameters, orders it against the actions running
on the same state, records it as one step of the history and rolls a
failed one back. A control dispatches the action it stands for
(dispatchFromControl); settings controls and camera gestures are recorded by
observation. The state is split into fifteen state domains (cells,
mapping, field-overlays, … layout), each with one owner in
app/state/domains/ that captures and restores its value through the
DataState methods the controls use. The history (app/history/) keeps
trackers of step trees and every domain value by content hash, so undo, redo
and a jump put values back instead of recomputing. Deep dive:
Action layer, state domains and history.
An agent (the chat bar with the person’s API key, Claude or ChatGPT
through the Cellucid relay, or a local app through cellucid mcp) acts
through the same catalogue: each action is one tool, every call runs through
one tool call (app/agent/bridge/tool-call.js) as origin agent, the
person’s permissions decide whether it runs, asks or is refused
(app/agent/presence/), and its steps undo as one turn. Deep dive:
Agents in the tab: chat bar, permissions, data guard and relay.
Persistence and reproducibility#
Cellucid has multiple persistence-like mechanisms; they are different and must not be conflated:
Session file (
.cellucid-session, session format 4)A downloadable, shareable file holding the dataset’s history: its trackers, their steps and every state value they refer to (or, as chosen in State › Session › Share…, the current state only, or the steps only as a replay plan). Load… adds its trackers beside the person’s and shows the active step.
Code:
cellucid/assets/js/app/session/(the file) overcellucid/assets/js/app/history/(what it holds).cellucid/assets/js/app/state-serializer/is only the sidebar’s control inventory (ui-controls.js), which thesettingsandlayoutdomains read.
History kept in this browser (the autosave and named saves)
Every step written to IndexedDB
cellucid-historyas it is made; reopening the dataset offers to continue (app/history/autosave.js).
My datasets
The datasets opened in this browser and how each opens again, in IndexedDB
cellucid-library(app/library/my-datasets.js); exported and imported with their saved states as one.cellucid-libraryfile (app/library/library-file.js).
Browser storage (preferences/caches)
Theme, debug flags, some community-annotation caches, etc.
Documented in: Configuration, environment variables, and feature flags and Privacy model.
URL state (deep links)
?dataset=…,?remote=…,?data=gh:…,?annotations=…(full list: Configuration, environment variables, and feature flags).Code:
cellucid/assets/js/app/url-state.js.
Deep dive: Sessions: persistence and serialization.
Performance invariants (rules contributors should treat as “hard”)#
These are the most common sources of accidental regressions:
No per-frame DOM work: the render loop should not query or update DOM every frame.
No hot-path allocations: avoid allocating arrays/objects in per-point loops; reuse typed-array scratch buffers.
State → viewer updates should be explicit: prefer
viewer.update*methods rather than “implicit” coupling.Batch expensive recomputations: when restoring filters or applying many changes, bracket the mutation with
DataState.beginBatch()andDataState.endBatch().A value captured for the history is never written again: a domain owner copies what the app writes in place, and caches by identity what it replaces, so capturing an unchanged domain costs nothing per cell.
Cancellation and failures (one rule each)#
Every thread of the app (the page, the reader, decode, analysis and
figure-export workers) follows the same three rules. A contract test
(tests/cancellation-guard-contract.test.mjs) keeps every module on them.
One way to stop work (
assets/js/utils/abort.js). Work stops throughcancel(controller, kind, words)or a controller aborted withcancelled(kind, words). A cancellation is anAbortErrorwith a kind:user(the person pressed Cancel or closed something),superseded(a newer choice replaced it),closed(what it belonged to went away),released(memory was given back under it; asked again, it works) orpage-left.isCancellation(error)answers by name alone, so the platform’s own aborts count too, andcancellationKind(error)tells the kinds apart. A cancellation is never shown as a failure. Shared work (SharedWork) stops only once every waiter has left, and its own abort names no waiter’s reason.awaitWithSignalandonAbortkeep one listener on a signal however many reads wait on it. They are the only way to wait on a signal: nothing callsaddEventListener('abort', …)itself (tests/abort-listener-contract.test.mjs). Anabortevent fires once and is not replayed, so a listener added to a signal that has already aborted never runs. That is how a text-matrix pass stopped before it began once read the whole matrix.onAbortcalls back at once when the signal has already aborted, before it returns. It returns a remover that the site calls when its work settles, so a dataset-long signal keeps no callback of finished requests. Work started with a stopped signal does nothing (tests/abort-before-start-contract.test.mjs). No module defines its own abort helpers, aborts with a plainError, builds anAbortErrorby hand, checksname === 'AbortError', or uses a subsystem code for supersession.No clock turns slowness into failure. An analysis task, a probe of a Cellucid server and a request to a viewer host have no deadline of their own. What bounds a wait is its owner’s signal (Cancel, a newer choice, closing) or measured silence (startup steps, an opening’s stall note). A request to a notebook kernel counts as a wait on the person (
utils/person-wait.js): the kernel answers between cells. A kernel that goes away is said, not timed (data/viewer-embed.js,ended).One wire format, one notice (
utils/error-wire.js,utils/errors.js). Every worker answers a failed request withtoWire(error)and the page rebuilds it withfromWire, so a refused allocation stays aRangeError, a cancellation keeps its kind, and an explained failure keeps its words, code and cause. The surface that shows a failure marks it (markReported), and a caller further up skips a marked one (isReported): a gene that fails to load is said once, by its loading card. Background work nobody awaits (a kept journal’s removal) is observed withsettleQuietly(promise, doing).
A worker that never loaded (a page that outlived its deploy) is retired, and
its calls fail saying “reload the page”. A stray error in a live worker is
logged and fails none of the reads waiting on it. A page put in the
back/forward cache keeps its reader worker, so the person comes back to reads
still under way. Cellucid checks before startup that the browser has the
newest platform features it calls (BROWSER_FEATURES in
app/startup-failure.js: AbortSignal.any, which sets the floor at Chrome
116, Firefox 124 and Safari 17.4).
Troubleshooting: how to report a bug in a way that can be fixed#
If you are reporting an issue (even as a non-developer), include:
Environment
Browser + OS
Hosted (
https://www.cellucid.com) vs local app (http://localhost:8000) vs a Python/R viewer (notebook or browser tab)
Dataset identity
dataset id (and where it came from: local-user folder, remote server, GitHub repo/path)
Exact steps
“click X, then select Y, then move slider Z”
Expected vs actual
What did you think would happen?
What happened instead?
Console + network clues
Turn on debug:
localStorage.setItem('CELLUCID_DEBUG','true'); location.reload();Copy the first error stack trace and any failed network requests
Deep playbook: Debugging playbook.
Next: State (DataState) and events.