From 6bc8cf1f3ecab8a84d3894d9b91b54618f0c4548 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 12:18:34 +0200 Subject: [PATCH 1/9] Read a script's caret without waiting for the UI thread The project options reactor looked up the caret itself through ServiceProvider.GlobalProvider, the RDT, IVsTextView and an IVsTextViewEvents connection point, all of which need the UI thread. When the UI thread synchronously waited on project options (breakpoint validation when a document frame is shown), the reactor waited for the UI thread and the UI thread for the reactor. An IWpfTextViewCreationListener now publishes the caret of the focused editor into the text buffer's properties, and the reactor only reads it. Only scripts look for it: FCS uses the caret only to skip the `#r "nuget: ..."` line being typed. Fixes #20522 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/FSharp.Editor/Common/Extensions.fs | 86 ------------------- .../src/FSharp.Editor/FSharp.Editor.fsproj | 1 + .../FSharpProjectOptionsManager.fs | 59 +++++-------- .../LanguageService/FocusedCaret.fs | 77 +++++++++++++++++ 4 files changed, 101 insertions(+), 122 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index f9695e68ecf..9d872a47cac 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -7,15 +7,9 @@ open System open System.IO open System.Collections.Immutable open System.Collections.Generic -open System.Runtime.InteropServices open System.Threading open System.Threading.Tasks -open Microsoft.VisualStudio -open Microsoft.VisualStudio.Shell -open Microsoft.VisualStudio.Shell.Interop -open Microsoft.VisualStudio.TextManager.Interop - open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text open Microsoft.CodeAnalysis.Host @@ -25,10 +19,6 @@ open FSharp.Compiler.Syntax open FSharp.Compiler.Text open Microsoft.VisualStudio.FSharp.Editor -open Microsoft.VisualStudio.Editor -open Microsoft.VisualStudio.Text.Editor -open Microsoft.VisualStudio -open Microsoft.VisualStudio.OLE.Interop type private FSharpGlyph = FSharp.Compiler.EditorServices.FSharpGlyph type private FSharpRoslynGlyph = Microsoft.CodeAnalysis.ExternalAccess.FSharp.FSharpGlyph @@ -69,56 +59,6 @@ type Project with member this.IsFSharp = this.Language = LanguageNames.FSharp -type TextViewEventsHandler - ( - onChangeCaretHandler: (IVsTextView * int * int -> unit) option, - onKillFocus: (IVsTextView -> unit) option, - onSetFocus: (IVsTextView -> unit) option - ) = - interface IVsTextViewEvents with - member this.OnChangeCaretLine(view: IVsTextView, newline: int, oldline: int) = - onChangeCaretHandler - |> Option.iter (fun handler -> handler (view, newline, oldline)) - - member this.OnChangeScrollInfo - (_view: IVsTextView, _iBar: int, _iMinUnit: int, _iMaxUnits: int, _iVisibleUnits: int, _iFirstVisibleUnit: int) - = - () - - member this.OnKillFocus(view: IVsTextView) = - onKillFocus |> Option.iter (fun handler -> handler (view)) - - member this.OnSetBuffer(_view: IVsTextView, _buffer: IVsTextLines) = () - - member this.OnSetFocus(view: IVsTextView) = - onSetFocus |> Option.iter (fun handler -> handler (view)) - -type ConnectionPointSubscription = System.IDisposable option - -// Usage example: -// If a handler is None, to not handle that event -// let subscription = subscribeToTextViewEvents (textView, onChangeCaretHandler, onKillFocus, onSetFocus) -// Unsubscribe using subscription.Dispose() -let subscribeToTextViewEvents (textView: IVsTextView, onChangeCaretHandler, onKillFocus, onSetFocus) : ConnectionPointSubscription = - let handler = TextViewEventsHandler(onChangeCaretHandler, onKillFocus, onSetFocus) - - match textView with - | :? IConnectionPointContainer as cpContainer -> - let riid = typeof.GUID - let mutable cookie = 0u - - match cpContainer.FindConnectionPoint(ref riid) with - | null -> None - | cp -> - Some( - cp.Advise(handler, &cookie) - - { new IDisposable with - member _.Dispose() = cp.Unadvise(cookie) - } - ) - | _ -> None - type Document with member this.TryGetLanguageService<'T when 'T :> ILanguageService>() = @@ -129,32 +69,6 @@ type Document with | null -> None | languageServices -> languageServices.GetService<'T>() |> Some - member this.TryGetIVsTextView() : IVsTextView option = - match ServiceProvider.GlobalProvider.GetService(typeof) with - | :? IVsTextManager as textManager -> - // Grab IVsRunningDocumentTable - match ServiceProvider.GlobalProvider.GetService(typeof) with - | :? IVsRunningDocumentTable as rdt -> - match rdt.FindAndLockDocument(uint32 _VSRDTFLAGS.RDT_NoLock, this.FilePath) with - | hr, _, _, docData, _ when ErrorHandler.Succeeded(hr) && docData <> IntPtr.Zero -> - match Marshal.GetObjectForIUnknown docData with - | :? IVsTextBuffer as ivsTextBuffer -> - match textManager.GetActiveView(0, ivsTextBuffer) with - | hr, vsTextView when ErrorHandler.Succeeded(hr) -> Some vsTextView - | _ -> None - | _ -> None - | _ -> None - | _ -> None - | _ -> None - - member this.TryGetTextViewAndCaretPos() : (IVsTextView * Position) option = - match this.TryGetIVsTextView() with - | Some textView -> - match textView.GetCaretPos() with - | hr, line, column when ErrorHandler.Succeeded(hr) -> Some(textView, Position.fromZ line column) - | _ -> None - | None -> None - member this.IsFSharpScript = isScriptFile this.FilePath member this.IsFSharpSignatureFile = isSignatureFile this.FilePath diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..42bbd8e8aeb 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -50,6 +50,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index db73206996b..afc6f73dfc1 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -20,7 +20,6 @@ open Microsoft.VisualStudio.FSharp.Editor.Extensions open System.Windows open Microsoft.VisualStudio open FSharp.Compiler.Text -open Microsoft.VisualStudio.TextManager.Interop #nowarn "57" @@ -129,7 +128,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = ConcurrentDictionary() let singleFileCache = - ConcurrentDictionary() + ConcurrentDictionary() // This is used to not constantly emit the same compilation. let weakPEReferences = ConditionalWeakTable() @@ -204,36 +203,29 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! fileStamp = document.GetTextVersionAsync(ct) - let textViewAndCaret () : (IVsTextView * Position) option = document.TryGetTextViewAndCaretPos() match singleFileCache.TryGetValue(document.Id) with | false, _ -> let! sourceText = document.GetTextAsync(ct) - let getProjectOptionsFromScript textViewAndCaret = - let caret = textViewAndCaret () - - match caret with - | None -> - checker.GetProjectOptionsFromScript( - document.FilePath, - sourceText.ToFSharpSourceText(), - previewEnabled = SessionsProperties.fsiPreview, - assumeDotNetFramework = not SessionsProperties.fsiUseNetCore, - userOpName = userOpName - ) - - | Some(_, caret) -> - checker.GetProjectOptionsFromScript( - document.FilePath, - sourceText.ToFSharpSourceText(), - caret, - previewEnabled = SessionsProperties.fsiPreview, - assumeDotNetFramework = not SessionsProperties.fsiUseNetCore, - userOpName = userOpName - ) - - let! scriptProjectOptions, _ = getProjectOptionsFromScript textViewAndCaret + // FCS reads the caret only to skip resolving the `#r "nuget: …"` line being typed, and only scripts have those. + let focusedCaret = + if isScriptFile document.FilePath then + FocusedCaret.TryGet sourceText + else + ValueNone + + let getProjectOptionsFromScript () = + checker.GetProjectOptionsFromScript( + document.FilePath, + sourceText.ToFSharpSourceText(), + ?caret = (focusedCaret |> ValueOption.toOption |> Option.bind _.Position), + previewEnabled = SessionsProperties.fsiPreview, + assumeDotNetFramework = not SessionsProperties.fsiUseNetCore, + userOpName = userOpName + ) + + let! scriptProjectOptions, _ = getProjectOptionsFromScript () let project = document.Project let otherOptions = @@ -270,25 +262,20 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = let updateProjectOptions () = async { - let! scriptProjectOptions, _ = getProjectOptionsFromScript textViewAndCaret + let! scriptProjectOptions, _ = getProjectOptionsFromScript () checker.NotifyFileChanged(document.FilePath, scriptProjectOptions) |> Async.Start } |> Async.Start - let onChangeCaretHandler (_, _newline: int, _oldline: int) = updateProjectOptions () - let onKillFocus (_) = updateProjectOptions () - let onSetFocus (_) = updateProjectOptions () - let addToCacheAndSubscribe value = match value with | projectId, fileStamp, parsingOptions, projectOptions, _ -> let subscription = - match textViewAndCaret () with - | Some(textView, _) -> - subscribeToTextViewEvents (textView, (Some onChangeCaretHandler), (Some onKillFocus), (Some onSetFocus)) - | None -> None + focusedCaret + |> ValueOption.toOption + |> Option.map (fun caret -> caret.LineChanged.Subscribe updateProjectOptions) (projectId, fileStamp, parsingOptions, projectOptions, subscription) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs b/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs new file mode 100644 index 00000000000..6f0021ce78d --- /dev/null +++ b/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System.ComponentModel.Composition + +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.Text.Editor +open Microsoft.VisualStudio.Utilities + +open FSharp.Compiler.Text + +/// The caret of the focused editor on a text buffer, published by the UI thread for the project options +/// reactor: the UI thread can be blocked waiting for the reactor, so the reactor must never wait for it. +[] +type internal FocusedCaret() = + + // A reference, not a voption: the reactor reads it while the UI thread writes, and must never see a torn struct. + [] + let mutable position: Position option = None + + let lineChanged = Event() + + /// None while no editor on the buffer has focus. + member _.Position = position + + /// Raised on the UI thread when the caret moves to another line, or focus enters or leaves the buffer's editors. + member _.LineChanged = lineChanged.Publish + + member _.Update(newPosition: Position option) = + let hasLineChanged = Option.map _.Line position <> Option.map _.Line newPosition + position <- newPosition + + if hasLineChanged then + lineChanged.Trigger() + + static member TryGet(sourceText: SourceText) = + match sourceText.Container.TryGetTextBuffer() with + | null -> ValueNone + | buffer -> + match buffer.Properties.TryGetProperty(typeof) with + | true, caret -> ValueSome caret + | _ -> ValueNone + +[)>] +[] +[] +type internal FocusedCaretTracker() = + + let caretOf (textView: ITextView) = + let caret = textView.Caret.Position.BufferPosition + let line = caret.GetContainingLine() + Position.fromZ line.LineNumber (caret.Position - line.Start.Position) + + interface IWpfTextViewCreationListener with + member _.TextViewCreated(textView) = + let focusedCaret = + textView.TextBuffer.Properties.GetOrCreateSingletonProperty(fun () -> FocusedCaret()) + + let publish _ = + focusedCaret.Update(Some(caretOf textView)) + + let subscriptions = + [ + textView.Caret.PositionChanged.Subscribe(fun _ -> + if textView.HasAggregateFocus then + publish ()) + textView.GotAggregateFocus.Subscribe publish + textView.LostAggregateFocus.Subscribe(fun _ -> focusedCaret.Update None) + ] + + if textView.HasAggregateFocus then + publish () + + textView.Closed.Add(fun _ -> + for subscription in subscriptions do + subscription.Dispose()) From 5b151637aaa8cfe7a492108480879268c9886da6 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 12:19:03 +0200 Subject: [PATCH 2/9] Release notes for the script caret hang Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index e6034dca8df..37a8b94e7f2 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -20,6 +20,7 @@ * Reduce allocations in the VS project options reactor: the command-line options and project options caches and the mailbox reply payloads now hold struct tuples, and `IProjectSite.CompilationBinOutputPath` returns `string voption` picked with a new `Array.tryPickV`. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413)) * Build a single-file project's `OtherOptions` reference flags with one array comprehension instead of two `Array.ofSeq` calls and an `Array.append`. ([PR #20499](https://github.com/dotnet/fsharp/pull/20499)) * Fix syntax coloring being lost for a whole file when one symbol resolves into metadata that could not be read. ([Issue #20269](https://github.com/dotnet/fsharp/issues/20269), [PR #20274](https://github.com/dotnet/fsharp/pull/20274)) +* Fix a hang when the UI thread waits on project options for a script or a file in F# Miscellaneous Files: the project options reactor reads the caret the UI thread publishes instead of asking the UI thread for it, and only for scripts. ([Issue #20522](https://github.com/dotnet/fsharp/issues/20522), [PR #20523](https://github.com/dotnet/fsharp/pull/20523)) ### Changed From b6e602ff7d412a6890f184b86717ac68fc491d3b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 12:30:51 +0200 Subject: [PATCH 3/9] Keep the caret in voption until GetProjectOptionsFromScript takes it Co-Authored-By: Claude Opus 5 (1M context) --- .../FSharpProjectOptionsManager.fs | 4 ++-- .../FSharp.Editor/LanguageService/FocusedCaret.fs | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index afc6f73dfc1..78accfcd5a0 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -219,7 +219,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = checker.GetProjectOptionsFromScript( document.FilePath, sourceText.ToFSharpSourceText(), - ?caret = (focusedCaret |> ValueOption.toOption |> Option.bind _.Position), + ?caret = (focusedCaret |> ValueOption.bind _.Position |> ValueOption.toOption), previewEnabled = SessionsProperties.fsiPreview, assumeDotNetFramework = not SessionsProperties.fsiUseNetCore, userOpName = userOpName @@ -274,8 +274,8 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = | projectId, fileStamp, parsingOptions, projectOptions, _ -> let subscription = focusedCaret + |> ValueOption.map _.LineChanged.Subscribe(updateProjectOptions) |> ValueOption.toOption - |> Option.map (fun caret -> caret.LineChanged.Subscribe updateProjectOptions) (projectId, fileStamp, parsingOptions, projectOptions, subscription) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs b/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs index 6f0021ce78d..70e75d5afb9 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs @@ -15,20 +15,21 @@ open FSharp.Compiler.Text [] type internal FocusedCaret() = - // A reference, not a voption: the reactor reads it while the UI thread writes, and must never see a torn struct. [] - let mutable position: Position option = None + let mutable position: Position voption = ValueNone let lineChanged = Event() - /// None while no editor on the buffer has focus. + /// ValueNone while no editor on the buffer has focus. member _.Position = position /// Raised on the UI thread when the caret moves to another line, or focus enters or leaves the buffer's editors. member _.LineChanged = lineChanged.Publish - member _.Update(newPosition: Position option) = - let hasLineChanged = Option.map _.Line position <> Option.map _.Line newPosition + member _.Update(newPosition: Position voption) = + let hasLineChanged = + (position |> ValueOption.map _.Line) <> (newPosition |> ValueOption.map _.Line) + position <- newPosition if hasLineChanged then @@ -58,7 +59,7 @@ type internal FocusedCaretTracker() = textView.TextBuffer.Properties.GetOrCreateSingletonProperty(fun () -> FocusedCaret()) let publish _ = - focusedCaret.Update(Some(caretOf textView)) + focusedCaret.Update(ValueSome(caretOf textView)) let subscriptions = [ @@ -66,7 +67,7 @@ type internal FocusedCaretTracker() = if textView.HasAggregateFocus then publish ()) textView.GotAggregateFocus.Subscribe publish - textView.LostAggregateFocus.Subscribe(fun _ -> focusedCaret.Update None) + textView.LostAggregateFocus.Subscribe(fun _ -> focusedCaret.Update ValueNone) ] if textView.HasAggregateFocus then From 57bc4ca93bb1a006fa557ab4ae22c90c17356159 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 18 Sep 2026 18:32:15 +0200 Subject: [PATCH 4/9] Avoid torn focused-caret publication Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../LanguageService/FSharpProjectOptionsManager.fs | 11 ++++++----- .../FSharp.Editor/LanguageService/FocusedCaret.fs | 13 +++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index 78accfcd5a0..061b0c24e09 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -211,15 +211,17 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = // FCS reads the caret only to skip resolving the `#r "nuget: …"` line being typed, and only scripts have those. let focusedCaret = if isScriptFile document.FilePath then - FocusedCaret.TryGet sourceText + match FocusedCaret.TryGet sourceText with + | ValueSome caret -> Some caret + | ValueNone -> None else - ValueNone + None let getProjectOptionsFromScript () = checker.GetProjectOptionsFromScript( document.FilePath, sourceText.ToFSharpSourceText(), - ?caret = (focusedCaret |> ValueOption.bind _.Position |> ValueOption.toOption), + ?caret = (focusedCaret |> Option.bind _.Position), previewEnabled = SessionsProperties.fsiPreview, assumeDotNetFramework = not SessionsProperties.fsiUseNetCore, userOpName = userOpName @@ -274,8 +276,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = | projectId, fileStamp, parsingOptions, projectOptions, _ -> let subscription = focusedCaret - |> ValueOption.map _.LineChanged.Subscribe(updateProjectOptions) - |> ValueOption.toOption + |> Option.map _.LineChanged.Subscribe(updateProjectOptions) (projectId, fileStamp, parsingOptions, projectOptions, subscription) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs b/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs index 70e75d5afb9..483b4c44a7d 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs @@ -15,20 +15,21 @@ open FSharp.Compiler.Text [] type internal FocusedCaret() = + // A reference, not an option: the reactor reads it while the UI thread writes, and must never see a torn struct. [] - let mutable position: Position voption = ValueNone + let mutable position: Position option = None let lineChanged = Event() - /// ValueNone while no editor on the buffer has focus. + /// None while no editor on the buffer has focus. member _.Position = position /// Raised on the UI thread when the caret moves to another line, or focus enters or leaves the buffer's editors. member _.LineChanged = lineChanged.Publish - member _.Update(newPosition: Position voption) = + member _.Update(newPosition: Position option) = let hasLineChanged = - (position |> ValueOption.map _.Line) <> (newPosition |> ValueOption.map _.Line) + (position |> Option.map _.Line) <> (newPosition |> Option.map _.Line) position <- newPosition @@ -59,7 +60,7 @@ type internal FocusedCaretTracker() = textView.TextBuffer.Properties.GetOrCreateSingletonProperty(fun () -> FocusedCaret()) let publish _ = - focusedCaret.Update(ValueSome(caretOf textView)) + focusedCaret.Update(Some(caretOf textView)) let subscriptions = [ @@ -67,7 +68,7 @@ type internal FocusedCaretTracker() = if textView.HasAggregateFocus then publish ()) textView.GotAggregateFocus.Subscribe publish - textView.LostAggregateFocus.Subscribe(fun _ -> focusedCaret.Update ValueNone) + textView.LostAggregateFocus.Subscribe(fun _ -> focusedCaret.Update None) ] if textView.HasAggregateFocus then From 6e9472d05d0c2d3b41bff23fb9d6fd20f8d80c34 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 18 Sep 2026 21:39:16 +0200 Subject: [PATCH 5/9] Format project options manager Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../LanguageService/FSharpProjectOptionsManager.fs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index 061b0c24e09..25627a28555 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -275,8 +275,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = match value with | projectId, fileStamp, parsingOptions, projectOptions, _ -> let subscription = - focusedCaret - |> Option.map _.LineChanged.Subscribe(updateProjectOptions) + focusedCaret |> Option.map _.LineChanged.Subscribe(updateProjectOptions) (projectId, fileStamp, parsingOptions, projectOptions, subscription) From bdd422eaa15af2d944f3473c18dbbdea6c433d52 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 20 Sep 2026 18:26:01 +0200 Subject: [PATCH 6/9] Keep the local caret lookup in voption after the torn-read fix Only the shared FocusedCaret.Position field needed to become `option` for the atomic reference write; the reactor's own local binding was converted along with it for no reason. Route it back through ValueOption and land on `option` once, at the two points that need it. Co-Authored-By: Claude Sonnet 5 --- .../LanguageService/FSharpProjectOptionsManager.fs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index 25627a28555..abd4faab51d 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -211,17 +211,15 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = // FCS reads the caret only to skip resolving the `#r "nuget: …"` line being typed, and only scripts have those. let focusedCaret = if isScriptFile document.FilePath then - match FocusedCaret.TryGet sourceText with - | ValueSome caret -> Some caret - | ValueNone -> None + FocusedCaret.TryGet sourceText else - None + ValueNone let getProjectOptionsFromScript () = checker.GetProjectOptionsFromScript( document.FilePath, sourceText.ToFSharpSourceText(), - ?caret = (focusedCaret |> Option.bind _.Position), + ?caret = (focusedCaret |> ValueOption.toOption |> Option.bind _.Position), previewEnabled = SessionsProperties.fsiPreview, assumeDotNetFramework = not SessionsProperties.fsiUseNetCore, userOpName = userOpName @@ -275,7 +273,9 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = match value with | projectId, fileStamp, parsingOptions, projectOptions, _ -> let subscription = - focusedCaret |> Option.map _.LineChanged.Subscribe(updateProjectOptions) + focusedCaret + |> ValueOption.map _.LineChanged.Subscribe(updateProjectOptions) + |> ValueOption.toOption (projectId, fileStamp, parsingOptions, projectOptions, subscription) From 1826a7e2a6b947ce28e3a54004a11521fc71f4c0 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 13:56:41 +0200 Subject: [PATCH 7/9] Forget the caret of a closed editor, and test what the reactor reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caret belongs to the buffer, which outlives the view. A view closed while it held focus left its line published, and the reactor went on skipping the `#r "nuget: …"` on a line no editor was on. `FocusedCaretTests` covers what the reactor reads and when it is told to read again: a move to another line, a move along one, focus leaving, focus already gone, and a text no view is open on. Co-Authored-By: Claude Opus 5 (1M context) --- .../LanguageService/FocusedCaret.fs | 8 ++- .../FSharp.Editor.Tests.fsproj | 1 + .../FSharp.Editor.Tests/FocusedCaretTests.fs | 63 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 vsintegration/tests/FSharp.Editor.Tests/FocusedCaretTests.fs diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs b/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs index 483b4c44a7d..5505f8d1d03 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FocusedCaret.fs @@ -76,4 +76,10 @@ type internal FocusedCaretTracker() = textView.Closed.Add(fun _ -> for subscription in subscriptions do - subscription.Dispose()) + subscription.Dispose() + + // The caret belongs to the buffer, which outlives the view. A view closed while it held focus + // would leave its line published, and the reactor would go on skipping the `#r` on a line no + // editor is on. A view that does not hold focus published nothing to clear. + if textView.HasAggregateFocus then + focusedCaret.Update None) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..9a9a2c3f0f4 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -32,6 +32,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/FocusedCaretTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FocusedCaretTests.fs new file mode 100644 index 00000000000..59f344e9dd7 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/FocusedCaretTests.fs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// What the project options reactor reads off the focused editor, and when it is told to read it again. +/// Only the line matters: the caret decides which `#r "nuget: …"` line is still being typed, and a script's +/// references are resolved again once it leaves that line or the editors lose focus. +module FSharp.Editor.Tests.FocusedCaretTests + +open Xunit +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor +open FSharp.Compiler.Text + +let private caretWithRecordedChanges () = + let caret = FocusedCaret() + let changes = ResizeArray() + caret.LineChanged.Add(fun () -> changes.Add caret.Position) + caret, changes + +[] +let ``a caret that moves to another line is published as a line change`` () = + let caret, changes = caretWithRecordedChanges () + + caret.Update(Some(Position.mkPos 7 0)) + caret.Update(Some(Position.mkPos 8 4)) + + Assert.Equal([ 7; 8 ], [ for position in changes -> position.Value.Line ]) + Assert.Equal(8, caret.Position.Value.Line) + +[] +let ``a caret that moves along its line is not a line change`` () = + let caret, changes = caretWithRecordedChanges () + + caret.Update(Some(Position.mkPos 7 0)) + caret.Update(Some(Position.mkPos 7 12)) + + Assert.Equal(1, changes.Count) + // The position is still published: the reactor reads the column of the line it skips. + Assert.Equal(12, caret.Position.Value.Column) + +[] +let ``editors losing focus is a line change, and the caret is gone`` () = + let caret, changes = caretWithRecordedChanges () + + caret.Update(Some(Position.mkPos 7 0)) + caret.Update None + + Assert.Equal(2, changes.Count) + Assert.True(caret.Position.IsNone) + +[] +let ``no editor has focus twice over is one line change`` () = + let caret, changes = caretWithRecordedChanges () + + caret.Update None + caret.Update None + + Assert.Empty changes + Assert.True(caret.Position.IsNone) + +[] +let ``a text with no editor behind it has no caret`` () = + // What the reactor sees for a document Visual Studio has not opened a view on. + Assert.True((FocusedCaret.TryGet(SourceText.From "#r \"nuget: Newtonsoft.Json\"\n")).IsNone) From 5bf26ce75c1b18d8abcac444544b824f2ac21749 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 20:35:31 +0200 Subject: [PATCH 8/9] Resolve a script's references from its current text when the caret leaves a line The options recomputed when the caret moves to another line were built from the text captured when the cache entry was created. Edits made since then had not reached them, so leaving a `#r "nuget: ..."` line could resolve the half-typed name it held at that point - the extra package download the caret was introduced to avoid. Read the buffer's current text instead; it is a snapshot read and needs no UI thread. Co-Authored-By: Claude Opus 5.5 --- .../LanguageService/FSharpProjectOptionsManager.fs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index abd4faab51d..e7a7822ecc6 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -215,17 +215,17 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = else ValueNone - let getProjectOptionsFromScript () = + let getProjectOptionsFromScript (text: ISourceText) = checker.GetProjectOptionsFromScript( document.FilePath, - sourceText.ToFSharpSourceText(), + text, ?caret = (focusedCaret |> ValueOption.toOption |> Option.bind _.Position), previewEnabled = SessionsProperties.fsiPreview, assumeDotNetFramework = not SessionsProperties.fsiUseNetCore, userOpName = userOpName ) - let! scriptProjectOptions, _ = getProjectOptionsFromScript () + let! scriptProjectOptions, _ = getProjectOptionsFromScript (sourceText.ToFSharpSourceText()) let project = document.Project let otherOptions = @@ -262,7 +262,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = let updateProjectOptions () = async { - let! scriptProjectOptions, _ = getProjectOptionsFromScript () + let! scriptProjectOptions, _ = getProjectOptionsFromScript (sourceText.Container.CurrentText.ToFSharpSourceText()) checker.NotifyFileChanged(document.FilePath, scriptProjectOptions) |> Async.Start From ba8622f7b50ded51277b2efb0e8d54986e6c6b91 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 20:37:37 +0200 Subject: [PATCH 9/9] Release note for resolving script references from the current text Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 37a8b94e7f2..c505ca7d0a2 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -12,6 +12,7 @@ * Fixed Find All References crash when F# project contains non-F# files like `.cshtml`. ([Issue #16394](https://github.com/dotnet/fsharp/issues/16394), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Avoid using `cancellableTask` in `DocumentCache`; the editor cache now uses direct `CancellationToken`-aware `task` wrappers, avoiding the background `Task.Run` offload and a larger wrapper closure from the `cancellableTask` builder. ([Issue #20268](https://github.com/dotnet/fsharp/issues/20268)) * Cache document diagnostics by version stamp, so an unchanged document is not reanalyzed on every crawler pass. ([Issue #20120](https://github.com/dotnet/fsharp/issues/20120), [PR #20121](https://github.com/dotnet/fsharp/pull/20121)) +* Resolve a script's `#r "nuget: …"` lines from the text in the editor when the caret leaves one, not from the text the script's options were computed from. ([PR #20645](https://github.com/dotnet/fsharp/pull/20645)) * Find All References for external DLL symbols now only searches projects that reference the specific assembly. ([Issue #10227](https://github.com/dotnet/fsharp/issues/10227), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Improve static compilation of state machines. ([PR #19297](https://github.com/dotnet/fsharp/pull/19297)) * Make Alt+F1 (momentary toggle) work for inlay hints. ([PR #19421](https://github.com/dotnet/fsharp/pull/19421))