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