Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading