NSDocument doesn't autosave last changes

I had noticed an unsettling behaviour about NSDocument some years ago and created FB7392851, but the feedback didn't go forward, so I just updated it and hopefully here or there someone can explain what's going on.

When running a simple document-based app with a text view, what I type before closing the app may be discarded without notice. To reproduce it, you can use the code below, then:

  1. Type "asdf" in the text view.
  2. Wait until the Xcode console logs "saving". You can trigger it by switching to another app and back again.
  3. Type something else in the text view, such as "asdf" on a new line.
  4. Quit the app.
  5. Relaunch the app. The second line has been discarded.

Am I doing something wrong or is this a bug? Is there a workaround?

class ViewController: NSViewController {

    @IBOutlet var textView: NSTextView!

}

class Document: NSDocument {

    private(set) var text = ""

    override class var autosavesInPlace: Bool {
        return true
    }

    override func makeWindowControllers() {
        let storyboard = NSStoryboard(name: NSStoryboard.Name("Main"), bundle: nil)
        let windowController = storyboard.instantiateController(withIdentifier: NSStoryboard.SceneIdentifier("Document Window Controller")) as! NSWindowController
        (windowController.contentViewController as? ViewController)?.textView.string = text
        self.addWindowController(windowController)
    }

    override func data(ofType typeName: String) throws -> Data {
        Swift.print("saving")
        text = (windowControllers.first?.contentViewController as? ViewController)?.textView.string ?? ""
        return Data(text.utf8)
    }

    override func read(from data: Data, ofType typeName: String) throws {
        text = String(decoding: data, as: UTF8.self)
        (windowControllers.first?.contentViewController as? ViewController)?.textView.string = text
    }

}
Answered by DTS Engineer in 853894022

What you described is an as-designed behavior. Basically, the autosave feature automatically saves the document (if there are unsaved changes) every once a while, or when some events happen (such as when the app is activated or deactivated). If you made some changes on the document, and kill the app before autosave is triggered, the changes will be lost.

On macOS, a user can kill an app in different ways – They can run the Quit app menu by pressing CMD+Q, run the Finder > Force Quit... menu, or execute kill -9 from Terminal.app, or even turn off the power.

To avoid losing the unsaved changes, folks typically implement applicationShouldTerminate(_:), which is triggered when the user runs the Quit app menu (CMD+Q). For a NSDocument-based app, however, this is not needed (assuming the document is correctly marked dirty) because AppKit handles that for you.

Force Quit and kill -9 are meant to quit the app immediately, and so there is no good way to run your code when the events happen. I won't worry too much though, because when a user quits an app in those ways, they are clear that they'd risk data loss.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

@Nickkk The issue was been tracked, the response provided to your Feedback report FB7392851 was for your to please file a new feedback report if the issue persists and attach diagnostic information.

Given that you're still able to reproduce the issue, could you please post the new Feedback number. That would help with investigating and tracking the issue.

Thanks

I just created another one, FB17662376. Yes, in the previous one I was asked to create a new one, but without giving any reason. To be honest, it can be quite irritating when you guys think that a bug has been solved and ask to verify and if it's not solved, to open a new feedback. It happens regularly. It's still the same exact issue, so why would one have to create a completely new feedback with the same data as before? It comes across as time-consuming bureaucracy.

What you described is an as-designed behavior. Basically, the autosave feature automatically saves the document (if there are unsaved changes) every once a while, or when some events happen (such as when the app is activated or deactivated). If you made some changes on the document, and kill the app before autosave is triggered, the changes will be lost.

On macOS, a user can kill an app in different ways – They can run the Quit app menu by pressing CMD+Q, run the Finder > Force Quit... menu, or execute kill -9 from Terminal.app, or even turn off the power.

To avoid losing the unsaved changes, folks typically implement applicationShouldTerminate(_:), which is triggered when the user runs the Quit app menu (CMD+Q). For a NSDocument-based app, however, this is not needed (assuming the document is correctly marked dirty) because AppKit handles that for you.

