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:
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)