Skip to content

Commit e8bde75

Browse files
ozgesolidkeyclaude
andcommitted
Prevent UI freezes with chunked rendering and batched DOM writes
- Add yieldToUI() helper that yields to browser event loop via requestAnimationFrame - Chunk renderSearchConfigsResults() and renderSearchResultsList() in batches of 200 - Use DocumentFragment for minimap markers instead of direct DOM appends - Use DocumentFragment for search config chips - Cap minimap bookmark markers at 500 with stepping - Add yields between heavy render phases in runSearchConfigsBatch() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ae5224a commit e8bde75

1 file changed

Lines changed: 112 additions & 83 deletions

File tree

src/renderer/renderer.ts

Lines changed: 112 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2950,6 +2950,11 @@ function applyHighlightsWithSearchJson(text: string, searchRanges: SearchRange[]
29502950
return result;
29512951
}
29522952

2953+
// Yield to the browser event loop so UI stays responsive during heavy work
2954+
function yieldToUI(): Promise<void> {
2955+
return new Promise(resolve => requestAnimationFrame(() => resolve()));
2956+
}
2957+
29532958
function escapeHtml(text: string): string {
29542959
return sanitizeText(text)
29552960
.replace(/&/g, '&amp;')
@@ -3239,6 +3244,7 @@ function renderMinimapMarkers(): void {
32393244
if (totalLines === 0) return;
32403245

32413246
const minimapHeight = minimapElement.clientHeight;
3247+
const fragment = document.createDocumentFragment();
32423248

32433249
// Add saved notes range markers (drawn first, behind other markers)
32443250
for (const range of state.savedRanges) {
@@ -3249,19 +3255,22 @@ function renderMinimapMarkers(): void {
32493255
marker.style.top = `${top}px`;
32503256
marker.style.height = `${height}px`;
32513257
marker.title = `Saved: Lines ${range.startLine + 1}-${range.endLine + 1}`;
3252-
minimapElement.appendChild(marker);
3258+
fragment.appendChild(marker);
32533259
}
32543260

32553261
// Add bookmark markers with colors and tooltips
3256-
for (const bookmark of state.bookmarks) {
3262+
const maxBookmarkMarkers = 500;
3263+
const bookmarkStep = Math.max(1, Math.floor(state.bookmarks.length / maxBookmarkMarkers));
3264+
for (let i = 0; i < state.bookmarks.length; i += bookmarkStep) {
3265+
const bookmark = state.bookmarks[i];
32573266
const marker = document.createElement('div');
32583267
marker.className = 'minimap-bookmark';
32593268
marker.style.top = `${(bookmark.lineNumber / totalLines) * minimapHeight}px`;
32603269
if (bookmark.color) {
32613270
marker.style.backgroundColor = bookmark.color;
32623271
}
32633272
marker.title = bookmark.label || `Bookmark: Line ${bookmark.lineNumber + 1}`;
3264-
minimapElement.appendChild(marker);
3273+
fragment.appendChild(marker);
32653274
}
32663275

32673276
// Add search result markers (limit to prevent performance issues)
@@ -3272,7 +3281,7 @@ function renderMinimapMarkers(): void {
32723281
const marker = document.createElement('div');
32733282
marker.className = 'minimap-search-marker';
32743283
marker.style.top = `${(result.lineNumber / totalLines) * minimapHeight}px`;
3275-
minimapElement.appendChild(marker);
3284+
fragment.appendChild(marker);
32763285
}
32773286

32783287
// Add search config markers (colored by config)
@@ -3287,9 +3296,11 @@ function renderMinimapMarkers(): void {
32873296
marker.className = 'minimap-sc-marker';
32883297
marker.style.top = `${(r.lineNumber / totalLines) * minimapHeight}px`;
32893298
marker.style.backgroundColor = config.color;
3290-
minimapElement.appendChild(marker);
3299+
fragment.appendChild(marker);
32913300
}
32923301
}
3302+
3303+
minimapElement.appendChild(fragment);
32933304
}
32943305

32953306
function handleLogClick(event: MouseEvent): void {
@@ -4375,7 +4386,7 @@ function closeSearchResultsPanel(): void {
43754386

43764387
const SEARCH_RESULTS_LIST_CAP = 500;
43774388

4378-
function renderSearchResultsList(): void {
4389+
async function renderSearchResultsList(): Promise<void> {
43794390
const list = elements.searchResultsList;
43804391
const results = state.searchResults;
43814392
const searchPattern = elements.searchInput.value;
@@ -4391,53 +4402,59 @@ function renderSearchResultsList(): void {
43914402
? `Showing ${SEARCH_RESULTS_LIST_CAP} of ${results.length}`
43924403
: `${results.length} results`;
43934404

4394-
const fragment = document.createDocumentFragment();
4405+
list.innerHTML = '';
4406+
const CHUNK_SIZE = 200;
43954407

4396-
for (let i = 0; i < displayCount; i++) {
4397-
const r = results[i];
4398-
const item = document.createElement('div');
4399-
item.className = 'search-result-item';
4400-
if (i === state.currentSearchIndex) {
4401-
item.classList.add('current');
4402-
}
4403-
item.dataset.index = String(i);
4408+
for (let chunkStart = 0; chunkStart < displayCount; chunkStart += CHUNK_SIZE) {
4409+
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, displayCount);
4410+
const fragment = document.createDocumentFragment();
44044411

4405-
const lineNum = document.createElement('span');
4406-
lineNum.className = 'search-result-line-num';
4407-
lineNum.textContent = `${r.lineNumber + 1}`;
4412+
for (let i = chunkStart; i < chunkEnd; i++) {
4413+
const r = results[i];
4414+
const item = document.createElement('div');
4415+
item.className = 'search-result-item';
4416+
if (i === state.currentSearchIndex) {
4417+
item.classList.add('current');
4418+
}
4419+
item.dataset.index = String(i);
4420+
4421+
const lineNum = document.createElement('span');
4422+
lineNum.className = 'search-result-line-num';
4423+
lineNum.textContent = `${r.lineNumber + 1}`;
4424+
4425+
const text = document.createElement('span');
4426+
text.className = 'search-result-text';
4427+
const lineText = r.lineText || '';
4428+
const truncated = lineText.length > 300 ? lineText.substring(0, 300) + '...' : lineText;
4429+
if (searchPattern && r.column >= 0 && r.length > 0) {
4430+
const before = escapeHtml(truncated.substring(0, r.column));
4431+
const match = escapeHtml(truncated.substring(r.column, r.column + r.length));
4432+
const after = escapeHtml(truncated.substring(r.column + r.length));
4433+
text.innerHTML = `${before}<mark>${match}</mark>${after}`;
4434+
} else {
4435+
text.textContent = truncated;
4436+
}
44084437

4409-
const text = document.createElement('span');
4410-
text.className = 'search-result-text';
4411-
const lineText = r.lineText || '';
4412-
const truncated = lineText.length > 300 ? lineText.substring(0, 300) + '...' : lineText;
4413-
if (searchPattern && r.column >= 0 && r.length > 0) {
4414-
const before = escapeHtml(truncated.substring(0, r.column));
4415-
const match = escapeHtml(truncated.substring(r.column, r.column + r.length));
4416-
const after = escapeHtml(truncated.substring(r.column + r.length));
4417-
text.innerHTML = `${before}<mark>${match}</mark>${after}`;
4418-
} else {
4419-
text.textContent = truncated;
4438+
item.appendChild(lineNum);
4439+
item.appendChild(text);
4440+
item.addEventListener('click', () => {
4441+
goToSearchResult(i);
4442+
updateSearchResultsCurrent();
4443+
scrollSearchResultIntoView();
4444+
});
4445+
fragment.appendChild(item);
44204446
}
44214447

4422-
item.appendChild(lineNum);
4423-
item.appendChild(text);
4424-
item.addEventListener('click', () => {
4425-
goToSearchResult(i);
4426-
updateSearchResultsCurrent();
4427-
scrollSearchResultIntoView();
4428-
});
4429-
fragment.appendChild(item);
4448+
list.appendChild(fragment);
4449+
if (chunkEnd < displayCount) await yieldToUI();
44304450
}
44314451

44324452
if (results.length > SEARCH_RESULTS_LIST_CAP) {
44334453
const notice = document.createElement('div');
44344454
notice.className = 'search-results-cap-notice';
44354455
notice.textContent = `... and ${results.length - SEARCH_RESULTS_LIST_CAP} more results`;
4436-
fragment.appendChild(notice);
4456+
list.appendChild(notice);
44374457
}
4438-
4439-
list.innerHTML = '';
4440-
list.appendChild(fragment);
44414458
}
44424459

44434460
function updateSearchResultsCurrent(): void {
@@ -4637,7 +4654,7 @@ async function deleteSearchConfig(id: string): Promise<void> {
46374654
state.searchConfigResults.delete(id);
46384655
await window.api.searchConfigDelete(id);
46394656
renderSearchConfigsChips();
4640-
renderSearchConfigsResults();
4657+
await renderSearchConfigsResults();
46414658
renderVisibleLines();
46424659
}
46434660

@@ -4652,7 +4669,7 @@ async function toggleSearchConfigEnabled(id: string): Promise<void> {
46524669
// Run batch just for this one
46534670
await runSearchConfigsBatch();
46544671
} else {
4655-
renderSearchConfigsResults();
4672+
await renderSearchConfigsResults();
46564673
renderVisibleLines();
46574674
}
46584675
}
@@ -4661,7 +4678,7 @@ async function runSearchConfigsBatch(): Promise<void> {
46614678
const enabledConfigs = state.searchConfigs.filter(c => c.enabled);
46624679
if (enabledConfigs.length === 0) {
46634680
state.searchConfigResults.clear();
4664-
renderSearchConfigsResults();
4681+
await renderSearchConfigsResults();
46654682
renderVisibleLines();
46664683
return;
46674684
}
@@ -4688,8 +4705,11 @@ async function runSearchConfigsBatch(): Promise<void> {
46884705
}
46894706

46904707
renderSearchConfigsChips(); // Update counts
4691-
renderSearchConfigsResults();
4708+
await yieldToUI();
4709+
await renderSearchConfigsResults();
4710+
await yieldToUI();
46924711
renderVisibleLines();
4712+
renderMinimapMarkers();
46934713
}
46944714

46954715
function renderSearchConfigsChips(): void {
@@ -4698,6 +4718,8 @@ function renderSearchConfigsChips(): void {
46984718
const addBtn = elements.btnAddSearchConfig;
46994719
container.innerHTML = '';
47004720

4721+
const fragment = document.createDocumentFragment();
4722+
47014723
for (const config of state.searchConfigs) {
47024724
const chip = document.createElement('div');
47034725
chip.className = `search-config-chip${config.enabled ? '' : ' disabled'}`;
@@ -4747,9 +4769,10 @@ function renderSearchConfigsChips(): void {
47474769
showSearchConfigContextMenu(e, config);
47484770
});
47494771

4750-
container.appendChild(chip);
4772+
fragment.appendChild(chip);
47514773
}
47524774

4775+
container.appendChild(fragment);
47534776
container.appendChild(addBtn);
47544777

47554778
// Update badge
@@ -4762,7 +4785,7 @@ function renderSearchConfigsChips(): void {
47624785

47634786
const SC_RESULTS_LIST_CAP = 1000;
47644787

4765-
function renderSearchConfigsResults(): void {
4788+
async function renderSearchConfigsResults(): Promise<void> {
47664789
const list = elements.searchConfigsResults;
47674790
const enabledConfigs = state.searchConfigs.filter(c => c.enabled);
47684791

@@ -4794,52 +4817,58 @@ function renderSearchConfigsResults(): void {
47944817
? `Showing ${SC_RESULTS_LIST_CAP} of ${allResults.length}`
47954818
: `${allResults.length} matches`;
47964819

4797-
const fragment = document.createDocumentFragment();
4798-
4799-
for (let i = 0; i < displayCount; i++) {
4800-
const r = allResults[i];
4801-
const item = document.createElement('div');
4802-
item.className = 'sc-result-item';
4803-
4804-
const dot = document.createElement('span');
4805-
dot.className = 'sc-result-dot';
4806-
dot.style.backgroundColor = r.color;
4807-
4808-
const lineNum = document.createElement('span');
4809-
lineNum.className = 'sc-result-line-num';
4810-
lineNum.textContent = `${r.lineNumber + 1}`;
4820+
list.innerHTML = '';
4821+
const CHUNK_SIZE = 200;
4822+
4823+
for (let chunkStart = 0; chunkStart < displayCount; chunkStart += CHUNK_SIZE) {
4824+
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, displayCount);
4825+
const fragment = document.createDocumentFragment();
4826+
4827+
for (let i = chunkStart; i < chunkEnd; i++) {
4828+
const r = allResults[i];
4829+
const item = document.createElement('div');
4830+
item.className = 'sc-result-item';
4831+
4832+
const dot = document.createElement('span');
4833+
dot.className = 'sc-result-dot';
4834+
dot.style.backgroundColor = r.color;
4835+
4836+
const lineNum = document.createElement('span');
4837+
lineNum.className = 'sc-result-line-num';
4838+
lineNum.textContent = `${r.lineNumber + 1}`;
4839+
4840+
const text = document.createElement('span');
4841+
text.className = 'sc-result-text';
4842+
const lineText = r.lineText || '';
4843+
const truncated = lineText.length > 300 ? lineText.substring(0, 300) + '...' : lineText;
4844+
if (r.column >= 0 && r.length > 0) {
4845+
const before = escapeHtml(truncated.substring(0, r.column));
4846+
const match = escapeHtml(truncated.substring(r.column, r.column + r.length));
4847+
const after = escapeHtml(truncated.substring(r.column + r.length));
4848+
text.innerHTML = `${before}<mark style="background:${r.color};color:#000">${match}</mark>${after}`;
4849+
} else {
4850+
text.textContent = truncated;
4851+
}
48114852

4812-
const text = document.createElement('span');
4813-
text.className = 'sc-result-text';
4814-
const lineText = r.lineText || '';
4815-
const truncated = lineText.length > 300 ? lineText.substring(0, 300) + '...' : lineText;
4816-
if (r.column >= 0 && r.length > 0) {
4817-
const before = escapeHtml(truncated.substring(0, r.column));
4818-
const match = escapeHtml(truncated.substring(r.column, r.column + r.length));
4819-
const after = escapeHtml(truncated.substring(r.column + r.length));
4820-
text.innerHTML = `${before}<mark style="background:${r.color};color:#000">${match}</mark>${after}`;
4821-
} else {
4822-
text.textContent = truncated;
4853+
item.appendChild(dot);
4854+
item.appendChild(lineNum);
4855+
item.appendChild(text);
4856+
item.addEventListener('click', () => {
4857+
goToLine(r.lineNumber);
4858+
});
4859+
fragment.appendChild(item);
48234860
}
48244861

4825-
item.appendChild(dot);
4826-
item.appendChild(lineNum);
4827-
item.appendChild(text);
4828-
item.addEventListener('click', () => {
4829-
goToLine(r.lineNumber);
4830-
});
4831-
fragment.appendChild(item);
4862+
list.appendChild(fragment);
4863+
if (chunkEnd < displayCount) await yieldToUI();
48324864
}
48334865

48344866
if (allResults.length > SC_RESULTS_LIST_CAP) {
48354867
const notice = document.createElement('div');
48364868
notice.className = 'sc-results-cap-notice';
48374869
notice.textContent = `Showing first ${SC_RESULTS_LIST_CAP} of ${allResults.length} matches`;
4838-
fragment.appendChild(notice);
4870+
list.appendChild(notice);
48394871
}
4840-
4841-
list.innerHTML = '';
4842-
list.appendChild(fragment);
48434872
}
48444873

48454874
function showSearchConfigContextMenu(e: MouseEvent, config: SearchConfigDef): void {

0 commit comments

Comments
 (0)