← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


commit 2b98cca2466f4b78e34f8d7535615809db558b45
parent f9f00df839ad3190df2c5cec036f77658307701b
Author: Michael Camilleri <[email protected]>
Date:   Tue, 31 Mar 2026 14:52:53 +0900

Introduce ItemValue snapshot type to prevent Core Data crashes

Before this commit, accessing @NSManaged properties on an invalidated
NSManagedObject (e.g. after a CloudKit sync delete on another device) crashes
because the ObjC-to-Swift bridge force-unwraps nil. This affected any ForEach
body that held a reference to an ItemEntity during a render cycle.

This commit adds an ItemValue struct that snapshots the view-relevant fields of
ItemEntity. The computed properties in ItemListView+Logic.swift now return
an array of ItemValue structs and all view code (iOS, macOS, watchOS) operates
on this value type instead of NSManagedObject. ItemEntity.id is also changed
from @NSManaged to a safe computed property using `primitiveValue(forKey:)` as a
belt-and-suspenders guard.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 8++++++++
MListless/Extensions/ItemListView+Logic.swift | 86+++++++++++++++++++++++++++++++++++++++++--------------------------------------
MListless/Models/ItemEntity.swift | 9+++++++--
MListless/Models/ItemStore.swift | 13++++++++++---
AListless/Models/ItemValue.swift | 17+++++++++++++++++
MListlessMac/Views/ItemListView.swift | 28++++++++++++++--------------
MListlessMac/Views/ItemRowView.swift | 32+++++++++++++-------------------
MListlessWatch/Views/ItemListView.swift | 5+++--
MListlessWatch/Views/ItemRowView.swift | 4++--
MListlessiOS/Extensions/ItemListView+Undo.swift | 8++++----
MListlessiOS/Views/ItemListView.swift | 12++++++------
MListlessiOS/Views/ItemRowView.swift | 26+++++++++++++-------------
MTests/UI/ListlessiOSUITests.swift | 2+-

13 files changed, 143 insertions(+), 107 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -30,6 +30,7 @@
 		2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; };
 		2F5309630692E89276CC3149 /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A09A6C1C2251E96E1B5D96 /* ItemListView.swift */; };
 		3383645AE13E9C3AAECFBD0B /* ItemListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23060125DF03EE84F3AED8CB /* ItemListView+PullToClear.swift */; };
+		345C3D4D9A82B9AE0C8CB153 /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; };
 		37AEF10712B3325BF9BC72E4 /* BackgroundClickMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */; };
 		3EDB6A9A30B4226C15E7F44D /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */; };
 		4263E6472020564AA702D117 /* ItemCardModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF4BADA48F30C5F471EA910 /* ItemCardModifier.swift */; };
@@ -44,11 +45,13 @@
 		5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
 		5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E998119283F784B9ADEE28 /* AppColors.swift */; };
 		5E6BE0BA881F6CAEF455D9ED /* ListlessMacUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */; };
+		60FB7A1F3B2F037C655E10DB /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; };
 		614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
 		620D9398218A88B7E4C2331C /* ItemStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FCE9FC4EFD26C9016389B7 /* ItemStoreEdgeCaseTests.swift */; };
 		63C08E89303BD17601271D2C /* ItemListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E209A0014EE95F2BC300CE42 /* ItemListView+SyncUI.swift */; };
 		65E97DE8C190E9E9B71EC356 /* ListlessWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE7F4637B4F4C1FF4BE160B /* ListlessWatchApp.swift */; };
 		6FE9D247153209BD4CFD9E34 /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
+		73C05B273DC49DE48B82822E /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; };
 		763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; };
 		77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; };
 		785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567DBAC2A39FA2760D006AAB /* PullToClear.swift */; };
@@ -226,6 +229,7 @@
 		C611E04943F1D82D6F975592 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
 		C6812E535A24C599C28F9278 /* Listless watchOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless watchOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
 		C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
+		C7EB9F584EF43678536F5FDE /* ItemValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemValue.swift; sourceTree = "<group>"; };
 		C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless macOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
 		CF3374CE58E7D9378C6997D2 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
@@ -387,6 +391,7 @@
 				74255E6B6C40899E9B17D927 /* .gitkeep */,
 				138DCA35ED82A745E4745175 /* ItemEntity.swift */,
 				2B8EC97702E7B218A03B7898 /* ItemStore.swift */,
+				C7EB9F584EF43678536F5FDE /* ItemValue.swift */,
 				C093494053E6C348F245D4EC /* Listless.xcdatamodeld */,
 			);
 			path = Models;
@@ -776,6 +781,7 @@
 				BDA8D53342F745B27B72B242 /* ItemRowDragGesture.swift in Sources */,
 				194CAF0FBC308BE96CE8AA7B /* ItemRowView.swift in Sources */,
 				D24133A6C0105FE8E4528EF2 /* ItemStore.swift in Sources */,
+				73C05B273DC49DE48B82822E /* ItemValue.swift in Sources */,
 				2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */,
 				172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */,
 				96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */,
@@ -798,6 +804,7 @@
 				2F5309630692E89276CC3149 /* ItemListView.swift in Sources */,
 				8FB18395E5436F6C91A0F077 /* ItemRowView.swift in Sources */,
 				47D9272442A5F15B324D3DAC /* ItemStore.swift in Sources */,
+				345C3D4D9A82B9AE0C8CB153 /* ItemValue.swift in Sources */,
 				264BD64C1DD30376E8BDAF79 /* KeyValueSyncBridge.swift in Sources */,
 				6FE9D247153209BD4CFD9E34 /* Listless.xcdatamodeld in Sources */,
 				65E97DE8C190E9E9B71EC356 /* ListlessWatchApp.swift in Sources */,
@@ -837,6 +844,7 @@
 				B0F8B09E2AB38C8C4AF74C10 /* ItemRowSwipeGesture.swift in Sources */,
 				DB0CBB6C3EE56406AF86FDE3 /* ItemRowView.swift in Sources */,
 				790843E40F28B4E186F88F16 /* ItemStore.swift in Sources */,
+				60FB7A1F3B2F037C655E10DB /* ItemValue.swift in Sources */,
 				12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */,
 				19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */,
 				F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */,

diff --git a/Listless/Extensions/ItemListView+Logic.swift b/Listless/Extensions/ItemListView+Logic.swift