Force Quit and kill -9 are meant to quit the app immediately, and so there is no good way to run your code when the events happen. I won't worry too much though, because when a user quits an app in those ways, they are clear that they'd risk data loss.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

On macOS, a user can kill an app in different ways

I don't expect the document changes to be saved when the user intentionally kills the app, but I would expect that when the app is quit normally via the main menu or keyboard shortcut Command-Q, the pending changes would be saved. Are such changes really intended to be discarded? After all, if there is an open document that has never been saved and I try to close it, I'm asked if I want to save or delete it. Why would the last changes not be autosaved when I gracefully quit the app? It seems weird that changes are saved periodically, but not at the very end of the lifetime of the app.

If I'm really supposed to autosave all documents manually in applicationShouldTerminate, which API should I call? Again, it seems weird that this is taken care of automatically until before quitting the app, and then I have to manually track which documents are saved and when everything's saved, quit the app. It would seem to me like boilerplate code that every document-based app would automatically want.

I tried this implementation to autosave changes before quitting the app, but it doesn't work since NSDocument.hasUnautosavedChanges is unexpectedly false. I now get the impression that perhaps a document would be saved automatically before quitting, but there is an issue with how NSTextView marks a document as edited. I would appreciate any insight into this issue.

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
    let edited = documents.filter({ $0.hasUnautosavedChanges })
    if edited.isEmpty {
        return .terminateNow
    }
    var remaining = edited.count
    for document in edited {
        document.autosave(withImplicitCancellability: false) { [self] error in
            if let error = error {
                presentError(error)
            } else {
                remaining -= 1
                if remaining == 0 {
                    NSApp.reply(toApplicationShouldTerminate: true)
                }
            }
        }
    }
    return .terminateLater
}

Thanks for following up via the DTS channel, which reminded me to revisit this thread.

To continue our discussion:

I don't expect the document changes to be saved when the user intentionally kills the app

That's great. I guess I was unclear how you "quit" the app (in your step 4). Thanks for your clarification.

I would expect that when the app is quit normally via the main menu or keyboard shortcut Command-Q, the pending changes would be saved. Are such changes really intended to be discarded?

The answer is no. When you app is quit in a managed way (CMD+Q, etc), AppKit automatically saves your pending changes if:

  1. autosavesInPlace returns true. (Otherwise, AppKit prompts the user as needed to choose a course of action.)
  2. You have marked the document dirty.

Your code doesn't do step 2, and that's why your changes are lost. You can fix the issue by changing your code in the following way:

// Conform to NSTextViewDelegate
class Document: NSDocument, NSTextViewDelegate {
    ...
    override func makeWindowControllers() {
        ...     
        // Set up the delegate for your text view.
        let viewController = windowController.contentViewController as? ViewController
        viewController?.textView.delegate = self
    }
    ...
    // Mark the document dirty when the text changes.
    func textDidChange(_ notification: Notification) {
        self.updateChangeCount(.changeDone)
    }
}

In my previous response, I mentioned implementing applicationShouldTerminate(_:) as a way to save pending changes, without being clear that it isn't needed for a NSDocument-based app if you have marked the document dirty. That's misleading, and I have rephrased the part to make it clear.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks.

Your code doesn't do step 2, and that's why your changes are lost.

But again, even without doing that, the document is regularly saved, so the fact that the very last changes are not saved is, from my point of view, unexpected behaviour.

I came across NSDocument.updateChangeCount(_:) some time ago, but concluded that I shouldn't interfere with automatic saving done by the text view. For instance, when using your latest code, if I type anything in an empty document, then undo all the changes and try to close the now empty document, it presents a save panel. This doesn't happen when I just let the text view track the changes (which is also what I would expect).

The documentation for that method reads:

If you are implementing undo and redo in an app, you should increment the change count every time you create an undo group and decrement the change count when an undo or redo operation is performed.

Note that if you are using the NSDocument default undo/redo features, setting the document’s edited status by updating the change count happens automatically. You only need to invoke this method when you are not using these features.

