commit f9583647242fa49b1c146c73dd887d3e0f354db4
parent 6cc37b4bd278bb5fad64012ee1b9db59e2aa28d3
Author: Michael Camilleri <[email protected]>
Date: Thu, 14 May 2026 13:07:04 +0900
Trace PlayerRoster only after the post-fetch publish
Each refresh of PlayerRoster publishes twice: once immediately with share: nil
so the menu has something to render, then again after fetching the CKShare.
The diagnostic trace lived inside applyRoster and ran on both passes, which
guaranteed two different signatures per refresh (share=[] followed by
share=[…]). The lastTracedSignature dedup could never collapse them, and rapid
refresh cycles surfaced as a share=[] ↔ share=[…] flicker in the logs.
Move the signature emission out of applyRoster into a traceRoster helper that
refresh calls once, after the post-fetch applyRoster. The interim publish stops
emitting a trace, the dedup is no longer fighting against itself, and the two
publishes still happen for UI responsiveness.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
1 file changed, 31 insertions(+), 15 deletions(-)
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -204,6 +204,16 @@ final class PlayerRoster {
ckZoneOwnerName: ckZoneOwnerName
)
applyRoster(localAuthorID: localAuthorID, namesMap: namesMap, moveAuthorIDs: moveAuthorIDs, rawSelections: rawSelections, share: share)
+ // Trace only the post-fetch roster. The interim publish above always
+ // has `share: nil` and would otherwise emit a second signature on
+ // every refresh, defeating the dedup and producing the
+ // `share=[]` ↔ `share=[…]` flicker seen in the logs.
+ traceRoster(
+ localAuthorID: localAuthorID,
+ namesMap: namesMap,
+ moveAuthorIDs: moveAuthorIDs,
+ share: share
+ )
}
private func applyRoster(
@@ -235,21 +245,6 @@ final class PlayerRoster {
otherAuthorIDs.insert(authorID)
}
- // Diagnostic — surfaces the inputs that drive the entries list so we
- // can tell whether a "ghost" authorID came from a stale PlayerEntity,
- // a stray MovesEntity, or the share's participant list. Trim noisy
- // re-entries by only emitting when the signature actually changes.
- if let tracer {
- let participantIDs: [String] = share?.participants.compactMap {
- $0.userIdentity.userRecordID?.recordName
- } ?? []
- let signature = "local=\(localAuthorID) | names=\(namesMap.keys.sorted()) | moves=\(moveAuthorIDs.sorted()) | share=\(participantIDs.sorted())"
- if signature != lastTracedSignature {
- lastTracedSignature = signature
- tracer("PlayerRoster[\(gameID.uuidString.prefix(8))]: \(signature)")
- }
- }
-
// Build remote entries, assigning stable colours.
var remoteEntries: [Entry] = []
let reservedColorIDs: Set<String> = [preferences.color.id]
@@ -324,6 +319,27 @@ final class PlayerRoster {
// MARK: - Private helpers
+ /// Diagnostic — surfaces the inputs that drive the entries list so we
+ /// can tell whether a "ghost" authorID came from a stale `PlayerEntity`,
+ /// a stray `MovesEntity`, or the share's participant list. Called once
+ /// per refresh after the final `applyRoster` so the interim `share: nil`
+ /// publish doesn't push a second signature through and defeat the dedup.
+ private func traceRoster(
+ localAuthorID: String,
+ namesMap: [String: String],
+ moveAuthorIDs: [String],
+ share: CKShare?
+ ) {
+ guard let tracer else { return }
+ let participantIDs: [String] = share?.participants.compactMap {
+ $0.userIdentity.userRecordID?.recordName
+ } ?? []
+ let signature = "local=\(localAuthorID) | names=\(namesMap.keys.sorted()) | moves=\(moveAuthorIDs.sorted()) | share=\(participantIDs.sorted())"
+ guard signature != lastTracedSignature else { return }
+ lastTracedSignature = signature
+ tracer("PlayerRoster[\(gameID.uuidString.prefix(8))]: \(signature)")
+ }
+
private func fetchShare(
databaseScope: Int16,
ckShareRecordName: String?,