Skip to content

Fix markdown preview scroll sync after link navigation - #271555

Closed
Michal Jurosz (mj41) wants to merge 1 commit into
microsoft:mainfrom
mj41:fix-markdown-preview-scroll-sync
Closed

Fix markdown preview scroll sync after link navigation#271555
Michal Jurosz (mj41) wants to merge 1 commit into
microsoft:mainfrom
mj41:fix-markdown-preview-scroll-sync

Conversation

@mj41

Copy link
Copy Markdown

Note: All this was vibe coded by Claude Sonnet 4.5. and GPT-5-Codex. Seems reasonable to me (typescipt/vscode codebase newbie) so I got courage to submit this pull request.

Markdown Preview Scroll Sync Fix

Issue Description

Problem: Markdown preview doesn't scroll to cursor position when clicking back in the editor after clicking a link in the preview.

Scenario:

  1. Open a markdown file with split editor (editor on left, preview on right)
  2. Position cursor at a specific line (e.g., line 106) in the editor
  3. Click a link in the preview (e.g., "Configuration Guide")
  4. Preview navigates to the linked document (configuration.md)
  5. Click back in the original editor at the same cursor position (line 106)
  6. Expected: Preview scrolls back to show line 106
  7. Actual: Preview stays at current scroll position (doesn't sync)

Root Cause

Two related issues were identified:

1. Missing Scroll Sync

The preview wasn't calling scrollTo() when the user interacted with the editor after the preview had navigated to a different document via link click.

2. Race Condition (Edge Case)

When clicking at the exact same cursor position after preview navigation:

  • onDidChangeTextEditorSelection doesn't fire (selection didn't change)
  • When trying to sync via onDidChangeViewState:
    • Webview loses focus immediately when user clicks in editor
    • At that moment, vscode.window.activeTextEditor is still undefined
    • The editor activation happens a few milliseconds later
    • By the time editor is active, the sync opportunity was missed

Solution

1. Added State Tracking Flag

private _needsScrollSync: boolean = false;
  • Set to true when preview navigates to different document
  • Cleared when sync occurs or selection changes
  • Prevents unwanted scrolling during normal editing

2. Enhanced Selection Change Handler

this._register(vscode.window.onDidChangeTextEditorSelection(event => {
    if (this._preview.isPreviewOf(event.textEditor.document.uri)) {
        const cursorLine = event.selections[0].active.line;
        this._needsScrollSync = false;
        this._preview.postMessage({...});
        this._preview.scrollTo(cursorLine); // ← Added this
    }
}));

This handles clicking at different cursor positions.

3. Added Webview Focus Handler (Race Condition Fix)

this._register(this._webviewPanel.onDidChangeViewState(e => {
    this._onDidChangeViewStateEmitter.fire(e);

    if (!e.webviewPanel.active && this._needsScrollSync) {
        setTimeout(() => {
            const editor = vscode.window.activeTextEditor;
            if (editor && this._preview.isPreviewOf(editor.document.uri) && this._needsScrollSync) {
                const cursorLine = editor.selection.active.line;
                this._needsScrollSync = false;
                this._preview.scrollTo(cursorLine);
            }
        }, 10); // 10ms delay allows editor activation to complete
    }
}));

Why setTimeout(10ms)?

  • Defers the activeTextEditor check until the next event loop tick
  • Allows VS Code's event system to process editor activation
  • By the time our callback runs, vscode.window.activeTextEditor is populated
  • 10ms is imperceptible to users but sufficient for event processing

This handles clicking at the exact same cursor position.

4. Updated Preview Navigation

public update(newResource: vscode.Uri, scrollLocation?: StartingScrollLocation) {
    const isSameResource = this._preview.isPreviewOf(newResource);

    if (isSameResource) {
        // Handle same document...
        return;
    }

    // Different document - set flag to sync on next editor interaction
    this._needsScrollSync = true;
    this._preview.dispose();
    this._preview = this._createPreview(newResource, scrollLocation);
}

Testing Performed

