Skip to content

fix(web): stop the editor caret from jumping while typing - #29

Open
hrlics wants to merge 1 commit into
duoan:masterfrom
hrlics:fix/editor-cursor-jump
Open

hrlics wants to merge 1 commit into
duoan:masterfrom
hrlics:fix/editor-cursor-jump

Conversation

@hrlics

@hrlics hrlics commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Problem

Typing in the code editor intermittently throws the caret onto a different line and swallows the characters typed around the jump.

Cause

The editor is driven as a controlled component:

<Editor value={code} onChange={(value) => setCode(value || "")} />

@monaco-editor/react synchronises that prop from a passive effect, and when the prop and the live model disagree it rewrites the whole document (dist/index.js):

value !== editor.getValue() && (
  editor.executeEdits("", [{
    range: model.getFullModelRange(),   // the entire document
    text: value,
    forceMoveMarkers: true
  }]),
  editor.pushUndoStop()
)

The two can disagree on any keystroke:

  1. A keypress updates Monaco's model immediately.
  2. onChange is dispatched from Monaco's onDidChangeModelContent, not from a React event, so setCode is scheduled at default priority instead of being flushed synchronously.
  3. The prop-sync effect is a passive effect, so it runs after the commit and after paint.
  4. A keystroke landing inside that window leaves the committed value behind the live model. The effect then replaces the full model range with the stale text, forceMoveMarkers: true pushes the caret to the end of the replaced text, and the characters typed in between are lost.

Because code lives in React state, every keystroke also re-renders Home and re-parses the problem description and its KaTeX, which widens the window that triggers the jump.

Fix

Make the editor uncontrolled:

  • path={`${taskId}.py`} gives each task its own Monaco model, so switching problems swaps models (and restores their view state) instead of rewriting one shared buffer.
  • defaultValue seeds a task's contents. Path and contents come from a single piece of state, so they can never disagree.
  • The live buffer is read back from the editor instance on submit and on reset, so no React state tracks the code any more and typing does not re-render the page.
  • options moved to module scope so the wrapper stops calling updateOptions() on every render, and onChange is wrapped in useCallback so the content-change listener is no longer disposed and re-subscribed on every render.

Verification

Same harness on both builds: 40 keystrokes dispatched one macrotask apart, caret starting on line 4, column 12.

before after
model change events 45 (5 extra rewrites) 40 (one per keystroke)
buffer rewrites (length going backwards) 2 0
final caret line 9, col 37 line 4, col 52
characters lost 2 0
Home re-renders while typing one per keystroke 0

Checked manually in Chrome against the local FastAPI backend:

  • typing leaves the caret where it belongs and still persists to localStorage
  • switching problems swaps content, keeps per-problem edits, and restores each problem's cursor and scroll position; the editor instance is not recreated
  • Reset restores the template and clears the saved entry
  • Run Tests submits the live buffer (ReLU returns Accepted, 4/4)
  • no console errors

npx tsc --noEmit, npx eslint src and next build all pass.

🤖 Generated with Claude Code

The Monaco editor was driven as a controlled component via `value={code}`.
@monaco-editor/react syncs that prop from a passive effect, and whenever the
prop and the live model disagree it rewrites the entire document:

    editor.executeEdits("", [{ range: model.getFullModelRange(),
                               text: value, forceMoveMarkers: true }])

`onChange` is invoked from Monaco's `onDidChangeModelContent` rather than from
a React event, so `setCode` is scheduled at default priority and the effect
only runs after the next paint. A keystroke landing inside that window leaves
the committed `value` behind the model, the full-range rewrite kicks in, and
`forceMoveMarkers` pushes the caret to the end of the replaced text, dropping
the characters typed in between.

Make the editor uncontrolled instead: `path` gives each task its own model and
view state, `defaultValue` seeds it, and the live buffer is read back from the
editor instance when a submission is sent. Typing no longer re-renders `Home`,
so the problem description and its KaTeX are no longer re-parsed per keystroke.

Measured by driving 40 keystrokes one macrotask apart, caret starting on line 4:

  before: 45 change events, 2 buffer rewrites, caret ended on line 9 col 37,
          2 characters lost
  after:  40 change events, 0 rewrites, caret ended on line 4 col 52, none lost

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant