Skip to content

Native EditContext: focusing the editor scrolls natively-scrolling ancestors (missing the textarea path's scroll-preservation guard) #324758

Description

@nstrayer

Type: Bug

When the editor uses the native EditContext input path (editor.editContext: true, the default on Chromium), focusing the editor scrolls every natively-scrolling ancestor of the hidden edit context node. The textarea input path has guarded against exactly this for years; the native EditContext path is missing the same guard.

Root cause

FocusTracker.focus() in src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts calls this._domNode.focus() bare:

public focus(): void {
	this._domNode.focus();
	this.refreshFocusState();
}

The hidden .native-edit-context node is absolutely positioned at the editor's last cursor position. When the editor gains focus (e.g. on mouse down), the browser scrolls every scrollable ancestor to reveal that node. If any ancestor scrolls natively (overflow: auto), its scroll position jumps, shifting layout between Monaco's mouse-down hit test and the click resolution -- so the click can land the cursor on the wrong line, and the container visibly jumps.

The textarea path already handles this in writeNativeTextAreaContent (src/vs/editor/browser/controller/editContext/textArea/textAreaEditContextInput.ts):

// If the focus is outside the textarea, browsers will try really hard to reveal the textarea.
// Here, we try to undo the browser's desperate reveal.
try {
	const scrollState = dom.saveParentsScrollTop(textArea);
	this.setIgnoreSelectionChangeTime('setSelectionRange');
	textArea.focus();
	textArea.setSelectionRange(selectionStart, selectionEnd);
	dom.restoreParentsScrollTop(textArea, scrollState);
} catch (e) {

Why VS Code itself doesn't show it

VS Code's own surfaces cannot reproduce this: workbench scrolling is synthetic, and ListView actively neutralizes browser-initiated scrolls (src/vs/base/browser/ui/list/listView.ts, scroll listener resetting element.scrollTop = 0). The bug only manifests for embedders of the Monaco editor whose ancestors scroll natively.

Downstream data point: Positron (Monaco embedder) hit this as posit-dev/positron#14085 (click places the cursor on the wrong line in an editor taller than its scroll container) and posit-dev/positron#14656 (click yanks the container scroll to the top), and carries this exact fix with a Playwright regression test in posit-dev/positron#14696.

Repro (Chromium, monaco-editor 0.55.1)

Editor inside a natively-scrolling container, editor taller than the container:

repro.html
<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8" />
	<style>
		#scroller { height: 300px; width: 700px; overflow: auto; border: 2px solid #c00; }
		#editor { height: 900px; width: 100%; }
	</style>
</head>
<body>
	<div id="scroller"><div id="editor"></div></div>
	<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js"></script>
	<script>
		const useEditContext = new URLSearchParams(location.search).get('editContext') !== 'false';
		require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs' } });
		require(['vs/editor/editor.main'], function () {
			monaco.editor.create(document.getElementById('editor'), {
				value: Array.from({ length: 60 }, (_, i) => '// line ' + (i + 1)).join('\n'),
				language: 'javascript',
				editContext: useEditContext,
				scrollBeyondLastLine: false,
				minimap: { enabled: false }
			});
		});
	</script>
</body>
</html>
  1. Open the page in Chromium (EditContext is Chromium-only). Do not focus the editor.
  2. Scroll the red container down (e.g. scroller.scrollTop = 400).
  3. Click a visible line.

Actual: the container scroll jumps back to the top (scrollTop 400 -> 0) to reveal the hidden edit context node parked at the previous cursor position; with the layout shift the cursor can land on a different line than the one clicked.

Expected: cursor lands on the clicked line and the container does not scroll -- which is exactly what happens when you open the same page with ?editContext=false (textarea path): scrollTop stays at 400.

Measured with the harness above (same synthetic click at the container center, line 31 under the pointer):

input path scrollTop before click scrollTop after click
EditContext (default) 400 0
textarea (editContext: false) 400 400

Proposed fix

Mirror the textarea guard in FocusTracker.focus() using the existing saveParentsScrollTop / restoreParentsScrollTop helpers from base/browser/dom.ts:

public focus(): void {
	// If the focus is outside the dom node, browsers will try really hard to reveal it.
	// Here, we try to undo the browser's desperate reveal.
	const scrollState = saveParentsScrollTop(this._domNode);
	this._domNode.focus();
	restoreParentsScrollTop(this._domNode, scrollState);
	this.refreshFocusState();
}

Verified: rebuilding monaco-editor-core from main (e8abd58) with this guard and pointing the same harness at it, the same click keeps scrollTop at 400, lands the cursor on the clicked line, and the editor is focused. PR to follow.

VS Code version: main @ e8abd58

Metadata

Metadata

Assignees

Labels

bugIssue identified by VS Code Team member as probable bugeditor-edit-context

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions