Skip to content

Commit 17497a8

Browse files
committed
perf: optimise find search with headless formatters and CSS Highlight API
Replace per-cell DOM-based highlighting with CSS Highlight API ranges, eliminating row.reformat() calls during search. Extract searchable text via headless formatter execution with a reusable mock cell and per-row cache, bypassing getCells() and its O(rows × cols) DOM element creation. Skip innerHTML parsing for non-HTML formatter results. Highlight ranges now span across adjacent text nodes for cross-element match support.
1 parent df6ab9a commit 17497a8

1 file changed

Lines changed: 217 additions & 42 deletions

File tree

  • log-viewer/src/tabulator/module

log-viewer/src/tabulator/module/Find.ts

Lines changed: 217 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
/*
22
* Copyright (c) 2024 Certinia Inc. All rights reserved.
33
*/
4-
import { Module, type GroupComponent, type RowComponent, type Tabulator } from 'tabulator-tables';
4+
import {
5+
Module,
6+
type CellComponent,
7+
type ColumnComponent,
8+
type GroupComponent,
9+
type RowComponent,
10+
type Tabulator,
11+
} from 'tabulator-tables';
512

613
type FindArgs = { text: string; count: number; options: { matchCase: boolean } };
714
type GoToRowOptions = { scrollIfVisible: boolean; focusRow: boolean };
@@ -20,6 +27,18 @@ export class Find extends Module {
2027
_currentMatchIndex = 0;
2128
_matchIndexes: { [key: number]: RowComponent } = {};
2229

30+
// Headless formatter execution: single detached element (never in the document)
31+
// and a per-row-field text cache keyed by the stable row-data object reference.
32+
_mockSearchElem: HTMLElement = document.createElement('div');
33+
_cellTextCache: WeakMap<object, Map<string, string>> = new WeakMap();
34+
35+
// Reusable mock cell — mutable fields updated before each formatter call.
36+
_mcData: object = {};
37+
_mcValue: unknown = undefined;
38+
_mcField = '';
39+
_mcColumn: ColumnComponent | null = null;
40+
_mockCell: CellComponent = this._createMockCell();
41+
2342
constructor(table: Tabulator) {
2443
super(table);
2544
// @ts-expect-error registerTableFunction() needs adding to tabulator types
@@ -30,6 +49,12 @@ export class Find extends Module {
3049
}
3150

3251
initialize() {
52+
// Reset the text cache whenever a new dataset is loaded so stale entries
53+
// from the previous log never pollute a fresh search.
54+
this.table.on('tableBuilt', () => {
55+
this._cellTextCache = new WeakMap();
56+
});
57+
3358
this.table.on('renderComplete', () => {
3459
if (this._findArgs?.text) {
3560
this._applyHighlights();
@@ -110,6 +135,28 @@ export class Find extends Module {
110135

111136
let totalMatches = 0;
112137
if (searchString) {
138+
// Avoid row.getCells() — for uninitialized off-screen rows it calls generateCells()
139+
// which creates a DOM element per cell (document.createElement). For 10k rows that
140+
// is O(rows × cols) element creation before a single search character is matched.
141+
// Instead iterate columnsByIndex directly, which is the same array generateCells()
142+
// would use, avoiding all Cell object and DOM element creation.
143+
// columnManager.getRealColumns() is internal — returns columnsByIndex, same order as getCells()
144+
const internalCols: Array<{
145+
field: string;
146+
getComponent: () => ColumnComponent;
147+
getFieldValue: (data: object) => unknown;
148+
modules?: {
149+
format?: {
150+
formatter?: (
151+
cell: CellComponent,
152+
params: object,
153+
onRendered: () => void,
154+
) => string | HTMLElement;
155+
params?: object | ((cell: CellComponent) => object);
156+
};
157+
};
158+
}> = this.table.columnManager?.getRealColumns?.() ?? [];
159+
113160
const len = flattenedRows.length;
114161
for (let i = 0; i < len; i++) {
115162
const row = flattenedRows[i];
@@ -119,17 +166,39 @@ export class Find extends Module {
119166

120167
const data = row.getData();
121168
data.highlightIndexes = [];
122-
row.getCells().forEach((cell) => {
123-
const elem = cell.getElement();
124-
const matchCount = this._countMatches(elem, regex);
169+
170+
let rowCache = this._cellTextCache.get(data as object);
171+
if (!rowCache) {
172+
rowCache = new Map<string, string>();
173+
this._cellTextCache.set(data as object, rowCache);
174+
}
175+
176+
for (const col of internalCols) {
177+
const field = col.field;
178+
if (!field) continue;
179+
180+
let text = rowCache.get(field);
181+
if (text === undefined) {
182+
text = this._runFormatterForColumn(
183+
data as object,
184+
field,
185+
col.getFieldValue(data as object),
186+
col.getComponent(),
187+
col.modules?.format,
188+
);
189+
rowCache.set(field, text);
190+
}
191+
192+
regex.lastIndex = 0;
193+
const matchCount = text.match(regex)?.length ?? 0;
125194
if (matchCount) {
126195
for (let k = 0; k < matchCount; k++) {
127196
totalMatches++;
128197
data.highlightIndexes.push(totalMatches);
129198
result.matchIndexes[totalMatches] = row;
130199
}
131200
}
132-
});
201+
}
133202
}
134203
}
135204

@@ -199,31 +268,32 @@ export class Find extends Module {
199268
let matchIdx = 0;
200269
row.getCells().forEach((cell) => {
201270
const elem = cell.getElement();
202-
this._walkTextNodes(elem, (textNode) => {
203-
const text = textNode.textContent;
204-
if (!text) {
205-
return;
206-
}
207-
208-
regex.lastIndex = 0;
209-
let match: RegExpExecArray | null;
210-
while ((match = regex.exec(text)) !== null) {
211-
const highlightIndex = data.highlightIndexes?.[matchIdx];
212-
matchIdx++;
213-
214-
const range = new Range();
215-
range.setStart(textNode, match.index);
216-
range.setEnd(textNode, match.index + match[0].length);
217-
218-
if (highlightIndex === this._currentMatchIndex) {
219-
Find._currentHighlight!.add(range);
220-
this._myCurrentRanges.push(range);
221-
} else {
222-
Find._findHighlight!.add(range);
223-
this._myFindRanges.push(range);
224-
}
271+
// Build a flat text-node map so we can create Ranges that span across
272+
// adjacent elements (e.g. two <span>s whose text forms a single match).
273+
const { text: fullText, nodes: textNodeMap } = this._buildTextNodeMap(elem);
274+
if (!fullText) return;
275+
276+
regex.lastIndex = 0;
277+
let match: RegExpExecArray | null;
278+
while ((match = regex.exec(fullText)) !== null) {
279+
const highlightIndex = data.highlightIndexes?.[matchIdx];
280+
matchIdx++;
281+
282+
const range = this._createMatchRange(
283+
textNodeMap,
284+
match.index,
285+
match.index + match[0].length,
286+
);
287+
if (!range) continue;
288+
289+
if (highlightIndex === this._currentMatchIndex) {
290+
Find._currentHighlight!.add(range);
291+
this._myCurrentRanges.push(range);
292+
} else {
293+
Find._findHighlight!.add(range);
294+
this._myFindRanges.push(range);
225295
}
226-
});
296+
}
227297
});
228298
}
229299

@@ -268,19 +338,6 @@ export class Find extends Module {
268338
}
269339
}
270340

271-
_countMatches(elem: Node, regex: RegExp): number {
272-
let count = 0;
273-
this._walkTextNodes(elem, (textNode) => {
274-
const text = textNode.textContent;
275-
if (!text) {
276-
return;
277-
}
278-
const match = text.match(regex);
279-
count += match?.length ?? 0;
280-
});
281-
return count;
282-
}
283-
284341
_getRenderedRows(): RowComponent[] {
285342
// Returns all rendered rows including buffer (not just viewport).
286343
// This allows highlights to be applied to off-screen buffer rows,
@@ -338,4 +395,122 @@ export class Find extends Module {
338395

339396
return output;
340397
}
398+
399+
// Collects all text nodes under root with their cumulative byte offsets into a
400+
// flat array, plus the concatenated full text. Used by _applyHighlights so
401+
// that a single regex match can span multiple sibling elements.
402+
_buildTextNodeMap(root: Node): { text: string; nodes: Array<{ node: Text; start: number }> } {
403+
const nodes: Array<{ node: Text; start: number }> = [];
404+
let offset = 0;
405+
this._walkTextNodes(root, (textNode) => {
406+
nodes.push({ node: textNode, start: offset });
407+
offset += textNode.textContent?.length ?? 0;
408+
});
409+
return { text: nodes.map((n) => n.node.textContent ?? '').join(''), nodes };
410+
}
411+
412+
// Creates a Range covering [matchStart, matchEnd) in the text-node map
413+
// produced by _buildTextNodeMap. Supports ranges that cross node boundaries.
414+
_createMatchRange(
415+
nodes: Array<{ node: Text; start: number }>,
416+
matchStart: number,
417+
matchEnd: number,
418+
): Range | null {
419+
const range = new Range();
420+
let startSet = false;
421+
422+
for (const { node, start } of nodes) {
423+
const nodeEnd = start + (node.textContent?.length ?? 0);
424+
425+
if (!startSet && matchStart < nodeEnd) {
426+
range.setStart(node, matchStart - start);
427+
startSet = true;
428+
}
429+
430+
if (startSet && matchEnd <= nodeEnd) {
431+
range.setEnd(node, matchEnd - start);
432+
return range;
433+
}
434+
}
435+
436+
return null;
437+
}
438+
439+
// Runs the column formatter headlessly for a given row data + column, without
440+
// requiring a CellComponent. This avoids row.getCells(), which triggers
441+
// generateCells() and DOM element creation for every uninitialized off-screen row.
442+
// Results are cached by (rowData, field) and reused on repeat searches.
443+
_createMockCell(): CellComponent {
444+
const mockElem = this._mockSearchElem;
445+
return {
446+
getElement: () => mockElem,
447+
getData: () => this._mcData,
448+
getValue: () => this._mcValue,
449+
getInitialValue: () => this._mcValue,
450+
getField: () => this._mcField,
451+
getRow: () => ({ getData: () => this._mcData }) as unknown as RowComponent,
452+
getColumn: () => this._mcColumn!,
453+
checkHeight: () => {},
454+
edit: () => {},
455+
cancelEdit: () => {},
456+
isEdited: () => false,
457+
clearEdited: () => {},
458+
isValid: () => true,
459+
clearValidation: () => {},
460+
validate: () => true,
461+
popup: () => {},
462+
} as unknown as CellComponent;
463+
}
464+
465+
_runFormatterForColumn(
466+
data: object,
467+
field: string,
468+
value: unknown,
469+
columnComponent: ColumnComponent,
470+
fmt:
471+
| {
472+
formatter?: (
473+
cell: CellComponent,
474+
params: object,
475+
onRendered: () => void,
476+
) => string | HTMLElement;
477+
params?: object | ((cell: CellComponent) => object);
478+
}
479+
| undefined,
480+
): string {
481+
if (!fmt?.formatter) {
482+
return String(value ?? '');
483+
}
484+
485+
this._mcData = data;
486+
this._mcValue = value;
487+
this._mcField = field;
488+
this._mcColumn = columnComponent;
489+
490+
const mockCell = this._mockCell;
491+
const resolvedParams =
492+
typeof fmt.params === 'function' ? fmt.params(mockCell) : (fmt.params ?? {});
493+
494+
let result: string | HTMLElement | undefined;
495+
try {
496+
const ctx: object = this.table.modules?.format ?? { table: this.table };
497+
result = fmt.formatter.call(ctx, mockCell, resolvedParams, () => {});
498+
} catch {
499+
return String(value ?? '');
500+
}
501+
502+
if (typeof result === 'string') {
503+
if (result.includes('<') && result.includes('>')) {
504+
const mockElem = this._mockSearchElem;
505+
mockElem.innerHTML = result;
506+
const text = mockElem.textContent ?? '';
507+
mockElem.textContent = '';
508+
return text;
509+
}
510+
511+
return result;
512+
}
513+
514+
return result?.textContent ?? '';
515+
}
341516
}

0 commit comments

Comments
 (0)