Troubleshooting
Concrete symptoms, the actual cause, and the fix. Every entry here is a fault that has really occurred, not a hypothetical.
Black screen after opening a page #
Symptom. The page loads but stays black. The console shows Uncaught SyntaxError: Unexpected token pointing at a file under /assets/.
Cause. The service worker served a cached bundle that no longer matches the current build. Because /assets/ is cache-first, a stale copy can persist indefinitely.
Fix. The app now recovers automatically: the error boundary purges caches and reloads once per build. If that attempt is already spent you will see a Refresh required prompt with a Refresh now button.
Your data is safe during recoverycaches.delete() only clears HTTP responses. It cannot touch localStorage or IndexedDB, so your session and study data are untouched. You will not be signed out.
To clear it by hand:
// DevTools console await caches.keys().then(k => Promise.all(k.map(n => caches.delete(n)))); location.reload();
Fix applied but the browser still shows old behaviour #
Cause. Same root as above. The cache name now includes a build token derived from VERSION plus the server.mjs modification time, so editing a patch rotates the cache automatically. If you are behind that change, one manual clear is needed.
Verify the browser and server agree:
# server-side: the cache name the server expects curl -s http://127.0.0.1:3000/api/version | python3 -c 'import sys,json;print(json.load(sys.stdin)["pwa_cache"])' # browser-side: what is actually cached # DevTools → Application → Cache Storage
If they differ, the client is stale.
A feature silently does nothing #
Cause. A serve-time patch failed to match. The server rewrites minified bundles using exact string anchors; when an anchor misses, the bundle is served unpatched and whatever that patch added is simply absent.
Diagnose. A healthy start logs about 52 patch lines and zero failures:
bash bin/isotope restart && sleep 6 # any output here is a problem grep -iE 'anchor not found|String not found' ~/.isotope/logs/server.log # what did apply grep -E '^\[[A-Za-z]+(Patch|CrashFix|KeyboardFix)' ~/.isotope/logs/server.log | sort -u
Failures are also surfaced in the UIMissed anchors are collected and rendered as a banner in the served HTML, so a silent mismatch cannot hide indefinitely.
Leaderboard is empty #
Cause. A leaderboard must read other users stats, which needs a public SELECT policy on user_stats_summary and daily_user_stats. An older stats_own policy used FOR ALL, which also blocked that read.
Fix. Run leaderboard-rls-fix.sql in the Supabase SQL editor. Fresh installs already have the corrected policy.
Confirm the policies exist:
select tablename, policyname, cmd
from pg_policies
where schemaname = 'public'
and tablename in ('user_stats_summary','daily_user_stats')
order by tablename, policyname;
-- expect stats_read_all / daily_read_all with cmd = SELECT
Update button returns 403 #
Cause. The request did not arrive over loopback and carried no admin cookie. Most often the browser is on a different device reaching the server across the network.
Fix. Either open the app on the same machine as the server, or run isotope update in a terminal.
# what the server thinks curl -s http://127.0.0.1:3000/api/update-status | python3 -m json.tool
authorized: false means neither condition was met. admin_available: false additionally means admin unlock is not an option on this install.
Update returns 409 confirmation_required #
This is intentional. isotope update runs git pull, which auto-stashes local modifications. The gate exists so that never happens silently.
Commit your work, or accept the stash and recover afterwards:
git stash list # your work is here git stash pop # bring it back
Mobile keyboard closes while typing #
Cause. A React focus bug in the community modal. Its focus-trap effect depended on onClose, and every call site passed an inline arrow — so the effect re-ran on every keystroke and its cleanup moved focus off the input. Android dismisses the keyboard on blur.
Fix. Already patched at serve time. If you see it, your bundle is stale — see the stale bundle section.
Opening a group shows an error #
Two distinct causes with the same appearance.
| Error | Cause and fix |
|---|---|
Could not choose the best candidate function | Two overloads of community_get_group exist — one taking text, one taking uuid. PostgREST cannot disambiguate. Drop the text version. |
Cannot read properties of undefined (reading 'reduce') | The RPC returned no members array. Apply community-patch-v6.sql. |
-- find duplicate overloads select p.proname, pg_get_function_identity_arguments(p.oid) as args from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'community_get_group'; -- remove the text variant if both exist drop function if exists public.community_get_group(p_group_id text, p_period text);
Port already in use #
# who holds 3000 isotope status # stop the managed server isotope stop # or run on another port PORT=4000 isotope start
Server stops when switching apps on Android #
Cause. Android kills background processes. This is OS behaviour, not a bug.
Fix.
- Acquire a wake lock from the Termux notification.
- Disable battery optimisation for Termux in Android settings.
- Keep Termux in recents rather than swiping it away.
Sync is stuck or not running #
Most likely cause. The sync engine is auth-blocked. When a JWT cannot be refreshed it stops retrying deliberately — retrying with a dead token cannot succeed.
Fix. Sign out and back in. Recovery is triggered by obtaining a valid session, not by waiting.
npm run test:supabase-sync
Data missing after reinstall #
Check the cloud copy before assuming loss. Sync selects by richness before recency, so an empty snapshot cannot overwrite real data.
# richest available backup, not merely the newest curl -s http://127.0.0.1:3000/__auth/backup/best \ -H "Authorization: Bearer $USER_JWT" | python3 -m json.tool | head -40
Admin mode also exposes /__admin/sync, which shows per user which backup would win and why.
Google sign-in does nothing, or is missing #
The button is not there at all. That is the default.
server.mjs hides it (hideGoogleUI) because Google will not
accept http://127.0.0.1:3000 as an authorised origin, so an unconfigured
button would fail on click. It reappears once you complete
Supabase setup → Google sign-in and
remove the hiding function.
The button is there and the popup returns you signed out. Supabase finished the code exchange and then refused to redirect back, because your local origin is not in its allow-list. Add both forms — they are different origins to a browser:
Supabase → Authentication → URL Configuration → Redirect URLs http://127.0.0.1:3000/dashboard http://localhost:3000/dashboard
Exact paths, not patterns. Supabase does accept ** wildcards, but it
recommends exact URLs outside preview-deployment use — and Google, on the other side of the
same flow, rejects wildcards entirely. Listing the literal URL keeps both happy.
redirect_uri_mismatch from Google. The URI in Google
Cloud must be your Supabase callback, not your local address, with no trailing
slash:
https://<project-ref>.supabase.co/auth/v1/callback
You land on /dashboard and bounce straight back to
/auth. The session was written under a different project's
storage key than the one the guard reads. Check which key exists:
// DevTools console Object.keys(localStorage).filter(k => /-auth-token$/.test(k))
The key is sb-<project-ref>-auth-token, and the ref must match
SUPABASE_URL in .env. If it does not, the app is talking to
one project while the session came from another — usually left over from a project
migration.
Reaching the app over your LAN? That exact origin needs whitelisting too. If the device's IP changes with the network, the allow-list needs updating each time — which is a practical reason to stay with email sign-in on a phone-hosted install.
Reading the logs #
isotope logs # last 80 lines, secrets redacted tail -f ~/.isotope/logs/server.log # follow tail -20 ~/.isotope/logs/browser-errors.log # errors posted by the browser isotope doctor # full diagnostic