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 /// Fetches fresh pinned docs and the current tab's documents to compare
/// against what's displayed, without replacing either. Bails silently /// against what's displayed, without replacing either. Bails silently
/// on a fetch failure rather than treating it as "changed" a /// 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 { func checkForRemoteChanges(tab: HomeTab) async {
async let freshPinned = fetchPinned() async let freshPinnedTask = fetchPinnedThrowing()
guard let freshTab = try? await fetch(tab: tab) else { return } 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) if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) { || Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
hasRemoteChanges = true hasRemoteChanges = true
} }
} }
private func fetchPinned() async -> [OutlineDocument] {
(try? await fetchPinnedThrowing()) ?? []
}
/// `pins.list` only returns pin records, not the documents themselves /// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated /// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable /// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar. /// where it wouldn't be in the sidebar.
private func fetchPinned() async -> [OutlineDocument] { private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else { let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
return []
}
var documents: [OutlineDocument] = [] var documents: [OutlineDocument] = []
for pin in pins { for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) { 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 welcomeName: String?
@State private var starStore = StarStore() @State private var starStore = StarStore()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @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 { var body: some View {
ZStack { ZStack {
@@ -33,13 +45,13 @@ struct RootView: View {
starStore.reset() starStore.reset()
} }
} }
// Replay whatever queued up while offline the moment the network's // Replay whatever queued up while offline the moment either signal
// back no need to wait for the user to open Settings and hit Retry. // clears a real reconnect, or the user turning Offline Mode back
// Also catches Full Local Sync back up immediately on reconnect, // off no need to wait for the user to open Settings and hit Retry.
// rather than leaving it to wait out the rest of the periodic loop // Also catches Full Local Sync back up immediately, rather than
// below. // leaving it to wait out the rest of the periodic loop below.
.task(id: session.networkMonitor.isOnline) { .task(id: isEffectivelyOnline) {
guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return } guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
_ = await cachingClient.flushPendingOperations() _ = await cachingClient.flushPendingOperations()
if isFullLocalSyncEnabled { if isFullLocalSyncEnabled {
_ = await cachingClient.performFullSync() _ = await cachingClient.performFullSync()
@@ -55,7 +67,7 @@ struct RootView: View {
.task(id: isFullLocalSyncEnabled) { .task(id: isFullLocalSyncEnabled) {
guard isFullLocalSyncEnabled else { return } guard isFullLocalSyncEnabled else { return }
while !Task.isCancelled { while !Task.isCancelled {
if session.networkMonitor.isOnline, let cachingClient = session.cachingClient { if isEffectivelyOnline, let cachingClient = session.cachingClient {
_ = await cachingClient.performFullSync() _ = await cachingClient.performFullSync()
} }
try? await Task.sleep(for: .seconds(1200)) try? await Task.sleep(for: .seconds(1200))