Skip to content

Commit e9851a1

Browse files
authored
Merge pull request #1616 from Nwakaego-Ego/fix/searchprovider-index-rebuild-main
Apply SearchProvider index rebuild fix to main
2 parents 958bc40 + 0358049 commit e9851a1

2 files changed

Lines changed: 154 additions & 19 deletions

File tree

‎src/components/SearchProvider/index.tsx‎

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from "preact/hooks";
2-
import Fuse, { type FuseResult } from "fuse.js";
2+
import Fuse from "fuse.js";
33
import SearchResults from "../SearchResults";
44
import { defaultLocale } from "@/src/i18n/const";
55

@@ -14,8 +14,12 @@ type SearchResult = {
1414
title: string;
1515
relativeUrl: string;
1616
description?: string;
17+
alias?: string;
1718
};
1819

20+
type SearchIndexEntry = Omit<SearchResult, "id" | "category" | "title">;
21+
type SearchIndexData = Record<string, Record<string, SearchIndexEntry>>;
22+
1923
/**
2024
* SearchProvider this component is responsible for handling client-side search.
2125
* It reads the search term from query params and fetches the search index for the current locale.
@@ -28,6 +32,9 @@ const SearchProvider = ({
2832
}: SearchProviderProps) => {
2933
const [searchTerm, setSearchTerm] = useState("");
3034
const [results, setResults] = useState<SearchResult[]>([]);
35+
const [searchIndex, setSearchIndex] = useState<Fuse<SearchResult> | null>(
36+
null,
37+
);
3138

3239
// Flattens the search index data
3340

@@ -48,16 +55,16 @@ const SearchProvider = ({
4855
}
4956
}, [searchTerm]);
5057

51-
// Fetch the search index for the current locale and search for the search term
52-
// This effect runs whenever the search term or the current locale changes
58+
// Fetch the search index for the current locale
5359
useEffect(() => {
60+
setSearchIndex(null);
61+
5462
if (!currentLocale) {
5563
console.warn("No locale provided to SearchProvider");
5664
return;
5765
}
5866

59-
if (!searchTerm) return;
60-
const flattenData = (data: FuseResult<SearchResult>) => {
67+
const flattenData = (data: SearchIndexData) => {
6168
const flatData: SearchResult[] = [];
6269
let flatId = 0;
6370
Object.entries(data).forEach(([category, entries]) => {
@@ -82,12 +89,11 @@ const SearchProvider = ({
8289
return flatData;
8390
};
8491

85-
let flatData;
86-
87-
fetch(`/search-indices/${currentLocale}.json`)
92+
const controller = new AbortController();
93+
fetch(`/search-indices/${currentLocale}.json`, { signal: controller.signal })
8894
.then((response) => response.json())
8995
.then((data) => {
90-
flatData = flattenData(data);
96+
const flatData = flattenData(data);
9197

9298
const fuseOptions = {
9399
includeScore: true,
@@ -100,18 +106,26 @@ const SearchProvider = ({
100106
threshold: 0.3,
101107
};
102108

103-
const fuse = new Fuse(flatData, fuseOptions);
109+
setSearchIndex(new Fuse(flatData, fuseOptions));
110+
})
111+
.catch((error) => {
112+
if (error.name !== "AbortError") {
113+
console.error("Error fetching or indexing data:", error);
114+
}
115+
});
104116

105-
const searchResults = fuse
106-
.search(searchTerm)
107-
.map((result) => result.item);
117+
return () => controller.abort();
118+
}, [currentLocale]);
108119

109-
setResults(searchResults);
110-
})
111-
.catch((error) =>
112-
console.error("Error fetching or indexing data:", error),
113-
);
114-
}, [searchTerm, currentLocale]);
120+
// Search after search term updates
121+
useEffect(() => {
122+
if (!searchTerm || !searchIndex) {
123+
setResults([]);
124+
return;
125+
}
126+
127+
setResults(searchIndex.search(searchTerm).map((result) => result.item));
128+
}, [searchTerm, searchIndex]);
115129

116130
const handleSearchTermChange = (term?: string) => {
117131
if (term !== undefined) {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
act,
3+
cleanup,
4+
fireEvent,
5+
render,
6+
screen,
7+
waitFor,
8+
} from "@testing-library/preact";
9+
import { afterEach, expect, it, suite, vi } from "vitest";
10+
import SearchProvider from "@components/SearchProvider";
11+
12+
const uiTranslations = {
13+
Search: "Search",
14+
"No Results": "No Results",
15+
Reference: "Reference",
16+
};
17+
18+
const responseWith = (data: unknown) =>
19+
({ json: vi.fn().mockResolvedValue(data) }) as unknown as Response;
20+
21+
suite("SearchProvider", () => {
22+
afterEach(() => {
23+
cleanup();
24+
vi.unstubAllGlobals();
25+
history.replaceState(null, "", "/");
26+
});
27+
28+
it("reuses the search index for new terms", async () => {
29+
const fetchMock = vi.fn().mockResolvedValue(
30+
responseWith({
31+
reference: {
32+
"createCanvas()": {
33+
relativeUrl: "/reference/p5/createCanvas",
34+
alias: "createCanvas",
35+
},
36+
"circle()": {
37+
relativeUrl: "/reference/p5/circle",
38+
alias: "circle",
39+
},
40+
},
41+
}),
42+
);
43+
vi.stubGlobal("fetch", fetchMock);
44+
history.replaceState(null, "", "/search/?term=createCanvas");
45+
46+
render(
47+
<SearchProvider currentLocale="en" uiTranslations={uiTranslations} />,
48+
);
49+
50+
expect(
51+
await screen.findByRole("link", { name: "createCanvas()" }),
52+
).toBeTruthy();
53+
54+
const searchInput = screen.getByLabelText("Search through site content");
55+
fireEvent.input(searchInput, { target: { value: "circle" } });
56+
fireEvent.keyDown(searchInput, { key: "Enter" });
57+
58+
expect(await screen.findByRole("link", { name: "circle()" })).toBeTruthy();
59+
expect(fetchMock).toHaveBeenCalledTimes(1);
60+
});
61+
62+
it("clears the old index while a new locale loads", async () => {
63+
let resolveSpanishResponse: (response: Response) => void = () => {};
64+
const spanishResponse = new Promise<Response>((resolve) => {
65+
resolveSpanishResponse = resolve;
66+
});
67+
const fetchMock = vi
68+
.fn()
69+
.mockResolvedValueOnce(
70+
responseWith({
71+
reference: {
72+
"English result": {
73+
relativeUrl: "/reference/english",
74+
alias: "shared",
75+
},
76+
},
77+
}),
78+
)
79+
.mockReturnValueOnce(spanishResponse);
80+
vi.stubGlobal("fetch", fetchMock);
81+
history.replaceState(null, "", "/search/?term=shared");
82+
83+
const { rerender } = render(
84+
<SearchProvider currentLocale="en" uiTranslations={uiTranslations} />,
85+
);
86+
87+
expect(
88+
await screen.findByRole("link", { name: "English result" }),
89+
).toBeTruthy();
90+
const firstRequestSignal = fetchMock.mock.calls[0][1]
91+
?.signal as AbortSignal;
92+
93+
rerender(
94+
<SearchProvider currentLocale="es" uiTranslations={uiTranslations} />,
95+
);
96+
97+
await waitFor(() => expect(firstRequestSignal.aborted).toBe(true));
98+
await waitFor(() =>
99+
expect(screen.queryByRole("link", { name: "English result" })).toBeNull(),
100+
);
101+
102+
await act(async () => {
103+
resolveSpanishResponse(
104+
responseWith({
105+
reference: {
106+
"Spanish result": {
107+
relativeUrl: "/reference/spanish",
108+
alias: "shared",
109+
},
110+
},
111+
}),
112+
);
113+
});
114+
115+
const spanishResult = await screen.findByRole("link", {
116+
name: "Spanish result",
117+
});
118+
expect(spanishResult.getAttribute("href")).toBe("/es/reference/spanish");
119+
expect(fetchMock).toHaveBeenCalledTimes(2);
120+
});
121+
});

0 commit comments

Comments
 (0)