An ATproto social media client -- with an independent Appview.

Don't kick to login screen on network error (#4911)

* Don't kick the user on network errors

* Track online status for RQ

* Use health endpoint

* Update test with new behavior

* Only poll while offline

* Handle races between the check and network events

* Reduce the poll kickoff interval

* Don't cache partially fetched pinned feeds

This isn't a new issue but it's more prominent with the offline handling. We're currently silently caching pinned infos that failed to fetch. This avoids showing a big spinner on failure but it also kills all feeds which is very confusing. If the request to get feed gens fails, let's fail the whole query.

Then it can be retried.

authored by danabra.mov and committed by

GitHub 57be2ea1 7e11b862

+117 -14
+66 -1
src/lib/react-query.tsx
··· 2 2 import {AppState, AppStateStatus} from 'react-native' 3 3 import AsyncStorage from '@react-native-async-storage/async-storage' 4 4 import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' 5 - import {focusManager, QueryClient} from '@tanstack/react-query' 5 + import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query' 6 6 import { 7 7 PersistQueryClientProvider, 8 8 PersistQueryClientProviderProps, 9 9 } from '@tanstack/react-query-persist-client' 10 10 11 11 import {isNative} from '#/platform/detection' 12 + import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' 12 13 13 14 // any query keys in this array will be persisted to AsyncStorage 14 15 export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' 15 16 const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot] 17 + 18 + async function checkIsOnline(): Promise<boolean> { 19 + try { 20 + const controller = new AbortController() 21 + setTimeout(() => { 22 + controller.abort() 23 + }, 15e3) 24 + const res = await fetch('https://public.api.bsky.app/xrpc/_health', { 25 + cache: 'no-store', 26 + signal: controller.signal, 27 + }) 28 + const json = await res.json() 29 + if (json.version) { 30 + return true 31 + } else { 32 + return false 33 + } 34 + } catch (e) { 35 + return false 36 + } 37 + } 38 + 39 + let receivedNetworkLost = false 40 + let receivedNetworkConfirmed = false 41 + let isNetworkStateUnclear = false 42 + 43 + listenNetworkLost(() => { 44 + receivedNetworkLost = true 45 + onlineManager.setOnline(false) 46 + }) 47 + 48 + listenNetworkConfirmed(() => { 49 + receivedNetworkConfirmed = true 50 + onlineManager.setOnline(true) 51 + }) 52 + 53 + let checkPromise: Promise<void> | undefined 54 + function checkIsOnlineIfNeeded() { 55 + if (checkPromise) { 56 + return 57 + } 58 + receivedNetworkLost = false 59 + receivedNetworkConfirmed = false 60 + checkPromise = checkIsOnline().then(nextIsOnline => { 61 + checkPromise = undefined 62 + if (nextIsOnline && receivedNetworkLost) { 63 + isNetworkStateUnclear = true 64 + } 65 + if (!nextIsOnline && receivedNetworkConfirmed) { 66 + isNetworkStateUnclear = true 67 + } 68 + if (!isNetworkStateUnclear) { 69 + onlineManager.setOnline(nextIsOnline) 70 + } 71 + }) 72 + } 73 + 74 + setInterval(() => { 75 + if (AppState.currentState === 'active') { 76 + if (!onlineManager.isOnline() || isNetworkStateUnclear) { 77 + checkIsOnlineIfNeeded() 78 + } 79 + } 80 + }, 2000) 16 81 17 82 focusManager.setEventListener(onFocus => { 18 83 if (isNative) {
+16
src/state/events.ts
··· 22 22 return () => emitter.off('session-dropped', fn) 23 23 } 24 24 25 + export function emitNetworkConfirmed() { 26 + emitter.emit('network-confirmed') 27 + } 28 + export function listenNetworkConfirmed(fn: () => void): UnlistenFn { 29 + emitter.on('network-confirmed', fn) 30 + return () => emitter.off('network-confirmed', fn) 31 + } 32 + 33 + export function emitNetworkLost() { 34 + emitter.emit('network-lost') 35 + } 36 + export function listenNetworkLost(fn: () => void): UnlistenFn { 37 + emitter.on('network-lost', fn) 38 + return () => emitter.off('network-lost', fn) 39 + } 40 + 25 41 export function emitPostCreated() { 26 42 emitter.emit('post-created') 27 43 }
+2 -1
src/state/queries/feed.ts
··· 454 454 }), 455 455 ) 456 456 457 - await Promise.allSettled([feedsPromise, ...listsPromises]) 457 + await feedsPromise // Fail the whole query if it fails. 458 + await Promise.allSettled(listsPromises) // Ignore individual failing ones. 458 459 459 460 // order the feeds/lists in the order they were pinned 460 461 const result: SavedFeedSourceInfo[] = []
+4 -6
src/state/session/__tests__/session-test.ts
··· 1184 1184 expect(state.currentAgentState.did).toBe('bob-did') 1185 1185 }) 1186 1186 1187 - it('does soft logout on network error', () => { 1187 + it('ignores network errors', () => { 1188 1188 let state = getInitialState([]) 1189 1189 1190 1190 const agent1 = new BskyAgent({service: 'https://alice.com'}) ··· 1217 1217 }, 1218 1218 ]) 1219 1219 expect(state.accounts.length).toBe(1) 1220 - // Network error should reset current user but not reset the tokens. 1221 - // TODO: We might want to remove or change this behavior? 1222 1220 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') 1223 1221 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') 1224 - expect(state.currentAgentState.did).toBe(undefined) 1222 + expect(state.currentAgentState.did).toBe('alice-did') 1225 1223 expect(printState(state)).toMatchInlineSnapshot(` 1226 1224 { 1227 1225 "accounts": [ ··· 1242 1240 ], 1243 1241 "currentAgentState": { 1244 1242 "agent": { 1245 - "service": "https://public.api.bsky.app/", 1243 + "service": "https://alice.com/", 1246 1244 }, 1247 - "did": undefined, 1245 + "did": "alice-did", 1248 1246 }, 1249 1247 "needsPersist": true, 1250 1248 }
+27
src/state/session/agent.ts
··· 12 12 import {getAge} from '#/lib/strings/time' 13 13 import {logger} from '#/logger' 14 14 import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' 15 + import {emitNetworkConfirmed, emitNetworkLost} from '../events' 15 16 import {addSessionErrorLog} from './logging' 16 17 import { 17 18 configureModerationForAccount, ··· 227 228 } 228 229 229 230 // Not exported. Use factories above to create it. 231 + let realFetch = globalThis.fetch 230 232 class BskyAppAgent extends BskyAgent { 231 233 persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = 232 234 undefined ··· 234 236 constructor({service}: {service: string}) { 235 237 super({ 236 238 service, 239 + async fetch(...args) { 240 + let success = false 241 + try { 242 + const result = await realFetch(...args) 243 + success = true 244 + return result 245 + } catch (e) { 246 + success = false 247 + throw e 248 + } finally { 249 + if (success) { 250 + emitNetworkConfirmed() 251 + } else { 252 + emitNetworkLost() 253 + } 254 + } 255 + }, 237 256 persistSession: (event: AtpSessionEvent) => { 238 257 if (this.persistSessionHandler) { 239 258 this.persistSessionHandler(event) ··· 257 276 258 277 // Now the agent is ready. 259 278 const account = agentToSessionAccountOrThrow(this) 279 + let lastSession = this.sessionManager.session 260 280 this.persistSessionHandler = event => { 281 + if (this.sessionManager.session) { 282 + lastSession = this.sessionManager.session 283 + } else if (event === 'network-error') { 284 + // Put it back, we'll try again later. 285 + this.sessionManager.session = lastSession 286 + } 287 + 261 288 onSessionChange(this, account.did, event) 262 289 if (event !== 'create' && event !== 'update') { 263 290 addSessionErrorLog(account.did, event)
+2 -6
src/state/session/reducer.ts
··· 79 79 return state 80 80 } 81 81 if (sessionEvent === 'network-error') { 82 - // Don't change stored accounts but kick to the choose account screen. 83 - return { 84 - accounts: state.accounts, 85 - currentAgentState: createPublicAgentState(), 86 - needsPersist: true, 87 - } 82 + // Assume it's transient. 83 + return state 88 84 } 89 85 const existingAccount = state.accounts.find(a => a.did === accountDid) 90 86 if (