Skip to content

Commit 3dea6c8

Browse files
committed
fix: switch to synchronous checkSpelling + disable system pass on 15.x
Two issues causing underlines to disappear after edits and reappear inconsistently: 1. requestChecking(of:…) completion handler unreliable on macOS 15.x — after the first call, subsequent completions on NSTextCheckingOperationQueue sometimes never fire, leaving the cache permanently empty after edits. Switch to synchronous checkSpelling(of:startingAt:) walk on a background queue — fast for typical notes and deterministically returns results. 2. System's own continuous spell-check pass races with the engine's driver — on macOS 15.7.7 the system paints .spellingState unreliably (sometimes after mount, sometimes after selection, rarely after edits), producing the inconsistent underlines seen in testing. Disable textView.isContinuousSpellCheckingEnabled when scheduling the engine's pass on 15.x so the engine is the sole painter.
1 parent 6ddf1bc commit 3dea6c8

1 file changed

Lines changed: 53 additions & 27 deletions

File tree

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

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@
1111
// `MarkdownTextLayoutFragment.draw(at:in:)`.
1212
//
1313
// Design (see devlog-0610-spellcheck-15x-fallback-design.md):
14-
// - Async `NSSpellChecker.requestChecking(of:…)` — no main-thread XPC.
14+
// - Synchronous `checkSpelling(of:startingAt:)` walk on a background
15+
// queue — deterministic, no unreliable completion handlers.
1516
// - 400 ms debounce. Cache is cleared **synchronously** on every
1617
// `textDidChange` before the next pass is scheduled, so the stale-
1718
// offset bug class never surfaces.
19+
// - System's own continuous-spell-check pass is disabled on 15.x so it
20+
// can't race with the engine's driver (the unreliable underlines that
21+
// sometimes appeared after selection changes were from the system's
22+
// pass, not ours).
1823
// - Reuses existing zone helpers (`isInsideCode`, `isInsideLatex`,
1924
// `isInsideSpellcheckSuppressedToken`) — same gates as
2025
// `NativeTextView+SpellingPolicy`. Belt-and-suspenders with the
@@ -56,6 +61,13 @@ extension NativeTextViewCoordinator {
5661
clearSpellMisspellings(textView: textView)
5762
return
5863
}
64+
// Disable the system's own continuous spell-check pass on 15.x
65+
// so it can't race with the engine's driver. On macOS 15.7.7 the
66+
// system's pass paints `.spellingState` unreliably (sometimes
67+
// after mount, sometimes after selection, rarely after edits),
68+
// producing the inconsistent underlines reported in testing.
69+
// The engine is the sole painter on 15.x.
70+
textView.isContinuousSpellCheckingEnabled = false
5971
let work = DispatchWorkItem { [weak self, weak textView] in
6072
guard let self, let textView else { return }
6173
self.runSpellCheckPass(textView: textView)
@@ -67,47 +79,61 @@ extension NativeTextViewCoordinator {
6779
)
6880
}
6981

70-
/// Run an async full-document scan via `NSSpellChecker.requestChecking`.
71-
/// Filters results against the engine's existing zone helpers so code,
72-
/// LaTeX, links, and image embeds never end up underlined.
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.
87+
///
88+
/// Filters results against the engine's existing zone helpers so
89+
/// code, LaTeX, links, and image embeds never end up underlined.
7390
func runSpellCheckPass(textView: NSTextView) {
7491
guard #unavailable(macOS 26) else { return }
7592
guard userPrefersContinuousSpellChecking else {
76-
clearSpellMisspellings(textView: textView)
93+
DispatchQueue.main.async {
94+
self.clearSpellMisspellings(textView: textView)
95+
}
7796
return
7897
}
7998
let string = textView.string
80-
let length = (string as NSString).length
99+
let ns = string as NSString
100+
let length = ns.length
81101
guard length > 0 else {
82-
updateSpellMisspelledRanges([], textView: textView)
102+
DispatchQueue.main.async {
103+
self.updateSpellMisspelledRanges([], textView: textView)
104+
}
83105
return
84106
}
85107

86108
let checker = NSSpellChecker.shared
87109
let docTag = textView.spellCheckerDocumentTag
88-
let wholeDocument = NSRange(location: 0, length: length)
89110

90-
checker.requestChecking(
91-
of: string,
92-
range: wholeDocument,
93-
types: NSTextCheckingTypes(NSTextCheckingResult.CheckingType.spelling.rawValue),
94-
options: [:],
95-
inSpellDocumentWithTag: docTag,
96-
completionHandler: { [weak self, weak textView] (_ seq: Int, results: [NSTextCheckingResult], _ orthography: NSOrthography, _ wordCount: Int) in
97-
guard let self, let textView else { return }
98-
let filtered = results.compactMap { result -> NSRange? in
99-
guard result.resultType == .spelling else { return nil }
100-
return self.shouldSuppressSpellMark(range: result.range, in: string)
101-
? nil
102-
: result.range
103-
}
104-
// NSSpellChecker calls back on NSTextCheckingOperationQueue
105-
// (a background queue). Hop to main before touching UI.
106-
DispatchQueue.main.async {
107-
self.updateSpellMisspelledRanges(filtered, textView: textView)
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)
108130
}
131+
searchStart = NSMaxRange(misspelled)
109132
}
110-
)
133+
DispatchQueue.main.async {
134+
self.updateSpellMisspelledRanges(misspelledRanges, textView: textView)
135+
}
136+
}
111137
}
112138

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

0 commit comments

Comments
 (0)