Repository navigation
Fix the Status writer's tab race, reload gap and silent errors #492
Description
Summary
useHandleUserStatus writes users.status for the signed-in user. It has five faults: a tab race, a reload gap, the dedupe guard, a dead offline listener, and silent errors.
Step 1 fixes faults 2 to 5 and the in-window tab switch. It changes one hook and one doc line. It needs no SQL and no ruling, because it keeps today's hide write.
Problem
Code read at HEAD a192da752. The cited files are identical at db481158b.
Current code
- Mount.
AppProviders.tsx:45callsuseHandleUserStatus(documentShell). It is enabled only on/[...slugs],/editorand/c/[channelId](routePolicy.ts:9). - One debounce for both directions. Show and hide both go through one 3 s debounce (
useHandleUserStatus.ts:43-78). A 60 s interval writesONLINEwhile the tab is visible (:34-41,:58-61). - Dedupe guard.
:16-32skips a write whensessionStorage.userLastStatusalready equals it anduser.statusequals it.user.statusis always'ONLINE', becauseapplySignedInProfile.ts:32pins it. The client cannot readstatusback (Stop exposing every user's online status and last-active time #434). - Unload.
beforeunloadsends a keepalivePATCH {status:'OFFLINE'}to Supabase REST with the JWT held inaccessTokenRef(:80-116). - Effect deps. Every callback depends on the whole
profileobject (:31,:140-149). Aprofile_changedbroadcast callssetProfile(useProfileSync.ts:48). That re-runs the effect, and its cleanup cancels a pending hide write (:138).
The five faults
- Tab race. Tab A hides and writes
OFFLINE3 s later while tab B is visible. The row readsOFFLINEuntil B's next heartbeat, up to 60 s later. In an in-window tab switch, A'sOFFLINEand B'sONLINEleave through equal 3 s debounces, so their order is not fixed. With side-by-side windows, B gets no show event at all. - Reload gap.
beforeunloadwritesOFFLINE, and the pending 3 s write dies. SosessionStorage.userLastStatusstill reads'ONLINE'. On remount the guard skips theONLINEwrite. The first heartbeat comes 60 s later. - The dedupe guard records a write before it runs.
sessionStorage.setItemruns before the write (:23). A failed write still counts as done. - Dead
offlinelistener (:128). The global fetch rejects when!navigator.onLine(component.ts:24-27), so that write never leaves the browser. - Silent errors.
updateUserreturns{ error }(updateUser.ts:4-9). postgrest-js 2.117.3 catches fetch failures and also returns{ error }, and it never retries aPATCH. So thecatchblocks at:26and:38never run.
Who reads the flag
| Reader | Rule | After a hide, today and Step 1 |
|---|---|---|
Bell for plain messages (10-func-notifications.sql:383) |
raw flag; the lease after #490 | about 3 s |
Push (07-4-push-notifications-pgmq.sql:211-213) |
lease internal.is_user_online; an online receiver's push is dropped at insert, with no retry |
about 3 s |
Email (07-5-email-notifications-pgmq.sql:330) |
follows the bell row; 15 min delay | follows bell |
| Admin Online Now and Users badge | raw flag | about 3 s |
Mention and reply bell rows do not read status. No app code subscribes to postgres_changes on public.users (grep). Client Realtime presence keeps its own state and is not affected.
The lease is internal.is_user_online: status = 'ONLINE' AND online_at > now() - interval '2 minutes'. The cron job update-user-status runs */2 * * * * and sets OFFLINE where online_at < now() - interval '2 minutes'. Both were read from the local database.
These paths already leave by the lease today: sign-out (useSignOut.ts writes no status), a move to Home, and a frozen mobile or PWA page.
Decisions
None. Step 1 keeps today's hide write, so it changes no product rule.
A heartbeat-only writer (no tab writes OFFLINE) was considered and rejected; see #496 Rejected by the review. The two wrong states cost different things:
- A false
OFFLINE(today's tab race) gives a user at the screen an extra bell and push. - A false
ONLINEloses the notification. A plain message makes no bell row, push or email, and mention push is dropped at insert. - A heartbeat-only writer opens a false
ONLINEwindow of 60 to 120 s whenever the last visible tab hides. A switch to another app does this, so it is common. - Step 1 leaves a false
OFFLINEonly in rarer cases: side-by-side windows, a background tab close, and abeforeunloadthat does not unload.
Depends on
- Nothing must ship first. This issue can ship in any order with Stamp online_at only on ONLINE writes and clear users stuck ONLINE #489, Make the chat bell use the online lease instead of the raw status flag #490 and Make presence heartbeats HOT updates and drop the duplicate username index #491.
- Stop exposing every user's online status and last-active time #434 is shipped. Its closing comment says migration
20261009120500is applied in production.authenticatedkeepsUPDATEonusers.status, and Step 1 still writesstatus. - Stamp online_at only on ONLINE writes and clear users stuck ONLINE #489 makes the trigger stamp
online_atonly on anONLINEwrite. After Stamp online_at only on ONLINE writes and clear users stuck ONLINE #489, the Step 1 hide write no longer movesonline_at. - Use one client presence rule so the @ picker agrees with the avatars #493 says this issue owns
useHandleUserStatus.ts:21. Step 1 deletes that line. Thestatuspin stays, so Use one client presence rule so the @ picker agrees with the avatars #493 still holds.
Fix plan
Work on main. Read AGENTS.md, apps/webapp/CLAUDE.md and CONTEXT.md §Product HTTP first. Use Bun only.
Name the writer writeStatus. CONTEXT.md:100 lists updateUserStatus under Avoid. Do not touch the unrelated updateUserStatus in useBroadcastListener.ts:38.
Step 1. Fix the writer
Rewrite apps/webapp/src/hooks/useHandleUserStatus.ts to this shape. Two blocks come from today's file:
- The JWT effect body is today's
:82-96, withuserreplaced byuserId. Do not paste:81or:97; the template already has theuseEffectwrapper. - The unload body is today's
:102-115, the keepalivefetch, withuser.idreplaced byuserId. Keep thefetchcall exactly as it is.
import { updateUser } from '@api'
import { useAuthStore } from '@stores'
import { supabaseClient } from '@utils/supabase'
import debounce from 'lodash/debounce'
import { useEffect, useRef } from 'react'
const ONLINE = 'ONLINE'
const OFFLINE = 'OFFLINE'
const HEARTBEAT_INTERVAL = 60_000 // under the 2-minute lease in internal.is_user_online
const HIDE_DELAY = 3_000
// 2 s past HIDE_DELAY: a hidden tab's timers can fire about 1 s late, and the shown tab must write last.
const SHOW_DELAY = 5_000
export const useHandleUserStatus = (enabled = true) => {
const userId = useAuthStore((state) => state.profile?.id)
const accessTokenRef = useRef<string | null>(null)
// Hold the JWT so unload can start keepalive in the same turn.
useEffect(() => {
/* today's :82-96 (the effect body), with `user` replaced by `userId` */
}, [enabled, userId])
useEffect(() => {
if (!enabled || !userId) return
let heartbeat: ReturnType<typeof setInterval> | null = null
const writeStatus = async (status: typeof ONLINE | typeof OFFLINE) => {
if (!navigator.onLine) return // the global fetch rejects offline; skip to keep the log quiet
const { error } = await updateUser(userId, { status })
if (error) console.warn(`Status write ${status} failed`, error.message)
}
const showWrite = debounce(() => void writeStatus(ONLINE), SHOW_DELAY)
const hideWrite = debounce(() => void writeStatus(OFFLINE), HIDE_DELAY)
const stopHeartbeat = () => {
if (heartbeat) clearInterval(heartbeat)
heartbeat = null
}
const show = () => {
hideWrite.cancel()
showWrite()
stopHeartbeat()
heartbeat = setInterval(() => void writeStatus(ONLINE), HEARTBEAT_INTERVAL)
}
const hide = () => {
showWrite.cancel()
stopHeartbeat()
hideWrite()
}
const onVisibility = () => (document.visibilityState === 'visible' ? show() : hide())
const onOnline = () => {
if (document.visibilityState === 'visible') showWrite()
}
const handleUnload = () => {
showWrite.cancel()
hideWrite.cancel()
/* today's :102-115 keepalive PATCH, with userId */
}
if (document.visibilityState === 'visible') show()
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('online', onOnline)
window.addEventListener('beforeunload', handleUnload)
return () => {
stopHeartbeat()
showWrite.cancel()
hideWrite.cancel()
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('online', onOnline)
window.removeEventListener('beforeunload', handleUnload)
}
}, [enabled, userId])
}The rules this shape must keep:
ONLINEandOFFLINEeach have their own debounce. Show cancels the pendingOFFLINE, and hide cancels the pendingONLINE. Without the cross-cancel, a show then a hide within 5 s races.SHOW_DELAYmust stay at least 2 s longer thanHIDE_DELAY. Browsers throttle timers in a hidden tab, so the hide write can fire about 1 s late. This is read from browser behaviour; browser check 1 measures it.- The 60 s interval calls
writeStatus(ONLINE)directly, not through the debounce. It starts on show and stops on hide. - The
onlineevent goes through theONLINEdebounce, and only when the tab is visible. - An offline skip writes nothing and logs nothing. The server row did not change.
Deletes:
- the
sessionStorageguard (faults 2 and 3) - the
offlinelistener (fault 4) - both dead
try/catchblocks - the
'Updated user status'console.info - the
useAuthStore ?guard at:12
Adds: one console.warn on { error } (fault 5). PostgREST returns no error when RLS filters an UPDATE to 0 rows. So the warn covers transport and permission failures only. Do not add count.
Behaviour changes to state in the PR:
- In-window tab switch: A writes
OFFLINEat about +3 s, and B writesONLINEat +5 s. The row readsOFFLINEfor at most about 2 s. Today it can readOFFLINEfor up to about 57 s when theOFFLINEwrite lands last. - Reload: the old page's keepalive
OFFLINElands at once. The new page writesONLINE5 s after the profile loads. Nothing guarantees this order, but the 5 s gap makes it hold in practice. - Reorder: an
OFFLINEand a laterONLINEcan still swap. In a tab switch, theOFFLINErequest must stay in flight for more than about 1 s. In one tab, it must stay in flight for more than 5 s. - Return latency: a tab that shows writes
ONLINEafter 5 s, up from 3 s. - Background mount: a tab opened hidden writes nothing until it is shown. Today it writes
ONLINEafter 3 s. - Keyed on
userId: aprofile_changedbroadcast no longer re-runs the effect, so a profile save no longer cancels a pending hide write. - Not fixed in Step 1:
- side-by-side windows, where B gets no show event;
- a
beforeunloadthat fires while the page stays open, for example the Report row'smailto:link (reportContent.ts:25), read from code only; - closing or reloading a background pad tab while another pad tab is visible:
OFFLINEfor up to 60 s.
Doc:
apps/webapp/CLAUDE.md:221: change "Heartbeat, visibility, online, and offline stay" to "Heartbeat, visibility, and online stay".
Check:
bunx eslint --max-warnings=0 apps/webapp/src/hooks/useHandleUserStatus.ts
bun run --filter @docs.plus/webapp typecheckBoth must exit 0. Do not run next build while next dev is running.
Rollout
PR, then app deploy. No SQL. Old tabs keep today's writer until they reload, which is safe.
Rollback
Revert the webapp commit and redeploy. There is no data and no SQL to undo.
Verify
Run the app with make dev-local from the repo root. It runs next dev on http://localhost:3000.
Sign in from Home at http://localhost:3000 with an email link, not from a pad URL; a magic link to a pad drops the auth hash. Read the link in the local mail catcher at http://localhost:54324. Then open a pad.
Find your user id, then poll the row once a second. N is the number of polls:
docker exec supabase_db_docsplus_supabase psql -U postgres -d postgres -Atc "select id, email from auth.users order by last_sign_in_at desc nulls last limit 3;"
USER_ID=<your id>
poll() { for i in $(seq 1 "$1"); do docker exec supabase_db_docsplus_supabase psql -U postgres -d postgres -Atc "select now()::time(0), status, online_at::time(0), internal.is_user_online(id) from public.users where id = '$USER_ID';"; sleep 1; done; }Step 1. Start a new poll 30 run for each check:
- In-window tab switch. Open two pad tabs in one window. Switch from A to B after about 5 polls. Expected:
OFFLINEfor at most about 2 s, from about +3 s, thenONLINEfrom about +5 s to the end. Do this 5 times, because the old race was intermittent. Every run must pass. - Reload. Reload a pad tab after about 5 polls. Expected:
OFFLINEright after the reload, thenONLINEabout 5 s after the pad loads. Pass:ONLINEwithin 15 s of the reload, and noOFFLINEafter that. 15 s is a chosen threshold. Today the row staysOFFLINEuntil the first 60 s heartbeat. - Hide and return. Hide a tab for 4 to 5 s, then return. Expected:
ONLINEagain about 5 s after return. - Error log. In the hook's own
updateUsercall, change{ status }to{ statusx: status }as a temporary edit. Expected: oneStatus write ONLINE failedwarn per failed write. Revert the edit. Do not breakapps/webapp/src/api/users/updateUser.ts, because Settings shares it. DevTools Offline mode shows no warn, because of thenavigator.onLineskip.
Add no Cypress test. Cypress drives one tab, and this is not dense logic (AGENTS.md §Test Policy).
Scale
- About 181 users in production (2026-09-28 Supabase review). The hook sends about one heartbeat per visible tab per minute. Writes stay negligible to 1000x; the cost per heartbeat is Make presence heartbeats HOT updates and drop the duplicate username index #491's subject.
- A heartbeat fires no
profile_changedbroadcast and no Realtime change. Measured locally: the only update triggers aretrigger_update_user_online_atandusers_profile_changed, and the second skipsstatus. Stop exposing every user's online status and last-active time #434 tookpublic.usersout ofsupabase_realtime. - Step 1 writes about as often as today. It adds at most one write per hide shorter than 3 s, which the old guard skipped.
Acceptance criteria
-
useHandleUserStatus.tshas nosessionStorage, noofflinelistener, notry/catchand noconsole.info. -
ONLINEandOFFLINEuse separate debounces, withSHOW_DELAY5000 andHIDE_DELAY3000. Each one cancels the other. - Both effects depend on
[enabled, userId], not onprofile. - A failed write logs
Status write <status> failedonce per failed write (browser check 4). - In 5 of 5 in-window tab switches, the row reads
OFFLINEfor at most about 2 s (browser check 1). - After a reload, the row reads
ONLINEwithin 15 s and staysONLINE(browser check 2). -
apps/webapp/CLAUDE.md:221no longer lists "offline". - The
eslintandtypecheckcommands exit 0.
Out of scope
- The bell reader and the one lease rule. Make the chat bell use the online lease instead of the raw status flag #490 owns them.
- Admin Online Now and the Users badge. Make the chat bell use the online lease instead of the raw status flag #490 Decision 1 owns them.
- The
online_atstamp and the one-time data fix. Stamp online_at only on ONLINE writes and clear users stuck ONLINE #489 owns them. - Making heartbeats HOT. Make presence heartbeats HOT updates and drop the duplicate username index #491 owns it.
- The client presence rule (
isFacePresent). Use one client presence rule so the @ picker agrees with the avatars #493 owns it. - Removing the
statuspin atapplySignedInProfile.ts:32. It needs a full trace of the self-profile readers. Measure workspace presence join fan-out before changing it #495 Out of scope notes the 11-key tracked object it feeds. - A
pagehideOFFLINEwrite. Mobile already leaves by the lease, and it would add tab-race writes. - A heartbeat-only writer and a server Status writer. The review rejected both; see [Tracking] Stuck-Online users and the presence review follow-ups #496. Status is not a Hono route (
apps/hocuspocus.server/CLAUDE.md:88). - Idle detection for a visible tab. A visible but idle tab stays
ONLINE, today and after Step 1. - A per-user write floor on the server. It needs an RPC, because the client cannot read
online_atafter Stop exposing every user's online status and last-active time #434. Revisit at 1000x.
Generated by Claude Code
Activity
refactor(webapp): make the presence room the only presence writer
HMarzban commentedon Oct 19, 2026
Browser checks, 2026-10-19 (local stack)
Run in Chrome for Testing over raw CDP, because Playwright reports every tab as visible. Evidence came from a 500 ms read-only DB poll, the CDP network log and the console.
useHandleUserStatus.tshas nosessionStorage, noofflinelistener, notry/catchand noconsole.info. The debounces are separate:SHOW_DELAY5000 andHIDE_DELAY3000, and each cancels the other. The deps are[enabled, userId].Status write … failed. Offline, no request was sent and no warn was logged.