Skip to content

fix(connectivity): stop spurious 'Wallet is offline' banners on intermittent connections - #669

Merged
islandbitcoin merged 5 commits into
mainfrom
fix/intermittent-offline-banner
Jul 17, 2026
Merged

fix(connectivity): stop spurious 'Wallet is offline' banners on intermittent connections#669
islandbitcoin merged 5 commits into
mainfrom
fix/intermittent-offline-banner

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Problem

Users intermittently see "Wallet is offline" on the home screen when no deploy is happening and the backend is healthy. Two client-side causes:

  1. NetInfo's default reachability probe hits clients3.google.com/generate_204. On networks where that endpoint is slow or filtered, isInternetReachable flips to false and the banner shows — even though the Flash API is perfectly reachable.
  2. Firebase App Check attested on every GraphQL request. The appCheckLink ran initializeAppCheck() + getToken(true) (force refresh) per call, adding a full attestation round-trip to every query — slow, and a frequent source of "Network request failed".

Changes

  • Probe our own API for reachabilityNetInfo.configure sends a GET to galoyInstance.graphqlUri; only the wallet API's own 400-to-bare-GET signature counts as reachable, so a captive portal's 200/30x reads as unreachable (the protection the google-204 probe provided, kept). Configured exactly once per JS session, before any subscriber mounts — NetInfo.configure() tears down the library's shared state and orphans every mounted listener, so it must never re-run (it previously sat in the client effect that re-runs on token change).
  • App Check initializes once (the init promise is memoized synchronously, so parallel cold-start operations share one attestation) and uses the SDK's cached auto-refreshing token (getToken(false)).
  • New useConnectivity hook with an 8s offline debounce — deliberately longer than one full failed-probe cycle (reachabilityShortTimeout 5s + probe RTT; the constants are cross-referenced in both files and the coupling is test-enforced), so a single failed probe (one 502 during a deploy, a killed socket on foreground) never flashes the banner. justReconnected pulses only on the confirmed (true) edge of NetInfo's real false→null→true recovery sequence.
  • Home screen silently refetches on reconnect — balances/transactions recover without pull-to-refresh; refetch rejections on the still-shaky edge are swallowed.
  • Only transport-level failures are folded into the bannerServerError/ServerParseError (non-2xx, carry statusCode) keep displaying, so a backend incident the probe can't see is never hidden.
  • Localized copy: HomeScreen.offlineShowingCached ("No connection — showing cached balances"), en + all 23 locales.

Testing

  • __tests__/hooks/use-connectivity.spec.tsx — 10 tests, all timings derived from the exported OFFLINE_DEBOUNCE_MS: single-failed-probe-cycle absorption (asserts the constant coupling), debounce boundary, confirmed-edge pulse (false→null→true), failed-probe re-latch (false→null→false), blip-after-real-offline (kills the stale-latch mutant found by mutation testing), flapping restart, exactly-one-pulse-per-offline-period.
  • __tests__/hooks/use-device-token.spec.ts — 4 tests: init-once with isTokenAutoRefreshEnabled: true asserted, overlapping-calls-during-init (kills the memoize-after-await mutant), init-failure retry, transient token failure keeps the singleton.
  • Full suite: 39 suites / 175 tests green; yarn tsc:check clean; changed files lint clean; translation-drift clean.
  • Hardened by 3 rounds of multi-agent adversarial review (findings verified against the vendored NetInfo 11.5.2 source and by empirically running test mutants); round 3 returned zero confirmed findings.
  • Manual: toggle airplane mode — banner appears only after ~8s offline, clears immediately on reconnect, home refreshes silently at the confirmed edge.

Notes

First step toward full offline capability. Follow-ups: getErrorMessages still carries a now-unreachable "Wallet is offline" mapping (pre-existing); cache persistor trimming and an offline mutation queue are the next milestones. A dev-screen instance switch retargets the probe only after an app reload (documented in client.tsx).

🤖 Generated with Claude Code

forge0x and others added 3 commits July 15, 2026 22:02
…mittent connections

- Probe our own API for reachability instead of clients3.google.com,
  which produces false offline states on networks where it is slow or
  filtered (NetInfo.configure in the Apollo client provider)
