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
38 changes: 37 additions & 1 deletion src/components/mui/__tests__/search-input.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
* */

import React from "react";
import { render, screen } from "@testing-library/react";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import SearchInput from "../search-input";
import { DEBOUNCE_WAIT } from "../../../utils/constants";

describe("SearchInput", () => {
test("renders with custom placeholder", () => {
Expand Down Expand Up @@ -58,4 +59,39 @@ describe("SearchInput", () => {
rerender(<SearchInput term="new" onSearch={jest.fn()} />);
expect(screen.getByDisplayValue("new")).toBeInTheDocument();
});

describe("debounced prop", () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

test("calls onSearch after debounce delay when debounced is true", async () => {
const onSearch = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<SearchInput term="" onSearch={onSearch} debounced />);
const input = screen.getByPlaceholderText("Search...");
await user.type(input, "something");
expect(onSearch).not.toHaveBeenCalled();
act(() => jest.advanceTimersByTime(DEBOUNCE_WAIT));
expect(onSearch).toHaveBeenCalledWith("something");
});

test("does not call onSearch on Enter when debounced is true", async () => {
const onSearch = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<SearchInput term="" onSearch={onSearch} debounced />);
const input = screen.getByPlaceholderText("Search...");
await user.type(input, "something{Enter}");
expect(onSearch).not.toHaveBeenCalled();
});

test("does not call onSearch on typing without Enter when not debounced", async () => {
const onSearch = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<SearchInput term="" onSearch={onSearch} />);
const input = screen.getByPlaceholderText("Search...");
await user.type(input, "something");
act(() => jest.runAllTimers());
expect(onSearch).not.toHaveBeenCalled();
});
});
});
60 changes: 37 additions & 23 deletions src/components/mui/search-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,53 +11,67 @@
* limitations under the License.
* */

import React, { useEffect, useState } from "react";
import { TextField, IconButton } from "@mui/material";
import React, { useEffect, useState, useMemo } from "react";
import { TextField, IconButton, InputAdornment } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import ClearIcon from "@mui/icons-material/Clear";
import { debounce } from "lodash";
import { DEBOUNCE_WAIT } from "../../utils/constants";

const SearchInput = ({ term, onSearch, placeholder = "Search..." }) => {
const SearchInput = ({ term, onSearch, placeholder = "Search...", debounced }) => {
const [searchTerm, setSearchTerm] = useState(term);

useEffect(() => {
setSearchTerm(term || "");
}, [term]);

const handleSearch = (ev) => {
if (ev.key === "Enter") {
onSearch(searchTerm);
}
};

const handleClear = () => {
onSearchDebounced?.cancel();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tomrndom handleClear references onSearchDebounced before it is declared

setSearchTerm("");
onSearch("");
};

const onSearchDebounced = useMemo(
Copy link
Copy Markdown
Collaborator

@smarcet smarcet Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tomrndom
debounce silently broken when parent passes inline onSearch.
useMemo recomputes whenever onSearch changes identity. The cleanup effect then cancel()s the previous debounced instance. A debounced function cancelled before its timer fires loses its pending invocation forever. That is the entire bug.

Why the existing tests miss it

  render(<SearchInput term="" onSearch={onSearch} debounced />);

In a test, onSearch is a stable jest.fn() reference and there are no parent re-renders. So useMemo never recomputes, the debounced function is never cancelled, and the timer fires normally. The test passes. Production breaks.

Proposed fix

Replace the current memoization with a ref-stable wrapper so the debounced function is created once per debounced value and is not destroyed by parent re-renders, while still calling the latest
onSearch.

() => debounced ? debounce((value) => onSearch(value), DEBOUNCE_WAIT) : null,
[onSearch, debounced]
);

useEffect(() => () => onSearchDebounced?.cancel(), [onSearchDebounced]);

const handleChange = (value) => {
setSearchTerm(value);
if (debounced) onSearchDebounced(value);
};

const handleKeyDown = (ev) => {
if (!debounced && ev.key === "Enter") {
onSearch(searchTerm);
}
};

return (
<TextField
variant="outlined"
value={searchTerm}
placeholder={placeholder}
slotProps={{
input: {
endAdornment: term ? (
<IconButton
size="small"
onClick={handleClear}
sx={{ position: "absolute", right: 0 }}
>
<ClearIcon sx={{ color: "#0000008F" }} />
</IconButton>
) : (
<SearchIcon
sx={{ mr: 1, color: "#0000008F", position: "absolute", right: 0 }}
/>
startAdornment: (
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tomrndom
Visual/contract change for ALL existing consumers (regardless of debounced)
keep the legacy single-icon layout when debounced is not set

<InputAdornment position="start">
<SearchIcon sx={{ color: "#0000008F" }} />
</InputAdornment>
),
endAdornment: term && (
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tomrndom
Clear button hidden during debounced typing
in debounced mode the parent doesn't learn about the new value for 500ms. The user types, there is text in the box, but no clear affordance until the debounce flushes , the affordance the icon refactor was supposed to improve. Pre-existing pattern, but debouncing amplifies it materiall.
Suggested fix
Use the local searchTerm (or searchTerm || term) to drive the clear-button visibility.

<InputAdornment position="end">
<IconButton size="small" onClick={handleClear}>
<ClearIcon fontSize="small" sx={{ color: "#0000008F" }} />
</IconButton>
</InputAdornment>
)
}
}}
onChange={(event) => setSearchTerm(event.target.value)}
onKeyDown={handleSearch}
onChange={(ev) => handleChange(ev.target.value)}
onKeyDown={handleKeyDown}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
Expand Down
Loading