commit b65bba508ef1e12f7f634ed0e63e94517e193d0b
parent 787275c98b4bd03ae014994fd026f2e11e9e4f81
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 15:35:32 +0900
Preload the move journal before enabling Undo
This commit stops the puzzle controls from synchronously loading the
entire local move journal just to decide whether Undo or Redo should be
enabled. The previous canUndo and canRedo path could run a blocking Core
Data fetch on first render after opening a puzzle.
Now a new GameMutator starts an async journal preload on creation, and
the controls stay disabled until that preload has populated the
in-memory stack. The actual record, undo, and redo paths still force a
synchronous load when needed, so an early edit remains correct while the
render path stays cheap. Stale async loads are generation-guarded so
they cannot replace a newer synchronous snapshot.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 74 insertions(+), 16 deletions(-)
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -78,6 +78,7 @@ final class GameMutator {
self.isShared = isShared
self.isAccessRevoked = isAccessRevoked
self.isCompleted = isCompleted
+ prefetchJournal()
}
// MARK: - Single-cell mutations
@@ -183,6 +184,15 @@ final class GameMutator {
return movesJournal.canRedo(gameID: gameID)
}
+ func prefetchJournal() {
+ guard let movesJournal else { return }
+ Task { @MainActor [weak self, movesJournal, gameID] in
+ await movesJournal.preload(gameID: gameID)
+ guard let self, self.gameID == gameID else { return }
+ self.journalRevision += 1
+ }
+ }
+
/// Reverts the most recent still-standing move. Each restored cell is
/// applied as a fresh forward mutation (so it syncs like any edit) and
/// recorded as an `undo` row. Cells a collaborator has changed since are
diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift
@@ -138,6 +138,8 @@ final class MovesJournal {
private var bySeq: [Int64: JournalValue] = [:]
private var lastSeqAtCell: [GridPosition: Int64] = [:]
private var nextSeq: Int64 = 0
+ private var loadingGameID: UUID?
+ private var loadGeneration = 0
/// Steps whose every cell was found superseded when the caller tried to
/// apply them, so they should be passed over. Transient (session-only): a
@@ -213,12 +215,12 @@ final class MovesJournal {
// MARK: - Undo / redo queries
func canUndo(gameID: UUID) -> Bool {
- ensureLoaded(gameID)
+ guard loadedGameID == gameID else { return false }
return stacks().live.contains { !consumedUndoSteps.contains(stepID($0)) }
}
func canRedo(gameID: UUID) -> Bool {
- ensureLoaded(gameID)
+ guard loadedGameID == gameID else { return false }
return stacks().redo.contains { !consumedRedoSteps.contains(stepID($0)) }
}
@@ -358,12 +360,25 @@ final class MovesJournal {
// MARK: - Loading & persistence
+ func preload(gameID: UUID) async {
+ guard loadedGameID != gameID else { return }
+ loadGeneration += 1
+ let generation = loadGeneration
+ loadingGameID = gameID
+ let loaded = await loadValues(gameID)
+ guard generation == loadGeneration else { return }
+ applyLoaded(loaded, for: gameID)
+ loadingGameID = nil
+ }
+
private func ensureLoaded(_ gameID: UUID) {
guard loadedGameID != gameID else { return }
- load(gameID)
+ loadGeneration += 1
+ loadingGameID = nil
+ applyLoaded(loadValuesSynchronously(gameID), for: gameID)
}
- private func load(_ gameID: UUID) {
+ private func applyLoaded(_ loaded: [JournalValue], for gameID: UUID) {
entries.removeAll()
bySeq.removeAll()
lastSeqAtCell.removeAll()
@@ -371,18 +386,6 @@ final class MovesJournal {
consumedRedoSteps.removeAll()
nextSeq = 0
loadedGameID = gameID
-
- let ctx = backgroundContext
- let loaded: [JournalValue] = ctx.performAndWait {
- let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity")
- // `sourceDeviceID == nil` keeps this to *this device's* log: rows
- // cached from other devices for replay (see GameStore's replay
- // cache) carry a source key and must not enter the undo/redo model.
- req.predicate = NSPredicate(format: "gameID == %@ AND sourceDeviceID == nil", gameID as CVarArg)
- req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)]
- let rows = (try? ctx.fetch(req)) ?? []
- return rows.map(Self.value(from:))
- }
for value in loaded {
entries.append(value)
bySeq[value.seq] = value
@@ -391,6 +394,31 @@ final class MovesJournal {
}
}
+ private func loadValues(_ gameID: UUID) async -> [JournalValue] {
+ let ctx = backgroundContext
+ return await ctx.perform {
+ Self.fetchValues(for: gameID, in: ctx)
+ }
+ }
+
+ private func loadValuesSynchronously(_ gameID: UUID) -> [JournalValue] {
+ let ctx = backgroundContext
+ return ctx.performAndWait {
+ Self.fetchValues(for: gameID, in: ctx)
+ }
+ }
+
+ private static func fetchValues(for gameID: UUID, in ctx: NSManagedObjectContext) -> [JournalValue] {
+ let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity")
+ // `sourceDeviceID == nil` keeps this to *this device's* log: rows cached
+ // from other devices for replay carry a source key and must not enter
+ // the undo/redo model.
+ req.predicate = NSPredicate(format: "gameID == %@ AND sourceDeviceID == nil", gameID as CVarArg)
+ req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)]
+ let rows = (try? ctx.fetch(req)) ?? []
+ return rows.map(Self.value(from:))
+ }
+
/// Awaits the background persistence queue so every recorded entry has
/// landed in the store. `record(...)` persists asynchronously and the
/// in-memory list is authoritative for play, but the Phase 2 upload
diff --git a/Tests/Unit/MovesJournalTests.swift b/Tests/Unit/MovesJournalTests.swift
@@ -258,6 +258,26 @@ struct MovesJournalTests {
#expect(journal.canUndo(gameID: gameID)) // clear (top) is undoable
}
+ @Test("undo availability stays disabled until persisted journal preload finishes")
+ func undoAvailabilityWaitsForPreload() async throws {
+ let (_, _, entity, persistence) = try makeTestGame()
+ let gameID = try #require(entity.id)
+ let pos = GridPosition(row: 0, col: 0)
+ let writer = MovesJournal(persistence: persistence)
+ writer.record(
+ gameID: gameID, position: pos,
+ state: JournalCellState(letter: "A", mark: .none, cellAuthorID: nil),
+ actingAuthorID: nil, kind: .input, targetSeq: nil, batchID: nil
+ )
+ await writer.flush()
+
+ let reader = MovesJournal(persistence: persistence)
+ #expect(!reader.canUndo(gameID: gameID))
+
+ await reader.preload(gameID: gameID)
+ #expect(reader.canUndo(gameID: gameID))
+ }
+
// MARK: - Supersession guard
@Test("undo skips a cell a collaborator changed since")