Fix push notifications permanently suppressed by silent background reloads

Root cause of "still not getting push notifications while backgrounded"
despite #31: joinRoom() was made to send unconditionally on the theory
that opening a room always implies genuine visibility (a real click
can't happen on a truly hidden tab). That's wrong for one real case --
mobile Chrome can silently discard and later reload a long-backgrounded
tab from memory, which re-mounts the room and calls joinRoom() again
with nobody actually looking at the screen. Each such reload re-joined
the room's presence with no matching "leave" (a discard skips normal
unmount cleanup), so a room could accumulate a stuck presence entry
that permanently suppressed push notifications for it -- confirmed
live via a user's server logs (repeated silent WS reconnects, and
their account still showing present in the room's Redis presence hash
while genuinely backgrounded).

joinRoom() and the reconnect replay now check document.visibilityState
live instead of trusting a cached ref or sending unconditionally: a
still-hidden reload correctly stays "left" (the room stays in
desiredRoomsRef, so the next genuine foreground transition still joins
it, just deferred instead of wrongly immediate), while a real
user-driven open still joins immediately as before.

Verified both directions in the browser: mounting a room while
genuinely hidden leaves the room's presence hash empty; a subsequent
real visibility transition to visible correctly triggers the deferred
join.

Note: this prevents new stuck entries but doesn't retroactively clear
any that already exist -- an affected user needs one real close
(not just backgrounding) to send a clean disconnect and reset the
stuck refcount.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 19:14:05 -06:00
co-authored by Claude Sonnet 5
parent 85dc83f4e5
commit 2cd44dc3f7
+20 -15
View File
@@ -60,9 +60,11 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
setConnected(true) setConnected(true)
// Re-join whatever rooms were joined before a reconnect -- the // Re-join whatever rooms were joined before a reconnect -- the
// server has no memory of a dropped connection's prior state. Only // server has no memory of a dropped connection's prior state. Only
// while visible: reconnecting from a backgrounded tab should stay // while visible (checked live, not from the ref -- see joinRoom's
// comment): reconnecting from a backgrounded tab should stay
// "left" for the same reason backgrounding leaves in the first // "left" for the same reason backgrounding leaves in the first
// place (see desiredRoomsRef's comment above). // place (see desiredRoomsRef's comment above).
isVisibleRef.current = document.visibilityState === 'visible'
if (isVisibleRef.current) { if (isVisibleRef.current) {
for (const roomId of desiredRoomsRef.current) { for (const roomId of desiredRoomsRef.current) {
sendRoomFrame('join', roomId) sendRoomFrame('join', roomId)
@@ -135,20 +137,23 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
const joinRoom = useCallback( const joinRoom = useCallback(
(roomId: string) => { (roomId: string) => {
desiredRoomsRef.current.add(roomId) desiredRoomsRef.current.add(roomId)
// Unconditional, not gated on isVisibleRef: this fires from a // Reads the live API, not a cached ref: a room can "mount" (calling
// component actually mounting (opening a room in the UI), which by // this) without genuine user interaction -- mobile Chrome can
// definition only happens while the user is interacting with the // silently discard and later reload a long-backgrounded tab from
// page -- a genuinely backgrounded tab can't run the click handler // memory, which re-runs this exact effect with nobody looking at the
// that leads here in the first place. Gating this too (rather than // screen. An earlier version of this trusted isVisibleRef and/or
// only the automatic hide/show transitions below) meant a stale or // sent unconditionally on the theory that "you can't click into a
// momentarily-wrong visibilityState at mount time could silently // room while hidden" -- true for a real click, not true for a silent
// skip the join entirely, with nothing to ever retry it. Also // background reload, which re-joined the room's presence on every
// self-corrects isVisibleRef -- opening a room this way is itself // such reload with no matching "leave" (the discard skips normal
// stronger evidence of visibility than whatever the ref currently // unmount cleanup), permanently suppressing push notifications for
// holds, so a wrong/stale `false` doesn't also skip replaying this // that room until the tab was genuinely reopened. Checking fresh
// join on a later reconnect (which does still check the ref). // here means a still-hidden reload correctly stays "left" -- the
isVisibleRef.current = true // room stays in desiredRoomsRef regardless, so the next genuine
sendRoomFrame('join', roomId) // foreground transition (handleVisibilityChange below) still joins
// it, just deferred instead of wrongly immediate.
isVisibleRef.current = document.visibilityState === 'visible'
if (isVisibleRef.current) sendRoomFrame('join', roomId)
}, },
[sendRoomFrame], [sendRoomFrame],
) )