- Initialize Firebase App Check once and use the cached token instead of
  re-initializing and force-refreshing on every GraphQL request — this
  added an attestation round-trip per call and was a frequent source of
  'Network request failed'
- Add useConnectivity hook with 3s offline hysteresis so momentary blips
  never surface, and a justReconnected signal
- Home screen silently refetches when connectivity returns
- Suppress pure network errors in the home Info banner (retries usually
  succeed; real offline state is handled by the debounced banner)
- Clearer offline copy: 'No connection — showing cached balances'

First step toward full offline capability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o HEAD

Also guard the App Check init-retry against clobbering a newer attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate machines

Review follow-ups:
- reachabilityTest now requires exactly the wallet API's 400-to-bare-GET
  signature. status<500 let captive portals (200/30x) read as reachable,
  which combined with the suppressed network errors meant stale balances
  with no offline indicator — the failure the google-204 probe caught.
- "No connection — showing cached balances" moved to i18n
  (HomeScreen.offlineShowingCached, all 23 locales).
- useConnectivity: 7 tests via consumer-effect harness with fake timers
  (blip never latches, 3s latches, instant recovery, exactly-one pulse
  per offline period, flapping restarts debounce, null = online).
- getAppCheckToken: 3 tests (init-once memo, init-failure retry,
  transient token failure keeps the instance).
- Stale "getDeviceToken error" log label corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@islandbitcoin
islandbitcoin force-pushed the fix/intermittent-offline-banner branch from 524777a to 25a7406 Compare July 17, 2026 07:05
Dread and others added 2 commits July 17, 2026 00:45
…, show server errors

Adversarial-review round 1 (4 confirmed findings):

- NetInfo.configure() moved behind a once-per-session guard. It tears
  down the library's shared state and orphans every mounted subscriber
  (useNetInfo never re-subscribes), and it sat in the client effect that
  re-runs on every auth-token change — one account upgrade mid-session
  and the banner + reconnect refetch silently died. Runs once, before
  any subscriber can mount.
- justReconnected now pulses only on a CONFIRMED recovery
  (isInternetReachable === true). With useNativeReachability:false,
  NetInfo emits false→null→true; pulsing at null fired the refetch into
  a dead network and consumed the latch so the true edge never pulsed.
- Info.tsx suppression narrowed to transport-level failures: ServerError
  / ServerParseError (non-2xx, carry statusCode) keep displaying —
  suppressing them hid backend incidents the probe can't see.
- Reconnect refetches catch rejections (fire on shaky edges).
- 3 new tests: blip-after-real-offline (kills the wasOfflineRef-reset
  mutant found by mutation testing), false→null→true pulses once at the
  confirmed edge, false→null→false keeps the latch and re-latches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…den tests

Adversarial-review round 2 (3 confirmed findings):

- OFFLINE_DEBOUNCE_MS 3s → 8s. NetInfo re-probes no sooner than
  reachabilityShortTimeout (5s) after a failure, so a 3s debounce
  latched the banner on EVERY isolated probe failure (one 502 during a
  deploy, a killed socket on foreground) — arithmetically guaranteed,
  then a synchronized refetch burst on recovery. 8s absorbs one full
  failed-probe cycle; only consecutive failures surface. The two
  constants are cross-referenced in both files, the spec derives all
  timings from the exported constant, and a dedicated test asserts the
  coupling (fails loudly if they decouple).
- App Check spec: concurrency test holds init open across overlapping
  calls (kills the memoize-after-await mutant that would fire one
  attestation per parallel cold-start operation) and asserts
  isTokenAutoRefreshEnabled:true (the option getToken(false) depends on).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@islandbitcoin
islandbitcoin merged commit 6a7bbdd into main Jul 17, 2026
7 checks passed
islandbitcoin pushed a commit that referenced this pull request Jul 18, 2026
main's schema snapshot (synced from the flash#452 branch) predated the
Plaid fields, so neither side was a superset: public-schema.graphql now
carries the union, with the three Plaid members verified field-by-field
against the live production API via introspection. Codegen and
i18n-types regenerated from the merged sources; hooks barrel keeps all
three new exports. Full suite on the union: 42 suites / 194 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants