From 58c27ec39ca47f416962f5fc10a5c56feb8cc47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Mon, 6 Jul 2026 10:56:46 +0200 Subject: [PATCH] fix(blazor): guard Monaco against external-echo clobber while editing (typing does nothing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A data-bound Value can arrive on a re-render WHILE the user is mid-edit — a live node-stream echo behind MarkdownEditorView, or a lagging MessageText round-trip behind the chat composer. Reconciling it via SetValue wiped the in-progress keystrokes and reset the cursor: the chronic 'typing / backspace / arrows do nothing' report (Safari composer). Regressed when the single userIsTyping flag was dropped in 9084b7ad5. Two complementary suppressors on the OnAfterRenderAsync reconcile: • userIsTyping — set on each content change; suppresses the synchronous same-render echo the keystroke itself triggers, then re-arms. • editorHasFocus — set from Monaco's onDidFocus/BlurEditorText (JS → HandleFocusChanged); the user owns the buffer whenever focused, so ASYNC stream echoes are suppressed too. Legitimate programmatic pushes use SetValueAsync (set lastSetValue), so nothing real is blocked. NOTE: recovered from an uncommitted working-tree change this session; compiles under Portal build, needs a real-browser confirm in Safari before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Components/Monaco/MonacoEditorView.razor | 41 ++++++++++++++++++- .../Monaco/MonacoEditorView.razor.js | 18 ++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor b/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor index b8ae527aa..b78797a48 100644 --- a/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor +++ b/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor @@ -41,6 +41,22 @@ private int lastCompletionItemCount = 0; private string? lastSetValue; // Track last value set to editor to detect external changes + // 🚨 External-echo clobber guard. A data-bound Value can arrive on a re-render WHILE the user is + // mid-edit — a live node-stream echo behind MarkdownEditorView, or a lagging MessageText round-trip + // behind the chat composer. Reconciling it into the editor via SetValue then WIPES the in-progress + // keystrokes and resets the cursor: the "typing / backspace / arrows do nothing" report. Two + // complementary suppressors (the single userIsTyping flag was removed in 9084b7ad5 and this + // regressed): + // • userIsTyping — set on every content change; suppresses the ONE reconcile that the keystroke + // itself triggers (synchronous echo on the same render), then re-arms. + // • editorHasFocus — the user owns the buffer whenever the editor is focused, so suppress EVERY + // external reconcile then. Covers ASYNC stream echoes that land on their own + // render cycle (the Markdown case). Legitimate programmatic pushes never use + // this reconcile path — they go through SetValueAsync / SetValueSuppressTracking + // (which set lastSetValue) — so suppressing while focused blocks nothing real. + private bool userIsTyping; + private bool editorHasFocus; + [CascadingParameter(Name = "ThemeMode")] public DesignThemeModes Mode { get; set; } @@ -343,6 +359,11 @@ public async Task HandleContentChanged(string? value) { value ??= string.Empty; + // The user just edited: mark active typing so the reconcile in OnAfterRenderAsync + // (triggered by the ValueChanged round-trip below) can't overwrite this keystroke + // with a lagging data-bound echo. Set synchronously here — this JS invoke carries + // the edit, so it beats the async focus event on the very first keystroke. + userIsTyping = true; if (value != Value) { Value = value; @@ -363,6 +384,15 @@ await OnBlur.InvokeAsync(); } + /// + /// Tracks Monaco text focus (from the JS module's onDidFocusEditorText / onDidBlurEditorText + /// listeners). While the editor is focused the user owns the buffer, so + /// must not reconcile an external/echoed + /// into it — that would wipe the in-progress edit and reset the cursor. + /// + [JSInvokable] + public void HandleFocusChanged(bool focused) => editorHasFocus = focused; + /// /// Fires when the user accepts a completion suggestion. /// The parameter is the path of the accepted item. @@ -790,9 +820,13 @@ Theme = themeChanged ? GetMonacoTheme() : null }); - // Check if Value changed from an external source (e.g., data binding). + // Reconcile a genuine EXTERNAL Value change into the editor — but NEVER while the user + // owns the buffer (actively typing, or simply focused). Reconciling mid-edit wiped the + // in-progress keystrokes and reset the cursor (the "typing / backspace / arrows do + // nothing" report). Legitimate programmatic pushes use SetValueAsync, not this path. + // See the userIsTyping / editorHasFocus fields. var currentValue = Value ?? ""; - if (currentValue != lastSetValue) + if (currentValue != lastSetValue && !userIsTyping && !editorHasFocus) { Logger.LogDebug("Value changed externally from '{OldValue}' to '{NewValue}'", lastSetValue?.Substring(0, Math.Min(50, lastSetValue?.Length ?? 0)), @@ -800,6 +834,9 @@ lastSetValue = currentValue; await editor.SetValue(currentValue); } + // Re-arm: the keystroke-triggered render has passed, so a later genuine external change + // can reconcile again (while unfocused). editorHasFocus stays authoritative until blur. + userIsTyping = false; // Try to register completion provider (will skip if already registered with same items) await TryRegisterCompletionProvider(); diff --git a/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js b/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js index 217ed2a9f..af62db311 100644 --- a/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js +++ b/src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js @@ -440,6 +440,24 @@ export function initEditor(editorId, placeholder, dotNetRef, codeEditMode = fals } }); + // Track text focus so the C# side can suppress external Value reconciles while the user + // owns the buffer. Without this, a data-bound echo (a live node-stream emission behind the + // Markdown editor, or a lagging MessageText round-trip behind the chat composer) reconciles + // via SetValue mid-edit and WIPES the in-progress keystrokes / resets the cursor — the + // "typing / backspace / arrows do nothing" report. onDidFocus/BlurEditorText fire for the + // text input specifically (not the surrounding widget), which is exactly what "the user is + // editing" means. Best-effort invoke: a disposed dotNetRef rejects and we swallow it. + editorInstance.onDidFocusEditorText(() => { + const st = editorState.get(editorId); + st?.dotNetRef?.invokeMethodAsync('HandleFocusChanged', true) + .catch(err => console.debug('HandleFocusChanged(true) failed (editor disposed?):', err)); + }); + editorInstance.onDidBlurEditorText(() => { + const st = editorState.get(editorId); + st?.dotNetRef?.invokeMethodAsync('HandleFocusChanged', false) + .catch(err => console.debug('HandleFocusChanged(false) failed (editor disposed?):', err)); + }); + // Force layout after initialization setTimeout(() => { editorInstance.layout();