crossmate

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

commit a64d7c1356973fe4763e8138bad4ca87fe1b8b45
parent e2f9a3feac8c660697bb92f2686a10b38d3fdfe4
Author: Michael Camilleri <[email protected]>
Date:   Thu,  9 Jul 2026 15:10:06 +0900

Keep shared players visible across navigation

Friends could disappear briefly whenever a puzzle reopened, while the
Game List could keep showing only the local player's colour after a
friend joined. The puzzle presented an empty roster before its
asynchronous refresh, and Game List summaries depended on a stale Core
Data inverse relationship.

This commit preloads durable local roster entries before presenting the
puzzle, then refines them from CKShare without blocking on the network.
Game List summaries now fetch Player rows directly so background sync
inserts are reflected when cached sections rebuild.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 2++
MCrossmate/Models/PlayerRoster.swift | 15+++++++++++++++
MCrossmate/Persistence/GameStore.swift | 19+++++++++++++------
MCrossmate/Views/Puzzle/PuzzleModifiers.swift | 3---
MTests/Unit/PlayerRosterTests.swift | 14++++++++++++++
5 files changed, 44 insertions(+), 9 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -818,6 +818,8 @@ private struct PuzzleDisplayView: View { cursorStore: services.cursorStore ) let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences) + await newRoster.preload() + guard !Task.isCancelled else { return } roster = newRoster session = newSession openPuzzleFollowUpTask = Task { @MainActor in diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -237,7 +237,18 @@ final class PlayerRoster { // MARK: - Refresh + /// Loads the durable local roster without waiting on CKShare. Used before + /// presenting a puzzle so returning to a game never briefly renders an + /// empty Players list while CloudKit metadata is fetched. + func preload() async { + await refresh(includingShare: false) + } + func refresh() async { + await refresh(includingShare: true) + } + + private func refresh(includingShare: Bool) async { guard !isStaticPreview else { return } refreshGeneration += 1 let generation = refreshGeneration @@ -334,7 +345,11 @@ final class PlayerRoster { ) } + guard generation == refreshGeneration else { return } applyRoster(localAuthorID: localAuthorID, fetched: fetched, share: nil) + scheduleLeaseExpiryRecompute() + + guard includingShare else { return } // Fetch the CKShare if not already cached. This can be noticeably // slower on device, so publish the local Core Data roster first and diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -4,6 +4,16 @@ import Foundation import Observation import Security +/// Fetches child rows explicitly instead of reading an already-realised +/// inverse relationship. Background sync can insert a child in another +/// context without refreshing a loaded GameEntity's to-many collection. +private func playerEntities(for entity: GameEntity) -> [PlayerEntity] { + guard let context = entity.managedObjectContext else { return [] } + let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + request.predicate = NSPredicate(format: "game == %@", entity) + return (try? context.fetch(request)) ?? [] +} + /// Per-cell state for rendering a thumbnail. Plain value type so /// SwiftUI can diff it cheaply. enum GameThumbnailCell: Equatable { @@ -196,8 +206,7 @@ struct GameSummary: Identifiable, Equatable { } var namesByAuthor: [String: String] = [:] - let playerEntities = (entity.players as? Set<PlayerEntity>) ?? [] - for player in playerEntities { + for player in playerEntities(for: entity) { guard let authorID = player.authorID, !authorID.isEmpty else { continue } if let name = player.name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { @@ -338,8 +347,7 @@ final class GameSummaryCache { } private static func playersSignature(for entity: GameEntity) -> [String] { - let players = (entity.players as? Set<PlayerEntity>) ?? [] - return players.map { player in + playerEntities(for: entity).map { player in "\(player.authorID ?? "")|\(player.name ?? "")|\(player.updatedAt?.timeIntervalSinceReferenceDate ?? 0)" } .sorted() @@ -486,8 +494,7 @@ extension GameEntity { authors.insert(authorID) } } - let players = (game.players as? Set<PlayerEntity>) ?? [] - for player in players { + for player in playerEntities(for: game) { if let authorID = player.authorID, !authorID.isEmpty { authors.insert(authorID) } diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift @@ -278,9 +278,6 @@ struct PuzzleLifecycleModifier: ViewModifier { func body(content: Content) -> some View { content - .task { - await roster.refresh() - } .onAppear { if session.game.completionState == .solved { hasSolved = true diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift @@ -193,6 +193,20 @@ struct PlayerRosterTests { #expect(remote?.name == "Alice") } + @Test("Preload restores durable names before presentation") + func preloadRestoresDurableNames() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence) + + let roster = makeRoster(gameID: gameID, persistence: persistence) + #expect(roster.entries.isEmpty) + + await roster.preload() + + #expect(roster.entries.map(\.authorID) == ["_Local", "_B"]) + #expect(roster.entries.first { $0.authorID == "_B" }?.name == "Alice") + } + @Test("Entry name waits for PlayerEntity when no game-specific name has arrived") func entryNameWaitsForPlayerEntity() async throws { let (persistence, gameID) = try makePersistenceWithGame()