From 05c1b9e5338b735e963c12fed2a887dcff82a1aa Mon Sep 17 00:00:00 2001
From: Nad Alaba <37968805+NadAlaba@users.noreply.github.com>
Date: Fri, 12 Sep 2025 11:36:58 +0300
Subject: [PATCH] perf(quote search): optimize highlighting search matches
(@NadAlaba) (#6944)
- use a simpler and 6x faster `highlightMatches` method.
- add tests.
---
frontend/__tests__/utils/strings.spec.ts | 150 +++++++++++++++++++++++
frontend/src/ts/modals/quote-search.ts | 19 +--
frontend/src/ts/utils/strings.ts | 22 ++++
3 files changed, 173 insertions(+), 18 deletions(-)
diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts
index eaa0cd72ffbe..062eefd46e0b 100644
--- a/frontend/__tests__/utils/strings.spec.ts
+++ b/frontend/__tests__/utils/strings.spec.ts
@@ -2,6 +2,156 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as Strings from "../../src/ts/utils/strings";
describe("string utils", () => {
+ describe("highlightMatches", () => {
+ const shouldHighlight = [
+ {
+ description: "word at the beginning",
+ text: "Start here.",
+ matches: ["Start"],
+ expected: 'Start here.',
+ },
+ {
+ description: "word at the end",
+ text: "reach the end",
+ matches: ["end"],
+ expected: 'reach the end',
+ },
+ {
+ description: "mutliple matches",
+ text: "one two three",
+ matches: ["one", "three"],
+ expected:
+ 'one two three',
+ },
+ {
+ description: "repeated matches",
+ text: "one two two",
+ matches: ["two"],
+ expected:
+ 'one two two',
+ },
+ {
+ description: "longest possible match",
+ text: "abc ab",
+ matches: ["ab", "abc"],
+ expected:
+ 'abc ab',
+ },
+ {
+ description: "if wrapped in parenthesis",
+ text: "(test)",
+ matches: ["test"],
+ expected: '(test)',
+ },
+ {
+ description: "if wrapped in commas",
+ text: ",test,",
+ matches: ["test"],
+ expected: ',test,',
+ },
+ {
+ description: "if wrapped in underscores",
+ text: "_test_",
+ matches: ["test"],
+ expected: '_test_',
+ },
+ {
+ description: "words in russian",
+ text: "Привет, мир!",
+ matches: ["Привет", "мир"],
+ expected:
+ 'Привет, мир!',
+ },
+ {
+ description: "words with chinese punctuation",
+ text: "你好,世界!",
+ matches: ["你好", "世界"],
+ expected:
+ '你好,世界!',
+ },
+ {
+ description: "words with arabic punctuation",
+ text: "؟مرحبا، بكم؛",
+ matches: ["مرحبا", "بكم"],
+ expected:
+ '؟مرحبا، بكم؛',
+ },
+ {
+ description: "standalone numbers",
+ text: "My number is 1234.",
+ matches: ["1234"],
+ expected: 'My number is 1234.',
+ },
+ ];
+ const shouldNotHighlight = [
+ {
+ description: "a match within a longer word",
+ text: "together",
+ matches: ["get"],
+ },
+ {
+ description: "a match with leading letters",
+ text: "welcome",
+ matches: ["come"],
+ },
+ {
+ description: "a match with trailing letters",
+ text: "comets",
+ matches: ["come"],
+ },
+ {
+ description: "japanese matches within longer words",
+ text: "こんにちは世界",
+ matches: ["こんにちは"],
+ },
+ {
+ description: "numbers within words",
+ text: "abc1234def",
+ matches: ["1234"],
+ },
+ ];
+ const returnOriginal = [
+ {
+ description: "if matches is an empty array",
+ text: "Nothing to match.",
+ matches: [],
+ },
+ {
+ description: "if matches has an empty string only",
+ text: "Nothing to match.",
+ matches: [""],
+ },
+ {
+ description: "if no matches found in text",
+ text: "Hello world.",
+ matches: ["absent"],
+ },
+ {
+ description: "if text is empty",
+ text: "",
+ matches: ["anything"],
+ },
+ ];
+ it.each(shouldHighlight)(
+ "should highlight $description",
+ ({ text, matches, expected }) => {
+ expect(Strings.highlightMatches(text, matches)).toBe(expected);
+ }
+ );
+ it.each(shouldNotHighlight)(
+ "should not highlight $description",
+ ({ text, matches }) => {
+ expect(Strings.highlightMatches(text, matches)).toBe(text);
+ }
+ );
+ it.each(returnOriginal)(
+ "should return original text $description",
+ ({ text, matches }) => {
+ expect(Strings.highlightMatches(text, matches)).toBe(text);
+ }
+ );
+ });
+
describe("splitIntoCharacters", () => {
it("splits regular characters", () => {
expect(Strings.splitIntoCharacters("abc")).toEqual(["a", "b", "c"]);
diff --git a/frontend/src/ts/modals/quote-search.ts b/frontend/src/ts/modals/quote-search.ts
index 04880f0b6068..1d2c15df679d 100644
--- a/frontend/src/ts/modals/quote-search.ts
+++ b/frontend/src/ts/modals/quote-search.ts
@@ -10,7 +10,6 @@ import {
SearchService,
TextExtractor,
} from "../utils/search-service";
-import { splitByAndKeep } from "../utils/strings";
import QuotesController, { Quote } from "../controllers/quotes-controller";
import { isAuthenticated } from "../firebase";
import { debounce } from "throttle-debounce";
@@ -21,6 +20,7 @@ import * as TestState from "../test/test-state";
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
import * as TestLogic from "../test/test-logic";
import { createErrorMessage } from "../utils/misc";
+import { highlightMatches } from "../utils/strings";
const searchServiceCache: Record> = {};
@@ -43,23 +43,6 @@ function getSearchService(
return newSearchService;
}
-function highlightMatches(text: string, matchedText: string[]): string {
- if (matchedText.length === 0) {
- return text;
- }
- const words = splitByAndKeep(text, `.,"/#!$%^&*;:{}=-_\`~() `.split(""));
-
- const normalizedWords = words.map((word) => {
- const shouldHighlight =
- matchedText.find((match) => {
- return word.startsWith(match);
- }) !== undefined;
- return shouldHighlight ? `${word}` : word;
- });
-
- return normalizedWords.join("");
-}
-
function applyQuoteLengthFilter(quotes: Quote[]): Quote[] {
if (!modal.isOpen()) return [];
const quoteLengthFilterValue = $(
diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts
index 669b2503e6c5..264d54560633 100644
--- a/frontend/src/ts/utils/strings.ts
+++ b/frontend/src/ts/utils/strings.ts
@@ -96,6 +96,28 @@ export function splitByAndKeep(text: string, delimiters: string[]): string[] {
return splitString;
}
+/**
+ * Highlights all occurrences of specified words within a given text.
+ * Each match is wrapped in a element.
+ * Matches are ignored if they appear as part of a larger word
+ * not included in the matches array.
+ * @param text The full text in which to highlight words.
+ * @param matches An array of words to highlight.
+ * @return The full text with all matching words highlighted.
+ */
+export function highlightMatches(text: string, matches: string[]): string {
+ matches = matches.filter((match) => match !== "");
+ if (matches.length === 0) return text;
+
+ // matches that don't have a letter before or after them
+ const pattern = new RegExp(
+ `(?$&');
+}
+
/**
* Returns a display string for the given language, optionally removing the size indicator.
* @param language The language string.