Skip to content

Resync the sidebar after a mobile background gap - #3997

Merged
ymichael merged 1 commit into
mainfrom
bb/fix-mobile-sidebar-synchronization-thr_bgbu5hcqys
Sep 21, 2026
Merged

ymichael merged 1 commit into
mainfrom
bb/fix-mobile-sidebar-synchronization-thr_bgbu5hcqys

Conversation

@ymichael

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

Backgrounding the app on a phone kills the realtime socket. Recovery runs through invalidateRealtimeQueriesAfterServerReconnect, which refetches any realtime-owned query whose dataUpdatedAt predates disconnectedAt. Two independent defects stopped that from happening, so a thread that finished while the app was backgrounded kept spinning in the sidebar indefinitely.

setQueryData always stamps dataUpdatedAt with the write time, so the local cache patches that realtime pushes apply (#2169) moved the sidebar's watermark forward. disconnectedAt is always pinned at or before the moment the page resumes — the app cannot notice the drop while suspended — while the hidden-change flush stamps the sidebar at resume time. The strict comparison therefore always failed and the catch-up fetch was skipped. The skip required that patches landed during the gap, which required that a thread was running: the wave was suppressed precisely when it was needed.

Separately, probeOrReconnect took ~30s rather than 5s to notice the dead socket. sendPing skips the probe when a frame arrived within REALTIME_PONG_TIMEOUT_MS, but lastServerActivityAt records when the app read the bytes, not when they crossed the network. On resume the OS delivers everything it buffered during the freeze, so the guard saw fresh activity from stale data and suppressed the very probe that exists for that transition, deferring detection to the next 25s interval tick.

What changed

cache-owners/cache-effect-utils.ts: patchCachedQueryData writes cache patches without advancing dataUpdatedAt, keeping that timestamp meaning "when this data last came from the server" as the reconnect watermark assumes. query-cache.ts, thread-list-cache-data.ts and project-cache-owner.ts route the sidebar navigation, thread list and timeline patch helpers through it, which covers the realtime and optimistic-mutation writers that funnel through them. Status pushes still patch one row instead of refetching the bootstrap document, so #2169's optimization is unaffected.

lib/ws.ts: probeOrReconnect passes ignoreRecentActivity, since frame recency is only evidence of liveness if the page was awake for that window. The 25s interval keeps the guard.

lib/ws.ts: only a pong now clears an outstanding probe. Frames are ordered on one connection, so a frame the server sent after our ping cannot overtake the pong; a non-pong frame arriving with the probe still outstanding was therefore sent before the ping landed and proves nothing about the socket now. The server answers every ping unconditionally (ws/client-protocol.ts) and serves the app bundle, so the two cannot be version-skewed. This narrows the "any inbound frame is liveness" rule from #1883 to the probe window and updates its test accordingly. Without it, a straggler buffered frame cancels the reconnect and detection falls back to ~30s, which is browser-ordering-dependent and would have made the probe fix land only about half the time.

No wire change: HOST_DAEMON_PROTOCOL_VERSION is untouched, and no CLI or configuration surface is affected.

Perf notes, since both mechanisms here were tuned for mobile foreground cost in #1883 and #2435: the added traffic is one GET /api/v1/sidebar-bootstrap per reconnect where realtime activity happened during the gap — the fetch the design always intended and the one that corrects the stale row — plus one ping frame per foreground. cancelRefetch: false and the default refetchType: 'active' are untouched, so in-flight downloads still are not aborted and inactive queries are still marked stale without fetching. Preserving dataUpdatedAt cannot add sidebar refetches because that query is staleTime: Infinity; the queries where it could are the thread list (10s, mounted only for the forks panel) and the timeline (2s, effectively always stale), and focus refetches are gated on the socket being disconnected.

How you verified

  • pnpm exec turbo run typecheck lint test --filter=@bb/app: 552 files, 4959 tests pass, 0 lint errors (191 pre-existing warnings in untouched files).
  • Three regression tests, each confirmed failing before its fix: a hidden flush followed by a reconnect still invalidates the sidebar (realtime-cache-effects.test.ts); the resume probe fires despite buffered frames (ws.test.ts); a stale frame does not answer a probe (ws.test.ts).
  • Not verified on a real device. The ~30s-to-~5s detection improvement and the browser task ordering behind the third change are reasoned from the code and the ping/pong protocol, not observed in iOS Safari.

Fixes #

AGENT GENERATED

🤖 Generated with Claude Code

## Root cause

Backgrounding the app on a phone kills the realtime socket. Recovery
runs through `invalidateRealtimeQueriesAfterServerReconnect`, which
refetches any realtime-owned query whose `dataUpdatedAt` predates
`disconnectedAt`. Two independent defects stopped that from happening.

`setQueryData` always stamps `dataUpdatedAt` with the write time, so the
local cache patches that realtime pushes apply moved the sidebar's
watermark forward. `disconnectedAt` is always pinned at or before the
moment the page resumes, while the hidden-change flush stamps the
sidebar at resume time, so the strict comparison always failed and the
catch-up fetch was skipped. The skip required that patches landed during
the gap, which required that a thread was running: the wave was
suppressed precisely when it was needed. The sidebar kept rendering the
last status it received before the socket died.

`probeOrReconnect` then took ~30s rather than 5s to notice the dead
socket. `sendPing` skips the probe when a frame arrived within
`REALTIME_PONG_TIMEOUT_MS`, but `lastServerActivityAt` records when the
app read the bytes, not when they crossed the network. On resume the OS
delivers everything it buffered during the freeze, so the guard saw
fresh activity from stale data and suppressed the probe that exists for
that transition, deferring detection to the next 25s interval tick.

## Change

`patchCachedQueryData` writes cache patches without advancing
`dataUpdatedAt`, keeping that timestamp meaning "when this data last
came from the server" as the reconnect watermark assumes. The sidebar
navigation, thread list and timeline patch helpers use it, which covers
the realtime and optimistic-mutation writers that funnel through them.
Status pushes still patch one row instead of refetching the bootstrap
document, so #2169 is unaffected.

`probeOrReconnect` passes `ignoreRecentActivity`, since frame recency is
only evidence of liveness if the page was awake for that window. The
25s interval keeps the guard.

Only a `pong` now clears an outstanding probe. Frames are ordered on one
connection, so a frame the server sent after our ping cannot overtake
the pong; a non-pong frame arriving with the probe still outstanding was
therefore sent before the ping landed and proves nothing about the
socket now. The server answers every ping unconditionally
(`client-protocol.ts`), and it serves the app bundle, so the two cannot
be version-skewed. This narrows the "any inbound frame is liveness" rule
from #1883 to the probe window and its test is updated accordingly;
without it a straggler buffered frame cancels the reconnect and
detection falls back to ~30s.

## Verification

- `pnpm exec turbo run typecheck lint test --filter=@bb/app`: 552 files,
  4959 tests pass, 0 lint errors (191 pre-existing warnings in untouched
  files).
- Three regression tests, each confirmed failing before its fix: a
  hidden flush followed by a reconnect still invalidates the sidebar; the
  resume probe fires despite buffered frames; a stale frame does not
  answer a probe.

> AGENT GENERATED

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ymichael
ymichael merged commit 31db0a7 into main Sep 21, 2026
16 checks passed
@ymichael
ymichael deleted the bb/fix-mobile-sidebar-synchronization-thr_bgbu5hcqys branch September 21, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant