crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit e5f5fdc8a5ec555784791c4b3f402f7a2467b11a
parent bc26a85e9ed8b8d2eb3605a9640e7c42427e5803
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 14:05:12 +0900

Refresh viewContext after MovesUpdater flush

MovesUpdater.persistAndMerge bumps game.updatedAt on a newBackgroundContext()
and saves. viewContext.automaticallyMergesChangesFromParent applies that change
to the in-memory snapshot but doesn't reliably fire the ObjectsDidChange
notification that @FetchRequest's NSFetchedResultsController listens for, so
GameListView kept showing a stale "last updated" relative time on the device
that did the typing.

The MovesUpdater sink in AppServices now hops to MainActor and calls
viewContext.refresh(entity, mergeChanges: true) for each affected GameEntity
before falling through to the existing iCloud enqueue. Refresh emits
ObjectsDidChange with refreshedObjects, which NSFRC treats as a per-entity
update, so @FetchRequest re-fires and GameSummaryCache picks up the merged
updatedAt. The nudge is not conditioned on isICloudSyncEnabled so local-only
games get the same fix.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 25++++++++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1,4 +1,5 @@ import CloudKit +import CoreData import Foundation import UserNotifications @@ -58,7 +59,29 @@ final class AppServices { debounceInterval: .milliseconds(500), persistence: persistence, writerAuthorIDProvider: { await MainActor.run { identity.currentID } }, - sink: { gameIDs in + sink: { [persistence] gameIDs in + // MovesUpdater bumps game.updatedAt on a background context. + // viewContext.automaticallyMergesChangesFromParent applies that + // change in-memory but doesn't reliably fire the ObjectsDidChange + // notification that @FetchRequest's NSFetchedResultsController + // listens for, so the library list keeps showing the stale + // "last updated" time until something else nudges the context. + // The inbound path is masked by noteIncomingMovesUpdate's + // explicit viewContext save; the outbound path has no analog. + // Refreshing the affected entities re-emits ObjectsDidChange + // with refreshedObjects, which NSFRC treats as a per-entity + // update — that path runs unconditionally so local-only games + // get the same nudge even when iCloud sync is off. + await MainActor.run { + let viewContext = persistence.viewContext + for gameID in gameIDs { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? viewContext.fetch(req).first else { continue } + viewContext.refresh(entity, mergeChanges: true) + } + } let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } guard isEnabled else { return } await syncEngine.enqueueMoves(gameIDs: gameIDs)