Skip to content

Commit 48f83e1

Browse files
kckernclaude
andcommitted
fix(web): cap rows of empty pieces in the highlighter's prefix step
An empty query piece (two spaces or dots in a row) is [a-z]* between two \W+ in the prefix step, so a long row of them made the regex try every way of splitting each separator run it crossed: 'the', 1000 spaces and 'lord' took 40 s on D&C 132:19. Rows longer than 8 empty pieces are cut to 8; no realistic query has nine spaces or dots in a row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fea1834 commit 48f83e1

2 files changed

Lines changed: 25 additions & 1 deletion

File tree

‎apps/web/src/services/SearchService.js‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,25 @@ function joinPieces(pieces) {
7373
return src;
7474
}
7575

76+
// In the prefix step an empty piece is [a-z]*, which matches nothing inside a run of separators,
77+
// so a long row of them makes the regex try every way of splitting each run it crosses (a query of
78+
// 'the', 1000 spaces and 'lord' took 40 s on one verse). Rows of more than MAX_GAP empty pieces
79+
// (more than MAX_GAP + 1 spaces or dots in a row, which no realistic query has) are cut to MAX_GAP.
80+
const MAX_GAP = 8;
81+
function prefixPieces(pieces) {
82+
const out = [];
83+
let empties = 0;
84+
for (const p of pieces) {
85+
empties = p ? 0 : empties + 1;
86+
if (empties <= MAX_GAP) out.push(p);
87+
}
88+
return out;
89+
}
90+
7691
export function makeHighlighter(query) {
7792
const pieces = String(query).split(' ');
7893
const phrase = `\\b${joinPieces(pieces.map(escapeRegex))}[a-z]*`;
79-
const prefixes = `\\b${pieces.map(p => `${escapeRegex(p.slice(0, 3))}[a-z]*`).join('\\W+')}[a-z]*`;
94+
const prefixes = `\\b${prefixPieces(pieces).map(p => `${escapeRegex(p.slice(0, 3))}[a-z]*`).join('\\W+')}[a-z]*`;
8095
const stages = [phrase, prefixes].map(src => ({ test: new RegExp(src, 'i'), all: new RegExp(src, 'gi') }));
8196
const words = new Set(String(query).split(/[^a-z]+/i).filter(Boolean).map(q => q.slice(0, 3).toLowerCase()));
8297
const marked = w => {

‎apps/web/test/unit/searchService.test.js‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,15 @@ describe('helpers', () => {
203203
expect(out).toContain("<span class='searchterm'>and</span> labour");
204204
expect(Date.now() - start).toBeLessThan(2000);
205205
});
206+
test('a long row of separators in the query does not make the prefix step backtrack', () => {
207+
// Runs of two separators (', ' and '; ') can each be split two ways; before the cut, 14 of these
208+
// took over 20 s
209+
const verse = `the ${Array(14).fill('word, word; word—word').join(' ')}`;
210+
const start = Date.now();
211+
const out = highlight(verse, `the${' '.repeat(1000)}lord`);
212+
expect(out).not.toMatch(/<span[^>]*>[^<]*<span/);
213+
expect(Date.now() - start).toBeLessThan(1000);
214+
});
206215
test('highlight treats repeated separators as live does', () => {
207216
expect(highlight('faith, hope', 'faith hope')).toBe("<span class='searchterm'>faith, hope</span>");
208217
expect(highlight('faith hope', 'faith hope')).toBe("<span class='searchterm'>faith</span> <span class='searchterm'>hope</span>");

0 commit comments

Comments
 (0)