diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 3186e186..1835f7d7 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -171,7 +171,7 @@ struct LUIChildData: Equatable { /// Per-child layout fields keyed by child id; extension and unmounted /// children have no entry. var layout: [Int: LUIChildLayout] = [:] - /// First `context-menu` child containing at least one `menu-item`. + /// First `context-menu` child, retained even while its actions are empty. var contextMenuID: Int? /// First `dropdown-menu` / `tooltip` child carrying an `anchor` prop. var anchoredMenuID: Int? @@ -1635,9 +1635,10 @@ public final class LUIAppleBackend { case .contextMenu: if !sawContextMenu { sawContextMenu = true - if child.children.contains(where: { models[$0]?.kind == .menuItem }) { - data.contextMenuID = childID - } + // Menu membership can change before a native list row + // is removed. Its presentation anchor must retain the + // same lifetime as the context-menu node itself. + data.contextMenuID = childID } case .swipeActions, .listSectionHeader, .listSectionFooter: break diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 2d97bbab..7b9974a8 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -4845,7 +4845,8 @@ private struct LUIListItemView: View { ) { Spacer(minLength: 8) } - if let contextMenu, swipeMenu == nil { + if let contextMenu, !contextMenu.children.isEmpty, swipeMenu == nil, + !model.derived.styleClassTokens.contains("context-menu-only") { Menu { LUINativeMenuActions(model: contextMenu, backend: backend) } label: { @@ -5004,22 +5005,21 @@ private struct LUIListItemView: View { } /// Whether the row should keep a `.swipeActions` slot at all, decided only - /// by node kinds (immutable), so an item's enabled/disabled flips never - /// attach or detach the modifier. Toggling `.swipeActions` on a live + /// by the context-menu node's lifetime, so action membership and enabled + /// changes never attach or detach the modifier. Toggling `.swipeActions` on a live /// collection cell makes SwiftUI re-create the cell; when that races a /// row-delete coalesced into the same batch update, the collection sees a /// stale item count and throws NSInternalInconsistencyException. private var swipeActionsAnchor: LUINodeModel? { #if os(iOS) - guard isNativeListRow, let contextMenu else { return nil } + guard isNativeListRow, + !model.derived.styleClassTokens.contains("context-menu-only"), + let contextMenu else { return nil } // An explicit swipe-actions child takes over the edges; the context // menu is not consumed and stays reachable via the ellipsis button. guard !model.children.contains(where: { backend.model(id: $0)?.kind == .swipeActions }) else { return nil } - guard contextMenu.children.contains(where: { - backend.model(id: $0)?.kind == .menuItem - }) else { return nil } return contextMenu #else return nil diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 0bbc310b..7063dca7 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -70,6 +70,31 @@ struct LUISwiftUIBackendTests { #expect(tracker.title == nil) } + @Test("removing the last context action retains its native row anchor") + func emptyContextMenuRetainsRowAnchor() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"list-item"}, + {"op":"set-prop","id":1,"property":"text","value":"Local graph"}, + {"op":"create-node","id":2,"kind":"context-menu"}, + {"op":"create-node","id":3,"kind":"menu-item"}, + {"op":"set-prop","id":3,"property":"text","value":"Delete local graph"}, + {"op":"set-prop","id":3,"property":"press-enabled","value":true}, + {"op":"insert-child","parent":2,"child":3,"index":0}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + let row = try #require(backend.model(id: 1)) + #expect(row.childData.contextMenuID == 2) + try backend.apply(json: """ + {"generation":2,"ops":[{"op":"remove-child","parent":2,"child":3},{"op":"detach-subtree","id":3}]} + """) + #expect(backend.model(id: 1) === row) + #expect(row.childData.contextMenuID == 2) + #expect(backend.model(id: 2)?.children.isEmpty == true) + } + @Test("virtual window replacement bypasses the collection burst delay") func virtualWindowReplacementCommitsImmediately() throws { let backend = LUIAppleBackend()