fix(offline): false "New changes available" banner, sync stuck on toggle-off

Home's checkForRemoteChanges compared fresh pinned docs against what
was displayed, but the pinned fetch (listPins isn't a cached endpoint)
silently collapsed any failure to []  — every poll while offline
compared "[]" against the real non-empty pinned list, which always
looked like a change and popped the banner every ~45s. Split fetchPinned
into a throwing variant checkForRemoteChanges can bail on (matching the
already-correct pattern in CollectionsViewModel/DocumentsViewModel,
which don't have this bug), keeping the non-throwing version for the
initial load where collapsing to [] is the right behavior.

RootView's auto-flush was keyed only to session.networkMonitor.isOnline
— turning the manual Offline Mode toggle back off while the real
network had been up the whole time never changes that value, so queued
operations sat stuck until the next real network blip. Keyed the flush
(and the Full Local Sync loop's online check) to a combined
isEffectivelyOnline instead, so either signal clearing resumes sync
immediately.
This commit is contained in:
2026-08-15 01:40:07 +01:00
parent 8d8c8fead8
commit 284ad07251
2 changed files with 35 additions and 15 deletions
+15 -7
View File
@@ -57,25 +57,33 @@ final class HomeViewModel {
/// Fetches fresh pinned docs and the current tab's documents to compare
/// against what's displayed, without replacing either. Bails silently
/// on a fetch failure rather than treating it as "changed" a
/// transient network hiccup shouldn't pop the refresh banner.
/// transient network hiccup (or, offline, `listPins` failing outright
/// it isn't one of the cached endpoints) shouldn't pop the refresh
/// banner. This needs the *throwing* pinned-fetch specifically: the
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
/// which used to read here as "pins changed" against whatever was
/// already displayed and falsely popped the banner on every offline
/// poll.
func checkForRemoteChanges(tab: HomeTab) async {
async let freshPinned = fetchPinned()
async let freshPinnedTask = fetchPinnedThrowing()
guard let freshTab = try? await fetch(tab: tab) else { return }
let pinned = await freshPinned
guard let pinned = try? await freshPinnedTask else { return }
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
hasRemoteChanges = true
}
}
private func fetchPinned() async -> [OutlineDocument] {
(try? await fetchPinnedThrowing()) ?? []
}
/// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar.
private func fetchPinned() async -> [OutlineDocument] {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
return []
}
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
var documents: [OutlineDocument] = []
for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
+20 -8
View File
@@ -6,6 +6,18 @@ struct RootView: View {
@State private var welcomeName: String?
@State private var starStore = StarStore()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Real connectivity is only half of "can talk to the server" the
/// manual Offline Mode toggle is the other half. Syncing needs to
/// resume on either one clearing, not just a real reconnect: turning
/// the toggle off while the network had been up the whole time never
/// changes `networkMonitor.isOnline`, so keying the flush task off that
/// alone left pending operations stuck until the next real network
/// blip.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
ZStack {
@@ -33,13 +45,13 @@ struct RootView: View {
starStore.reset()
}
}
// Replay whatever queued up while offline the moment the network's
// back no need to wait for the user to open Settings and hit Retry.
// Also catches Full Local Sync back up immediately on reconnect,
// rather than leaving it to wait out the rest of the periodic loop
// below.
.task(id: session.networkMonitor.isOnline) {
guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return }
// Replay whatever queued up while offline the moment either signal
// clears a real reconnect, or the user turning Offline Mode back
// off no need to wait for the user to open Settings and hit Retry.
// Also catches Full Local Sync back up immediately, rather than
// leaving it to wait out the rest of the periodic loop below.
.task(id: isEffectivelyOnline) {
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
_ = await cachingClient.flushPendingOperations()
if isFullLocalSyncEnabled {
_ = await cachingClient.performFullSync()
@@ -55,7 +67,7 @@ struct RootView: View {
.task(id: isFullLocalSyncEnabled) {
guard isFullLocalSyncEnabled else { return }
while !Task.isCancelled {
if session.networkMonitor.isOnline, let cachingClient = session.cachingClient {
if isEffectivelyOnline, let cachingClient = session.cachingClient {
_ = await cachingClient.performFullSync()
}
try? await Task.sleep(for: .seconds(1200))