Object deleted in MOC not properly deleted in child MOC

I have two contexts, MAIN and VIEW. I construct an object in MAIN and it appears in VIEW (which I use to display it in the UI). Then I delete the object in MAIN. Because the UI holds a reference to the object in VIEW, VIEW records it as a pending delete (Problem 1). I don't understand why it does this nor can I find this behaviour documented. Docs for deletedObjects say "objects that will be removed from their persistent store during the next save". This has already happened!

(Problem 2) Then I rollback the VIEW context, and the object is resurrected. awakeFromInsert is called again. While the object (correctly) does not appear in a freshly executed fetch request, it does appear in the @FetchRequest of the SwiftUI View which is now displaying stale data. I cannot figure out how to get SwiftUI to execute the fetch request again (I know I can force regeneration of the UI, but would like to avoid this).

This is self-contained demonstration of the problem that can be run in a Playground. Press Create, then Delete (note console output), then Rollback (note console output, and that element count changes from 0 to 1 in the UI)

import CoreData
import SwiftUI

@objc(TestEntity)
class TestEntity : NSManagedObject, Identifiable{
    @NSManaged var id : UUID?
    override func awakeFromInsert() {
        print("Awake from insert")
        if id == nil {
            // Avoid resetting ID when we resurrect the phantom delete
            self.id = UUID()
        }
        super.awakeFromInsert()
    }
    
    
    class func add(in context: NSManagedObjectContext) -> UUID {
        let id = UUID()
        context.performAndWait {
            let mo = TestEntity(context: context)
            mo.id = id
        }
        return id
    }
    
    class func fetch(in context: NSManagedObjectContext) -> [TestEntity] {
        let fr = TestEntity.fetchRequest()
        return try! context.fetch(fr) as! [TestEntity]
    }
}

class CoreDataStack {
    // Main is attached to the store
    var main : NSManagedObjectContext!
    
    // View is a child context of main and used to display the UI
    var view : NSManagedObjectContext!
    
    // Set up a simple entity with an ID attribute
    func getEntities() -> [NSEntityDescription] {
        let testEntity = NSEntityDescription()
        testEntity.managedObjectClassName = "TestEntity"
        testEntity.name = "TestEntity"
        let idAttribute = NSAttributeDescription()
        idAttribute.name = "id"
        idAttribute.type = .uuid
        testEntity.properties.append(idAttribute)
        return [testEntity]
    }
    
    init() {
        let model = NSManagedObjectModel()
        model.entities = getEntities()
        let container = NSPersistentContainer(name: "TestModel", managedObjectModel: model)
        let description = NSPersistentStoreDescription()
        description.type = NSInMemoryStoreType
        container.persistentStoreDescriptions = [description]
        container.loadPersistentStores { desc, error in
            if error != nil {
                fatalError("Failed to set up coredata")
            }
        }
        main = container.viewContext
        view = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
        view.automaticallyMergesChangesFromParent = true
        view.parent = main

    }
    
    func create() {
        let entityId = TestEntity.add(in: main)
        main.performAndWait {
            try! main.save()
        }
    }
    
    func delete() {
        main.performAndWait {
            if let mo = TestEntity.fetch(in: main).first {
                main.delete(mo)
                try! main.save()
            }
        }
        self.view.perform {
            // We only find that we have a pending delete here if we hold a reference to the object, e.g. in the UI via @FetchRequest
            if(self.view.deletedObjects.count != 0) {
                print("!!! view has a pending delete, even though main has saved the delete !!!")
            }
        }
    }
    
    func rollback() {
        self.view.perform {
            self.view.rollback()
            // PROBLEM We now have a resurrected object. Note that awakeFromInsert
            // was called again.
        }
    }
}

import SwiftUI
import PlaygroundSupport

let stack = CoreDataStack()

struct ContentView: View {
    @FetchRequest(sortDescriptors: []) private var entities: FetchedResults<TestEntity>


    @State var renderID = UUID()
    var body: some View {
        VStack {
            Text("\(entities.count) elements")
            Button("Create") {
                stack.create()
            }
            Button("Delete") {
                stack.delete()
            }
            Button("Rollback") {
                stack.rollback()
                // PROBLEM After rollback we get the element displaying in
                // the UI again, even though it isn't present in a freshly
                // executed fetch request.
                // The @FetchRequest is picking up the resurrected TestEntity in view
                // But not actually issuing a fetch.
                self.renderID = UUID()
                entities.nsPredicate
            }
        }.id(renderID)
    }
}

//stack.execute()
let view = ContentView()
    .environment(\.managedObjectContext, stack.view)

PlaygroundPage.current.setLiveView(view)

I am not quite familiar with the Playground environment and can't explain the behavior in a playground just yet, but running your code via an Xcode project doesn't seem to trigger the same issue.

I tried with the following steps:

  1. Create a brand new Xcode SwiftUI project.
  2. In the Xcode-generated ContentView.swift, replace the existing code with yours.
  3. Remove the playground related code.
  4. Inject main to the SwiftUI environment of ContentView, as shown below, and manage to build and run the app.
let stack = CoreDataStack()

@main
struct testApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, stack.main)

        }
    }
}

I ran the app with Xcode 16.0 (16A242d) + iOS 18 (22A3351), and confirmed that the issue you described didn't happen.

Would you mind to check if you see the same thing?

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

The playground has

let view = ContentView()
    .environment(\.managedObjectContext, stack.view)

Note stack.view not stack.main. I constructed a test Xcode project as per your instructions (but using the view MOC) and got the same results as in the playground.

"let view = ContentView() .environment(.managedObjectContext, stack.view) Note stack.view not stack.main. I constructed a test Xcode project as per your instructions (but using the view MOC) and got the same results as in the playground."

That indeed helped me reproduce the issue. Thanks!

It does seem that, if the SwiftUI view holds the deleted object (via FetchRequest), the saved deletion in Stack.main is treated as an un-saved deletion in Stack.view, which can be rolled back.

That behavior is a surprise to me as well, and so I'd suggest that you file a feedback report to see what the Core Data team has to say – If you do so, please share your report ID here so I can keep an eye on it.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Can I check

a) You saw my comment with the Feedback report ID - https://feedbackassistant.apple.com/feedback/15590237

b) Now that this has migrated from a technical support request to a forum post to a feedback report, is there any point waiting for a response on this, or should I work under the assumption that it's a bug, and won't be fixed anytime soon?

Object deleted in MOC not properly deleted in child MOC
 
 
Q