perf: linkAware slow in large files (#6422)

* bugfix: linkAware slow in large files
- Added link detection and class addition operations in an editor.operation block for atomic operations and prevent multiple small rerenders.
- linkAware works on currently visible lines in the viewport. This is to speedup linkAware and defer detection for lines not in viewport.
- linkAware now runs after initial render and not before it. This ensures that we calculate to the lines on viewport and does not pause render.

* test(bruno-app): fix linkAware spec for debounced viewport marking
This commit is contained in:
Chirag Chandrashekhar
2025-12-18 15:52:04 +05:30
committed by GitHub
parent 80e09d1a26
commit 678fa88a7c
2 changed files with 102 additions and 21 deletions

View File

@@ -1,8 +1,27 @@
import LinkifyIt from 'linkify-it';
import { isMacOS } from 'utils/common/platform';
import { debounce } from 'lodash';
/**
* Gets the visible line range using scroll info and lineAtHeight
* @param {Object} editor - The CodeMirror editor instance
* @param {number} padding - Number of lines to add above and below viewport
* @returns {Object} Object with from and to line numbers
*/
function getVisibleLineRange(editor, padding = 3) {
const doc = editor.getDoc();
const scroll = editor.getScrollInfo();
const topLine = editor.lineAtHeight(scroll.top, 'local');
const bottomLine = editor.lineAtHeight(scroll.top + scroll.clientHeight, 'local');
return {
from: Math.max(0, topLine - padding),
to: Math.min(doc.lineCount(), bottomLine + padding + 1) // +1 because to is exclusive
};
}
/**
* Marks URLs in the CodeMirror editor with clickable link styling
* Only processes links in the visible viewport for performance
* @param {Object} editor - The CodeMirror editor instance
* @param {Object} linkify - The LinkifyIt instance for URL detection
* @param {string} linkClass - CSS class name for links
@@ -10,25 +29,55 @@ import { debounce } from 'lodash';
*/
function markUrls(editor, linkify, linkClass, linkHint) {
const doc = editor.getDoc();
const text = doc.getValue();
const { from: fromLine, to: toLine } = getVisibleLineRange(editor, 3);
// Clear existing link marks
editor.getAllMarks().forEach((mark) => {
if (mark.className === linkClass) mark.clear();
});
// Use editor.operation() to batch all mark operations for better performance
editor.operation(() => {
// Clear only link marks that overlap the visible range
editor.getAllMarks().forEach((mark) => {
if (mark.className !== linkClass) return;
// Find and mark new URLs
const matches = linkify.match(text);
matches?.forEach(({ index, lastIndex, url }) => {
const from = editor.posFromIndex(index);
const to = editor.posFromIndex(lastIndex);
editor.markText(from, to, {
className: linkClass,
attributes: {
'data-url': url,
'title': linkHint
// Check if mark overlaps visible range
const pos = mark.find?.();
if (!pos) {
// If we can't find position, clear it to be safe
mark.clear();
return;
}
// Clear marks that overlap the visible range
if (pos.to.line >= fromLine && pos.from.line < toLine) {
mark.clear();
}
});
// Find and mark URLs in visible lines only
for (let lineNum = fromLine; lineNum < toLine; lineNum++) {
const lineContent = doc.getLine(lineNum);
if (!lineContent) continue;
const matches = linkify.match(lineContent);
if (!matches) continue;
matches.forEach(({ index, lastIndex, url }) => {
try {
editor.markText(
{ line: lineNum, ch: index },
{ line: lineNum, ch: lastIndex },
{
className: linkClass,
attributes: {
'data-url': url,
'title': linkHint
}
}
);
} catch (e) {
// Silently ignore marking errors (e.g., if positions are invalid)
// This can happen if the line content changed between getting it and marking
}
});
}
});
}
@@ -153,16 +202,24 @@ function setupLinkAware(editor, options = {}) {
const boundHandleMouseEnter = (event) => handleMouseEnter(event, linkClass, linkHoverClass, boundUpdateCmdCtrlClass);
const boundHandleMouseLeave = (event) => handleMouseLeave(event, linkClass, linkHoverClass);
// Create debounced version of markUrls
// Create debounced version of markUrls that runs after rendering
const debouncedMarkUrls = debounce(() => {
requestAnimationFrame(boundMarkUrls);
requestAnimationFrame(() => {
// Skip if the editor is hidden (e.g., tab not visible)
if (!editorWrapper.offsetParent) return;
boundMarkUrls();
});
}, 150);
// Initial URL marking
boundMarkUrls();
// Run after the first render/refresh
editor.on('refresh', debouncedMarkUrls);
// Set up event listeners
editor.on('changes', debouncedMarkUrls);
// Listen for scroll events to update marks when viewport changes
editor.on('scroll', debouncedMarkUrls);
window.addEventListener('keydown', boundUpdateCmdCtrlClass);
window.addEventListener('keyup', boundUpdateCmdCtrlClass);
editorWrapper.addEventListener('click', boundHandleClick);
@@ -171,7 +228,9 @@ function setupLinkAware(editor, options = {}) {
// Cleanup function to remove all event listeners
editor._destroyLinkAware = () => {
editor.off('refresh', debouncedMarkUrls);
editor.off('changes', debouncedMarkUrls);
editor.off('scroll', debouncedMarkUrls);
window.removeEventListener('keydown', boundUpdateCmdCtrlClass);
window.removeEventListener('keyup', boundUpdateCmdCtrlClass);
editorWrapper.removeEventListener('click', boundHandleClick);

View File

@@ -54,7 +54,9 @@ describe('setupLinkAware', () => {
remove: jest.fn()
},
addEventListener: jest.fn(),
removeEventListener: jest.fn()
removeEventListener: jest.fn(),
// Link marking is skipped if editor is hidden; offsetParent is null when hidden
offsetParent: {}
};
mockMark = {
@@ -63,7 +65,11 @@ describe('setupLinkAware', () => {
};
mockDoc = {
getValue: jest.fn().mockReturnValue('Check out https://example.com and http://test.org')
getValue: jest.fn().mockReturnValue('Check out https://example.com and http://test.org'),
getLine: jest.fn().mockImplementation((lineNum) =>
lineNum === 0 ? 'Check out https://example.com and http://test.org' : ''
),
lineCount: jest.fn().mockReturnValue(1)
};
mockEditor = {
@@ -72,6 +78,9 @@ describe('setupLinkAware', () => {
markText: jest.fn(),
posFromIndex: jest.fn().mockImplementation((index) => ({ line: 0, ch: index })),
getWrapperElement: jest.fn().mockReturnValue(mockWrapperElement),
operation: jest.fn((fn) => fn()),
getScrollInfo: jest.fn().mockReturnValue({ top: 0, clientHeight: 100 }),
lineAtHeight: jest.fn().mockReturnValue(0),
on: jest.fn(),
off: jest.fn(),
_destroyLinkAware: undefined
@@ -142,6 +151,10 @@ describe('setupLinkAware', () => {
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
changeHandler();
jest.runAllTimers();
// Verify that markUrls was called which sets the hint
expect(mockEditor.markText).toHaveBeenCalledWith(expect.anything(),
expect.anything(),
@@ -156,6 +169,10 @@ describe('setupLinkAware', () => {
isMacOS.mockReturnValue(false);
setupLinkAware(mockEditor);
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
changeHandler();
jest.runAllTimers();
// Verify that markUrls was called which sets the hint
expect(mockEditor.markText).toHaveBeenCalledWith(expect.anything(),
expect.anything(),
@@ -303,8 +320,13 @@ describe('setupLinkAware', () => {
});
it('should apply link tooltips when marking URLs', () => {
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
changeHandler();
jest.runAllTimers();
expect(mockEditor.markText).toHaveBeenCalledWith({ line: 0, ch: 10 },
{ line: 0, ch: 28 },
{