Test Cases

  1. Different cursor positions (106 → 108)

    • Click in editor at line 106
    • Click preview link (navigates to configuration.md)
    • Click in editor at line 108
    • Result: Preview scrolls to line 108 via onDidChangeTextEditorSelection
  2. Same cursor position (106 → 106)

    • Click in editor at line 106
    • Click preview link (navigates to configuration.md)
    • Click in editor at line 106 (exact same position)
    • Result: Preview scrolls to line 106 via onDidChangeViewState + setTimeout
  3. Multiple navigations

    • Navigate through several links in preview
    • Click back in editor each time
    • Result: Each navigation correctly syncs back to cursor position
  4. Normal editing

    • Type, scroll, edit normally without clicking preview links
    • Result: No unwanted scrolling (flag prevents this)

Related GitHub Issues

Technical Notes

Event Ordering in VS Code

When user clicks in editor after webview has focus:

  1. webviewPanel.active becomes falseonDidChangeViewState fires
  2. At this moment: vscode.window.activeTextEditor is undefined
  3. ~10ms later: Editor activation completes
  4. vscode.window.activeTextEditor gets populated
  5. If selection changed: onDidChangeTextEditorSelection fires

Why Not Use Other Events?

  • onDidChangeActiveTextEditor - Doesn't fire when clicking in already-active editor
  • onDidChangeTextEditorVisibleRanges - Too aggressive (fires on every scroll)
  • onDidChangeViewState with setTimeout - Perfect for catching same-position clicks

Performance Considerations

  • setTimeout(10ms) is minimal overhead
  • Only executes when _needsScrollSync flag is true
  • Flag is cleared immediately after sync
  • No impact on normal editing operations

Debugging Log Pattern (Used During Development)

Emoji markers were used during debugging to track event flow:

  • 🔀 Different document navigation
  • 🔄 Same document update
  • 🚩 Flag set to true
  • 📍 Selection change event
  • 📄 Visible ranges event
  • ✅ Sync occurred
  • 🔍 Webview state change

Example log sequence for edge case (same position):

📍 selection change {"cursorLine":106}
🔀 update (DIFFERENT doc) {"old":"main-document.md","new":"configuration.md"}
🚩 Setting needsScrollSync = TRUE
🔍 webview state change {"active":true}
🔍 webview state change {"active":false,"hasActiveEditor":false} ← Race condition
✅ SYNCING via webview focus loss (deferred) {"cursorLine":106} ← Fix works!

Fixes the issue where markdown preview doesn't scroll to cursor position
when clicking back in the editor after clicking a link in the preview.

Problem:
When using split editor with markdown file and preview side-by-side:
1. User positions cursor at a specific line in editor
2. User clicks a link in the preview (navigates to different document)
3. User clicks back in the original editor at the same cursor position
4. Preview should scroll to show that cursor position, but doesn't

Root Cause:
The preview wasn't syncing scroll position when user interacted with
the editor after the preview had navigated via link click.

Additionally, there was a race condition when clicking at the exact
same cursor position: the webview loses focus before the editor becomes
the activeTextEditor, so vscode.window.activeTextEditor is undefined
when the webview focus change event fires.

Solution:
1. Added _needsScrollSync flag to track when preview navigates to
   different document and needs to sync on next editor interaction

2. Enhanced onDidChangeTextEditorSelection to call scrollTo() - handles
   clicking at different cursor positions

3. Added onDidChangeViewState handler with setTimeout(10ms) - handles
   the race condition when clicking at exact same cursor position.
   The delay allows the event loop to process editor activation before
   checking if sync is needed.

Testing:
- Clicking at different cursor positions: Works via selection change event
- Clicking at same cursor position: Works via deferred webview focus check
- Normal editing/scrolling: No unwanted syncing (flag-based control)
- Multiple link navigations: Each navigation correctly syncs back
Copilot AI review requested due to automatic review settings October 15, 2025 17:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR fixes a scroll synchronization issue in the Markdown preview where the preview fails to sync to the editor's cursor position after navigating via a link in the preview. The fix introduces a state flag to track when scroll sync is needed and handles both normal selection changes and an edge case where clicking at the same cursor position doesn't trigger selection change events.

