I'm developing a SwiftUI app using SwiftData and encountering a persistent issue:
Error Message:
Thread 1: Fatal error: Duplicate keys of type 'Bland' were found in a Dictionary.
This usually means either that the type violates Hashable's requirements, or that members of such a dictionary were mutated after insertion.
Details:
Occurrence: The error always occurs on the first launch of the app after installation. Specifically, it happens approximately 1 minute after the app starts. Inconsistent Behavior: Despite no changes to the code or server data, the error occurs inconsistently. Data Fetching Process:
I fetch data for entities (Bland, CrossZansu, and Trade) from the server using the following process:
Fetch Bland and CrossZansu entities via URLSession. Insert or update these entities into the SwiftData context. The fetched data is managed as follows:
func refleshBlandsData() async throws {
if let blandsOnServer = try await DataModel.shared.getBlands() {
await MainActor.run {
blandsOnServer.forEach { blandOnServer in
if let blandOnLocal = blandList.first(where: { $0.code == blandOnServer.code }) {
blandOnLocal.update(serverBland: blandOnServer)
} else {
modelContext.insert(blandOnServer.bland)
}
}
}
}
}
This is a simplified version of my StockListView. The blandList is a @Query property and dynamically retrieves data from SwiftData:
struct StockListView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Bland.sname) var blandList: [Bland]
@Query var users: [User]
@State private var isNotLoaded = true
@State private var isLoading = false
@State private var loadingErrorState = ""
var body: some View {
NavigationStack {
List {
ForEach(blandList, id: \.code) { bland in
NavigationLink(value: bland) {
Text(bland.sname)
}
}
}
.navigationTitle("Stock List")
.onAppear {
doIfFirst()
}
}
}
// This function handles data loading when the app launches for the first time
func doIfFirst() {
if isNotLoaded {
loadDataWithAnimationIfNotLoading()
isNotLoaded = false
}
}
// This function ensures data is loaded with an animation and avoids multiple triggers
func loadDataWithAnimationIfNotLoading() {
if !isLoading {
isLoading = true
Task {
do {
try await loadData()
} catch {
// Capture and store any errors during data loading
loadingErrorState = "Data load failed: \(error.localizedDescription)"
}
isLoading = false
}
}
}
// Fetch data from the server and insert it into the SwiftData model context
func loadData() async throws {
if let blandsOnServer = try await DataModel.shared.getBlands() {
for bland in blandsOnServer {
// Avoid inserting duplicate keys by checking for existing items in blandList
if !blandList.contains(where: { $0.code == bland.code }) {
modelContext.insert(bland.bland)
}
}
}
}
}
Entity Definitions:
Here are the main entities involved:
Bland:
@Model
class Bland: Identifiable {
@Attribute(.unique) var code: String
var sname: String
@Relationship(deleteRule: .cascade, inverse: \CrossZansu.bland)
var zansuList: [CrossZansu]
@Relationship(deleteRule: .cascade, inverse: \Trade.bland)
var trades: [Trade]
}
CrossZansu:
@Model
class CrossZansu: Equatable {
@Attribute(.unique) var id: String
var brocker: Broker?
var zansu: Int
var bland: Bland?
}
Trade:
@Model
class Trade {
@Relationship(deleteRule: .nullify)
var user: User?
var date: Date
var bland: Bland
var amountOfKabus: Int
var broker: Broker
var k_saishu: String?
var tradeState: TradeState
init(user: User?, date: Date, bland: Bland, amountOfKabus: Int, broker: Broker) {
self.user = user
self.date = date
self.bland = bland
self.amountOfKabus = amountOfKabus
self.broker = broker
self.tradeState = .traiding
}
var id: String {
(user?.id.description ?? "nil") + date.description + bland.code + amountOfKabus.description + broker.rawValue
}
}
User:
class User {
var id: UUID
@Relationship(deleteRule: .cascade, inverse: \Trade.user)
var trades: [Trade]
}
Observations:
Error Context: The error occurs after the data is fetched and inserted into SwiftData. This suggests an issue with Hashable requirements or duplicate keys being inserted unintentionally. Concurrency Concerns: The fetch and update operations are performed in asynchronous tasks. Could this cause race conditions? Questions:
Could this issue be related to how @Relationship and @Attribute(.unique) are managed in SwiftData? What are potential pitfalls with Equatable implementations (e.g., in CrossZansu) when used in SwiftData entities? Are there any recommended approaches for debugging "Duplicate keys" errors in SwiftData? Additional Info: Error Timing: The error occurs only during the app's first launch and consistently within the first minute.
All of the StockListView code is listed below.
import SwiftUI
import SwiftData
import Flow
import os
struct StockListView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.modelContext) private var modelContext
@Query(sort: \Bland.sname) var blandList: [Bland]
@Query var users: [User]
@Query var crossZansuList: [CrossZansu]
@AppStorage("last_load") var storedLastLoadTime = Date().timeIntervalSinceReferenceDate
var lastLoadTime: Date {
set {storedLastLoadTime = newValue.timeIntervalSinceReferenceDate}
get {return Date(timeIntervalSinceReferenceDate: storedLastLoadTime)}
}
@State var filterParameter = FilterParameter()
var displayBlands: [Bland] {
return filterParameter.filterBlands(allBlands: blandList)
}
@State var isShowSettingView = false
@State var isShowFilteringView = false
@State var sortState = SortState.rateOfReturn
// Related to loading
@State var isNotLoaded = true // Only true on the first app launch to prevent loading data after onAppear is called for the second time
@State private var isLoading = false
@State var loadingErrorState = ""
// MARK: - View Components
var body: some View {
rootView
.sheet(isPresented: $isShowSettingView, content: {
SettingView()
})
.sheet(isPresented: $isShowFilteringView, content: {
BlandFilterView(filterParameter: $filterParameter, displayBlands: displayBlands)
})
.accentColor(Color.green)
.onAppear {
doIfFirst()
}
}
private var rootView: some View {
NavigationStack {
navigationContent
.navigationTitle("Stock Search")
.navigationDestination(for: Bland.self) { bland in
AStockDetailView(bland: bland)
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button( action: {
isShowSettingView.toggle()
}) {
Image(systemName: "gear")
}
}
ToolbarItemGroup(placement: .topBarTrailing) {
Menu {
Picker("", selection: $sortState) {
ForEach(SortState.allCases, id: \.title) { sortState in
Text(sortState.title)
}
}
} label: {
Image(systemName: "arrow.up.arrow.down")
}
Button {
isShowFilteringView.toggle()
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
}
}
}
private var navigationContent: some View {
scrollView
.appBackground()
.refreshable {
await loadDataWithAnimationIfNotLoading()
}
}
private var scrollView: some View {
ScrollView {
HStack(spacing: 2) {
Text("Last Updated:")
if isLoading {
Text("Fetching data...")
.font(.body)
.fontWeight(.regular)
.padding(.leading)
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
} else {
if loadingErrorState.isEmpty {
Text("\(lastLoadTime.convertedString(format: "yyyy/MM/dd HH:mm"))")
} else {
Text(loadingErrorState)
.foregroundStyle(Color.red)
}
}
Spacer()
}
.foregroundStyle(.secondary)
.fontWeight(.semibold)
.padding(.horizontal, 14)
if blandList.isEmpty {
emptyStateOfEmptyBlands
} else if displayBlands.isEmpty {
emptyStateOfFiltering
}
let sortedBlandList = sortBlandList(blandList, by: sortState)
ForEach(sortedBlandList, id: \.code) { bland in
if displayBlands.contains(bland) {
NavigationLink(value: bland) {
tileView(bland)
.contextMenu(menuItems: {
Button(action: {
UIPasteboard.general.string = bland.code
}){
Label("Copy Stock Code", systemImage: "doc.on.doc")
}
})
.padding(.horizontal, 14)
.padding(.vertical, 6)
}
}
}
}
}
private func tileView(_ bland: Bland) -> some View {
VStack {
HStack(alignment: .lastTextBaseline) {
Text(bland.yutai.prefix(20))
.lineLimit(1)
.minimumScaleFactor(0.8)
.font(.title2)
.fontWeight(.semibold)
Spacer()
FavoriteStar(isFavorite: Binding(get: {
bland.isFavorite
}, set: { bool in
bland.isFavorite = bool
}))
}
Text("\(bland.benefitType.rawValue)・\(bland.realWorth) yen equivalent")
.putTo(side: .left)
.foregroundStyle(.secondary)
if let note = bland.note {
HStack(spacing: 2) {
Image(systemName: "note.text")
.foregroundStyle(Color.accentColor)
Text(note)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
}
.padding(.top, 5)
.padding(.bottom, -5)
.padding(.leading, -2)
}
let columnCount: Int = bland.zansuList.isEmpty ? 3 : 4
let columns: [GridItem] = Array(repeating: .init(.flexible(), alignment: .leading), count: columnCount)
LazyVGrid(columns: columns, spacing: 2) {
label(bland.code, systemName: "tag")
label(bland.k_saishu_formatted(), systemName: "calendar")
if let maxRateOfReturn = bland.maxRateOfReturn {
label(maxRateOfReturn.description + "%", systemName: "chart.bar")
.foregroundStyle(maxRateOfReturn.labelColorIfMinusIsRed())
}
label("\(bland.p_saitei) million yen", systemName: "banknote.fill")
}
.lineLimit(1)
.minimumScaleFactor(0.8)
let isUserSet = users.isUserSet
let bottomColumns: [GridItem] = [GridItem(.flexible(maximum: 50), spacing: 0)] + Array(repeating: GridItem(.flexible(), spacing: 0), count: isUserSet ? 3 : 2)
let brokersOfHasInvestryOrNowHolding: [Broker] = brokersOfHasInvestryOrNowHolding(bland: bland)
if !brokersOfHasInvestryOrNowHolding.isEmpty {
LazyVGrid(columns: bottomColumns, spacing: 3) {
GridRowTitle("")
GridRowTitle("Inventory")
GridRowTitle("Currently Held")
if isUserSet {
GridRowTitle("Available for Trading")
}
ForEach(brokersOfHasInvestryOrNowHolding, id: \.self) { broker in
ForEach(0..<(isUserSet ? 4 : 3)) { _ in
Divider()
}
Text(broker.shortName)
.font(.headline)
if let inventryState = bland.zansuList.first(where: {$0.brocker == broker})?.inventryState {
HStack(spacing: 3) {
Rectangle()
.frame(width: 3, height: 20)
.cornerRadius(5)
.foregroundStyle(inventryState.color)
Text(inventryState.rawValue)
}
} else {
Text("None")
.foregroundStyle(.secondary)
}
if isUserSet {
flowView(users: purchasedUsers(bland: bland, broker: broker))
flowView(users: purchasableUsers(bland: bland, broker: broker))
} else {
let tradesFilteredByBroker = bland.trades.filter({$0.broker == broker})
VStack {
ForEach(tradesFilteredByBroker, id: \.id) { trade in
Text(trade.amountOfKabus.description + " shares")
.greenBackground()
}
}
}
}
}
.padding(.top)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
.tile()
.foregroundStyle(Color(.label))
.dyanamicShadow(colorScheme: colorScheme)
}
private func label(_ text: String, systemName: String) -> some View {
HStack(spacing: 5) {
Image(systemName: systemName)
.resizable()
.scaledToFit()
.frame(width: 16)
.foregroundStyle(.primary)
.foregroundStyle(.green)
Text(text)
}
}
private func flowView(users: [User]) -> some View {
return HFlow(horizontalAlignment: .leading, verticalAlignment: .center, horizontalSpacing: 5, verticalSpacing: 1, justified: false, distributeItemsEvenly: false) {
ForEach(users, id: \.id) { user in
Text(user.name.prefix(1))
.foregroundStyle(Color.accentColor)
.font(.body)
.bold()
.padding(.horizontal, 5)
.background(Color.accentColor.opacity(0.1))
.cornerRadius(7)
}
}
}