Skip to content

Commit 9870f5a

Browse files
fix(ui): race condition with gallery search
It was possible to clear the search term while a debounced setSearchTerm is still pending. This resulted in the gallery getting out of sync w/ the search term. To fix this, we need to lift the state up a bit and cancel any pending debounced setSearchTerm calls when closing the search or clearing the search term box.
1 parent c296ae8 commit 9870f5a

File tree

3 files changed

+64
-39
lines changed

3 files changed

+64
-39
lines changed

invokeai/frontend/web/src/features/gallery/components/Gallery.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
useDisclosure,
1313
} from '@invoke-ai/ui-library';
1414
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
15-
import { galleryViewChanged, searchTermChanged } from 'features/gallery/store/gallerySlice';
15+
import { useGallerySearchTerm } from 'features/gallery/components/ImageGrid/useGallerySearchTerm';
16+
import { galleryViewChanged } from 'features/gallery/store/gallerySlice';
1617
import type { CSSProperties } from 'react';
1718
import { useCallback } from 'react';
1819
import { useTranslation } from 'react-i18next';
@@ -41,8 +42,9 @@ export const Gallery = () => {
4142
const { t } = useTranslation();
4243
const dispatch = useAppDispatch();
4344
const galleryView = useAppSelector((s) => s.gallery.galleryView);
44-
const searchTerm = useAppSelector((s) => s.gallery.searchTerm);
45-
const searchDisclosure = useDisclosure({ defaultIsOpen: !!searchTerm.length });
45+
const initialSearchTerm = useAppSelector((s) => s.gallery.searchTerm);
46+
const searchDisclosure = useDisclosure({ defaultIsOpen: initialSearchTerm.length > 0 });
47+
const [searchTerm, onChangeSearchTerm, onResetSearchTerm] = useGallerySearchTerm();
4648

4749
const handleClickImages = useCallback(() => {
4850
dispatch(galleryViewChanged('images'));
@@ -53,13 +55,9 @@ export const Gallery = () => {
5355
}, [dispatch]);
5456

5557
const handleClickSearch = useCallback(() => {
56-
if (searchTerm.length) {
57-
dispatch(searchTermChanged(''));
58-
searchDisclosure.onToggle();
59-
} else {
60-
searchDisclosure.onToggle();
61-
}
62-
}, [searchTerm, dispatch, searchDisclosure]);
58+
searchDisclosure.onToggle();
59+
onResetSearchTerm();
60+
}, [onResetSearchTerm, searchDisclosure]);
6361

6462
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
6563
const boardName = useBoardName(selectedBoardId);
@@ -92,7 +90,11 @@ export const Gallery = () => {
9290
<Box w="full">
9391
<Collapse in={searchDisclosure.isOpen} style={COLLAPSE_STYLES}>
9492
<Box w="full" pt={2}>
95-
<GallerySearch />
93+
<GallerySearch
94+
searchTerm={searchTerm}
95+
onChangeSearchTerm={onChangeSearchTerm}
96+
onResetSearchTerm={onResetSearchTerm}
97+
/>
9698
</Box>
9799
</Collapse>
98100
</Box>

invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,37 @@
11
import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invoke-ai/ui-library';
2-
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
2+
import { useAppSelector } from 'app/store/storeHooks';
33
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
4-
import { searchTermChanged } from 'features/gallery/store/gallerySlice';
5-
import { debounce } from 'lodash-es';
64
import type { ChangeEvent } from 'react';
7-
import { useCallback, useEffect, useMemo, useState } from 'react';
5+
import { useCallback } from 'react';
86
import { useTranslation } from 'react-i18next';
97
import { PiXBold } from 'react-icons/pi';
108
import { useListImagesQuery } from 'services/api/endpoints/images';
119

12-
export const GallerySearch = () => {
13-
const dispatch = useAppDispatch();
14-
const searchTerm = useAppSelector((s) => s.gallery.searchTerm);
10+
type Props = {
11+
searchTerm: string;
12+
onChangeSearchTerm: (value: string) => void;
13+
onResetSearchTerm: () => void;
14+
};
15+
16+
export const GallerySearch = ({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => {
1517
const { t } = useTranslation();
16-
const [searchTermInput, setSearchTermInput] = useState(searchTerm);
1718
const queryArgs = useAppSelector(selectListImagesQueryArgs);
1819
const { isPending } = useListImagesQuery(queryArgs, {
1920
selectFromResult: ({ isLoading, isFetching }) => ({ isPending: isLoading || isFetching }),
2021
});
21-
const debouncedSetSearchTerm = useMemo(() => {
22-
return debounce((value: string) => {
23-
dispatch(searchTermChanged(value));
24-
}, 1000);
25-
}, [dispatch]);
2622

2723
const handleChangeInput = useCallback(
2824
(e: ChangeEvent<HTMLInputElement>) => {
29-
setSearchTermInput(e.target.value);
30-
debouncedSetSearchTerm(e.target.value);
25+
onChangeSearchTerm(e.target.value);
3126
},
32-
[debouncedSetSearchTerm]
27+
[onChangeSearchTerm]
3328
);
3429

35-
const handleClearInput = useCallback(() => {
36-
setSearchTermInput('');
37-
dispatch(searchTermChanged(''));
38-
}, [dispatch]);
39-
40-
useEffect(() => {
41-
setSearchTermInput(searchTerm);
42-
}, [searchTerm]);
43-
4430
return (
4531
<InputGroup>
4632
<Input
4733
placeholder={t('gallery.searchImages')}
48-
value={searchTermInput}
34+
value={searchTerm}
4935
onChange={handleChangeInput}
5036
data-testid="image-search-input"
5137
/>
@@ -54,10 +40,10 @@ export const GallerySearch = () => {
5440
<Spinner size="sm" opacity={0.5} />
5541
</InputRightElement>
5642
)}
57-
{!isPending && searchTermInput.length && (
43+
{!isPending && searchTerm.length && (
5844
<InputRightElement h="full" pe={2}>
5945
<IconButton
60-
onClick={handleClearInput}
46+
onClick={onResetSearchTerm}
6147
size="sm"
6248
variant="link"
6349
aria-label={t('boards.clearSearch')}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
2+
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
3+
import { searchTermChanged } from 'features/gallery/store/gallerySlice';
4+
import { debounce } from 'lodash-es';
5+
import { useCallback, useMemo, useState } from 'react';
6+
7+
export const useGallerySearchTerm = () => {
8+
// Highlander!
9+
useAssertSingleton('gallery-search-state');
10+
11+
const dispatch = useAppDispatch();
12+
const searchTerm = useAppSelector((s) => s.gallery.searchTerm);
13+
14+
const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm);
15+
16+
const debouncedSetSearchTerm = useMemo(() => {
17+
return debounce((val: string) => {
18+
dispatch(searchTermChanged(val));
19+
}, 1000);
20+
}, [dispatch]);
21+
22+
const onChange = useCallback(
23+
(val: string) => {
24+
setLocalSearchTerm(val);
25+
debouncedSetSearchTerm(val);
26+
},
27+
[debouncedSetSearchTerm]
28+
);
29+
30+
const onReset = useCallback(() => {
31+
debouncedSetSearchTerm.cancel();
32+
setLocalSearchTerm('');
33+
dispatch(searchTermChanged(''));
34+
}, [debouncedSetSearchTerm, dispatch]);
35+
36+
return [localSearchTerm, onChange, onReset] as const;
37+
};

0 commit comments

Comments
 (0)