Skip to content

Commit abfa4bb

Browse files
gaearonhaileyok
authored andcommitted
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. (cherry picked from commit 57be2ea)
1 parent 96cfe89 commit abfa4bb

File tree

6 files changed

+117
-14
lines changed

6 files changed

+117
-14
lines changed

src/lib/react-query.tsx

+66-1
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,83 @@ import React, {useRef, useState} from 'react'
22
import {AppState, AppStateStatus} from 'react-native'
33
import AsyncStorage from '@react-native-async-storage/async-storage'
44
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'
66
import {
77
PersistQueryClientProvider,
88
PersistQueryClientProviderProps,
99
} from '@tanstack/react-query-persist-client'
1010

1111
import {isNative} from '#/platform/detection'
12+
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
1213

1314
// any query keys in this array will be persisted to AsyncStorage
1415
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
1516
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
1617

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+
1782
focusManager.setEventListener(onFocus => {
1883
if (isNative) {
1984
const subscription = AppState.addEventListener(

src/state/events.ts

+16
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ export function listenSessionDropped(fn: () => void): UnlistenFn {
2222
return () => emitter.off('session-dropped', fn)
2323
}
2424

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+
2541
export function emitPostCreated() {
2642
emitter.emit('post-created')
2743
}

src/state/queries/feed.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,8 @@ export function usePinnedFeedsInfos() {
441441
}),
442442
)
443443

444-
await Promise.allSettled([feedsPromise, ...listsPromises])
444+
await feedsPromise // Fail the whole query if it fails.
445+
await Promise.allSettled(listsPromises) // Ignore individual failing ones.
445446

446447
// order the feeds/lists in the order they were pinned
447448
const result: SavedFeedSourceInfo[] = []

src/state/session/__tests__/session-test.ts

+4-6
Original file line numberDiff line numberDiff line change
@@ -1184,7 +1184,7 @@ describe('session', () => {
11841184
expect(state.currentAgentState.did).toBe('bob-did')
11851185
})
11861186

1187-
it('does soft logout on network error', () => {
1187+
it('ignores network errors', () => {
11881188
let state = getInitialState([])
11891189

11901190
const agent1 = new BskyAgent({service: 'https://alice.com'})
@@ -1217,11 +1217,9 @@ describe('session', () => {
12171217
},
12181218
])
12191219
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?
12221220
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
12231221
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')
12251223
expect(printState(state)).toMatchInlineSnapshot(`
12261224
{
12271225
"accounts": [
@@ -1242,9 +1240,9 @@ describe('session', () => {
12421240
],
12431241
"currentAgentState": {
12441242
"agent": {
1245-
"service": "https://public.api.bsky.app/",
1243+
"service": "https://alice.com/",
12461244
},
1247-
"did": undefined,
1245+
"did": "alice-did",
12481246
},
12491247
"needsPersist": true,
12501248
}

src/state/session/agent.ts

+27
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {tryFetchGates} from '#/lib/statsig/statsig'
1212
import {getAge} from '#/lib/strings/time'
1313
import {logger} from '#/logger'
1414
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
15+
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
1516
import {addSessionErrorLog} from './logging'
1617
import {
1718
configureModerationForAccount,
@@ -227,13 +228,31 @@ export function sessionAccountToSession(
227228
}
228229

229230
// Not exported. Use factories above to create it.
231+
let realFetch = globalThis.fetch
230232
class BskyAppAgent extends BskyAgent {
231233
persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
232234
undefined
233235

234236
constructor({service}: {service: string}) {
235237
super({
236238
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+
},
237256
persistSession: (event: AtpSessionEvent) => {
238257
if (this.persistSessionHandler) {
239258
this.persistSessionHandler(event)
@@ -257,7 +276,15 @@ class BskyAppAgent extends BskyAgent {
257276

258277
// Now the agent is ready.
259278
const account = agentToSessionAccountOrThrow(this)
279+
let lastSession = this.sessionManager.session
260280
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+
261288
onSessionChange(this, account.did, event)
262289
if (event !== 'create' && event !== 'update') {
263290
addSessionErrorLog(account.did, event)

src/state/session/reducer.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,8 @@ let reducer = (state: State, action: Action): State => {
7979
return state
8080
}
8181
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
8884
}
8985
const existingAccount = state.accounts.find(a => a.did === accountDid)
9086
if (

0 commit comments

Comments
 (0)