Debugging playbook#

This page is the “if it’s broken, do this” playbook for the Cellucid web app.

It is intentionally procedural: follow it top-to-bottom when you hit a bug, and you will usually end up with a minimal reproduction and the exact clues needed to fix it.

At a glance#

Audience

  • Wet lab / non-technical: use this to capture actionable bug reports.

  • Computational users: use this to diagnose data/loading/field issues quickly.

  • Developers: use this to debug state/rendering/persistence regressions.

Time

  • 5–10 minutes for most issues to get to a good bug report

  • 20–60 minutes for complex performance/memory issues


Step 1: Record the environment (always)#

Before you click anything else, write down:

  1. Where you ran Cellucid

    • Hosted: https://www.cellucid.com

    • Local app: http://localhost:8000 (your own cellucid/ checkout)

    • A viewer opened by Python (show_anndata(), in a notebook or a browser tab) or R (cellucid_view())

  2. Browser + OS

    • Browser name + version

    • OS name + version

  3. Dataset source

    • local-demo / local-user (and the file’s format) / remote server / link (which provider) / Python or R viewer

  4. Dataset identity

    • dataset id (uns['cellucid']['id'] in the file, or the id its route gives it; the Dataset box shows the name, not the id)

Why this matters:

  • the same bug can be caused by CORS in one environment and by caching in another.



Step 3: Identify the subsystem (pick one)#

Use the symptom list to choose your debugging path:

Symptom

Subsystem

Go to

Blank page / module load error

Boot / deployment

“Boot issues”

Dataset does not load / loads forever

Data loading

“Data loading issues”

Field selector empty / wrong colors

Fields + legends

“Fields/legend issues”

Filters don’t behave / count wrong

Filters + visibility

“Filtering issues”

Selection/highlights wrong

Highlights + tools

“Highlight issues”

Switching a view’s embedding breaks

Dimension manager + viewer positions

“Embedding switching issues”

WebGL context lost / slow

Rendering/GPU

“Rendering issues”

Session save/load wrong

Session serializer

“Session issues”

Undo misses a change, or a step reads “Changed outside the history”

Actions + history

“History issues”

Analysis panel broken

Analysis module

“Analysis issues”

Figure export broken

Figure export

“Export issues”

Community annotation broken

GitHub auth/sync

“Community annotation issues”


Boot issues (page won’t start)#

Symptom: “Module script failed to load”#

Likely causes (ordered):

  1. You opened via file:// instead of HTTP.

  2. Server sends wrong MIME type for .js.

  3. Deployed under a subpath and module URLs don’t resolve.

How to confirm:

  • DevTools → Console: look for the module load error.

  • DevTools → Network: click the failing .js request and check headers.

Fix:

Symptom: “WebGL2 is required but not supported”#

Likely causes:

  • WebGL disabled by browser or corporate policy

  • remote desktop environment without GPU support

Fix:

  • Try another browser/machine; confirm WebGL2 is enabled.


Data loading issues#

Symptom: “Dataset loads forever”#

Likely causes (ordered):

  1. CORS/mixed-content blocked a fetch.

  2. Wrong path, and the UI is waiting on a fetch that never completes.

  3. A large read on a slow host, or a file not written gene-major read over a slow link (the [cellucid:read] console lines show what is still reading).

How to confirm:

  • Read document.documentElement.dataset.startupPhase. Startup publishes the step it is in — sign-in, health, catalog, ui, dataset, positions, session, then ready — so a page that never finishes says where it stopped. The interface (ui) is built before the startup dataset loads; a dataset chosen in the data controls while it loads replaces it, and startup then publishes ready without it.

  • The request/response steps (sign-in, health, catalog and session, which reads the starting view a sample’s catalog names) each have a 60-second deadline. A server that never answers ends startup on the startup-failure page, naming the step and the URL it was waiting on. dataset (opening the startup dataset: its metadata, field lists and the embedding its first view draws) stalls only when nothing at all has arrived for 60 s; every request it makes is retried or fails on its own, naming its URL, and the error names the reads that were open. positions and ui have no deadline, because their time grows with the dataset; a slow but progressing load is not a stall.

  • Network tab: which request is pending/blocked?

  • Console: enable CELLUCID_DEBUG and look for the last “loading…” log.

Fix:

  • For a large cell-major (CSR) h5ad, the first gene waits for the gene index (watch the [cellucid:read] console lines); this is the same opened in the browser or served by cellucid-python.

  • For GitHub datasets, verify raw URLs are reachable.

  • For CORS, run the local app and connect from the same origin when possible.

Symptom: “Unexpected token <” during load#

Likely cause:

  • the server returned an HTML document for a JSON or binary request.

Fix:

  • Open the failed request URL in a new tab; verify it’s real JSON/binary and returns correct status codes.

Read the failure message before theorising#

