Skip to content

Commit aa28bde

Browse files
refactor(web): the theme search section compiles without manual memoization (#20778)
Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent b38aa02 commit aa28bde

1 file changed

Lines changed: 158 additions & 131 deletions

File tree

‎apps/web/src/components/settings/ThemeSearchSection.tsx‎

Lines changed: 158 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { RefreshIcon } from "~/components/ui/refresh-icon";
22
import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react";
3-
import { useCallback, useEffect, useRef, useState } from "react";
3+
import { useEffect, useEffectEvent, useRef, useState } from "react";
44
import {
55
importOpenVsxThemeExtension,
66
searchOpenVsxThemes,
@@ -43,14 +43,20 @@ const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> =
4343
];
4444
const SEARCH_DEBOUNCE_MS = 350;
4545

46-
function SourceLinkIcon({ url }: { url: string }) {
46+
function sourceHost(url: string): string | null {
4747
try {
48-
const host = new URL(url).hostname.toLowerCase();
49-
if (host === "github.com" || host.endsWith(".github.com"))
50-
return <GitHubIcon className="size-3.5" />;
51-
if (host === "gitlab.com" || host.endsWith(".gitlab.com"))
52-
return <GitLabIcon className="size-3.5" monochrome />;
53-
} catch {}
48+
return new URL(url).hostname.toLowerCase();
49+
} catch {
50+
return null;
51+
}
52+
}
53+
54+
function SourceLinkIcon({ url }: { url: string }) {
55+
const host = sourceHost(url);
56+
if (host === "github.com" || host?.endsWith(".github.com"))
57+
return <GitHubIcon className="size-3.5" />;
58+
if (host === "gitlab.com" || host?.endsWith(".gitlab.com"))
59+
return <GitLabIcon className="size-3.5" monochrome />;
5460
return <ExternalLinkIcon className="size-3.5" />;
5561
}
5662

@@ -90,163 +96,184 @@ export function ThemeSearchSection({
9096
const [installingId, setInstallingId] = useState<string | null>(null);
9197
const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null);
9298
const requestRef = useRef<AbortController | null>(null);
99+
// Recorded only once a search succeeds, so a finished install reruns the
100+
// search only when the query or sort changed while it was in flight, and the
101+
// install error the user needs to see survives.
102+
const [lastSearchKey, setLastSearchKey] = useState<string | null>(null);
103+
const [handledSearch, setHandledSearch] = useState<{
104+
key: string;
105+
query: string;
106+
fetch: boolean;
107+
} | null>(null);
108+
// An install aborts any search, so once it ends the current key is checked
109+
// again even though it has not changed.
110+
const [searchInterrupted, setSearchInterrupted] = useState(false);
93111

94-
const lastSearchKeyRef = useRef<string | null>(null);
95-
96-
const prevSearchKeyRef = useRef<string | null>(null);
97-
98-
useEffect(() => {
99-
requestRef.current?.abort();
100-
requestRef.current = null;
112+
const [wasOpen, setWasOpen] = useState(open);
113+
if (open !== wasOpen) {
114+
setWasOpen(open);
101115
if (open) {
102-
lastSearchKeyRef.current = null;
103-
prevSearchKeyRef.current = null;
104116
setQuery("");
105117
setSortBy("downloadCount");
106118
setResults(null);
107119
setError(null);
108120
setIsSearching(false);
109121
setInstallingId(null);
110122
setPendingUpdate(null);
123+
setLastSearchKey(null);
124+
setHandledSearch(null);
125+
setSearchInterrupted(false);
111126
}
127+
}
128+
129+
useEffect(() => {
130+
if (!open) return;
112131
return () => {
113132
requestRef.current?.abort();
114133
requestRef.current = null;
115134
};
116135
}, [open]);
117136

118-
const runSearch = useCallback(
119-
async (searchText: string, nextSort = sortBy) => {
120-
const trimmed = searchText.trim();
121-
if (!trimmed) return;
122-
requestRef.current?.abort();
123-
const controller = new AbortController();
124-
requestRef.current = controller;
125-
setError(null);
126-
setIsSearching(true);
127-
try {
128-
const nextResults = await searchOpenVsxThemes(trimmed, {
129-
signal: controller.signal,
130-
sortBy: nextSort,
131-
});
132-
if (!controller.signal.aborted) {
137+
const queryCleared = !query.trim() && installingId === null;
138+
if (
139+
queryCleared &&
140+
(results !== null ||
141+
error !== null ||
142+
isSearching ||
143+
lastSearchKey !== null ||
144+
handledSearch !== null)
145+
) {
146+
setResults(null);
147+
setError(null);
148+
setIsSearching(false);
149+
setLastSearchKey(null);
150+
setHandledSearch(null);
151+
}
152+
153+
useEffect(() => {
154+
if (!queryCleared) return;
155+
requestRef.current?.abort();
156+
requestRef.current = null;
157+
}, [queryCleared]);
158+
159+
const startRequest = () => {
160+
requestRef.current?.abort();
161+
const controller = new AbortController();
162+
requestRef.current = controller;
163+
return controller;
164+
};
165+
166+
const fetchResults = (trimmed: string, controller: AbortController) => {
167+
const key = `${trimmed}\u0000${sortBy}`;
168+
void searchOpenVsxThemes(trimmed, { signal: controller.signal, sortBy })
169+
.then(
170+
(nextResults) => {
171+
if (controller.signal.aborted) return;
133172
setResults(nextResults);
134-
lastSearchKeyRef.current = `${trimmed}\u0000${nextSort}`;
135-
}
136-
} catch (cause) {
137-
if (!controller.signal.aborted) {
173+
setLastSearchKey(key);
174+
},
175+
(cause: unknown) => {
176+
if (controller.signal.aborted) return;
138177
setResults(null);
139-
lastSearchKeyRef.current = null;
178+
setLastSearchKey(null);
140179
setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
141-
}
142-
}
143-
if (requestRef.current === controller) {
180+
},
181+
)
182+
.then(() => {
183+
if (requestRef.current !== controller) return;
144184
requestRef.current = null;
145185
setIsSearching(false);
146-
}
147-
},
148-
[sortBy],
149-
);
186+
});
187+
};
188+
189+
const runSearch = (searchText: string) => {
190+
const trimmed = searchText.trim();
191+
if (!trimmed) return;
192+
setError(null);
193+
setIsSearching(true);
194+
fetchResults(trimmed, startRequest());
195+
};
150196

151197
const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
198+
const searchKey = `${debouncedQuery}\u0000${sortBy}`;
199+
// An unsettled query would hit Open VSX for text that is no longer visible;
200+
// wait for the debounce to catch up to the current input instead.
201+
const searchSettled =
202+
open && installingId === null && debouncedQuery !== "" && debouncedQuery === query.trim();
152203

153-
useEffect(() => {
154-
if (query.trim() || installingId !== null) return;
155-
requestRef.current?.abort();
156-
requestRef.current = null;
157-
lastSearchKeyRef.current = null;
158-
setResults(null);
159-
setError(null);
160-
setIsSearching(false);
161-
}, [query, installingId]);
204+
const keyChanged = handledSearch?.key !== searchKey;
205+
if (searchSettled && (keyChanged || searchInterrupted)) {
206+
// Typing and then undoing lands back on the key already shown; keep those
207+
// results instead of searching again.
208+
const fetch = lastSearchKey !== searchKey;
209+
setHandledSearch({ key: searchKey, query: debouncedQuery, fetch });
210+
setSearchInterrupted(false);
211+
// An install error on an unchanged, already-shown query stays visible.
212+
if (fetch || keyChanged) setError(null);
213+
setIsSearching(fetch);
214+
}
215+
216+
const fetchHandledSearch = useEffectEvent((searchText: string) => {
217+
fetchResults(searchText, startRequest());
218+
});
162219

163220
useEffect(() => {
164-
if (!open) return;
165-
const searchKey = `${debouncedQuery}\u0000${sortBy}`;
166-
const keyChanged = prevSearchKeyRef.current !== searchKey;
167-
prevSearchKeyRef.current = searchKey;
168-
if (installingId !== null) return;
169-
if (!debouncedQuery) {
170-
lastSearchKeyRef.current = null;
171-
requestRef.current?.abort();
172-
requestRef.current = null;
173-
setResults(null);
174-
setError(null);
175-
setIsSearching(false);
176-
return;
177-
}
178-
if (debouncedQuery !== query.trim()) {
179-
// Searching it would hit Open VSX for a query that is no longer visible;
180-
// wait for the debounce to catch up to the current input instead.
221+
if (!handledSearch) return;
222+
if (handledSearch.fetch) {
223+
fetchHandledSearch(handledSearch.query);
181224
return;
182225
}
183-
if (lastSearchKeyRef.current === searchKey) {
184-
// A request for a newer key may still be in flight (typed and then
185-
// undone); abort it so it cannot overwrite the results. Only a genuine
186-
// key change makes a stale search error irrelevant, so an install error
187-
// on an unchanged query survives.
188-
requestRef.current?.abort();
189-
requestRef.current = null;
190-
setIsSearching(false);
191-
if (keyChanged) setError(null);
192-
return;
193-
}
194-
void runSearch(debouncedQuery);
195-
// `sortBy` is deliberately not a direct dependency: the guards above read
196-
// the current value from the fresh render closure. An install finishing
197-
// reruns the search only when the query or sort changed while it was in
198-
// flight (checked via lastSearchKeyRef, recorded only once a search
199-
// succeeds), so the install error the user needs to see is preserved
200-
// across that rerun.
201-
}, [open, query, debouncedQuery, installingId, runSearch]);
226+
// A request for a newer key may still be in flight; abort it so it cannot
227+
// overwrite the results being kept.
228+
requestRef.current?.abort();
229+
requestRef.current = null;
230+
}, [handledSearch]);
202231

203-
const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => {
232+
const handleSortChange = (value: OpenVsxThemeSort | null) => {
204233
const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value;
205234
if (!nextSort) return;
206235
setSortBy(nextSort);
207-
}, []);
236+
};
208237

209-
const handleInstall = useCallback(
210-
async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => {
211-
setError(null);
212-
let installedCollection: ReadonlyArray<ThemeDefinition>;
213-
try {
214-
installedCollection = getStoredCustomThemeCollection(extension.collectionId);
215-
} catch (cause) {
216-
setError(cause instanceof Error ? cause.message : "Installed themes could not be read.");
217-
return;
218-
}
219-
const updated = installedCollection.length > 0;
220-
if (updated && !allowUpdate) {
221-
setPendingUpdate(extension);
222-
return;
223-
}
238+
const handleInstall = async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => {
239+
setError(null);
240+
let installedCollection: ReadonlyArray<ThemeDefinition>;
241+
try {
242+
installedCollection = getStoredCustomThemeCollection(extension.collectionId);
243+
} catch (cause) {
244+
setError(cause instanceof Error ? cause.message : "Installed themes could not be read.");
245+
return;
246+
}
247+
const updated = installedCollection.length > 0;
248+
if (updated && !allowUpdate) {
249+
setPendingUpdate(extension);
250+
return;
251+
}
224252

225-
requestRef.current?.abort();
226-
const controller = new AbortController();
227-
requestRef.current = controller;
228-
setIsSearching(false);
229-
setInstallingId(extension.id);
230-
try {
231-
const themes = await importOpenVsxThemeExtension(extension, controller.signal);
232-
if (!controller.signal.aborted) {
233-
const imported = replaceCustomThemeCollection(extension.collectionId, themes, {
234-
expectedCollection: installedCollection,
235-
});
236-
onInstalled(imported, { updated });
237-
}
238-
} catch (cause) {
239-
if (!controller.signal.aborted) {
240-
setError(cause instanceof Error ? cause.message : "That theme could not be added.");
241-
}
253+
requestRef.current?.abort();
254+
const controller = new AbortController();
255+
requestRef.current = controller;
256+
setIsSearching(false);
257+
setSearchInterrupted(true);
258+
setInstallingId(extension.id);
259+
try {
260+
const themes = await importOpenVsxThemeExtension(extension, controller.signal);
261+
if (!controller.signal.aborted) {
262+
const imported = replaceCustomThemeCollection(extension.collectionId, themes, {
263+
expectedCollection: installedCollection,
264+
});
265+
onInstalled(imported, { updated });
242266
}
243-
if (requestRef.current === controller) {
244-
requestRef.current = null;
245-
setInstallingId(null);
267+
} catch (cause) {
268+
if (!controller.signal.aborted) {
269+
setError(cause instanceof Error ? cause.message : "That theme could not be added.");
246270
}
247-
},
248-
[onInstalled],
249-
);
271+
}
272+
if (requestRef.current === controller) {
273+
requestRef.current = null;
274+
setInstallingId(null);
275+
}
276+
};
250277

251278
return (
252279
<section className="space-y-3" aria-labelledby="theme-search-heading">
@@ -269,7 +296,7 @@ export function ThemeSearchSection({
269296
onKeyDown={(event) => {
270297
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
271298
if (event.key === "Enter" && !isSearching && installingId === null)
272-
void runSearch(query.trim());
299+
runSearch(query.trim());
273300
}}
274301
placeholder="Search themes..."
275302
size="lg"
@@ -290,7 +317,7 @@ export function ThemeSearchSection({
290317
variant="ghost"
291318
onClick={() => {
292319
if (query.trim() === suggestion) {
293-
void runSearch(suggestion);
320+
runSearch(suggestion);
294321
} else {
295322
setQuery(suggestion);
296323
}

0 commit comments

Comments
 (0)