SwiftData background inserts using ModelActor do not trigger SwiftUI @Query view updates, but background deletes do

In the WWDC 23 lounge, this exchange with @Dave N (Apple) indicated that using ModelActor is the right way to do background work using swift concurrency. https://developer.apple.com/forums/thread/731338

I've figured out how to use this approach to do background work using swift concurrency (inside a Task), and example code is below for those who find it useful, however I'm not seeing view updates when the background work is complete.

There seems to be no way to trigger a view update based on a @Query when inserts happen in a ModelActor’s context. However, if a delete happens on the ModelActor context, this DOES trigger a view redraw. I believe this is a bug because I expect the behavior to be the same for inserts and deletes. I've submitted this as Feedback FB12689036.

Below is a minimal project which is the default SwiftData template, where the Add and Delete buttons point to a ModelActor instead of the main view ModelContext. You will see that using the addItem function in the ModelActor does not trigger a UI update in ContentView. However if you relaunch the app the added Item will be present. In contrast, using the delete function on the ModelActor context does trigger an immediate view update in ContentView.

If this is intended behavior, we need a way to merge changes from background contexts, similar to what is described in the Core Data document “Loading and Displaying a Large Data Feed”: https://developer.apple.com/documentation/swiftui/loading_and_displaying_a_large_data_feed

In Core Data we have automaticallyMergesChangesFromParent and mergeChanges(fromContextDidSave:) to do this manually. There seems to be no equivalent for Swift Data.

If anyone has solved this problem of merging changes from other contexts, or can confirm that this is a bug, please let me know.

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @State private var simpleModelActor: SimpleModelActor!
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
                    } label: {
                        Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
            Text("Select an item")
        }
        .onAppear {
            simpleModelActor = SimpleModelActor(modelContainer: modelContext.container)
        }
    }

    private func addItem() {
        Task {
            await simpleModelActor.addItem()
        }
    }

    private func deleteItems(offsets: IndexSet) {
        Task {
            for index in offsets {
                await simpleModelActor.delete(itemWithID: items[index].objectID)
            }
        }
    }
}
import Foundation
import SwiftData

final actor SimpleModelActor: ModelActor {
    let executor: any ModelExecutor
    
    init(modelContainer: ModelContainer) {
        let modelContext = ModelContext(modelContainer)
        executor = DefaultModelExecutor(context: modelContext)
    }
    
    func addItem() {
        let newItem = Item(timestamp: Date())
        context.insert(newItem)
        try! context.save() // this does not impact a re-display by the @Query in ContentView. I would have expected it to cause a view redraw.
    }
    
    func delete(itemWithID itemID: Item.ID) {
        let item = context.object(with: itemID)
        context.delete(object: item) // this DOES cause a view redraw in ContentView. It triggers an update by @Query.
//        try! context.save() // this makes do difference to view redraw behavior.
    }
}
Post not yet marked as solved Up vote post of rkhamilton Down vote post of rkhamilton
1.9k views
  • For posterity: this behavior is being observed in Xcode 15 beta 4, iOS 17 beta 3

Add a Comment

Replies

My team is also investigating this functionality as we attempt to transition our CoreData App to swiftData.

Will update if we make any further progress.

In Xcode 15 beta 5 this behavior is perhaps fixed? My sample project does see view refreshes when adding an item via a ModelActor. However, after creating and deleting a few Items, the List will suddenly only show a single Item. If I relaunch the app, then all Items will appear again. It seems like maybe we are getting progress on fixing the ModelActor view refreshes (great!) but there are still some bugs.

BTW my sample code above needs a few small tweaks to run on Xcode 15 beta 5.

ContentView

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @State private var simpleModelActor: SimpleModelActor!
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
                    } label: {
                        Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
            Text("Select an item")
        }
        .onAppear {
            simpleModelActor = SimpleModelActor(modelContainer: modelContext.container)
        }
    }

    private func addItem() {
        Task {
            await simpleModelActor.addItem()
        }
    }

    private func deleteItems(offsets: IndexSet) {
        Task {
            for index in offsets {
                await simpleModelActor.delete(itemWithID: items[index].objectID)
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Item.self, inMemory: true)
}

SimpleModelActor

import Foundation
import SwiftData

final actor SimpleModelActor: ModelActor {
    let executor: any ModelExecutor
    
    init(modelContainer: ModelContainer) {
        let modelContext = ModelContext(modelContainer)
        executor = DefaultModelExecutor(context: modelContext)
    }
    
    func addItem() {
        let newItem = Item(timestamp: Date())
        context.insert(newItem)
        try! context.save() // this does not impact a re-display by the @Query in ContentView. I would have expected it to cause a view redraw.
    }
    
    func delete(itemWithID itemID: Item.ID) {
        let item = context.object(with: itemID)
        context.delete(item) // this DOES cause a view redraw in ContentView. It triggers an update by @Query.
//        try! context.save() // this makes do difference to view redraw behavior.
    }
}

Item

import Foundation
import SwiftData

@Model
final class Item: Identifiable {
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

And of course the main App file needs .modelContainer(for: Item.self) added to the window group.

This behavior is being observed on iOS 17.3, Xcode 15.3 beta 2 (15E5188j)

@rkhamilton any updates from your side?

I have the same issue (Xcode 15.3, iOS 17.4). Does anyone have a suggestion of a workaround for the issue?