crossmate

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

commit dbc7c471e765c111d5e9c376c307c8575307e794
parent b467735e62788bb9a811da6a0432f54b1dfa0a67
Author: Michael Camilleri <[email protected]>
Date:   Thu,  4 Jun 2026 23:53:13 +0900

Apply remote moves as a diff in GameStore.restore

A co-solving flurry could feel choppy even if the live engagement socket
stayed fast (~185ms). The cost is suspected to be on the main actor:
every inbound Moves record drove refreshCurrentGame -> restore, which
merged every device's grid and then wrote all squares into the
@Observable Game.squares unconditionally, followed by a full
recomputeCompletionCache scan. Because each Square field-write fires the
squares observation, a single peer keystroke forced a grid-body
re-evaluation and a completion rescan — at the collaborator's typing
speed, on top of the local user's own input.

The apply loop is now a guarded diff. A cell whose entry, mark, and
letterAuthorID already match the merged value (and that needs no
enqueuedAt clearing) is skipped; a changed cell is rebuilt locally and
assigned once, firing a single squares mutation rather than one per
field; and recomputeCompletionCache runs only when at least one cell
moved. A redundant catch-up — common in a flurry, where most inbound
records re-assert state already on screen — is now a true main-actor
no-op. The enqueuedAt mid-debounce guard and the completed-game
sealToSolution branch are unchanged.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 37+++++++++++++++++++++++++++++++------
1 file changed, 31 insertions(+), 6 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1502,26 +1502,51 @@ final class GameStore { && (localAuthorID == nil || $0.authorID == localAuthorID) }?.cells ?? [:] + // Apply the merge as a diff: an inbound catch-up usually carries one + // peer keystroke, yet the merged grid spans the whole board. Writing + // every square unconditionally fires the `@Observable squares` + // hundreds of times per catch-up — invalidating the grid view and + // re-running the completion scan — even when the merged result is + // identical to what's already on screen. Touch only the cells whose + // value actually changed, and rebuild the completion cache only if at + // least one did, so a redundant catch-up becomes a true no-op on the + // main actor (the co-solve hot path). + var changed = false 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 } + let current = game.squares[r][c] // 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 { + var clearEnqueued = false + if let stamp = current.enqueuedAt { guard let landed = localCells[position], landed.updatedAt >= stamp else { continue } - game.squares[r][c].enqueuedAt = nil + clearEnqueued = true } - game.squares[r][c].entry = cell.letter - game.squares[r][c].mark = cell.mark - game.squares[r][c].letterAuthorID = cell.authorID + guard clearEnqueued + || current.entry != cell.letter + || current.mark != cell.mark + || current.letterAuthorID != cell.authorID + else { continue } + // Build the new square locally and assign once, so the cell fires + // a single `squares` mutation rather than one per field. + var updated = current + updated.enqueuedAt = clearEnqueued ? nil : current.enqueuedAt + updated.entry = cell.letter + updated.mark = cell.mark + updated.letterAuthorID = cell.authorID + game.squares[r][c] = updated + changed = true + } + if changed { + game.recomputeCompletionCache() } - game.recomputeCompletionCache() if updateCache { updateCellCache(for: entity, from: grid)