Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/app/components/emoji-board/EmojiBoard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {
ChangeEventHandler,
ChangeEvent,
FocusEventHandler,
MouseEventHandler,
ReactNode,
Expand Down Expand Up @@ -525,6 +525,7 @@ export function EmojiBoard({
loading: gifsLoading,
error: gifsError,
searchGifs,
cancelSearch: cancelGifSearch,
} = useGifSearch(favoriteGifs, showGifPicker, gifSearch);
const [emojiGroupItems, stickerGroupItems, gifGroupItems] = useGroups(tab, imagePacks, gifs);
const [showFavoritesOnly, setShowFavoritesOnly] = useState(true);
Expand Down Expand Up @@ -553,16 +554,17 @@ export function EmojiBoard({
const groups = groupsByTab[tab];
const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth);

const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
const handleOnChange = useDebounce(
useCallback(
(evt) => {
(evt: ChangeEvent<HTMLInputElement>) => {
const term = evt.target.value;
if (tab === EmojiBoardTab.Gif) {
if (term) {
setShowFavoritesOnly(false);
searchGifs(term);
} else {
setShowFavoritesOnly(true);
cancelGifSearch();
resetGifSearch();
}
} else if (term) {
Expand All @@ -571,11 +573,18 @@ export function EmojiBoard({
resetEmojiSearch();
}
},
[emojiSearch, resetEmojiSearch, searchGifs, resetGifSearch, tab]
[cancelGifSearch, emojiSearch, resetEmojiSearch, searchGifs, resetGifSearch, tab]
),
{ wait: 200 }
);

useEffect(() => {
if (!showGifPicker) {
handleOnChange.cancel();
cancelGifSearch();
}
}, [cancelGifSearch, handleOnChange, showGifPicker]);

const contentScrollRef = useRef<HTMLDivElement>(null);
const virtualBaseRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
Expand Down
134 changes: 134 additions & 0 deletions src/app/components/emoji-board/useGifSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useGifSearch } from './useGifSearch';

const { fetchMock } = vi.hoisted(() => ({
fetchMock: vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(),
}));

vi.mock('$utils/fetch', () => ({ fetch: fetchMock }));
vi.mock('$hooks/useClientConfig', () => ({
useClientConfig: () => ({ gifs: { klipyApiKey: 'test-key' } }),
}));

type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};

const deferred = <T,>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
};

const responseFor = (id: string): Response =>
new Response(
JSON.stringify({
data: {
data: [
{
id,
title: id,
file: { xs: { gif: { url: `https://${id}.preview` } } },
},
],
},
}),
{ status: 200 }
);

const flushPromises = async () => {
await Promise.resolve();
await Promise.resolve();
};

afterEach(() => {
fetchMock.mockReset();
});

describe('useGifSearch', () => {
it('keeps stale success, error, and finally handlers from changing the latest request', async () => {
const first = deferred<Response>();
const second = deferred<Response>();
fetchMock.mockImplementation((url: string) =>
url.includes('q=first') ? first.promise : second.promise
);

const { result } = renderHook(() => useGifSearch([], true, vi.fn<() => void>()));

act(() => {
void result.current.searchGifs('first');
void result.current.searchGifs('second');
});

const firstSignal = fetchMock.mock.calls[0]?.[1]?.signal as AbortSignal;
expect(firstSignal.aborted).toBe(true);

first.reject(new Error('stale failure'));
await act(async () => {
await flushPromises();
});
expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull();

second.resolve(responseFor('second'));
await act(async () => {
await flushPromises();
});
expect(result.current.gifs.gifs[0]?.id).toBe('second');
expect(result.current.loading).toBe(false);

first.resolve(responseFor('first'));
await act(async () => {
await flushPromises();
});
expect(result.current.gifs.gifs[0]?.id).toBe('second');
});

it('aborts and invalidates a request when cancelled or closed', async () => {
const request = deferred<Response>();
fetchMock.mockReturnValue(request.promise);

const { result, rerender } = renderHook(
({ open }) => useGifSearch([], open, vi.fn<() => void>()),
{ initialProps: { open: true } }
);

act(() => {
void result.current.searchGifs('query');
});
const signal = fetchMock.mock.calls[0]?.[1]?.signal as AbortSignal;

rerender({ open: false });

expect(signal.aborted).toBe(true);
expect(result.current.loading).toBe(false);

request.resolve(responseFor('stale'));
await act(async () => {
await flushPromises();
});
expect(result.current.gifs.gifs).toEqual([]);
});

it('aborts the active request on unmount', () => {
const request = deferred<Response>();
fetchMock.mockReturnValue(request.promise);
const { result, unmount } = renderHook(() => useGifSearch([], true, vi.fn<() => void>()));

act(() => {
void result.current.searchGifs('query');
});
const signal = fetchMock.mock.calls[0]?.[1]?.signal as AbortSignal;

unmount();

expect(signal.aborted).toBe(true);
});
});
58 changes: 49 additions & 9 deletions src/app/components/emoji-board/useGifSearch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AsyncSearchHandler } from '$utils/AsyncSearch';
import { fetch } from '$utils/fetch';
import { useClientConfig } from '$hooks/useClientConfig';
Expand Down Expand Up @@ -57,14 +57,34 @@ export function useGifSearch(
const [error, setError] = useState<string | null>(null);
const clientConfig = useClientConfig();
const klipyApiKey = clientConfig.gifs?.klipyApiKey ?? '';
const requestGenerationRef = useRef(0);
const abortControllerRef = useRef<AbortController>();
const mountedRef = useRef(true);
const showGifPickerRef = useRef(showGifPicker);
showGifPickerRef.current = showGifPicker;

const cancelRequest = useCallback(() => {
requestGenerationRef.current += 1;
abortControllerRef.current?.abort();
abortControllerRef.current = undefined;
}, []);

const cancelSearch = useCallback(() => {
cancelRequest();
if (mountedRef.current) setLoading(false);
}, [cancelRequest]);

const searchGifs = useCallback(
async (query: string) => {
if (!showGifPicker) {
if (!mountedRef.current || !showGifPickerRef.current) {
return;
}

const trimmedQuery = query.trim();
cancelRequest();
const generation = requestGenerationRef.current;
const controller = new AbortController();
abortControllerRef.current = controller;

setLoading(true);
setError(null);
Expand All @@ -77,30 +97,50 @@ export function useGifSearch(
url.searchParams.set('q', trimmedQuery);
url.searchParams.set('per_page', '50'); // TODO: infinite scroll?

const response = await fetch(url.toString());
const response = await fetch(url.toString(), { signal: controller.signal });

if (response.status === 200) {
const data = (await response.json()) as KlipySearchResponse;
const results = data.data?.data;

setSearchResults(results ? results.map(parseKlipyResult) : []);
if (generation === requestGenerationRef.current && mountedRef.current) {
setSearchResults(results ? results.map(parseKlipyResult) : []);
}
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch {
setError('Failed to search GIFs');
setSearchResults([]);
if (generation === requestGenerationRef.current && mountedRef.current) {
setError('Failed to search GIFs');
setSearchResults([]);
}
} finally {
setLoading(false);
if (generation === requestGenerationRef.current && mountedRef.current) {
abortControllerRef.current = undefined;
setLoading(false);
}
}
},
[klipyApiKey, showGifPicker, gifSearch]
[cancelRequest, klipyApiKey, gifSearch]
);

useEffect(() => {
mountedRef.current = true;

return () => {
mountedRef.current = false;
cancelRequest();
};
}, [cancelRequest]);

useEffect(() => {
if (!showGifPicker) cancelSearch();
}, [cancelSearch, showGifPicker]);

const gifs = useMemo(
() => ({ gifs: searchResults, favorites: favoriteGifs }),
[searchResults, favoriteGifs]
);

return { gifs, loading, error, searchGifs };
return { gifs, loading, error, searchGifs, cancelSearch };
}
29 changes: 8 additions & 21 deletions src/app/components/upload-board/UploadBoard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MutableRefObject, ReactNode } from 'react';
import { useEffect, useImperativeHandle, useRef } from 'react';
import { useEffect, useImperativeHandle } from 'react';
import { Badge, Box, Chip, Header, Spinner, Text, as, percent } from 'folds';
import { CaretRight, CaretUp, X, sizedIcon } from '$components/icons/phosphor';
import classNames from 'classnames';
Expand Down Expand Up @@ -27,14 +27,15 @@ export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...p
</Box>
));

export type UploadBoardImperativeHandlers = { handleSend: () => Promise<void> };
// Progress ticks re-render this header, so the caller reads uploads on demand here
// instead of subscribing to them itself.
export type UploadBoardImperativeHandlers = { getSendableUploads: () => Upload[] };

type UploadBoardHeaderProps = {
open: boolean;
onToggle: () => void;
uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
onCancel: (uploads: Upload[]) => void;
onSend: (uploads: Upload[]) => Promise<void>;
onBusyChange?: (busy: boolean) => void;
imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
};
Expand All @@ -44,11 +45,9 @@ export function UploadBoardHeader({
onToggle,
uploadFamilyObserverAtom,
onCancel,
onSend,
onBusyChange,
imperativeHandlerRef,
}: UploadBoardHeaderProps) {
const sendingRef = useRef(false);
const uploads = useAtomValue(uploadFamilyObserverAtom);

const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success);
Expand All @@ -72,23 +71,11 @@ export function UploadBoardHeader({
{ loaded: 0, total: 0 }
);

const handleSend = async () => {
if (sendingRef.current) return;
sendingRef.current = true;
try {
await onSend(
uploads.filter(
(upload) =>
upload.status === UploadStatus.Success || upload.status === UploadStatus.Loading
)
);
} finally {
sendingRef.current = false;
}
};

useImperativeHandle(imperativeHandlerRef, () => ({
handleSend,
getSendableUploads: () =>
uploads.filter(
(upload) => upload.status === UploadStatus.Success || upload.status === UploadStatus.Loading
),
}));
const handleCancel = () => onCancel(uploads);

Expand Down
Loading
Loading