Bluesky app fork with some witchin' additions 💫

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 import {AppState, AppStateStatus} from 'react-native' 3 import AsyncStorage from '@react-native-async-storage/async-storage' 4 import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' 5 - import {focusManager, QueryClient} from '@tanstack/react-query' 6 import { 7 PersistQueryClientProvider, 8 PersistQueryClientProviderProps, 9 } from '@tanstack/react-query-persist-client' 10 11 import {isNative} from '#/platform/detection' 12 13 // any query keys in this array will be persisted to AsyncStorage 14 export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' 15 const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot] 16 17 focusManager.setEventListener(onFocus => { 18 if (isNative) {
··· 2 import {AppState, AppStateStatus} from 'react-native' 3 import AsyncStorage from '@react-native-async-storage/async-storage' 4 import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' 5 + import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query' 6 import { 7 PersistQueryClientProvider, 8 PersistQueryClientProviderProps, 9 } from '@tanstack/react-query-persist-client' 10 11 import {isNative} from '#/platform/detection' 12 + import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' 13 14 // any query keys in this array will be persisted to AsyncStorage 15 export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' 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) 81 82 focusManager.setEventListener(onFocus => { 83 if (isNative) {
+16
src/state/events.ts
··· 22 return () => emitter.off('session-dropped', fn) 23 } 24 25 export function emitPostCreated() { 26 emitter.emit('post-created') 27 }
··· 22 return () => emitter.off('session-dropped', fn) 23 } 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 + 41 export function emitPostCreated() { 42 emitter.emit('post-created') 43 }
+2 -1
src/state/queries/feed.ts
··· 454 }), 455 ) 456 457 - await Promise.allSettled([feedsPromise, ...listsPromises]) 458 459 // order the feeds/lists in the order they were pinned 460 const result: SavedFeedSourceInfo[] = []
··· 454 }), 455 ) 456 457 + await feedsPromise // Fail the whole query if it fails. 458 + await Promise.allSettled(listsPromises) // Ignore individual failing ones. 459 460 // order the feeds/lists in the order they were pinned 461 const result: SavedFeedSourceInfo[] = []
+4 -6
src/state/session/__tests__/session-test.ts
··· 1184 expect(state.currentAgentState.did).toBe('bob-did') 1185 }) 1186 1187 - it('does soft logout on network error', () => { 1188 let state = getInitialState([]) 1189 1190 const agent1 = new BskyAgent({service: 'https://alice.com'}) ··· 1217 }, 1218 ]) 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 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') 1223 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') 1224 - expect(state.currentAgentState.did).toBe(undefined) 1225 expect(printState(state)).toMatchInlineSnapshot(` 1226 { 1227 "accounts": [ ··· 1242 ], 1243 "currentAgentState": { 1244 "agent": { 1245 - "service": "https://public.api.bsky.app/", 1246 }, 1247 - "did": undefined, 1248 }, 1249 "needsPersist": true, 1250 }
··· 1184 expect(state.currentAgentState.did).toBe('bob-did') 1185 }) 1186 1187 + it('ignores network errors', () => { 1188 let state = getInitialState([]) 1189 1190 const agent1 = new BskyAgent({service: 'https://alice.com'}) ··· 1217 }, 1218 ]) 1219 expect(state.accounts.length).toBe(1) 1220 expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') 1221 expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') 1222 + expect(state.currentAgentState.did).toBe('alice-did') 1223 expect(printState(state)).toMatchInlineSnapshot(` 1224 { 1225 "accounts": [ ··· 1240 ], 1241 "currentAgentState": { 1242 "agent": { 1243 + "service": "https://alice.com/", 1244 }, 1245 + "did": "alice-did", 1246 }, 1247 "needsPersist": true, 1248 }
+27
src/state/session/agent.ts
··· 12 import {getAge} from '#/lib/strings/time' 13 import {logger} from '#/logger' 14 import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' 15 import {addSessionErrorLog} from './logging' 16 import { 17 configureModerationForAccount, ··· 227 } 228 229 // Not exported. Use factories above to create it. 230 class BskyAppAgent extends BskyAgent { 231 persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = 232 undefined ··· 234 constructor({service}: {service: string}) { 235 super({ 236 service, 237 persistSession: (event: AtpSessionEvent) => { 238 if (this.persistSessionHandler) { 239 this.persistSessionHandler(event) ··· 257 258 // Now the agent is ready. 259 const account = agentToSessionAccountOrThrow(this) 260 this.persistSessionHandler = event => { 261 onSessionChange(this, account.did, event) 262 if (event !== 'create' && event !== 'update') { 263 addSessionErrorLog(account.did, event)
··· 12 import {getAge} from '#/lib/strings/time' 13 import {logger} from '#/logger' 14 import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' 15 + import {emitNetworkConfirmed, emitNetworkLost} from '../events' 16 import {addSessionErrorLog} from './logging' 17 import { 18 configureModerationForAccount, ··· 228 } 229 230 // Not exported. Use factories above to create it. 231 + let realFetch = globalThis.fetch 232 class BskyAppAgent extends BskyAgent { 233 persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = 234 undefined ··· 236 constructor({service}: {service: string}) { 237 super({ 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 + }, 256 persistSession: (event: AtpSessionEvent) => { 257 if (this.persistSessionHandler) { 258 this.persistSessionHandler(event) ··· 276 277 // Now the agent is ready. 278 const account = agentToSessionAccountOrThrow(this) 279 + let lastSession = this.sessionManager.session 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 + 288 onSessionChange(this, account.did, event) 289 if (event !== 'create' && event !== 'update') { 290 addSessionErrorLog(account.did, event)
+2 -6
src/state/session/reducer.ts
··· 79 return state 80 } 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 - } 88 } 89 const existingAccount = state.accounts.find(a => a.did === accountDid) 90 if (
··· 79 return state 80 } 81 if (sessionEvent === 'network-error') { 82 + // Assume it's transient. 83 + return state 84 } 85 const existingAccount = state.accounts.find(a => a.did === accountDid) 86 if (