You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
# Fix: First keystroke lost after selection in Monaco editor
2
+
3
+
## Overview
4
+
5
+
Two separate bugs caused the first keystroke to be lost after making a selection in the hub-client Monaco editor:
6
+
7
+
1.**Intermittent (any selection direction)**: The `handleEditorChange` callback was recreated on every render, causing `@monaco-editor/react` to re-subscribe its `onDidChangeModelContent` listener via `useEffect` on every render. Keystrokes landing between paint and effect execution could be dropped.
8
+
9
+
2.**Deterministic (backward/RTL selections only)**: On some platforms, the browser's hidden textarea input pipeline silently drops the first character typed into a backward selection. Monaco receives the `keyDown` event but the `input` event never fires on the hidden textarea, so no model change occurs.
10
+
11
+
## Bug 1: Unstable onChange callback
12
+
13
+
### Root Cause
14
+
15
+
`@monaco-editor/react` v4.7.0 manages its `onDidChangeModelContent` listener inside a `useEffect([isReady, onChange])`. Because `handleEditorChange` was not memoized, it received a new function reference on every React render, causing the library to dispose and re-subscribe its listener on every render. Keystrokes landing in the window between paint and effect execution could be silently dropped.
16
+
17
+
A secondary issue: the `options` object passed to `<MonacoEditor>` was defined inline in JSX, creating a new reference every render, causing unnecessary `editor.updateOptions()` calls.
18
+
19
+
### Fix
20
+
21
+
-**`useAutomergeSync.ts`**: Added `currentFileRef` (a `useRef` that mirrors `currentFile`). Wrapped `handleEditorChange` in `useCallback([onContentOperations])` — stable across all renders since `onContentOperations` is itself `useCallback([], [])`.
22
+
-**`Editor.tsx`**: Promoted the inline `options` to a module-level `const editorOptions` (fully static, no `useMemo` needed).
23
+
24
+
### Tests
25
+
26
+
Two regression tests in `useAutomergeSync.test.ts`:
27
+
1.**Stable identity across re-renders** — asserts `handleEditorChange` is `===` across re-renders with different `fileContents`.
28
+
2.**Ref picks up file switches** — asserts the stable callback routes changes to the new file path after a file switch.
29
+
30
+
## Bug 2: Backward selection drops first keystroke
31
+
32
+
### Root Cause
33
+
34
+
On some platforms (confirmed on macOS), when the user makes a backward (RTL) selection in Monaco and types a character, the browser's input pipeline silently drops the keystroke. Diagnosis via instrumentation confirmed:
- The `@monaco-editor/react``onChange` callback is never called
38
+
39
+
The issue is at the browser/OS level: Monaco's hidden textarea has its selection set to represent the backward selection, and the platform's input method system fails to process the first keystroke in this state. This was confirmed by ruling out all application-level causes (selection sync, presence, reconciliation) via targeted disabling.
40
+
41
+
### Fix
42
+
43
+
**`Editor.tsx`**: Added an `editor.onKeyDown` handler in `handleEditorMount` that normalizes backward selections to forward on any printable keyDown. When a printable character key is pressed with an RTL selection active, the handler calls `editor.setSelection()` to flip the selection to LTR (same highlighted range, cursor moves to end). This allows the browser's input pipeline to process the character correctly.
44
+
45
+
```typescript
46
+
editor.onKeyDown((e) => {
47
+
const sel =editor.getSelection();
48
+
if (!sel||sel.isEmpty() ||sel.getDirection() ===0) return;
49
+
const key =e.browserEvent.key;
50
+
if (!key||key.length!==1) return;
51
+
editor.setSelection({
52
+
selectionStartLineNumber: sel.startLineNumber,
53
+
selectionStartColumn: sel.startColumn,
54
+
positionLineNumber: sel.endLineNumber,
55
+
positionColumn: sel.endColumn,
56
+
});
57
+
});
58
+
```
59
+
60
+
## Analysis
61
+
62
+
### What was ruled out
63
+
64
+
-**Automerge sync path**: Fully synchronous. `getFileContent()` always matches `model.getValue()` for local edits.
65
+
-**Reconciliation effect**: Always a no-op for local edits (reads live Automerge state, not stale closure).
1.**Sync layer** (`handleEditorChange`): logged all calls including dropped ones (replay/remote guards). Result: no log at all for backward selections → callback never invoked.
76
+
2.**Monaco events** (`editor.onKeyDown`, `model.onDidChangeContent`): `keyDown` fired but `modelChange` did not → keystroke received by Monaco but never applied to model.
77
+
3.**Selection sync disabled**: Bug persisted → not caused by selection sync.
78
+
79
+
This narrowed the cause to the browser's input pipeline between Monaco's `keyDown` handler and the hidden textarea's `input` event.
0 commit comments