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:
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"))
+ }
}