Four distinct load failures used to report themselves identically. They no longer do, and the wording identifies the cause:

Message contains

Cause

Error code

“was cancelled”

An abort — re-thrown untouched, never relabelled

(the original AbortError)

“is longer than the longest text this browser can hold”

A metadata document exceeds the engine’s longest string

VALIDATION_ERROR

“response body transfer failed”

The connection dropped mid-body

NETWORK_ERROR

“must contain valid JSON”

Genuinely malformed content

INVALID_FORMAT

The connection and catalogue UI classifies these by error code and HTTP status, never by matching message text, so a message you rephrase will keep its user-facing sentence — but a code you get wrong will silently move the failure into the wrong bucket. Set the code deliberately.

Deep dive:


Fields/legend issues#

Symptom: “Field selector is empty”#

Likely causes (ordered):

  1. The file’s obs has no columns (or none Cellucid can show).

  2. The launch defaults list only some columns (uns['cellucid']['defaults']['obsKeys'], or cellucid serve --obs-key).

  3. You are in a snapshot view with a different view context.

How to confirm:

  • Read the file with anndata (adata.obs.columns).

  • Console: check window._cellucidState.pointCount and whether a field loader exists.

Fix:

  • Add the columns to obs and save the file again (or write it with prepare(obs=...)).

  • Ensure the correct dataset is open.

Symptom: “Colors look wrong / legend doesn’t match”#

Likely causes:

  • field change didn’t trigger a color recompute

  • colormap override or category rename/delete registry is involved

How to confirm:

  • Subscribe to field:changed events and see what fires when you change a field.

  • Inspect window._cellucidState.getActiveField() in console.

Fix:

  • Ensure state owns the recompute and calls viewer.updateColors(...).


Filtering issues#

Symptom: “Filter count changes but points don’t”#

Likely causes (ordered):

  1. transparency/visibility updated in state but not pushed to the viewer;

  2. it was pushed, but the alpha bytes did not change, so the upload was correctly skipped — the count you are reading is derived from something else;

  3. the change is below the visible-alpha threshold, so it rounds to the same byte.

How to confirm:

  • Put a breakpoint in viewer.updateTransparency and look at its return value: it reports whether the published alpha generation actually moved. false means the bytes were identical, and no upload was needed.

  • Check window._cellucidState.categoryTransparency length and values.

Fix:

  • Ensure the filter manager calls viewer.updateTransparency(...) (state→viewer sync is state-owned).

  • If the return value is false but you expected a change, the bug is upstream in how the alpha array was derived, not in the upload.


Highlight issues#

Symptom: “Highlighted cells disappear after filtering”#

Clarify expected behavior:

  • Highlights are stored independently of visibility; filtering hides points but does not remove them from highlight groups.

If behavior differs:

  • Confirm whether you are looking at “visible highlight count” vs “total highlight membership”.

How to confirm:

  • Check highlight pages and group membership in window._cellucidState.getHighlightPages().


Embedding switching issues#

Symptom: “The picker or key 1/2/3 changes, but not the embedding”#

Likely causes (ordered):

  1. The embedding is not turned on (Session › Data mapping), or the dimension has none.

  2. Fetch failed (network/CORS), or its values were refused (NaN, Infinity, no variation).

  3. Positions buffer length mismatch caused viewer to reject it.

  4. Another view was selected: the picker and keys edit the selected view.

How to confirm:

  • Network tab: embedding fetches.

  • Console: look for HighPerfRenderer updatePositions expected … floats, received ….

Fix:

  • Verify dataset identity + embeddings metadata.

  • Validate positions array length before calling viewer.updatePositions.


Rendering issues (GPU / performance)#

Symptom: “WebGL context lost”#

Likely causes:

  • GPU memory pressure, especially with smoke or overlays on large datasets, or a very large highlight group (highlight geometry costs bytes per selected cell per view).

How to confirm:

  • DevTools console logs mention context loss.

Fix:

  • Reduce dataset size and reproduce.

  • Disable smoke/velocity and re-test to isolate.

  • Clear highlights and re-test.

Deep dive:

Symptom: “Frames got slower and I cannot see why”#

Start from the baseline: an idle frame uploads nothing. If your change made frames slower, the most likely cause is that you put work on the draw path without a gate.

How to confirm:

  1. Open the Benchmark panel once, so the harness module is published on window._cellucidBenchmarkHarness.

  2. Build a harness against the live viewer and canvas. It shadows the GL context and counts, per frame: buffer allocations and bytes, sub-updates and bytes, texture uploads and bytes, draw calls, sync stalls, and getError / finish / flush / readPixels calls.

  3. Hold the camera still. Every upload counter should be zero. A non-zero count while idle is the bug. Two frames after the last change a static scene stops drawing entirely, so draw calls fall to zero as well; if they do not, something keeps invalidating the frame or reports an animation (see the frame gate in Rendering pipeline (WebGL) and performance notes).

  4. Then move the camera. Element-buffer uploads should appear only when the admitted node set changes, not on every frame.

