From 503c8b4859c0c3af192b5cd213d0994c2293e9b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 13 May 2026 11:07:36 +0000 Subject: [PATCH] fix(macOS): avoid Replace crash when search highlight ranges are stale Find bar only reposts highlights when the query changes, not on every body edit. Debounced restyle skips out-of-bounds cached ranges in reapplySearchHighlights but leaves lastSearchHighlightRanges unchanged, so Replace could call replaceCharacters with an invalid NSRange and abort the app. Resync highlights before replace when the cached range no longer fits the storage, and add a regression test. Co-authored-by: Danny Peck --- macOS/SynapseNotes/EditorView.swift | 16 ++++++-- ...itorViewPendingStateConsumptionTests.swift | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 7617eec..e1f9684 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2719,10 +2719,20 @@ class LinkAwareTextView: NSTextView { private func replaceCurrentMatch(query: String, focusIndex: Int, replacement: String, advanceAfter: Bool) { guard !query.isEmpty, - lastSearchHighlightRanges.indices.contains(focusIndex) else { return } - let range = lastSearchHighlightRanges[focusIndex] + lastSearchHighlightRanges.indices.contains(focusIndex), + let storage = textStorage else { return } + var range = lastSearchHighlightRanges[focusIndex] + // Body edits do not repost the find query; debounced restyle only skips stale highlight + // ranges in `reapplySearchHighlights` without shrinking `lastSearchHighlightRanges`. + // Replacing with an out-of-bounds NSRange throws NSRangeException — resync first. + if NSMaxRange(range) > storage.length { + applySearchHighlights(query: query, focusIndex: focusIndex) + guard lastSearchHighlightRanges.indices.contains(focusIndex) else { return } + range = lastSearchHighlightRanges[focusIndex] + guard NSMaxRange(range) <= storage.length else { return } + } guard shouldChangeText(in: range, replacementString: replacement) else { return } - textStorage?.replaceCharacters(in: range, with: replacement) + storage.replaceCharacters(in: range, with: replacement) didChangeText() // Recompute matches against new text. Anchor on the position of the replacement diff --git a/macOS/SynapseNotesTests/EditorViewPendingStateConsumptionTests.swift b/macOS/SynapseNotesTests/EditorViewPendingStateConsumptionTests.swift index d42aa78..c99e71e 100644 --- a/macOS/SynapseNotesTests/EditorViewPendingStateConsumptionTests.swift +++ b/macOS/SynapseNotesTests/EditorViewPendingStateConsumptionTests.swift @@ -91,4 +91,42 @@ final class EditorViewPendingStateConsumptionTests: XCTestCase { let maxOffset = max(0, textView.bounds.height - scrollView.contentView.bounds.height) XCTAssertEqual(scrollView.contentView.bounds.origin.y, maxOffset, accuracy: 0.5) } + + /// Editing the note while the find bar stays open does not repost `scrollToSearchMatch`. + /// Debounced markdown restyle re-applies highlights but skips out-of-bounds cached ranges + /// without removing them, so Replace must not call `replaceCharacters` with a stale NSRange. + func test_replaceCurrentMatch_doesNotCrashWhenCachedHighlightRangeIsStaleAfterBodyEdit() { + let textView = RawEditor.configuredTextView(isEditable: true, settings: nil) + textView.participatesInGlobalSearch = true + textView.installSearchObservers() + + textView.setPlainText("aaaaaaaaaahello") + NotificationCenter.default.post( + name: .scrollToSearchMatch, + object: nil, + userInfo: [SearchMatchKey.query: "hello", SearchMatchKey.matchIndex: 0] + ) + + guard let storage = textView.textStorage else { + XCTFail("expected text storage") + return + } + storage.beginEditing() + storage.replaceCharacters(in: NSRange(location: 0, length: storage.length), with: "no") + storage.endEditing() + textView.didChangeText() + + NotificationCenter.default.post( + name: .replaceCurrentMatch, + object: nil, + userInfo: [ + SearchMatchKey.query: "hello", + SearchMatchKey.matchIndex: 0, + SearchMatchKey.replacement: "x", + SearchMatchKey.advanceAfter: false, + ] + ) + + XCTAssertEqual(textView.string, "no") + } }