Skip to content

Commit

Permalink
fix(core): update debounce behavior on onSearch in useSelect (#6125)
Browse files Browse the repository at this point in the history
Co-authored-by: Ali Emir Şen <[email protected]>
  • Loading branch information
Dominic-Preap and aliemir authored Jul 25, 2024
1 parent 466a68c commit 61aa346
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 32 deletions.
9 changes: 9 additions & 0 deletions .changeset/thin-adults-complain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@refinedev/core": minor
---

fix: update debounce behavior on `onSearch` in `useSelect`

Now debounce behavior is working correctly on `onSearch` in `useSelect` when using inside `Controller` of react-hook-form.

Resolves [#6096](https://github.com/refinedev/refine/issues/6096)
91 changes: 81 additions & 10 deletions packages/core/src/hooks/useSelect/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ describe("useSelect Hook", () => {
it("should onSearchFromProp work as expected", async () => {
const getListMock = jest.fn(() => Promise.resolve({ data: [], total: 0 }));

const { result } = renderHook(
const { result, rerender } = renderHook(
() =>
useSelect({
resource: "posts",
Expand All @@ -342,21 +342,92 @@ describe("useSelect Hook", () => {
},
);

const { onSearch } = result.current;
await waitFor(() => expect(getListMock).toHaveBeenCalledTimes(1));

onSearch("1");
await waitFor(() => {
expect(getListMock).toBeCalledTimes(2);
result.current.onSearch("1");
// force custom `onSearch` to reinitialize, this should not change `current.onSearch`
rerender();
result.current.onSearch("2");
// force custom `onSearch` to reinitialize, this should not change `current.onSearch`
rerender();
result.current.onSearch("3");

await waitFor(() => expect(getListMock).toHaveBeenCalledTimes(2));

result.current.onSearch("");

await waitFor(() => expect(getListMock).toHaveBeenCalledTimes(3));

await waitFor(() =>
expect(result.current.queryResult.isSuccess).toBeTruthy(),
);
});

it("should respond to onSearch prop changes without breaking the debounce interval", async () => {
const getListMock = jest.fn(() => Promise.resolve({ data: [], total: 0 }));
const initialOnSearch = jest.fn().mockImplementation((v) => [
{
field: "title",
operator: "contains",
value: v,
},
]);
const secondOnSearch = jest.fn().mockImplementation((v) => [
{
field: "title",
operator: "contains",
value: v,
},
]);

const { result, rerender } = renderHook<
Parameters<typeof useSelect>[0],
ReturnType<typeof useSelect>
>((props) => useSelect(props), {
initialProps: {
resource: "posts",
onSearch: initialOnSearch,
},
wrapper: TestWrapper({
dataProvider: {
default: {
...MockJSONServer.default!,
getList: getListMock,
},
},
resources: [{ name: "posts" }],
}) as any,
});

onSearch("");
await waitFor(() => {
expect(getListMock).toBeCalledTimes(3);
await waitFor(() => expect(getListMock).toHaveBeenCalledTimes(1));

result.current.onSearch("1");

rerender({
resource: "posts",
onSearch: secondOnSearch,
});

await waitFor(() => {
expect(result.current.queryResult.isSuccess).toBeTruthy();
result.current.onSearch("2");

await waitFor(() => expect(getListMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(initialOnSearch).toHaveBeenCalledTimes(0));
await waitFor(() => expect(secondOnSearch).toHaveBeenCalledTimes(1));

result.current.onSearch("");

rerender({
resource: "posts",
onSearch: initialOnSearch,
});

await waitFor(() => expect(getListMock).toHaveBeenCalledTimes(3));
await waitFor(() => expect(initialOnSearch).toHaveBeenCalledTimes(1));
await waitFor(() => expect(secondOnSearch).toHaveBeenCalledTimes(1));

await waitFor(() =>
expect(result.current.queryResult.isSuccess).toBeTruthy(),
);
});

it("should invoke queryOptions methods successfully", async () => {
Expand Down
56 changes: 34 additions & 22 deletions packages/core/src/hooks/useSelect/index.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 {
QueryObserverResult,
Expand Down Expand Up @@ -343,26 +343,6 @@ export const useSelect = <
dataProviderName,
});

const onSearch = (value: string) => {
if (onSearchFromProp) {
setSearch(onSearchFromProp(value));
return;
}

if (!value) {
setSearch([]);
return;
}

setSearch([
{
field: searchField,
operator: "contains",
value,
},
]);
};

const { elapsedTime } = useLoadingOvertime({
isLoading: queryResult.isFetching || defaultValueQueryResult.isFetching,
interval: overtimeOptions?.interval,
Expand All @@ -380,11 +360,43 @@ export const useSelect = <
[options, selectedOptions],
);

/**
* To avoid any changes in the `onSearch` callback,
* We're storing `onSearchFromProp` in a ref and accessing it in the `onSearch` callback.
*/
const onSearchFromPropRef = useRef(onSearchFromProp);

const onSearch = useMemo(() => {
return debounce((value: string) => {
if (onSearchFromPropRef.current) {
setSearch(onSearchFromPropRef.current(value));
return;
}

if (!value) {
setSearch([]);
return;
}

setSearch([
{
field: searchField,
operator: "contains",
value,
},
]);
}, debounceValue);
}, [searchField, debounceValue]);

useEffect(() => {
onSearchFromPropRef.current = onSearchFromProp;
}, [onSearchFromProp]);

return {
queryResult,
defaultValueQueryResult,
options: combinedOptions,
onSearch: debounce(onSearch, debounceValue),
onSearch,
overtime: { elapsedTime },
};
};

0 comments on commit 61aa346

Please sign in to comment.