Since the documentation says that I don't need to call that method when I use the default undo mechanism and, like I explained above, doing so introduces unwanted behaviour, I'm a little wary. I even had this code in my production app for some time many years ago, but then removed it again, possibly because it caused other unwanted side effects or bugs (although I don't remember exactly right now). I think I also tried implementing my own undo mechanism for text views, but quickly figured out that going beyond a basic implementation (registering a new undo group for each typed letter, which is a pain for the user to undo and not what they're used to) would take too much time.

It looks to me like the default undo mechanism would need to be nudged when the user tries to close a document or the app. Would that be something AppKit could do automatically in a future release, and can I nudge it somehow now?

It's true that if you use the default undo/redo NSDocument provides, you don't need to call updateChangeCount(_:), and that's because NSDocument does that for you. In your case, the changes in your text view don't go to the undo stack of NSDocument, and so AppKit skips the save in the app termination process because the document is clean.

It looks to me like the default undo mechanism would need to be nudged when the user tries to close a document or the app.

This is correct. If marking the document dirty while the user is inputting isn't a choice, you can probably consider implementing applicationShouldTerminate(_:), as mentioned before, to confirm there are unsaved inputs (by comparing viewController.textView.text against Document.text) and save the document as needed. That is the right use of the delegate method.

Would that be something AppKit could do automatically in a future release, and can I nudge it somehow now?

Please feel free to voice your opinion in your feedback report, though AppKit folks may see the current behavior, which skips the save for a clean document when terminating the app, as correct.

I don't have any comment about the future plan.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

In your case, the changes in your text view don't go to the undo stack

Only the last changes, all previous other changes do as expected.

save the document as needed

That is what I'm wondering about. What is the correct way of nudging the undo mechanism so that autosaving would see the document as dirty? We already saw that simply calling updateChangeCount(_:) breaks the undo mechanism.

though AppKit folks may see the current behavior, which skips the save for a clean document when terminating the app, as correct

Skipping save for a clean document is correct in my opinion as well, but the problem is that the document should not be clean in this case, and the question is how it should correctly be marked as dirty without breaking the undo mechanism. The text view probably has an open undo group (waiting for more text to be inserted) that should be closed when trying to quit the app, so that the document is then correctly marked as dirty.

What is the correct way of nudging the undo mechanism so that autosaving would see the document as dirty?

Yeah, I think this is the key in this conversation, and am glad that you brought it up.

There are two options to tell AppKit the document is dirty:

  1. Supporting undo / redo by using NSDocument.undoManager. This way, every change goes to the undo stack, and NSDocument knowns the change from there.

  2. Updating the change count explicitly by calling updateChangeCount(_:).

The code example I provided above uses option 2 to demonstrate the idea. Your real-world app uses option 1, which is appropriate because it supports undo.

Using NSTextView adds another complexity because NSTextView supports undo as well. By default, NSTextView and NSDocument share the same undo manager that AppKit provides. In this case, however, it seems that the change in your text view doesn't always lead to a dirty document – I am guessing this is why the issue happens.

You can provide NSTextView a custom undo manager via NSTextViewDelegate.undoManager(for:). I am not sure if that fits your use case, but I believe that if you have your document use its own undo manager and push every change on the document to its undo stack, your issue will go away.

But again, if the only issue is that the last change in your text view isn't synchronized to the document and hence is lost when your app is quit, implementing applicationShouldTerminate(_:) to detect the case and save your document explicitly should help.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

I'd really appreciate any concrete tip on how I should save the document, because everything I've tried doesn't work. Simply calling updateChangeCount(_:), like we already saw, messes with the undo mechanism. How is the text view able to save everything I input when the window first appears and then quit the app, but isn't able to when switching to another app and back again?

This is the last thing I tried, and when quitting the app the .terminateNow branch is always executed. Just calling endUndoGrouping() without beginUndoGrouping() logs an error in the Xcode console that no undo group is currently open.


func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
    for document in documents {
        document.undoManager?.beginUndoGrouping()
        document.undoManager?.endUndoGrouping()
    }
    let edited = documents.filter({ $0.hasUnautosavedChanges })
    if edited.isEmpty {
        return .terminateNow
    }
    var remaining = edited.count
    for document in edited {
        document.autosave(withImplicitCancellability: false) { [self] error in
            if let error = error {
                presentError(error)
            } else {
                remaining -= 1
                if remaining == 0 {
                    NSApp.reply(toApplicationShouldTerminate: true)
                }
            }
        }
    }
    return .terminateLater
}

The following code saves the current document for me. Does it work for you?

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
    let document = NSDocumentController.shared.currentDocument as? Document
    let windowController = document?.windowControllers.first
    let viewController = windowController?.contentViewController as? ViewController
    
    if let viewController, let document, viewController.textView.string != document.text,
       let fileURL = document.fileURL, let fileType = document.fileType {
        document.text = viewController.textView.string // Assuming document.text is public.
        document.save(to: fileURL, ofType: fileType, for: .saveOperation) {_ in
            print("Did save the current document.")
            NSApp.reply(toApplicationShouldTerminate: true)
        }
        return .terminateLater
    }
    return .terminateNow
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks. This is a start, but it still won't work for documents that have no fileURL. The documentation doesn't specify at what point it becomes non-nil. Can you tell us more about this? On iOS, UIDocument.fileURL is not even an optional.

Same with document.fileType. When do these properties become non-nil? I'm afraid they don't become non-nil soon enough for new documents.

You ignore the error parameter of the callback passed to save(to:ofType:for:callback:). Shouldn't app termination be cancelled if an error happens while saving?

I noticed that you used NSDocumentController.shared.currentDocument. In my example above I iterated through NSDocumentController.shared.documents. Is there a specific reason why you didn't do so as well?

I still can't wrap my head around this behaviour that sometimes discards the latest text changes. It appears to me as a bug, but I got the impression that you still see it as expected behaviour. As I mentioned at the beginning, when entering any text, switching to another app and back again, then entering some new text and quitting the app, the last changes are discarded. Why does this only happen for changes that are inserted after switching to another app and back again? Why do changes inserted in a new document always seem to be saved, provided that I don't switch app? I tried creating a new document and typing the letters "a b c d e" with a 1 second time interval after each one, and they were always correctly restored after restarting the app. Whereas when creating a new document, typing the letter "a", switching to another app and back again, then typing "b c d e" (again with a 1 second time interval after each one) and quitting the app, only "a" is restored. If this is intended, what's the intention/logic behind this behaviour?

but it still won't work for documents that have no fileURL. The documentation doesn't specify at what point it becomes non-nil. Can you tell us more about this?

NSDocument.fileURL is nil for a new document that hasn't been saved – In that case, when you try to terminate your app or close the document window, AppKit shows an alert asking if you'd save the document, and so you should be fine.

NSDocument.fileType points to your document type specified in the app’s Info.plist. In case you've correctly configured your document type, which you should, NSDocument.fileType should not be nil.

You ignore the error parameter of the callback passed to save(to:ofType:for:callback:). Shouldn't app termination be cancelled if an error happens while saving?

Yeah, I leave the error handling on you.

I noticed that you used NSDocumentController.shared.currentDocument. In my example above I iterated through NSDocumentController.shared.documents. Is there a specific reason why you didn't do so as well?

No specific reason – I just picked currentDocument so I don't need to handle the loop. After all, my intent is to demonstrate the idea, not to write code that can be used directly in your real-world app.

It appears to me as a bug, but I got the impression that you still see it as expected behaviour.

I agree it's a bug in that NSTextView, when sharing the undo stack with NSDocument, should mark the document dirty when there is unsaved user input. Please feel free to add our discussion to your feedback report, which I think will help the AppKit.

Before the issue is addressed from the framework side, I believe the technique we discussed provides a workaround for your app.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

NSDocument doesn't autosave last changes
 
 
Q