Skip to content

Fix the Status writer's tab race, reload gap and silent errors #492

Closed

Description

@HMarzban

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

The five faults

  1. Tab race. Tab A hides and writes OFFLINE 3 s later while tab B is visible. The row reads OFFLINE until B's next heartbeat, up to 60 s later. In an in-window tab switch, A's OFFLINE and B's ONLINE leave through equal 3 s debounces, so their order is not fixed. With side-by-side windows, B gets no show event at all.
  2. Reload gap. beforeunload writes OFFLINE, and the pending 3 s write dies. So sessionStorage.userLastStatus still reads 'ONLINE'. On remount the guard skips the ONLINE write. The first heartbeat comes 60 s later.
  3. The dedupe guard records a write before it runs. sessionStorage.setItem runs before the write (:23). A failed write still counts as done.
  4. Dead offline listener (:128). The global fetch rejects when !navigator.onLine (component.ts:24-27), so that write never leaves the browser.
  5. Silent errors. updateUser returns { error } (updateUser.ts:4-9). postgrest-js 2.117.3 catches fetch failures and also returns { error }, and it never retries a PATCH. So the catch blocks at :26 and :38 never 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 ONLINE loses 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 ONLINE window 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 OFFLINE only in rarer cases: side-by-side windows, a background tab close, and a beforeunload that does not unload.

Depends on

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, with user replaced by userId. Do not paste :81 or :97; the template already has the useEffect wrapper.
  • The unload body is today's :102-115, the keepalive fetch, with user.id replaced by userId. Keep the fetch call 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:

  • ONLINE and OFFLINE each have their own debounce. Show cancels the pending OFFLINE, and hide cancels the pending ONLINE. Without the cross-cancel, a show then a hide within 5 s races.
  • SHOW_DELAY must stay at least 2 s longer than HIDE_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 online event goes through the ONLINE debounce, and only when the tab is visible.
  • An offline skip writes nothing and logs nothing. The server row did not change.

Deletes:

  • the sessionStorage guard (faults 2 and 3)
  • the offline listener (fault 4)
  • both dead try/catch blocks
  • 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 OFFLINE at about +3 s, and B writes ONLINE at +5 s. The row reads OFFLINE for at most about 2 s. Today it can read OFFLINE for up to about 57 s when the OFFLINE write lands last.
  • Reload: the old page's keepalive OFFLINE lands at once. The new page writes ONLINE 5 s after the profile loads. Nothing guarantees this order, but the 5 s gap makes it hold in practice.
  • Reorder: an OFFLINE and a later ONLINE can still swap. In a tab switch, the OFFLINE request 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 ONLINE after 5 s, up from 3 s.
  • Background mount: a tab opened hidden writes nothing until it is shown. Today it writes ONLINE after 3 s.
  • Keyed on userId: a profile_changed broadcast 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 beforeunload that fires while the page stays open, for example the Report row's mailto: link (reportContent.ts:25), read from code only;
    • closing or reloading a background pad tab while another pad tab is visible: OFFLINE for 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 typecheck

Both 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:

  1. In-window tab switch. Open two pad tabs in one window. Switch from A to B after about 5 polls. Expected: OFFLINE for at most about 2 s, from about +3 s, then ONLINE from about +5 s to the end. Do this 5 times, because the old race was intermittent. Every run must pass.
  2. Reload. Reload a pad tab after about 5 polls. Expected: OFFLINE right after the reload, then ONLINE about 5 s after the pad loads. Pass: ONLINE within 15 s of the reload, and no OFFLINE after that. 15 s is a chosen threshold. Today the row stays OFFLINE until the first 60 s heartbeat.
  3. Hide and return. Hide a tab for 4 to 5 s, then return. Expected: ONLINE again about 5 s after return.
  4. Error log. In the hook's own updateUser call, change { status } to { statusx: status } as a temporary edit. Expected: one Status write ONLINE failed warn per failed write. Revert the edit. Do not break apps/webapp/src/api/users/updateUser.ts, because Settings shares it. DevTools Offline mode shows no warn, because of the navigator.onLine skip.

Add no Cypress test. Cypress drives one tab, and this is not dense logic (AGENTS.md §Test Policy).

Scale

Acceptance criteria

  • useHandleUserStatus.ts has no sessionStorage, no offline listener, no try/catch and no console.info.
  • ONLINE and OFFLINE use separate debounces, with SHOW_DELAY 5000 and HIDE_DELAY 3000. Each one cancels the other.
  • Both effects depend on [enabled, userId], not on profile.
  • A failed write logs Status write <status> failed once per failed write (browser check 4).
  • In 5 of 5 in-window tab switches, the row reads OFFLINE for at most about 2 s (browser check 1).
  • After a reload, the row reads ONLINE within 15 s and stays ONLINE (browser check 2).
  • apps/webapp/CLAUDE.md:221 no longer lists "offline".
  • The eslint and typecheck commands exit 0.

Out of scope


Generated by Claude Code

Activity

  1. added this to the v2.2.0 milestone on Oct 18, 2026
  2. HMarzban commented on Oct 19, 2026

    @HMarzban
    CollaboratorAuthor

    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.

    • Code items: pass. useHandleUserStatus.ts has no sessionStorage, no offline listener, no try/catch and no console.info. The debounces are separate: SHOW_DELAY 5000 and HIDE_DELAY 3000, and each cancels the other. The deps are [enabled, userId].
    • Check 1, tab switch, 5 of 5: pass. The OFFLINE gap was 1.07 to 1.23 s, and ONLINE was always the last write.
    • Check 2, reload: pass. The keepalive OFFLINE write left at +0.01 s, and ONLINE landed at +5.3 s and stayed for 40 s.
    • Check 3, hide 4.5 s and return: pass. OFFLINE landed at +3.2 s, and ONLINE 5.0 s after return.
    • Check 4, error log: pass. Every status PATCH was forced to a 400: 5 failed writes gave 5 warns, Status write … failed. Offline, no request was sent and no warn was logged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions