crossmate

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

commit d759c61b1c46ead344bb7e20c4635fde362cd127
parent eb8b0256bd2cc1bfb1f9482e823eee861e372edb
Author: Michael Camilleri <[email protected]>
Date:   Sat, 16 May 2026 06:18:07 +0900

Keep buffered letters from reverting on remote refresh

Prior to this commit, letters could vanish for a second or two before
reappearing during a co-solve burst. While a typed letter is buffered in
MovesUpdater during the ~500ms debounce, an inbound remote fetch triggers
refreshCurrentGame -> GameStore.restore, which rebuilds game.squares from the
merged MovesEntity rows. Those rows don't yet contain the buffered letter, so
the cell reverted to the merged-grid value until the next flush wrote the edit
and a later refresh restored it.

The Square model now carries an enqueuedAt flag, set by GameMutator.emitMove
synchronously on the MainActor — atomically with the typed value and before the
Task hop to the actor, so a fetch that races the hop still sees it. The same
timestamp flows through MovesUpdater.enqueue into the buffered cell and, on
flush, into the persisted TimestampedCell.updatedAt.

The restore function skips squares whose enqueuedAt is set, and retires the
flag itself once the local device's row shows that cell at updatedAt >= the
flag (the edit, or a newer one, has landed), then adopts the LWW-merged value.
This mirrors the hasPendingSave approach from 62674e9 without a new callback or
parallel store; restore is the flag's only consumer, so it both honours and
clears it using data it already fetches. Every uncertain interleaving fails
safe by keeping the user's letter, and a concurrent peer write converges to the
LWW winner on the next refresh after the local edit flushes.

The flag is in-memory only on Square; that's sufficient because
MovesUpdater.flush() runs on backgrounding, so nothing is in flight
across a cold launch.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Models/Square.swift | 8++++++++
MCrossmate/Persistence/GameMutator.swift | 12+++++++++++-
MCrossmate/Persistence/GameStore.swift | 23+++++++++++++++++++++++
MCrossmate/Sync/MovesUpdater.swift | 5+++--
ATests/Unit/PendingEditFlagTests.swift | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 301 insertions(+), 3 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; + 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D491B7232333AA8957732387 /* PendingEditFlagTests.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; }; 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; }; @@ -187,6 +188,7 @@ CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; }; D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; }; D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnseenMovesTests.swift; sourceTree = "<group>"; }; + D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; @@ -251,6 +253,7 @@ 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, + D491B7232333AA8957732387 /* PendingEditFlagTests.swift */, 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */, @@ -511,6 +514,7 @@ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */, + 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */, 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */, diff --git a/Crossmate/Models/Square.swift b/Crossmate/Models/Square.swift @@ -7,4 +7,12 @@ struct Square: Sendable { var mark: CellMark = .none var updatedAt: Date? var letterAuthorID: String? + /// Non-nil while a local edit to this square is buffered in + /// `MovesUpdater` but not yet flushed to `MovesEntity`. Carries the + /// MainActor-stamped enqueue timestamp. `GameStore.restore` leaves the + /// square's value fields untouched while this is set, so a remote-moves + /// refresh that lands mid-debounce can't revert the letter the user just + /// typed; the flag is cleared on the matching flush (timestamp-guarded so + /// a newer same-cell edit stays protected). + var enqueuedAt: Date? } diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -103,6 +103,15 @@ final class GameMutator { // reveal-of-correct preserved the original author. let cellAuthorID = square.letterAuthorID let actingAuthorID = authorIDProvider?() + // Stamp the flag on the MainActor *before* the Task hops to the + // actor, atomically with the value the user just typed. While it's + // set, `GameStore.restore` won't overwrite this square from a remote + // refresh — closing the window where the buffered letter exists only + // in the actor and a fetch could revert it. The same timestamp is + // persisted as the cell's `updatedAt` on flush, which is how + // `restore` later recognises the edit has landed and retires the flag. + let enqueuedAt = Date() + game.squares[row][col].enqueuedAt = enqueuedAt Task { await movesUpdater.enqueue( gameID: id, @@ -111,7 +120,8 @@ final class GameMutator { markKind: markKind, checkedWrong: checkedWrong, authorID: cellAuthorID, - actingAuthorID: actingAuthorID + actingAuthorID: actingAuthorID, + enqueuedAt: enqueuedAt ) } } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -674,10 +674,33 @@ final class GameStore { let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } let grid = GridStateMerger.merge(values) + // The local device's own row. Used to decide whether a buffered edit + // (flagged by `Square.enqueuedAt`) has landed durably yet: once the + // flush writes it, this row carries the cell with `updatedAt` equal + // to the flag's timestamp (`MovesUpdater` persists `enqueuedAt` as the + // cell's `updatedAt`). + let localDeviceID = RecordSerializer.localDeviceID + let localAuthorID = authorIDProvider() + let localCells: [GridPosition: TimestampedCell] = values.first { + $0.deviceID == localDeviceID + && (localAuthorID == nil || $0.authorID == localAuthorID) + }?.cells ?? [:] + for (position, cell) in grid { let r = position.row let c = position.col guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue } + // A non-nil `enqueuedAt` means the user typed here and the edit + // may still be buffered in `MovesUpdater`. Retire the flag only + // once the local row shows this cell at a timestamp >= the flag + // (the edit, or a newer one, has landed); until then leave the + // value fields alone so the just-typed letter stays on screen. + if let stamp = game.squares[r][c].enqueuedAt { + guard let landed = localCells[position], + landed.updatedAt >= stamp + else { continue } + game.squares[r][c].enqueuedAt = nil + } game.squares[r][c].entry = cell.letter game.squares[r][c].mark = decodeMark(kind: cell.markKind, checkedWrong: cell.checkedWrong) game.squares[r][c].letterAuthorID = cell.authorID diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift @@ -70,7 +70,8 @@ actor MovesUpdater { markKind: Int16, checkedWrong: Bool, authorID: String?, - actingAuthorID: String? = nil + actingAuthorID: String? = nil, + enqueuedAt: Date = Date() ) async { let key = Key(gameID: gameID, row: row, col: col) buffer[key] = Pending( @@ -78,7 +79,7 @@ actor MovesUpdater { markKind: markKind, checkedWrong: checkedWrong, authorID: authorID, - enqueuedAt: Date() + enqueuedAt: enqueuedAt ) scheduleDebounce() } diff --git a/Tests/Unit/PendingEditFlagTests.swift b/Tests/Unit/PendingEditFlagTests.swift @@ -0,0 +1,252 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down the disappearing-letter race. While a typed letter is buffered +/// in `MovesUpdater` (not yet in `MovesEntity`), `Square.enqueuedAt` is set +/// and `GameStore.restore` must not revert the cell from the merged grid on a +/// remote refresh. The flag is retired by `restore` itself, once the local +/// device's row shows the cell at a timestamp >= the flag. +@Suite("Pending-edit flag", .serialized) +@MainActor +struct PendingEditFlagTests { + + private static let localAuthorID = "local-author" + private static let otherAuthorID = "other-author" + + private static let puzzleSource = """ + Title: Test Puzzle + Author: Test + + + ABC + D#E + FGH + + + A1. Across 1 ~ ABC + A4. Across 4 ~ DE + A5. Across 5 ~ FGH + D1. Down 1 ~ ADF + D2. Down 2 ~ BG + D3. Down 3 ~ CEH + """ + + private func seedGame(in ctx: NSManagedObjectContext) throws -> (GameEntity, UUID) { + let gameID = UUID() + let xd = try XD.parse(Self.puzzleSource) + let puzzle = Puzzle(xd: xd) + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Test" + entity.puzzleSource = Self.puzzleSource + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + entity.populateCachedSummaryFields(from: puzzle) + try ctx.save() + return (entity, gameID) + } + + /// Upserts a `MovesEntity` row for `(authorID, deviceID)` carrying a + /// single cell at (0,0). + private func writeMovesRow( + for entity: GameEntity, + gameID: UUID, + authorID: String, + deviceID: String, + letter: String, + updatedAt: Date, + in ctx: NSManagedObjectContext + ) throws { + let recordName = RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: authorID, + deviceID: deviceID + ) + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + req.fetchLimit = 1 + let row = (try? ctx.fetch(req).first) ?? MovesEntity(context: ctx) + row.game = entity + row.authorID = authorID + row.deviceID = deviceID + row.ckRecordName = recordName + let cells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: letter, + markKind: 1, + checkedWrong: false, + updatedAt: updatedAt, + authorID: authorID + ) + ] + row.cells = try MovesCodec.encode(cells) + row.updatedAt = updatedAt + try ctx.save() + } + + private func makeStore(_ persistence: PersistenceController) -> GameStore { + makeTestStore( + persistence: persistence, + authorIDProvider: { Self.localAuthorID } + ) + } + + @Test("Remote refresh during the debounce window keeps the typed letter") + func remoteRefreshKeepsBufferedLetter() throws { + let persistence = makeTestPersistence() + let store = makeStore(persistence) + let ctx = persistence.viewContext + let (entity, gameID) = try seedGame(in: ctx) + + let t0 = Date(timeIntervalSinceNow: -10) + try writeMovesRow( + for: entity, gameID: gameID, + authorID: Self.localAuthorID, + deviceID: RecordSerializer.localDeviceID, + letter: "A", updatedAt: t0, in: ctx + ) + + let (game, _) = try store.loadGame(id: gameID) + #expect(game.squares[0][0].entry == "A") + + // User types "B" over "A". GameMutator stamps the flag synchronously; + // the buffered edit has NOT reached MovesEntity yet (still "A"@t0). + let t1 = Date() + game.squares[0][0].entry = "B" + game.squares[0][0].enqueuedAt = t1 + + // An inbound remote refresh re-runs restore against the merged grid, + // which still resolves to "A". + store.refreshCurrentGame() + + #expect(game.squares[0][0].entry == "B") + #expect(game.squares[0][0].enqueuedAt == t1) + } + + @Test("Flag retires once the edit lands in the local row") + func flagRetiresAfterFlush() throws { + let persistence = makeTestPersistence() + let store = makeStore(persistence) + let ctx = persistence.viewContext + let (entity, gameID) = try seedGame(in: ctx) + + let t0 = Date(timeIntervalSinceNow: -10) + try writeMovesRow( + for: entity, gameID: gameID, + authorID: Self.localAuthorID, + deviceID: RecordSerializer.localDeviceID, + letter: "A", updatedAt: t0, in: ctx + ) + + let (game, _) = try store.loadGame(id: gameID) + + let t1 = Date() + game.squares[0][0].entry = "B" + game.squares[0][0].enqueuedAt = t1 + + // Simulate the MovesUpdater flush: "B" lands in the local row with + // updatedAt == the flag's timestamp. + try writeMovesRow( + for: entity, gameID: gameID, + authorID: Self.localAuthorID, + deviceID: RecordSerializer.localDeviceID, + letter: "B", updatedAt: t1, in: ctx + ) + + store.refreshCurrentGame() + + #expect(game.squares[0][0].entry == "B") + #expect(game.squares[0][0].enqueuedAt == nil) + } + + @Test("A newer same-cell edit is not retired by an older landed value") + func newerEditNotRetiredByOlderLanded() throws { + let persistence = makeTestPersistence() + let store = makeStore(persistence) + let ctx = persistence.viewContext + let (entity, gameID) = try seedGame(in: ctx) + + let t0 = Date(timeIntervalSinceNow: -10) + try writeMovesRow( + for: entity, gameID: gameID, + authorID: Self.localAuthorID, + deviceID: RecordSerializer.localDeviceID, + letter: "A", updatedAt: t0, in: ctx + ) + + let (game, _) = try store.loadGame(id: gameID) + + // "B" typed and flushed (lands at t1)... + let t1 = Date(timeIntervalSinceNow: -1) + try writeMovesRow( + for: entity, gameID: gameID, + authorID: Self.localAuthorID, + deviceID: RecordSerializer.localDeviceID, + letter: "B", updatedAt: t1, in: ctx + ) + // ...then the user re-types "C" before the next flush. The flag now + // carries t2 > t1; the local row is still at "B"@t1. + let t2 = Date() + game.squares[0][0].entry = "C" + game.squares[0][0].enqueuedAt = t2 + + store.refreshCurrentGame() + + // t1 >= t2 is false, so the flag must not retire and "C" must stay. + #expect(game.squares[0][0].entry == "C") + #expect(game.squares[0][0].enqueuedAt == t2) + } + + @Test("Diverged peer write converges to the LWW winner after flush") + func divergesThenConvergesToLWWWinner() throws { + let persistence = makeTestPersistence() + let store = makeStore(persistence) + let ctx = persistence.viewContext + let (entity, gameID) = try seedGame(in: ctx) + + let t0 = Date(timeIntervalSinceNow: -10) + try writeMovesRow( + for: entity, gameID: gameID, + authorID: Self.localAuthorID, + deviceID: RecordSerializer.localDeviceID, + letter: "A", updatedAt: t0, in: ctx + ) + + let (game, _) = try store.loadGame(id: gameID) + + // Local user types "B" (buffered, not landed). + let t1 = Date(timeIntervalSinceNow: -1) + game.squares[0][0].entry = "B" + game.squares[0][0].enqueuedAt = t1 + + // A peer writes "Z" with a newer timestamp; it arrives via fetch. + let t2 = Date() + try writeMovesRow( + for: entity, gameID: gameID, + authorID: Self.otherAuthorID, + deviceID: "device-other", + letter: "Z", updatedAt: t2, in: ctx + ) + + store.refreshCurrentGame() + // Diverged: the still-buffered local edit wins on screen. + #expect(game.squares[0][0].entry == "B") + + // Local "B" flushes (lands at t1). Next refresh retires the flag and + // adopts the merged grid; peer "Z"@t2 beats local "B"@t1. + try writeMovesRow( + for: entity, gameID: gameID, + authorID: Self.localAuthorID, + deviceID: RecordSerializer.localDeviceID, + letter: "B", updatedAt: t1, in: ctx + ) + store.refreshCurrentGame() + + #expect(game.squares[0][0].entry == "Z") + #expect(game.squares[0][0].enqueuedAt == nil) + } +}