crossmate

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

commit 42feaecb07d3ea34ca4e117e8fac91f7b91bff1a
parent b6a48d0044e5f5f4171fa02d30176029a6ccbbde
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 11:28:49 +0900

Skip grid refresh when Moves save self-echoes

Receiving the device's own Moves record back through the inbound apply path
should not trigger a grid refresh. applyMovesRecord already protects the local
row from being clobbered with a stale server copy, but it still flagged the
game as moves-updated, which fires refreshCurrentGame and restores game.squares
from MovesEntity. When the user is mid-word, the most recent keystroke can sit
in MovesUpdater's 500ms debounce buffer before it reaches MovesEntity; a
refresh in that window briefly wipes the letter from the visible grid until a
later refresh restores it.

applyMovesRecord now returns whether the cells were adopted, and the inbound
callers only mark the game as moves-updated when the body actually ran.

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

Diffstat:
MCrossmate/Sync/RecordSerializer.swift | 10+++++++---
MCrossmate/Sync/SyncEngine.swift | 8++++----
2 files changed, 11 insertions(+), 7 deletions(-)

diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -375,13 +375,16 @@ enum RecordSerializer { /// Upserts the `MovesEntity` for `value`. The cells blob is taken straight /// off the record so any forward-compat fields the encoder added are /// preserved verbatim. Bumps the parent `GameEntity.updatedAt` if the - /// record is fresher. + /// record is fresher. Returns `true` when cells/updatedAt were adopted, + /// `false` when the local-device-row guard short-circuited the body so + /// callers can skip the downstream grid refresh. + @discardableResult static func applyMovesRecord( _ record: CKRecord, value: MovesValue, to ctx: NSManagedObjectContext, localAuthorID: String? = nil - ) { + ) -> Bool { let ckName = record.recordID.recordName let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") req.predicate = NSPredicate(format: "ckRecordName == %@", ckName) @@ -414,7 +417,7 @@ enum RecordSerializer { entity.deviceID = value.deviceID let isLocalDeviceRow = value.authorID == localAuthorID && value.deviceID == localDeviceID - guard !foundExisting || !isLocalDeviceRow else { return } + guard !foundExisting || !isLocalDeviceRow else { return false } entity.updatedAt = value.updatedAt entity.cells = (record["cells"] as? Data) ?? Data() @@ -422,6 +425,7 @@ enum RecordSerializer { game.updatedAt.map({ $0 < value.updatedAt }) ?? true { game.updatedAt = value.updatedAt } + return true } // MARK: - System fields encode/decode diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1035,13 +1035,13 @@ actor SyncEngine { if let id = entity.id { affected.insert(id) } case "Moves": if let value = RecordSerializer.parseMovesRecord(record) { - RecordSerializer.applyMovesRecord( + let cellsChanged = RecordSerializer.applyMovesRecord( record, value: value, to: ctx, localAuthorID: localAuthorID ) - movesUpdated.insert(value.gameID) + if cellsChanged { movesUpdated.insert(value.gameID) } affected.insert(value.gameID) } case "Player": @@ -1772,13 +1772,13 @@ actor SyncEngine { if let id = entity.id { affected.insert(id) } case "Moves": if let value = RecordSerializer.parseMovesRecord(record) { - RecordSerializer.applyMovesRecord( + let cellsChanged = RecordSerializer.applyMovesRecord( record, value: value, to: ctx, localAuthorID: localAuthorID ) - movesUpdated.insert(value.gameID) + if cellsChanged { movesUpdated.insert(value.gameID) } affected.insert(value.gameID) } case "Player":