@@ -4,12 +4,13 @@ extension ItemListViewProtocol {
 
     // MARK: - Computed Properties
 
-    var activeItems: [ItemEntity] {
+    var activeItems: [ItemValue] {
         Array(items.filter { !$0.isDeleted && !$0.isCompleted })
             .sorted { $0.sortOrder < $1.sortOrder }
+            .map(ItemValue.init)
     }
 
-    var displayActiveItems: [ItemEntity] {
+    var displayActiveItems: [ItemValue] {
         guard let visualOrder else {
             return activeItems
         }
@@ -19,12 +20,13 @@ extension ItemListViewProtocol {
         }
     }
 
-    var completedItems: [ItemEntity] {
+    var completedItems: [ItemValue] {
         Array(items.filter { !$0.isDeleted && $0.isCompleted })
             .sorted { $0.completedOrder > $1.completedOrder }
+            .map(ItemValue.init)
     }
 
-    var allItemsInDisplayOrder: [ItemEntity] {
+    var allItemsInDisplayOrder: [ItemValue] {
         displayActiveItems + completedItems
     }
 
@@ -129,15 +131,16 @@ extension ItemListViewProtocol {
         guard !title.isEmpty else { return }
 
         do {
-            let item = switch placement {
-            case .prepend:
-                try store.createItem(title: title, atBeginning: true)
-            case .append:
-                try store.createItem(title: title)
-            }
+            let newItem =
+                switch placement {
+                case .prepend:
+                    try store.createItem(title: title, atBeginning: true)
+                case .append:
+                    try store.createItem(title: title)
+                }
             try store.save()
             if placement == .append {
-                fState.selectedItemID = item.id
+                fState.selectedItemID = newItem.id
             }
         } catch {
             presentStoreError(error)
@@ -224,33 +227,32 @@ extension ItemListViewProtocol {
             return
         }
 
-        guard let item = items.first(where: { $0.id == itemID }) else {
+        guard let entity = items.first(where: { $0.id == itemID }) else {
             return
         }
-        let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
+        let trimmedTitle = entity.title.trimmingCharacters(in: .whitespacesAndNewlines)
         guard trimmedTitle.isEmpty else { return }
 
-        managedObjectContext.undoManager?.removeAllActions(withTarget: item)
+        managedObjectContext.undoManager?.removeAllActions(withTarget: entity)
         managedObjectContext.undoManager?.disableUndoRegistration()
-        deleteItem(item)
+        deleteItem(itemID: itemID)
         managedObjectContext.undoManager?.enableUndoRegistration()
     }
 
-    func updateTitle(_ item: ItemEntity, _ title: String) {
-        guard item.title != title else { return }
+    func updateTitle(itemID: UUID, title: String) {
         do {
-            try store.updateWithoutSaving(itemID: item.id, title: title)
+            try store.updateWithoutSaving(itemID: itemID, title: title)
         } catch {
             presentStoreError(error)
         }
     }
 
-    func toggleCompletion(_ item: ItemEntity) {
+    func toggleCompletion(itemID: UUID, isCompleted: Bool) {
         do {
-            if item.isCompleted {
-                try store.uncomplete(itemID: item.id)
+            if isCompleted {
+                try store.uncomplete(itemID: itemID)
             } else {
-                try store.complete(itemID: item.id)
+                try store.complete(itemID: itemID)
             }
         } catch {
             presentStoreError(error)
@@ -258,13 +260,12 @@ extension ItemListViewProtocol {
     }
 
     func handleSwipeComplete(_ itemID: UUID) {
-        guard let item = items.first(where: { $0.id == itemID }) else { return }
-        toggleCompletion(item)
+        guard let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return }
+        toggleCompletion(itemID: item.id, isCompleted: item.isCompleted)
     }
 
     func handleSwipeDelete(_ itemID: UUID) {
-        guard let item = items.first(where: { $0.id == itemID }) else { return }
-        deleteItem(item)
+        deleteItem(itemID: itemID)
     }
 
     func selectItem(
@@ -290,9 +291,7 @@ extension ItemListViewProtocol {
         }
     }
 
-    func deleteItem(_ item: ItemEntity) {
-        guard !item.isDeleted else { return }
-        let itemID = item.id
+    func deleteItem(itemID: UUID) {
         do {
             try store.delete(itemID: itemID)
             if fState.selectedItemID == itemID {
@@ -421,7 +420,7 @@ extension ItemListViewProtocol {
         let hasCompleted = itemsToToggle.contains { $0.isCompleted }
         guard !(hasActive && hasCompleted) else { return .handled }
         for item in itemsToToggle {
-            toggleCompletion(item)
+            toggleCompletion(itemID: item.id, isCompleted: item.isCompleted)
         }
         return .handled
     }
@@ -445,18 +444,19 @@ extension ItemListViewProtocol {
         let ids = fState.selectedItemIDs
         guard !ids.isEmpty else { return .handled }
         let displayOrder = allItemsInDisplayOrder
-        let itemsToDelete = displayOrder.filter { ids.contains($0.id) }
-        guard !itemsToDelete.isEmpty else { return .handled }
+        let idsToDelete = displayOrder.filter { ids.contains($0.id) }.map(\.id)
+        guard !idsToDelete.isEmpty else { return .handled }
 
         // Find the next item after the last selected one to move selection to.
-        let lastSelectedIndex = displayOrder.lastIndex(where: { ids.contains($0.id) })
+        let idsToDeleteSet = Set(idsToDelete)
+        let lastSelectedIndex = displayOrder.lastIndex(where: { idsToDeleteSet.contains($0.id) })
         let nextItem = lastSelectedIndex.flatMap { idx in
-            displayOrder.dropFirst(idx + 1).first(where: { !ids.contains($0.id) })
+            displayOrder.dropFirst(idx + 1).first(where: { !idsToDeleteSet.contains($0.id) })
         }
 
         fState.selectedItemID = nil
-        for item in itemsToDelete {
-            deleteItem(item)
+        for itemID in idsToDelete {
+            deleteItem(itemID: itemID)
         }
         if let nextItem {
             fState.selectedItemID = nextItem.id
@@ -467,7 +467,9 @@ extension ItemListViewProtocol {
     func moveSelectedItemUp() {
         guard focusedField == .scrollView else { return }
         guard let currentID = fState.selectedItemID else { return }
-        guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else { return }
+        guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else {
+            return
+        }
         guard currentIndex > 0 else { return }
 
         do {
@@ -480,7 +482,9 @@ extension ItemListViewProtocol {
     func moveSelectedItemDown() {
         guard focusedField == .scrollView else { return }
         guard let currentID = fState.selectedItemID else { return }
-        guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else { return }
+        guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else {
+            return
+        }
         guard currentIndex < activeItems.count - 1 else { return }
 
         do {
@@ -496,7 +500,7 @@ extension ItemListViewProtocol {
         guard !ids.isEmpty else { return }
         let itemsToToggle = allItemsInDisplayOrder.filter { ids.contains($0.id) }
         for item in itemsToToggle {
-            toggleCompletion(item)
+            toggleCompletion(itemID: item.id, isCompleted: item.isCompleted)
         }
     }
 
@@ -538,10 +542,10 @@ extension ItemListViewProtocol {
     }
 
     private func shouldDeleteIfEmpty(itemID: UUID) -> Bool {
-        guard let item = items.first(where: { $0.id == itemID }) else {
+        guard let entity = items.first(where: { $0.id == itemID }) else {
             return false
         }
-        let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
+        let trimmedTitle = entity.title.trimmingCharacters(in: .whitespacesAndNewlines)
         return trimmedTitle.isEmpty
     }
 

diff --git a/Listless/Models/ItemEntity.swift b/Listless/Models/ItemEntity.swift

@@ -3,12 +3,14 @@ import Foundation
 
 @objc(TaskItem)
 public class ItemEntity: NSManagedObject, Identifiable {
+    private static let invalidatedID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
+
     private enum Keys {
         private static func key<Value>(_ keyPath: KeyPath<ItemEntity, Value>) -> String {
             NSExpression(forKeyPath: keyPath).keyPath
         }
 
-        static let id = key(\ItemEntity.id as KeyPath<ItemEntity, UUID>)
+        static let id = "id"
         static let title = key(\ItemEntity.title)
         static let createdAt = key(\ItemEntity.createdAt)
         static let updatedAt = key(\ItemEntity.updatedAt)
@@ -16,7 +18,10 @@ public class ItemEntity: NSManagedObject, Identifiable {
         static let completedOrder = key(\ItemEntity.completedOrder)
     }
 
-    @NSManaged public var id: UUID
+    public var id: UUID {
+        (primitiveValue(forKey: Keys.id) as? UUID) ?? Self.invalidatedID
+    }
+
     @NSManaged public var title: String
     @NSManaged public var createdAt: Date
     @NSManaged public var updatedAt: Date

diff --git a/Listless/Models/ItemStore.swift b/Listless/Models/ItemStore.swift

@@ -49,7 +49,10 @@ final class ItemStore {
         }
     }
 
-    func createItem(title: String = "", atBeginning: Bool = false, sortOrder: Int64? = nil) throws -> ItemEntity {
+    @discardableResult
+    func createItem(title: String = "", atBeginning: Bool = false, sortOrder: Int64? = nil) throws
+        -> ItemEntity
+    {
         // Compute sort order before inserting the new object so we don't need
         // processPendingChanges() and the new item can't appear in our own query.
         let resolvedSortOrder: Int64
@@ -73,7 +76,9 @@ final class ItemStore {
     private func minActiveSortOrder() throws -> Int64? {
         let request = ItemEntity.fetchRequest()
         request.predicate = NSPredicate(format: "completedOrder == 0")
-        request.sortDescriptors = [NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: true)]
+        request.sortDescriptors = [
+            NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: true)
+        ]
         request.fetchLimit = 1
         do {
             return try context.fetch(request).first?.sortOrder
@@ -85,7 +90,9 @@ final class ItemStore {
     private func maxActiveSortOrder() throws -> Int64? {
         let request = ItemEntity.fetchRequest()
         request.predicate = NSPredicate(format: "completedOrder == 0")
-        request.sortDescriptors = [NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: false)]
+        request.sortDescriptors = [
+            NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: false)
+        ]
         request.fetchLimit = 1
         do {
             return try context.fetch(request).first?.sortOrder

diff --git a/Listless/Models/ItemValue.swift b/Listless/Models/ItemValue.swift

@@ -0,0 +1,17 @@
+import Foundation
+
+struct ItemValue: Identifiable {
+    let id: UUID
+    var title: String
+    var isCompleted: Bool
+    var sortOrder: Int64
+    var completedOrder: Int64
+
+    init(_ entity: ItemEntity) {
+        self.id = entity.id
+        self.title = entity.title
+        self.isCompleted = entity.isCompleted
+        self.sortOrder = entity.sortOrder
+        self.completedOrder = entity.completedOrder
+    }
+}

diff --git a/ListlessMac/Views/ItemListView.swift b/ListlessMac/Views/ItemListView.swift

@@ -116,18 +116,18 @@ struct ItemListView: View, ItemListViewProtocol {
         coord.newItem = { createNewItem() }
         coord.copySelectedItem = {
             guard let itemID = fState.selectedItemID,
-                  let item = items.first(where: { $0.id == itemID }) else { return }
+                  let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return }
             let pasteboard = NSPasteboard.general
             pasteboard.clearContents()
             pasteboard.setString(item.title, forType: .string)
         }
         coord.cutSelectedItem = {
             guard let itemID = fState.selectedItemID,
-                  let item = items.first(where: { $0.id == itemID }) else { return }
+                  let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return }
             let pasteboard = NSPasteboard.general
             pasteboard.clearContents()
             pasteboard.setString(item.title, forType: .string)
-            deleteItem(item)
+            deleteItem(itemID: itemID)
         }
         coord.pasteAfterSelectedItem = {
             guard let itemID = fState.selectedItemID,
@@ -204,9 +204,9 @@ struct ItemListView: View, ItemListViewProtocol {
                         totalItems: displayActiveItems.count,
                         isSelected: fState.isItemSelected(itemID),
                         focusedField: $focusedFieldBinding,
-                        onToggle: { toggleCompletion($0) },
-                        onTitleChange: { updateTitle($0, $1) },
-                        onDelete: { deleteItem($0) },
+                        onToggle: { handleSwipeComplete($0) },
+                        onTitleChange: { updateTitle(itemID: $0, title: $1) },
+                        onDelete: { deleteItem(itemID: $0) },
                         onSelect: {
                             let modifiers = NSApp.currentEvent?.modifierFlags ?? []
                             selectItem(
@@ -221,15 +221,15 @@ struct ItemListView: View, ItemListViewProtocol {
                     )
                     .itemDragGesture(
                         isActive: !item.isCompleted,
-                        itemID: item.id,
+                        itemID: itemID,
                         onDragStart: {
                             iState.liftedItemID = nil
-                            startDrag(itemID: item.id)
+                            startDrag(itemID: itemID)
                         },
-                        onLift: { iState.liftedItemID = item.id },
+                        onLift: { iState.liftedItemID = itemID },
                         onLiftEnd: {
-                            if iState.liftedItemID == item.id { iState.liftedItemID = nil }
-                            if draggedItemID == item.id { clearDragState() }
+                            if iState.liftedItemID == itemID { iState.liftedItemID = nil }
+                            if draggedItemID == itemID { clearDragState() }
                         }
                     )
                     .scaleEffect(isRowLifted(itemID) ? 1.03 : 1.0)
@@ -355,9 +355,9 @@ struct ItemListView: View, ItemListViewProtocol {
                         itemID: itemID,
                         isSelected: fState.isItemSelected(itemID),
                         focusedField: $focusedFieldBinding,
-                        onToggle: { toggleCompletion($0) },
-                        onTitleChange: { updateTitle($0, $1) },
-                        onDelete: { deleteItem($0) },
+                        onToggle: { handleSwipeComplete($0) },
+                        onTitleChange: { updateTitle(itemID: $0, title: $1) },
+                        onDelete: { deleteItem(itemID: $0) },
                         onSelect: {
                             selectItem(
                                 $0,

diff --git a/ListlessMac/Views/ItemRowView.swift b/ListlessMac/Views/ItemRowView.swift

@@ -1,14 +1,14 @@
 import SwiftUI
 
 struct ItemRowView: View {
-    let item: ItemEntity
+    let item: ItemValue
     let itemID: UUID
     let index: Int
     let totalItems: Int
     let isSelected: Bool
-    let onToggle: (ItemEntity) -> Void
-    let onTitleChange: (ItemEntity, String) -> Void
-    let onDelete: (ItemEntity) -> Void
+    let onToggle: (UUID) -> Void
+    let onTitleChange: (UUID, String) -> Void
+    let onDelete: (UUID) -> Void
     let onSelect: (UUID) -> Void
     let onStartEdit: (UUID) -> Void
     let onEndEdit: (UUID, _ shouldCreateNewItem: Bool) -> Void
@@ -37,15 +37,15 @@ struct ItemRowView: View {
     }
 
     init(
-        item: ItemEntity,
+        item: ItemValue,
         itemID: UUID,
         index: Int = 0,
         totalItems: Int = 1,
         isSelected: Bool,
         focusedField: FocusState<FocusField?>.Binding,
-        onToggle: @escaping (ItemEntity) -> Void,
-        onTitleChange: @escaping (ItemEntity, String) -> Void,
-        onDelete: @escaping (ItemEntity) -> Void,
+        onToggle: @escaping (UUID) -> Void,
+        onTitleChange: @escaping (UUID, String) -> Void,
+        onDelete: @escaping (UUID) -> Void,
         onSelect: @escaping (UUID) -> Void,
         onStartEdit: @escaping (UUID) -> Void = { _ in },
         onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in },
@@ -69,7 +69,7 @@ struct ItemRowView: View {
     var body: some View {
         HStack(alignment: .firstTextBaseline, spacing: 12) {
             Button {
-                onToggle(item)
+                onToggle(itemID)
             } label: {
                 Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
                     .foregroundStyle(item.isCompleted ? .secondary : .primary)
@@ -97,7 +97,7 @@ struct ItemRowView: View {
                 itemID: itemID,
                 onContentChange: { newTitle in
                     guard !item.isCompleted else { return }
-                    onTitleChange(item, newTitle)
+                    onTitleChange(itemID, newTitle)
                 }
             )
             .focused($focusedField, equals: .item(itemID))
@@ -139,7 +139,7 @@ struct ItemRowView: View {
         }
         .contextMenu {
             Button(item.isCompleted ? "Mark as Incomplete" : "Mark as Complete") {
-                onToggle(item)
+                onToggle(itemID)
             }
             Divider()
             Button("Cut") {
@@ -154,11 +154,10 @@ struct ItemRowView: View {
             .disabled(item.isCompleted)
             Divider()
             Button("Delete", role: .destructive) {
-                onDelete(item)
+                onDelete(itemID)
             }
         }
         .onChange(of: item.title) { _, newValue in
-            // Keep editingTitle in sync with item.title when not editing
             if !isCurrentlyEditing {
                 editingTitle = newValue
             }
@@ -173,7 +172,6 @@ struct ItemRowView: View {
             cachedAccentColor = computeAccentColor()
         }
         .onAppear {
-            // Initialize editingTitle and cache accent color (computed once)
             editingTitle = item.title
             cachedAccentColor = computeAccentColor()
         }
@@ -191,13 +189,9 @@ struct ItemRowView: View {
         }
     }
 
-    // These context menu actions only run in navigation mode — when a row is
-    // being edited, the NSTextField field editor is first responder and its
-    // native context menu handles Cut/Copy/Paste for text directly.
-
     private func cutToPasteboard() {
         copyToPasteboard()
-        onDelete(item)
+        onDelete(itemID)
     }
 
     private func copyToPasteboard() {

diff --git a/ListlessWatch/Views/ItemListView.swift b/ListlessWatch/Views/ItemListView.swift

@@ -18,9 +18,10 @@ struct ItemListView: View {
     private var items: FetchedResults<ItemEntity>
 
     var body: some View {
-        let activeItems = items.filter { !$0.isCompleted }
+        let activeItems = items.filter { !$0.isCompleted }.map(ItemValue.init)
         let completedItems = items.filter { $0.isCompleted }
             .sorted { $0.completedOrder > $1.completedOrder }
+            .map(ItemValue.init)
 
         NavigationStack {
             Group {
@@ -62,7 +63,7 @@ struct ItemListView: View {
         }
     }
 
-    private func toggleItem(_ item: ItemEntity) {
+    private func toggleItem(_ item: ItemValue) {
         do {
             if item.isCompleted {
                 try store.uncomplete(itemID: item.id)

diff --git a/ListlessWatch/Views/ItemRowView.swift b/ListlessWatch/Views/ItemRowView.swift

@@ -1,11 +1,11 @@
 import SwiftUI
 
 struct ItemRowView: View {
-    let item: ItemEntity
+    let item: ItemValue
     let index: Int
     let totalActive: Int
     let colorTheme: ColorTheme
-    let onToggle: (ItemEntity) -> Void
+    let onToggle: (ItemValue) -> Void
 
     var body: some View {
         Button {

diff --git a/ListlessiOS/Extensions/ItemListView+Undo.swift b/ListlessiOS/Extensions/ItemListView+Undo.swift

@@ -2,8 +2,8 @@ import SwiftUI
 
 extension ItemListView {
 
-    func deleteItemWithUndo(_ item: ItemEntity) {
-        deleteItem(item)
+    func deleteItemWithUndo(itemID: UUID) {
+        deleteItem(itemID: itemID)
         showUndoToast(message: "Item deleted")
     }
 
@@ -14,10 +14,10 @@ extension ItemListView {
         guard let currentID = fState.selectedItemID else {
             return .handled
         }
-        guard let item = allItemsInDisplayOrder.first(where: { $0.id == currentID }) else {
+        guard allItemsInDisplayOrder.contains(where: { $0.id == currentID }) else {
             return .handled
         }
-        deleteItemWithUndo(item)
+        deleteItemWithUndo(itemID: currentID)
         return .handled
     }
 

diff --git a/ListlessiOS/Views/ItemListView.swift b/ListlessiOS/Views/ItemListView.swift

@@ -355,9 +355,9 @@ struct ItemListView: View, ItemListViewProtocol {
                 isSwiping: $iState.isSwiping,
                 isLastActiveItem: index == displayActiveItems.count - 1,
                 focusedField: $focusedFieldBinding,
-                onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } },
-                onTitleChange: { updateTitle($0, $1) },
-                onDelete: { deleteItemWithUndo($0) },
+                onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } },
+                onTitleChange: { updateTitle(itemID: $0, title: $1) },
+                onDelete: { deleteItemWithUndo(itemID: $0) },
                 onSelect: { selectItem($0) },
                 onStartEdit: { startEditing($0) },
                 onEndEdit: {
@@ -427,9 +427,9 @@ struct ItemListView: View, ItemListViewProtocol {
                 isSelected: fState.selectedItemID == itemID,
                 isSwiping: $iState.isSwiping,
                 focusedField: $focusedFieldBinding,
-                onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } },
-                onTitleChange: { updateTitle($0, $1) },
-                onDelete: { deleteItemWithUndo($0) },
+                onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } },
+                onTitleChange: { updateTitle(itemID: $0, title: $1) },
+                onDelete: { deleteItemWithUndo(itemID: $0) },
                 onSelect: { selectItem($0) }
             )
             .opacity(isBeingCleared ? 0 : 1)

diff --git a/ListlessiOS/Views/ItemRowView.swift b/ListlessiOS/Views/ItemRowView.swift

@@ -1,16 +1,16 @@
 import SwiftUI
 
 struct ItemRowView: View {
-    let item: ItemEntity
+    let item: ItemValue
     let itemID: UUID
     let index: Int
     let totalItems: Int
     let isSelected: Bool
     @Binding var isDragging: Bool
     @Binding var isSwiping: Bool
-    let onToggle: (ItemEntity) -> Void
-    let onTitleChange: (ItemEntity, String) -> Void
-    let onDelete: (ItemEntity) -> Void
+    let onToggle: (UUID) -> Void
+    let onTitleChange: (UUID, String) -> Void
+    let onDelete: (UUID) -> Void
     let onSelect: (UUID) -> Void
     let isLastActiveItem: Bool
     let onStartEdit: (UUID) -> Void
@@ -28,7 +28,7 @@ struct ItemRowView: View {
     @State private var cachedAccentColor: Color = .clear
 
     init(
-        item: ItemEntity,
+        item: ItemValue,
         itemID: UUID,
         index: Int = 0,
         totalItems: Int = 1,
@@ -37,9 +37,9 @@ struct ItemRowView: View {
         isSwiping: Binding<Bool> = .constant(false),
         isLastActiveItem: Bool = false,
         focusedField: FocusState<FocusField?>.Binding,
-        onToggle: @escaping (ItemEntity) -> Void,
-        onTitleChange: @escaping (ItemEntity, String) -> Void,
-        onDelete: @escaping (ItemEntity) -> Void,
+        onToggle: @escaping (UUID) -> Void,
+        onTitleChange: @escaping (UUID, String) -> Void,
+        onDelete: @escaping (UUID) -> Void,
         onSelect: @escaping (UUID) -> Void,
         onStartEdit: @escaping (UUID) -> Void = { _ in },
         onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in }
@@ -64,7 +64,7 @@ struct ItemRowView: View {
     var body: some View {
         HStack(alignment: .center, spacing: ItemRowMetrics.contentSpacing) {
             Button {
-                onToggle(item)
+                onToggle(itemID)
             } label: {
                 // When a right-swipe is past the threshold, preview the toggled state
                 let previewCompleted = isSwipeTriggered && swipeDirection == .right
@@ -98,7 +98,7 @@ struct ItemRowView: View {
                     returnKeyType: isLastActiveItem && !editingTitle.isEmpty ? .next : .done,
                     onContentChange: { newTitle in
                         guard !item.isCompleted else { return }
-                        onTitleChange(item, newTitle)
+                        onTitleChange(itemID, newTitle)
                     },
                     uiAccessibilityIdentifier: "item-text-\(itemID.uuidString)",
                     initialCursorPoint: tapPoint
@@ -140,7 +140,7 @@ struct ItemRowView: View {
                 return
             }
             if item.isCompleted {
-                withAnimation { onToggle(item) }
+                withAnimation { onToggle(itemID) }
             } else {
                 tapPoint = nil
                 onSelect(itemID)
@@ -181,8 +181,8 @@ struct ItemRowView: View {
             swipeDirection: $swipeDirection,
             isTriggered: $isSwipeTriggered,
             completeColor: cachedAccentColor,
-            onComplete: { onToggle(item) },
-            onDelete: { onDelete(item) }
+            onComplete: { onToggle(itemID) },
+            onDelete: { onDelete(itemID) }
         )
         .onChange(of: isDragging) { _, newValue in
             if newValue {

diff --git a/Tests/UI/ListlessiOSUITests.swift b/Tests/UI/ListlessiOSUITests.swift

@@ -149,7 +149,7 @@ final class ListlessiOSUITests: XCTestCase {
     func testUncompleteItem() {
         createItem("Finish report")
         exitEditingMode()
-        usleep(300_000)  // Wait for draft row reveal animation to settle
+        usleep(500_000)  // Wait for draft row reveal animation to settle
 
         itemCheckbox(at: 0).tap()