Skip to content

Commit 083ec31

Browse files
committed
fix: run spell-check pass on main thread to eliminate data races
Background-queue approaches had two fatal flaws on macOS 15.x: 1. requestChecking(of:…) completion handler sometimes never fired after the first call, leaving cache permanently empty. 2. DispatchQueue.global approach read textView.string (not thread- safe) and called shouldSuppressSpellMark which accesses cachedParsedDocument — a property the main thread mutates during restyles. This data race silently produced empty results after edits (underlines disappeared and never returned on insert). Run the entire checkSpelling(of:startingAt:) walk synchronously on the main thread. For typical notes (hundreds of words, a handful of misspellings) this takes single-digit milliseconds — well within the 400ms debounce window. Eliminates all threading issues.
1 parent 3dea6c8 commit 083ec31

1 file changed

Lines changed: 36 additions & 35 deletions

File tree

Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+SpellCheck.swift

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -79,61 +79,62 @@ extension NativeTextViewCoordinator {
7979
)
8080
}
8181

82-
/// Run a full-document scan on a background queue using the
83-
/// synchronous `checkSpelling(of:startingAt:)` loop. The previous
84-
/// async `requestChecking(of:…)` API was unreliable on macOS 15.x —
85-
/// its completion handler sometimes didn't fire after the first call,
86-
/// leaving the cache permanently empty after edits.
82+
/// Run a full-document scan on the main thread using the
83+
/// synchronous `checkSpelling(of:startingAt:)` loop.
84+
///
85+
/// Previous approaches used a background queue (async
86+
/// `requestChecking` or `DispatchQueue.global`), but both were
87+
/// unreliable on macOS 15.x:
88+
/// - `requestChecking`'s completion handler sometimes never fired
89+
/// after the first call.
90+
/// - Background-queue access to `textView.string` (not thread-safe)
91+
/// and `shouldSuppressSpellMark` (reads `cachedParsedDocument`
92+
/// which the main thread mutates during restyles) caused data
93+
/// races that silently produced empty results after edits.
94+
///
95+
/// For typical notes (hundreds of words, a handful of
96+
/// misspellings) the synchronous walk takes single-digit
97+
/// milliseconds — well within the 400 ms debounce window.
8798
///
8899
/// Filters results against the engine's existing zone helpers so
89100
/// code, LaTeX, links, and image embeds never end up underlined.
90101
func runSpellCheckPass(textView: NSTextView) {
91102
guard #unavailable(macOS 26) else { return }
92103
guard userPrefersContinuousSpellChecking else {
93-
DispatchQueue.main.async {
94-
self.clearSpellMisspellings(textView: textView)
95-
}
104+
clearSpellMisspellings(textView: textView)
96105
return
97106
}
98107
let string = textView.string
99108
let ns = string as NSString
100109
let length = ns.length
101110
guard length > 0 else {
102-
DispatchQueue.main.async {
103-
self.updateSpellMisspelledRanges([], textView: textView)
104-
}
111+
updateSpellMisspelledRanges([], textView: textView)
105112
return
106113
}
107114

108115
let checker = NSSpellChecker.shared
109116
let docTag = textView.spellCheckerDocumentTag
110117

111-
// Synchronous walk on a background queue.
112-
// `checkSpelling(of:startingAt:)` is fast for typical note sizes
113-
// and deterministically calls back — no completion-handler race.
114-
DispatchQueue.global(qos: .userInitiated).async { [weak self, weak textView] in
115-
guard let self, let textView else { return }
116-
var misspelledRanges: [NSRange] = []
117-
var searchStart = 0
118-
while searchStart < length {
119-
let misspelled = checker.checkSpelling(
120-
of: ns as String,
121-
startingAt: searchStart,
122-
language: nil,
123-
wrap: false,
124-
inSpellDocumentWithTag: docTag,
125-
wordCount: nil
126-
)
127-
guard misspelled.location != NSNotFound else { break }
128-
if !self.shouldSuppressSpellMark(range: misspelled, in: string) {
129-
misspelledRanges.append(misspelled)
130-
}
131-
searchStart = NSMaxRange(misspelled)
132-
}
133-
DispatchQueue.main.async {
134-
self.updateSpellMisspelledRanges(misspelledRanges, textView: textView)
118+
// Synchronous walk on the main thread — avoids all threading
119+
// issues with NSTextView.string and the parsedDocument cache.
120+
var misspelledRanges: [NSRange] = []
121+
var searchStart = 0
122+
while searchStart < length {
123+
let misspelled = checker.checkSpelling(
124+
of: ns as String,
125+
startingAt: searchStart,
126+
language: nil,
127+
wrap: false,
128+
inSpellDocumentWithTag: docTag,
129+
wordCount: nil
130+
)
131+
guard misspelled.location != NSNotFound else { break }
132+
if !shouldSuppressSpellMark(range: misspelled, in: string) {
133+
misspelledRanges.append(misspelled)
135134
}
135+
searchStart = NSMaxRange(misspelled)
136136
}
137+
updateSpellMisspelledRanges(misspelledRanges, textView: textView)
137138
}
138139

139140
/// Empties the misspelling set and triggers a redraw so any leftover

0 commit comments

Comments
 (0)