How do I register undo actions for menu commands while preserving built-in view's undo management?

I've got a single-window app whose main ContentView is a table of records. It has some menu commands, defined in my App file, that allow record-level operations (add, delete, process, etc). It also uses some framework-provided editing views (i.e. TextFieldView) for individual fields on each record.

I'm having a lot of trouble implementing undo/redo. The menu commands don't have access to the Environment to obtain the undoManager there. The undoManager is nil during onAppear of the ContentView, so I can't set it into my view model before running some user-initiated action on the view itself. If I wire up custom Undo/Redo menu items with my own UndoManager, the TextFieldView undo no longer works. I even tried getting at the underlying NSWindowDelegate to provide my own UndoManager in windowWillReturnUndoManager, but that never gets called.

What's the correct pattern to use here?

Some more specific details in case that helps.

The key structures are set up in the following pattern.

struct MyApp: App {
   let viewModel = MyViewModel()
   var body: some Scene {
      Window {
      ...
      }
      .environment(viewModel)
      .commands {
         MyCommands(viewModel: viewModel)
      }
   }
}

struct MyCommands: Commands {
   let viewModel: MyViewModel()
    @FocusedValue(\.selectedEntryID) private var focusedID: UUID?
   var body: some Commands {
      CommandGroup(...) {
         Button(...) {
            viewModel.doSomething(with id: focusedID)
         }
        ...
      }
   }
}

struct ContentView: View {
   @Environment(MyViewModel.self) var viewModel

   var body: some View {
      Table(viewModel.records, selection: $viewModel.selectedID) {
          TableColumn("Column A") { record in
              TextField("",  text: Binding(
                  get: { record.field1 ?? "" },
                  set: { newValue in
                     record.field1 = newValue
                  }
               ...
      }
      .focusedSceneValue(\.selectedEntryID, viewModel.selectedEntryID)

I can verify that there is an UndoManager available to ContentView via @Environment(.undoManager) in the view and adding a button. But it's only present inside the button action, and is nil in .onAppear, for instance, so I don't have an opportunity to inject it into the viewModel before a menu-driven Add action.

Hi @Alazel, you wrote:

The undoManager is nil during onAppear of the ContentView, so I can't set it into my view model before running some user-initiated action on the view itself.

The value starts out nil and becomes valid once the view is attached to a window's undo context, and SwiftUI re-runs body when that happens. So you'd want to read it in the view body.

For example, start by defining an object that bundles together everything your menu command needs.

@main
struct MyApp: App {
    @State private var viewModel = MyViewModel()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(viewModel)
        }
        .commands {
            MyCommands()
        }
    }
}

@Observable
final class Record: Identifiable {
    let id = UUID()
    var name: String

    init(name: String = "New Record") {
        self.name = name
    }
}

struct RecordActions {
    let viewModel: MyViewModel
    let undoManager: UndoManager?
    let selectedID: UUID?

    func add()    { viewModel.addRecord(undoManager: undoManager) }
    func delete() { viewModel.deleteRecord(id: selectedID, undoManager: undoManager) }
}

extension FocusedValues {
    @Entry var recordActions: RecordActions?
}


struct MyCommands: Commands {
    @FocusedValue(\.recordActions) private var actions

    var body: some Commands {
        CommandGroup(after: .newItem) {
            Button("Add Record") { actions?.add() }
                .keyboardShortcut("n", modifiers: .command)
                .disabled(actions == nil)

            Button("Delete Record") { actions?.delete() }
                .disabled(actions?.selectedID == nil)
        }
    }
}

struct ContentView: View {
    @Environment(MyViewModel.self) private var viewModel
    @Environment(\.undoManager) private var undoManager

    var body: some View {
        @Bindable var viewModel = viewModel

        Table(viewModel.records, selection: $viewModel.selectedID) {
            TableColumn("Name") { record in
                @Bindable var record = record
                TextField("", text: $record.name)
            }
        }
        .focusedSceneValue(\.recordActions, RecordActions(
            viewModel: viewModel,
            undoManager: undoManager,
            selectedID: viewModel.selectedID
        ))
        .frame(minWidth: 400, minHeight: 300)
    }
}

On the very first pass the undoManager may still be nil, SwiftUI will run body again once the undo context exists, at that point the focused value is updated with a valid manager. Because you rebuild the structure on every pass, the value will be updated if the window's manager is ever replaced.

@Observable
final class MyViewModel {
    var records: [Record] = [Record(name: "First")]
    var selectedID: UUID?

    @MainActor
    func addRecord(undoManager: UndoManager?) {
        let record = Record()
        records.append(record)
        selectedID = record.id

        undoManager?.registerUndo(withTarget: self) { vm in
            vm.removeRecord(record, undoManager: undoManager)
        }
        undoManager?.setActionName("Add Record")
    }

    @MainActor
    func removeRecord(_ record: Record, undoManager: UndoManager?) {
        guard let index = records.firstIndex(where: { $0.id == record.id }) else { return }
        records.remove(at: index)
        if selectedID == record.id { selectedID = nil }

        undoManager?.registerUndo(withTarget: self) { vm in
            vm.insertRecord(record, at: index, undoManager: undoManager)
        }
        undoManager?.setActionName("Delete Record")
    }

    @MainActor
    func deleteRecord(id: UUID?, undoManager: UndoManager?) {
        guard let id, let record = records.first(where: { $0.id == id }) else { return }
        removeRecord(record, undoManager: undoManager)
    }

    @MainActor
    func insertRecord(_ record: Record, at index: Int, undoManager: UndoManager?) {
        let clampedIndex = min(index, records.count)
        records.insert(record, at: clampedIndex)
        selectedID = record.id

        undoManager?.registerUndo(withTarget: self) { vm in
            vm.removeRecord(record, undoManager: undoManager)
        }
        undoManager?.setActionName("Add Record")
    }
}

When registering undo, each operation should register its own inverse. When undo is triggered, your inverse operation runs, and because that inverse also registers an undo, the system now has a redo available.

How do I register undo actions for menu commands while preserving built-in view's undo management?
 
 
Q