Fix:

Note

The headline FPS and frame-time tiles in the benchmark panel are CPU wall-clock measurements between frames, so they include everything else the tab is doing. Only Analyze performance uses real GPU timer queries, and only where the driver provides the extension. Do not attribute a frame-time change to the GPU on the strength of the headline tiles alone.

Symptom: “A frame rate measurement is not reproducible”#

Check, in this order, that between the two runs you did not change: the window size, the layout (one view / two- or three-view row / 2×2 grid — these are three different workloads, not one curve), the filter state (hidden cells are rejected before rasterisation and cost nothing), or the machine’s background load. See Benchmarking methodology and metrics.


Session issues (save/load)#

Symptom: “Session loads but key state is missing”#

Likely causes:

  • the feature is intentionally not kept (a device preference, community annotation state, a selection not confirmed)

  • the state is not part of any state domain, so no step and no session holds it

  • the one awaited load is still reading its lazy chunks

  • the dataset identity, a missing field or a damaged file refused the load and rolled it back

How to confirm:

  • Require terminal Session fully restored.

  • Check the exact Console error and progress failure; there is no skip/partial mode.

  • In the console, compare what the domains hold now with what you expect: (await import('/assets/js/app/actions/index.js')).getStateDomains().owner('<domain>').capture().

Fix:

  • Confirm you loaded the same dataset id/fingerprint.

  • If the feature should persist, make it a state domain’s value (or part of one) with capture, restore and round-trip tests (Action layer, state domains and history, “How to add a piece of state”).

Deep dive:


History issues (undo, redo, steps)#

Symptom: “Undo does not put something back”#

Likely causes:

  • the action that changed it does not list that domain in touches, so the history did not capture it before and after

  • a control changed state without dispatching an action (a mutating state call from a UI module)

How to confirm:

  • window._cellucidHistories.current().active is the tracker in use; its steps map holds each step’s touches, before and after.

  • A step labelled Changed outside the history (type history.outside-change) names the domains that changed without an action; the console warns [history] <domains> changed outside an action.

  • The area’s browser spec proves touches (applyUndoRedo, tests/browser/helpers/state-history.mjs): run it for the action.

Fix:

  • Make the control dispatch the action (dispatchFromControl), or record its change by observation (recordObserved), and list every domain the handler can change in touches (Action layer, state domains and history, “How to add an action”).

Symptom: “An action is refused with ‘… is not available on this page yet’”#

The type has no implementation now (getActions().unprovided() lists them): its owner module has not started (a panel never opened, no dataset) or was torn down. Provide it where the owner starts and remove it where it ends.


Analysis issues#

Symptom: “Analysis panel is blank”#

Likely causes:

  • analysis init threw

  • Plotly load blocked

How to confirm:

  • Check window._comparisonModule exists.

  • Console error stack trace.

Deep dive:


Export issues#

Symptom: “SVG export freezes”#

Likely cause:

  • Full vector export on too many points.

Fix:

  • Use optimized-vector or hybrid.

Deep dive:


Community annotation issues#

Symptom: “Sign-in succeeds but repos list is empty”#

Likely causes:

  • GitHub App not installed on the org/repo

  • wrong user signed in (multiple accounts)

How to confirm:

  • In the UI: which GitHub user is shown?

  • Confirm app installation on the target repo owner.

Fix:

  • Install the GitHub App on the repo owner.

  • Reconnect and Pull.

Symptom: “Editing in two tabs causes disconnect”#

This is intentional:

  • a scope lock prevents silent data loss from concurrent edits.

How to confirm:

  • You’ll see a notification that the lock was lost.

Fix:

  • Close extra tabs for the same dataset/repo/user scope.

Symptom: “Pull warns that files were left out”#

This is intentional: a Pull leaves a file that fails the annotation contract out on its own (verdictOf in github-sync.js) instead of stopping, and keeps the reason in the file cache by blob id.

How to confirm:

  • The warning’s details list each file with its reason.

  • python scripts/validate_user_files.py in the annotation repository reports the same files.

Fix:

  • Fix or remove the files on GitHub; the next Pull reads only what changed.

Symptom: “… runs an older version (contract N) than this Cellucid”#

The deployed Worker’s root document names an older contract than WORKER_CONTRACT_VERSION (community-annotations/worker-contract.js). Deploy the Worker from the same source as the page (cellucid/docs/github-oauth-cloudflare-setup.md).

Developer references:

  • cellucid/assets/js/app/community-annotations/REPO_SETUP.md

  • cellucid/assets/js/app/community-annotations/scope-lock.js


Next: Testing, CI, and release process (how to validate changes before sharing).