commit 1b77a5259412e99213de995d4226061ba4702783
parent 11effb7e619b71a9199f63901cd33c6de6f5555a
Author: Michael Camilleri <[email protected]>
Date: Wed, 3 Jun 2026 04:37:42 +0900
Memoise the assembled replay timeline per game
Caching the contributors' journals in Core Data killed the CloudKit
round trip on re-entry, but the merge itself still re-ran every time:
PuzzleView holds the ReplayController as fresh @State, so rapid
finish-banner navigation tears the view down and recreates it, and each
new controller starts at .idle and re-assembles the whole timeline from
the cached rows. The controller's own load is already idempotent against
a ready/loading status, so this only bit across view recreation, never
within a stable view — and the cost is an in-memory sort plus per-cell
state rebuild, not a network call, so it was latent rather than visible.
A diagnostic showed the rebuild firing three times in six seconds during
quick navigation.
The assembled ReplayTimeline is now memoised per game on AppServices and
served verbatim on re-entry. Both construction paths — the local-only
ReplayTimeline(merging:) and the merged services.loadReplay — funnel
through one gate, ReplayAssembler.memoised, which returns a cached
timeline as .ready without re-running the merge and otherwise caches the
fresh result. Only a .ready assembly is cached; .waiting and
.unavailable stay uncached so retry() still re-checks when a
late-syncing contributor's journal finally lands. A 'served from
timeline memo' note on the hit path makes the skip visible in the
diagnostics stream.
This is correct only because a finished game's journals are frozen by
edit-lockout, so a once-assembled timeline can never go stale; the cache
therefore needs no invalidation and lives for the session. loadReplay is
reachable only from the solved-only Success Panel, so a non-finished
game never reaches the memo.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 131 insertions(+), 21 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -461,31 +461,47 @@ private struct PuzzleDisplayView: View {
},
onDelete: { try store.deleteGame(id: gameID) },
loadReplay: {
- // Local-first: if no *other* device wrote into this
- // game, this device's journal is the whole history, so
- // replay needs no CloudKit. `contributingDevices` reads
- // the per-device MovesEntity rows — the device-level
- // signal the author-keyed roster can't give, so it sees
- // this account's own second device, not just other
- // people. Any other contributor → merged fetch, which
- // gates on every contributing device's journal.
- let entries = store.localJournalEntries(for: gameID)
- let localDeviceID = RecordSerializer.localDeviceID
- let otherDevices = store.contributingDevices(for: gameID)
- .filter { $0.deviceID != localDeviceID }
let short = gameID.uuidString.prefix(8)
- if otherDevices.isEmpty {
+ // Finished-game timelines are immutable (edit-lockout),
+ // so a cached assembly is reused verbatim on re-entry —
+ // this is what stops rapid nav from re-running the merge
+ // each time a fresh `ReplayController` calls in.
+ return await ReplayAssembler.memoised(
+ cached: services.cachedReplayTimeline(gameID: gameID),
+ onHit: { cached in
+ services.syncMonitor.note(
+ "replay[\(short)]: served from timeline memo " +
+ "(steps=\(cached.count))"
+ )
+ },
+ store: { services.cacheReplayTimeline($0, gameID: gameID) }
+ ) {
+ // Local-first: if no *other* device wrote into this
+ // game, this device's journal is the whole history,
+ // so replay needs no CloudKit. `contributingDevices`
+ // reads the per-device MovesEntity rows — the
+ // device-level signal the author-keyed roster can't
+ // give, so it sees this account's own second device,
+ // not just other people. Any other contributor →
+ // merged fetch, which gates on every contributing
+ // device's journal.
+ let entries = store.localJournalEntries(for: gameID)
+ let localDeviceID = RecordSerializer.localDeviceID
+ let otherDevices = store.contributingDevices(for: gameID)
+ .filter { $0.deviceID != localDeviceID }
+ if otherDevices.isEmpty {
+ services.syncMonitor.note(
+ "replay[\(short)]: local-only path " +
+ "(no other contributing devices), localEntries=\(entries.count)"
+ )
+ return .ready(ReplayTimeline(merging: [entries]))
+ }
services.syncMonitor.note(
- "replay[\(short)]: local-only path " +
- "(no other contributing devices), localEntries=\(entries.count)"
+ "replay[\(short)]: merged path, " +
+ "otherDevices=\(otherDevices.count), localEntries=\(entries.count)"
)
- return .ready(ReplayTimeline(merging: [entries]))
+ return await services.loadReplay(gameID: gameID)
}
- services.syncMonitor.note(
- "replay[\(short)]: merged path, " +
- "otherDevices=\(otherDevices.count), localEntries=\(entries.count)"
- )
- return await services.loadReplay(gameID: gameID)
}
)
} else if let loadError {
diff --git a/Crossmate/Persistence/JournalReplay.swift b/Crossmate/Persistence/JournalReplay.swift
@@ -154,4 +154,29 @@ enum ReplayAssembler {
}
return .ready(ReplayTimeline(merging: Array(byDevice.values)))
}
+
+ /// Gates assembly on a per-game memo so re-entry doesn't re-run the merge.
+ /// A `cached` timeline short-circuits to `.ready` (a finished game's
+ /// journals are frozen, so it can never go stale); otherwise `assemble`
+ /// runs and *only* a `.ready` result is handed to `store` to cache —
+ /// `.waiting`/`.unavailable` stay uncached so they remain retryable. Pure
+ /// orchestration with its IO injected, so it's testable without
+ /// `AppServices`. `onHit` is a logging-only side hook for the cache-hit path.
+ @MainActor
+ static func memoised(
+ cached: ReplayTimeline?,
+ onHit: (ReplayTimeline) -> Void = { _ in },
+ store: (ReplayTimeline) -> Void,
+ assemble: () async -> JournalReplayResult
+ ) async -> JournalReplayResult {
+ if let cached {
+ onHit(cached)
+ return .ready(cached)
+ }
+ let result = await assemble()
+ if case .ready(let timeline) = result {
+ store(timeline)
+ }
+ return result
+ }
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -2731,6 +2731,26 @@ final class AppServices {
}
}
+ /// Assembled replay timelines, keyed by game. A finished game's journals are
+ /// frozen (edit-lockout), so its timeline never changes once built — caching
+ /// it here lets a `ReplayController` recreated by rapid finish-banner nav
+ /// re-entry skip re-running `ReplayAssembler.assemble`. Only `.ready` results
+ /// land here; `.waiting`/`.unavailable` stay retryable.
+ private var replayTimelineCache: [UUID: ReplayTimeline] = [:]
+
+ /// A previously assembled timeline for `gameID`, if one was cached this
+ /// session.
+ func cachedReplayTimeline(gameID: UUID) -> ReplayTimeline? {
+ replayTimelineCache[gameID]
+ }
+
+ /// Caches a fully assembled timeline so re-entry skips the re-merge. Safe
+ /// because the caller only ever passes a finished game's `.ready` result,
+ /// whose journals are frozen.
+ func cacheReplayTimeline(_ timeline: ReplayTimeline, gameID: UUID) {
+ replayTimelineCache[gameID] = timeline
+ }
+
/// Loads a finished game's replay: fetches every device's journal from
/// CloudKit, overlays this device's live log, and gates on strict
/// completeness. `.ready` carries a merged timeline; `.waiting(missing:)`
diff --git a/Tests/Unit/JournalReplayTests.swift b/Tests/Unit/JournalReplayTests.swift
@@ -203,4 +203,53 @@ struct JournalReplayTests {
}
#expect(timeline.count == 2) // live two-entry log, not the stale one
}
+
+ // MARK: - Per-game memo
+
+ @Test("a cached timeline short-circuits without re-assembling")
+ func memoServesCache() async {
+ let cached = ReplayTimeline(merging: [[entry(seq: 0, at: 10, row: 0, col: 0, letter: "A")]])
+ var assembleCalls = 0
+ var hits = 0
+
+ let result = await ReplayAssembler.memoised(
+ cached: cached,
+ onHit: { _ in hits += 1 },
+ store: { _ in Issue.record("must not cache on a hit") }
+ ) {
+ assembleCalls += 1
+ return .unavailable
+ }
+
+ #expect(result == .ready(cached))
+ #expect(assembleCalls == 0) // never re-ran the merge
+ #expect(hits == 1)
+ }
+
+ @Test("a ready assembly is cached; waiting/unavailable are not")
+ func memoCachesOnlyReady() async {
+ // Ready → stored.
+ var storedReady: ReplayTimeline?
+ let ready = ReplayTimeline(merging: [[entry(seq: 0, at: 10, row: 0, col: 0, letter: "A")]])
+ _ = await ReplayAssembler.memoised(cached: nil, store: { storedReady = $0 }) { .ready(ready) }
+ #expect(storedReady == ready)
+
+ // Waiting → never stored, stays retryable.
+ var storedWaiting = false
+ let waiting = await ReplayAssembler.memoised(
+ cached: nil,
+ store: { _ in storedWaiting = true }
+ ) { .waiting(missing: 2) }
+ #expect(waiting == .waiting(missing: 2))
+ #expect(!storedWaiting)
+
+ // Unavailable → never stored.
+ var storedUnavailable = false
+ let unavailable = await ReplayAssembler.memoised(
+ cached: nil,
+ store: { _ in storedUnavailable = true }
+ ) { .unavailable }
+ #expect(unavailable == .unavailable)
+ #expect(!storedUnavailable)
+ }
}