Skip to content

Commit c1115e3

Browse files
author
User
committed
perf: Implement DocumentFragment batch rendering for tab lists
1 parent 370af3d commit c1115e3

3 files changed

Lines changed: 133 additions & 6 deletions

File tree

src/components/panel-windows.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export class PanelWindowsElement extends HTMLElement {
255255
displayedContainers = displayedContainers.filter((displayedContainer) => displayedContainer.cookieStore.isPrivate == false);
256256
newGroupMenuItem.hidden = false;
257257
}
258-
displayedContainers = [... displayedContainers].sort((a, b) => {
258+
displayedContainers = [...displayedContainers].sort((a, b) => {
259259
return tabGroupDirectorySnapshot.cookieStoreIdSortingCallback(a.cookieStore.id, b.cookieStore.id);
260260
});
261261

@@ -332,6 +332,10 @@ export class PanelWindowsElement extends HTMLElement {
332332

333333
const pinnedTabsElement = this.shadowRoot.querySelector('.pinned-tabs') as HTMLDivElement;
334334
pinnedTabsElement.textContent = '';
335+
336+
// Use DocumentFragment for batch DOM updates to reduce layout reflows
337+
const fragment = document.createDocumentFragment();
338+
335339
for (const pinnedTab of pinnedTabs) {
336340
const userContext = userContextMap.get(pinnedTab.cookieStore.id);
337341
if (!userContext) {
@@ -340,8 +344,13 @@ export class PanelWindowsElement extends HTMLElement {
340344
}
341345
const tabElement = this._popupRenderer.renderTab(pinnedTab, userContext);
342346
this.defineDragHandlersForPinnedTab(pinnedTab, tabElement);
343-
pinnedTabsElement.appendChild(tabElement);
347+
348+
// Append to fragment (off-DOM) instead of directly to pinnedTabsElement
349+
fragment.appendChild(tabElement);
344350
}
351+
352+
// Single appendChild - only one layout reflow
353+
pinnedTabsElement.appendChild(fragment);
345354
}
346355

347356
private createMenuItem(className: string, messageName: string, iconUrl: string, eventSink: EventSink<void>): CtgMenuItemElement {
@@ -395,7 +404,7 @@ export class PanelWindowsElement extends HTMLElement {
395404
displayedContainers = displayedContainers.filter((displayedContainer) => displayedContainer.cookieStore.isPrivate != true);
396405
}
397406
let tags = Object.values(this._browserState.tags);
398-
const allUserContexts = [... displayedContainers];
407+
const allUserContexts = [...displayedContainers];
399408
const searchWords = new Set(searchString.split(/\s+/u).map((searchWord) => searchWord.toLowerCase()));
400409
let tabs = Object.values(windowStateSnapshot.tabs).map((dao) => TabDao.toCompatTab(dao));
401410
for (const searchWord of searchWords) {
@@ -432,15 +441,24 @@ export class PanelWindowsElement extends HTMLElement {
432441
containerElement.containerVisibilityToggleButton.disabled = true;
433442
searchResultsContainersElement.appendChild(containerElement);
434443
}
444+
445+
// Use DocumentFragment for batch DOM updates to reduce layout reflows
446+
const tabsFragment = document.createDocumentFragment();
447+
435448
for (const tab of tabs) {
436449
const userContext = userContextMap.get(tab.cookieStore.id);
437450
if (!userContext) {
438451
console.error('Could not find user context for tab', tab);
439452
continue;
440453
}
441454
const tabElement = this._popupRenderer.renderTab(tab, userContext);
442-
searchResultsTabsElement.appendChild(tabElement);
455+
456+
// Append to fragment (off-DOM) instead of directly to searchResultsTabsElement
457+
tabsFragment.appendChild(tabElement);
443458
}
459+
460+
// Single appendChild - only one layout reflow
461+
searchResultsTabsElement.appendChild(tabsFragment);
444462
}
445463

446464
public focusToSearchBox() {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/* -*- indent-tabs-mode: nil; tab-width: 2; -*- */
2+
/* vim: set ts=2 sw=2 et ai : */
3+
/**
4+
Container Tab Groups
5+
Copyright (C) 2023 Menhera.org
6+
7+
This program is free software: you can redistribute it and/or modify
8+
it under the terms of the GNU General Public License as published by
9+
the Free Software Foundation, either version 3 of the License, or
10+
(at your option) any later version.
11+
12+
This program is distributed in the hope that it will be useful,
13+
but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
GNU General Public License for more details.
16+
17+
You should have received a copy of the GNU General Public License
18+
along with this program. If not, see <https://www.gnu.org/licenses/>.
19+
@license
20+
**/
21+
22+
/**
23+
* Batched tab removal handler to prevent memory leaks and improve performance
24+
* when removing multiple tabs in quick succession.
25+
*
26+
* This handler debounces tab removal events and processes them in batches,
27+
* reducing the number of expensive operations and preventing UI freezes.
28+
*/
29+
export class BatchedTabRemovalHandler {
30+
private static instance: BatchedTabRemovalHandler;
31+
private static readonly DEBOUNCE_DELAY_MS = 100; // 100ms buffer for batching
32+
33+
private pendingRemovals = new Set<number>();
34+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
35+
36+
// Event listener management (simple Observer pattern)
37+
private listeners: ((tabIds: number[]) => void)[] = [];
38+
39+
private constructor() {
40+
// Private constructor for singleton
41+
}
42+
43+
public static getInstance(): BatchedTabRemovalHandler {
44+
if (!BatchedTabRemovalHandler.instance) {
45+
BatchedTabRemovalHandler.instance = new BatchedTabRemovalHandler();
46+
}
47+
return BatchedTabRemovalHandler.instance;
48+
}
49+
50+
/**
51+
* Add a tab ID to the pending removal queue.
52+
* The removal will be processed after the debounce delay.
53+
*/
54+
public addRemoval(tabId: number): void {
55+
this.pendingRemovals.add(tabId);
56+
this.scheduleProcessing();
57+
}
58+
59+
/**
60+
* Register a listener to be called when a batch of tabs is removed.
61+
* The listener will receive an array of tab IDs.
62+
*/
63+
public onBatchRemoved(listener: (tabIds: number[]) => void): void {
64+
this.listeners.push(listener);
65+
}
66+
67+
/**
68+
* Schedule batch processing with debounce.
69+
* If called multiple times within the debounce delay, the timer is reset.
70+
*/
71+
private scheduleProcessing(): void {
72+
if (this.debounceTimer) {
73+
clearTimeout(this.debounceTimer);
74+
}
75+
this.debounceTimer = setTimeout(() => {
76+
this.processBatch();
77+
}, BatchedTabRemovalHandler.DEBOUNCE_DELAY_MS);
78+
}
79+
80+
/**
81+
* Process the batch of pending tab removals.
82+
* Notifies all registered listeners with the batch of tab IDs.
83+
*/
84+
private processBatch(): void {
85+
const tabIds = Array.from(this.pendingRemovals);
86+
this.pendingRemovals.clear();
87+
this.debounceTimer = null;
88+
89+
if (tabIds.length > 0) {
90+
// Notify all listeners
91+
this.listeners.forEach(listener => listener(tabIds));
92+
}
93+
}
94+
}

src/pages/popup-v2/legacy/PopupRenderer.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ export class PopupRenderer {
155155
let tabCount = 0;
156156
let state = ContainerTabsState.NO_TABS;
157157
let tagId = 0;
158+
159+
// Collect elements for batch rendering with DocumentFragment
160+
const elementsToAppend: HTMLElement[] = [];
158161
for (const tab of tabs) {
159162
if (IndexTab.isIndexTabUrl(tab.url)) {
160163
continue;
@@ -172,14 +175,15 @@ export class PopupRenderer {
172175
const tag = tabAttributeMap.getTagForTab(tab.id);
173176
if (tag) {
174177
const tagElement = new MenulistTagElement(tag);
175-
element.appendChild(tagElement);
176178
tagElement.onTagButtonClicked.addListener(() => {
177179
const cookieStoreId = displayedContainer.cookieStore.id;
178180
const tagId = tag.tagId;
179181
this._containerTabOpenerService.openNewTabInContainer(cookieStoreId, true, windowId, tagId).catch((e) => {
180182
console.error(e);
181183
});
182184
});
185+
// Collect tag element for batch append
186+
elementsToAppend.push(tagElement);
183187
}
184188
}
185189
}
@@ -218,9 +222,20 @@ export class PopupRenderer {
218222
console.error(e);
219223
});
220224
});
221-
element.appendChild(tabElement);
225+
226+
// Collect tab element for batch append
227+
elementsToAppend.push(tabElement);
222228
state = ContainerTabsState.VISIBLE_TABS;
223229
}
230+
231+
// Use DocumentFragment for batch DOM updates to reduce layout reflows
232+
if (elementsToAppend.length > 0) {
233+
const fragment = document.createDocumentFragment();
234+
for (const el of elementsToAppend) {
235+
fragment.appendChild(el);
236+
}
237+
element.appendChild(fragment);
238+
}
224239
if (state === ContainerTabsState.HIDDEN_TABS) {
225240
element.containerHidden = true;
226241
}

0 commit comments

Comments
 (0)