Key Changes:

  • Added _needsScrollSync flag to track when preview navigation requires scroll synchronization
  • Enhanced selection change handler to always sync preview scroll position to cursor
  • Added webview focus handler with deferred check to handle race condition when clicking at the same cursor position

Comment on lines +719 to +723
} else if (line !== undefined) {
// Same document - sync preview to cursor position
// This handles clicking back in editor at the same cursor position
// after preview navigated via a link
this._preview.scrollTo(line);

Copilot AI Oct 15, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic in lines 715-724 has redundant scroll synchronization. Lines 715-718 handle the case when _needsScrollSync is true, but lines 719-723 perform the same scrollTo() operation when _needsScrollSync is false. This means every editor activation with a visible line will trigger a scroll, which contradicts the stated purpose of the _needsScrollSync flag to 'prevent unwanted scrolling during normal editing'. Consider removing lines 719-724 or clarifying the intended behavior.

Suggested change
} else if (line !== undefined) {
// Same document - sync preview to cursor position
// This handles clicking back in editor at the same cursor position
// after preview navigated via a link
this._preview.scrollTo(line);

Copilot uses AI. Check for mistakes.
Comment on lines +661 to 678
// When preview loses focus (user clicks editor), vscode.window.activeTextEditor is not yet
// populated - it's still undefined because the editor activation happens after this event.
// This is a race condition in VS Code's event ordering. The 10ms setTimeout defers our check
// until the next event loop tick, allowing time for the editor to become the activeTextEditor.
// Without this delay, clicking at the same cursor position wouldn't trigger onDidChangeTextEditorSelection,
// and we'd miss the opportunity to sync the preview scroll position.
if (!e.webviewPanel.active && this._needsScrollSync) {
setTimeout(() => {
const editor = vscode.window.activeTextEditor;
if (editor && this._preview.isPreviewOf(editor.document.uri) && this._needsScrollSync) {
const cursorLine = editor.selection.active.line;
this._needsScrollSync = false;
this._preview.scrollTo(cursorLine);
}
}, 10); // 10ms delay allows editor activation to complete
}
}));

Copilot AI Oct 15, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a hard-coded 10ms delay to handle race conditions is fragile and may not work reliably across different system loads or VS Code versions. Consider using vscode.window.onDidChangeActiveTextEditor event instead, or if the timing is truly unavoidable, use a named constant for the delay value to make it easier to adjust if needed.

Suggested change
// When preview loses focus (user clicks editor), vscode.window.activeTextEditor is not yet
// populated - it's still undefined because the editor activation happens after this event.
// This is a race condition in VS Code's event ordering. The 10ms setTimeout defers our check
// until the next event loop tick, allowing time for the editor to become the activeTextEditor.
// Without this delay, clicking at the same cursor position wouldn't trigger onDidChangeTextEditorSelection,
// and we'd miss the opportunity to sync the preview scroll position.
if (!e.webviewPanel.active && this._needsScrollSync) {
setTimeout(() => {
const editor = vscode.window.activeTextEditor;
if (editor && this._preview.isPreviewOf(editor.document.uri) && this._needsScrollSync) {
const cursorLine = editor.selection.active.line;
this._needsScrollSync = false;
this._preview.scrollTo(cursorLine);
}
}, 10); // 10ms delay allows editor activation to complete
}
}));
// When preview loses focus (user clicks editor), we set a flag to sync scroll when the editor becomes active.
if (!e.webviewPanel.active && this._needsScrollSync) {
// The actual scroll sync will be handled in onDidChangeActiveTextEditor.
}
}));
this._register(vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor && this._preview.isPreviewOf(editor.document.uri) && this._needsScrollSync) {
const cursorLine = editor.selection.active.line;
this._needsScrollSync = false;
this._preview.scrollTo(cursorLine);
}
}));

Copilot uses AI. Check for mistakes.
@mj41

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@mjbvz

Copy link
Copy Markdown
Contributor

Is there a linked issue?

@dmitrivMS

Copy link
Copy Markdown
Collaborator

Superseded by #333464

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.

5 participants