crossmate

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

commit b2b5b6719e991d5532878d4936e55960b82c6d39
parent 122cc21b2b95ef939cb18ac5fd02d37b93a574c0
Author: Michael Camilleri <[email protected]>
Date:   Fri,  8 May 2026 16:54:34 +0900

Push Moves immediately after debounced flush

Cross-device sync felt sluggish after the moves-architecture rewrite in 122cc21
even though far fewer records were going over the wire. With the previous
design, every cell edit produced a distinct CKRecord.ID in
pendingRecordZoneChanges, so the framework's scheduler almost always saw fresh
work and shipped it quickly. Under the new one-record-per-device model, every
keystroke for a given (game, authorID, deviceID) targets the same CKRecord.ID.
Repeated state.add calls collapse onto the already-queued intent and
CKSyncEngine's scheduler is free to sit on the change for a while before
deciding to send.

This commit adds a direct state.add at the two call sites
(enqueueMoves and enqueuePlayerRecord) and, after enqueueMoves finishes
staging, kicks the affected engines with sendChanges() via fire-and-forget
Tasks. MovesUpdater already debounces at 1500ms, so the kick fires at most once
per typing burst per game; the framework internally serialises concurrent
sends, so back-to-back kicks are safe.  The existing
duplicateMoveEnqueueIsDeduped test continues to pass, which confirms
CKSyncEngine dedupes pendingRecordZoneChanges by record ID on its own.

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

Diffstat:
MCrossmate/Models/PlayerRoster.swift | 9++++++---
MCrossmate/Sync/SyncEngine.swift | 30+++++++++++++++++++++---------
MTests/Unit/PlayerRosterTests.swift | 22++++++++++++++++++++++
3 files changed, 49 insertions(+), 12 deletions(-)

diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -176,7 +176,7 @@ final class PlayerRoster { let movesEntities = (try? ctx.fetch(movesReq)) ?? [] let authorIDs = Array( Set(movesEntities.compactMap { $0.authorID }) - .subtracting([localAuthorID, ""]) + .subtracting([localAuthorID, CKCurrentUserDefaultName, ""]) ) return ( entity.databaseScope, @@ -212,7 +212,10 @@ final class PlayerRoster { ) { // Collect all remote participant authorIDs. var otherAuthorIDs = Set<String>() - for key in namesMap.keys where key != localAuthorID && !key.isEmpty { + for key in namesMap.keys + where key != localAuthorID + && key != CKCurrentUserDefaultName + && !key.isEmpty { otherAuthorIDs.insert(key) } if let share { @@ -225,7 +228,7 @@ final class PlayerRoster { otherAuthorIDs.insert(recordName) } } - for authorID in moveAuthorIDs { + for authorID in moveAuthorIDs where authorID != CKCurrentUserDefaultName { otherAuthorIDs.insert(authorID) } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -157,16 +157,26 @@ actor SyncEngine { // MARK: - Outbound - /// Registers each game's local-device Moves record as a pending save. - /// Called by the `MovesUpdater` sink after the device's `MovesEntity` row - /// has been merged and persisted. Routes per-game to the correct engine. + /// Registers each game's local-device Moves record as a pending save and + /// kicks the affected engines to send immediately. Called by the + /// `MovesUpdater` sink after the device's `MovesEntity` row has been + /// merged and persisted; the sink already debounces edits, so this fires + /// at most once per typing burst per game. Routes per-game to the correct + /// engine. Going through `sendChanges()` rather than relying on the + /// framework's own scheduler matters because every keystroke targets the + /// same `CKRecord.ID` — the scheduler treats repeated `state.add` calls + /// as the same already-queued intent and may sit on the change for a + /// while before deciding to ship it. func enqueueMoves(gameIDs: Set<UUID>) { guard !gameIDs.isEmpty else { return } + var kickPrivate = false + var kickShared = false let ctx = persistence.container.newBackgroundContext() ctx.performAndWait { for gameID in gameIDs { guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue } - let engine = info.scope == 1 ? sharedEngine : privateEngine + let isShared = info.scope == 1 + let engine = isShared ? sharedEngine : privateEngine guard let engine else { continue } let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") req.predicate = NSPredicate( @@ -179,14 +189,16 @@ actor SyncEngine { let recordName = entity.ckRecordName else { continue } let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) - let already = engine.state.pendingRecordZoneChanges.contains { - if case .saveRecord(let id) = $0 { return id.recordName == recordName } - return false - } - guard !already else { continue } engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) + if isShared { kickShared = true } else { kickPrivate = true } } } + if kickPrivate, let engine = privateEngine { + Task { try? await engine.sendChanges() } + } + if kickShared, let engine = sharedEngine { + Task { try? await engine.sendChanges() } + } } /// Re-enqueues locally persisted Moves rows that do not yet have CloudKit diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift @@ -169,4 +169,26 @@ struct PlayerRosterTests { let remote = roster.entries.first { !$0.isLocal } #expect(remote?.name == "Player") } + + @Test("Current user placeholder is not shown as a remote player") + func currentUserPlaceholderIsHidden() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + addMoves( + authorIDs: [CKCurrentUserDefaultName, "_B"], + gameID: gameID, + persistence: persistence + ) + addPlayerEntity( + authorID: CKCurrentUserDefaultName, + name: "Ghost", + gameID: gameID, + persistence: persistence + ) + + let roster = makeRoster(gameID: gameID, persistence: persistence) + await roster.refresh() + + #expect(!roster.entries.contains { $0.authorID == CKCurrentUserDefaultName }) + #expect(roster.entries.map(\.authorID).contains("_B")) + } }