Importing Data into SwiftData in the Background Using ModelActor and @Query

I have an app with fairly typical requirements - I need to insert some data (in my case from the network but could be anything) and I want to do it in the background to keep the UI responsive.

I'm using SwiftData.

I've created a ModelActor that does the importing and using the debugger I can confirm that the data is indeed being inserted.

On the UI side, I'm using @Query and a SwiftUI List to display the data but what I am seeing is that @Query is not updating as the data is being inserted. I have to quit and re-launch the app in order for the data to appear, almost like the context running the UI isn't communicating with the context in the ModelActor.

I've included a barebones sample project. To reproduce the issue, tap the 'Background Insert' button. You'll see logs that show items being inserted but the UI is not showing any data.

I've tested on the just released iOS 18b3 seed (22A5307f).

The sample project is here:

https://hanchor.s3.amazonaws.com/misc/SwiftDataBackgroundV2.zip

Answered by DTS Engineer in 795327022

I see the issue a bug on SwiftData + SwiftUI side because @Query is supposed to merge the change from the model actor (ModelActor) and trigger a SwiftUI update, and so would suggest that you file a feedback report and post your report ID for folks to track.

For a workaround before the issue is fixed on the framework side, you might consider observing .NSManagedObjectContextDidSave notification and triggering a SwiftUI update from the notification handler. For example:

import Combine

extension NotificationCenter {
    var managedObjectContextDidSavePublisher: Publishers.ReceiveOn<NotificationCenter.Publisher, DispatchQueue> {
        return publisher(for: .NSManagedObjectContextDidSave).receive(on: DispatchQueue.main)
    }
}

struct MySwiftDataView: View {
    @Query private var items: [Item]
    
    // Use the notification time as a state to trigger a SwiftUI update.
    // Use a state more appropriate to your app, if any.
    @State private var contextDidSaveDate = Date()
    
    var body: some View {
        List {
            ForEach(items) { item in
                Text("\(item.timestamp)")
            }
            // Refresh the view by changing its `id`.
            // Use a way more appropriate to your app, if any.
            .id(contextDidSaveDate)
        }
        .onReceive(NotificationCenter.default.managedObjectContextDidSavePublisher{ notification in
            contextDidSaveDate = .now
        }
    }
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

A few observations on the behaviour in iOS 18.0(.0 and .1) as well as the latest 18.1 beta:

  • changes from background contexts seem to sometimes be merged into the main context at irregular intervals
  • disabling autosave on the main context seems to also prevent propagation of any changes from background contexts
  • listening to .NSManagedObjectContextDidSave as suggested by DTS Engineer only helps in the most basic cases

The first two points make it feel like the MainActor context my SwiftUI views use only checks and merges changes whenever the internal autosave is triggered, even if there have been multiple notifications that the underlying NSManagedObjectContext has been saved by other sources.

As an experiment I tried to manually trigger modelContext.save() for the main context whenever I caught a notification for a background save however this doesn't seem to help with merging of background changes (it actually has no effect - the new data is in the store but it never makes it to any @Query update).

With no mentions of SwiftData in any of the Release Notes (18.0.x as well as 18.1 beta) it's anything but easy to keep faith in SwiftData in it's current, very fragile feeling implementation.


One point especially is giving me grey hairs right now - if an @Model contains an array of other @Model's and a property of one of those changes via a background context the changes do not propagate at all even if the workaround by DTS Engineer is applied.

import SwiftData

@Model
final class HistoryRecord {
    // history records are added regularly via a background @ModelActor that also calls .save() on it's modelContext
    var recordedAt: Date
    var logbook: Logbook?
}

@Model
final class Logbook {
    // every time a HistoryRecord is added lastUpdate is also set to the current date
    var lastUpdate: Date
    @Relationship(delete: .cascade)
    var records: [HistoryRecord] = []()
    var shelf: Bookshelf?
}

@Model
final class Bookshelf {
    @Relationship(delete: .cascade)
    var books: [Logbook]
}

struct BookshelfView: View {
    @Environment(\.modelContext) private var modelContext
    @Query shelves: [Bookshelf]
    var body: some View {
        ForEach(shelves) { shelf in
            ForEach(shelf.books) { book in
                // this number doesn't go up at all
                Text("\(book.records.count) records in book")
            }
        }
    }
}

Anyone have any luck on this? I believe I'm seeing the same issue on iOS 18.0 (release) using both Xcode 16.0 and Xcode 16.1beta3.

Issue persists with Xcode 16.1 iOS 18.1 that was released today (October, 28 2024)

@DTS Engineer this issue is still in Xcode 16.2 iOS 18.2 beta 1. I don't understand how this major bug is still not fixed by now... Can someone at Apple seriously look into this bug?

Also, instead of observing the NSManagedObjectContextDidSave notification (which is part of Core Data + NSManagedObjectContext), we can observe the ModelContext didSave notification. The userInfo dictionary contains the PersistentIdentifiers of inserted/updated/deleted models, so we can only update views using changed models.

extension NotificationCenter {
    var modelContextDidSavePublisher: Publishers.ReceiveOn<NotificationCenter.Publisher, DispatchQueue> {
        return publisher(for: ModelContext.didSave, object: nil).receive(on: DispatchQueue.main)
    }
}

Documentation: https://developer.apple.com/documentation/swiftdata/modelcontext/didsave

Importing Data into SwiftData in the Background Using ModelActor and @Query
 
 
Q