Repository navigation
Resync the sidebar after a mobile background gap - #3997
Merged
ymichael merged 1 commit intoSep 21, 2026
Merged
Conversation
## 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
deleted the
bb/fix-mobile-sidebar-synchronization-thr_bgbu5hcqys
branch
September 21, 2026 18:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 whosedataUpdatedAtpredatesdisconnectedAt. Two independent defects stopped that from happening, so a thread that finished while the app was backgrounded kept spinning in the sidebar indefinitely.setQueryDataalways stampsdataUpdatedAtwith the write time, so the local cache patches that realtime pushes apply (#2169) moved the sidebar's watermark forward.disconnectedAtis 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,
probeOrReconnecttook ~30s rather than 5s to notice the dead socket.sendPingskips the probe when a frame arrived withinREALTIME_PONG_TIMEOUT_MS, butlastServerActivityAtrecords 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:patchCachedQueryDatawrites cache patches without advancingdataUpdatedAt, keeping that timestamp meaning "when this data last came from the server" as the reconnect watermark assumes.query-cache.ts,thread-list-cache-data.tsandproject-cache-owner.tsroute 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:probeOrReconnectpassesignoreRecentActivity, 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 apongnow 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_VERSIONis 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-bootstrapper 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: falseand the defaultrefetchType: 'active'are untouched, so in-flight downloads still are not aborted and inactive queries are still marked stale without fetching. PreservingdataUpdatedAtcannot add sidebar refetches because that query isstaleTime: 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).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).Fixes #
🤖 Generated with Claude Code