diff --git a/package.json b/package.json
index 924500452..a704cf83e 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
"@tanstack/react-query-persist-client": "5.22.2",
"@wagmi/core": "2.13.3",
"calendar-link": "^2.2.0",
+ "dequal": "2.0.3",
"dns-packet": "^5.4.0",
"graphql-request": "5.1.0",
"i18next": "^21.9.1",
@@ -80,7 +81,6 @@
"immer": "^9.0.15",
"intl-segmenter-polyfill": "^0.4.4",
"iso-639-1": "^2.1.15",
- "lodash": "^4.17.21",
"markdown-to-jsx": "^7.1.7",
"next": "13.5.6",
"node-fetch": "^3.3.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f5bc20d3f..303f4cd3c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -172,6 +172,9 @@ importers:
calendar-link:
specifier: ^2.2.0
version: 2.6.0
+ dequal:
+ specifier: 2.0.3
+ version: 2.0.3
dns-packet:
specifier: ^5.4.0
version: 5.6.1
@@ -199,9 +202,6 @@ importers:
iso-639-1:
specifier: ^2.1.15
version: 2.1.15
- lodash:
- specifier: ^4.17.21
- version: 4.17.21
markdown-to-jsx:
specifier: ^7.1.7
version: 7.4.7(react@18.3.1)
@@ -8868,6 +8868,7 @@ packages:
sudo-prompt@9.2.1:
resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
summary@2.1.0:
resolution: {integrity: sha512-nMIjMrd5Z2nuB2RZCKJfFMjgS3fygbeyGk9PxPPaJR1RIcyN9yn4A63Isovzm3ZtQuEkLBVgMdPup8UeLH7aQw==}
diff --git a/src/components/@molecules/BurnFuses/BurnFusesContent.tsx b/src/components/@molecules/BurnFuses/BurnFusesContent.tsx
index 895934566..1c8cf0843 100644
--- a/src/components/@molecules/BurnFuses/BurnFusesContent.tsx
+++ b/src/components/@molecules/BurnFuses/BurnFusesContent.tsx
@@ -1,4 +1,4 @@
-import isEqual from 'lodash/isEqual'
+import { dequal as isEqual } from 'dequal'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
diff --git a/src/components/@molecules/SearchInput/SearchInput.test.tsx b/src/components/@molecules/SearchInput/SearchInput.test.tsx
index 999299a7f..041e5f308 100644
--- a/src/components/@molecules/SearchInput/SearchInput.test.tsx
+++ b/src/components/@molecules/SearchInput/SearchInput.test.tsx
@@ -1,7 +1,7 @@
import { mockFunction, render, screen, userEvent } from '@app/test-utils'
import { act, waitFor } from '@testing-library/react'
-import { describe, expect, it, Mock, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'
import { useLocalStorage } from '@app/hooks/useLocalStorage'
import { useBreakpoint } from '@app/utils/BreakpointProvider'
@@ -25,12 +25,19 @@ mockSearchResult.mockImplementation(({ searchItem }) => (
window.scroll = vi.fn() as () => void
describe('SearchInput', () => {
- mockUseLocalStorage.mockReturnValue([[]])
- window.ResizeObserver = vi.fn()
- ;(window.ResizeObserver as Mock).mockImplementation(() => ({
- observe: vi.fn(),
- disconnect: vi.fn(),
- }))
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseLocalStorage.mockReturnValue([[]])
+ window.ResizeObserver = vi.fn()
+ ;(window.ResizeObserver as Mock).mockImplementation(() => ({
+ observe: vi.fn(),
+ disconnect: vi.fn(),
+ }))
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
it('should render on desktop layouts', () => {
mockUseBreakpoint.mockReturnValue({
@@ -258,4 +265,163 @@ describe('SearchInput', () => {
await userEvent.type(screen.getByTestId('search-input-box'), '.')
await waitFor(() => expect(screen.queryByText(`Invalid name`)).toBeInTheDocument())
})
+ it('should debounce search input changes', async () => {
+ mockUseBreakpoint.mockReturnValue({
+ xs: true,
+ sm: true,
+ md: true,
+ lg: false,
+ xl: false,
+ })
+
+ render()
+ const input = screen.getByTestId('search-input-box')
+
+ await userEvent.click(input)
+
+ await waitFor(() => {
+ screen.getByTestId('search-input-results')
+ })
+
+ await userEvent.clear(input)
+ await userEvent.type(input, 'test')
+
+ await waitFor(
+ () => {
+ const results = screen.getByTestId('search-input-results')
+ expect(results).toBeInTheDocument()
+ expect(results).toHaveTextContent('test.eth')
+ },
+ {
+ timeout: 300,
+ },
+ )
+ })
+
+ it('should cancel pending debounced search on rapid typing', async () => {
+ mockUseBreakpoint.mockReturnValue({
+ xs: true,
+ sm: true,
+ md: true,
+ lg: false,
+ xl: false,
+ })
+
+ render()
+ const input = screen.getByTestId('search-input-box')
+
+ await userEvent.click(input)
+
+ await waitFor(() => {
+ screen.getByTestId('search-input-results')
+ })
+
+ await userEvent.clear(input)
+
+ await userEvent.type(input, 'te')
+ await userEvent.type(input, 'st')
+
+ await waitFor(
+ () => {
+ const results = screen.getByTestId('search-input-results')
+ expect(results).toBeInTheDocument()
+ expect(results).toHaveTextContent('test.eth')
+ },
+ {
+ timeout: 300,
+ },
+ )
+ })
+
+ it('should debounce placeholder state when typing', async () => {
+ mockSearchResult.mockClear()
+ mockUseBreakpoint.mockReturnValue({
+ xs: true,
+ sm: true,
+ md: true,
+ lg: false,
+ xl: false,
+ })
+
+ render()
+ const input = screen.getByTestId('search-input-box')
+
+ await userEvent.click(input)
+ await userEvent.clear(input)
+ await userEvent.type(input, 'test')
+
+ // Check that SearchResult is called with usingPlaceholder=true initially
+ expect(mockSearchResult).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ usingPlaceholder: true,
+ }),
+ expect.anything(),
+ )
+
+ // Wait for debounce to complete
+ await waitFor(
+ () => {
+ // Check that SearchResult is called with usingPlaceholder=false after debounce
+ expect(mockSearchResult).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ usingPlaceholder: false,
+ }),
+ expect.anything(),
+ )
+ },
+ {
+ timeout: 300,
+ },
+ )
+ })
+
+ it('should reset placeholder state on rapid typing', async () => {
+ mockSearchResult.mockClear()
+ mockUseBreakpoint.mockReturnValue({
+ xs: true,
+ sm: true,
+ md: true,
+ lg: false,
+ xl: false,
+ })
+
+ render()
+ const input = screen.getByTestId('search-input-box')
+
+ await userEvent.click(input)
+ await userEvent.clear(input)
+
+ // Type first part
+ await userEvent.type(input, 'te')
+ expect(mockSearchResult).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ usingPlaceholder: true,
+ }),
+ expect.objectContaining({}),
+ )
+
+ // Type second part immediately
+ await userEvent.type(input, 'st')
+ expect(mockSearchResult).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ usingPlaceholder: true,
+ }),
+ expect.objectContaining({}),
+ )
+
+ // Wait for debounce to complete
+ await waitFor(
+ () => {
+ expect(mockSearchResult).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ usingPlaceholder: false,
+ }),
+ expect.anything(),
+ )
+ },
+ {
+ timeout: 350,
+ },
+ )
+ })
})
diff --git a/src/components/@molecules/SearchInput/SearchInput.tsx b/src/components/@molecules/SearchInput/SearchInput.tsx
index 0d091ee4c..d1d1b26a1 100644
--- a/src/components/@molecules/SearchInput/SearchInput.tsx
+++ b/src/components/@molecules/SearchInput/SearchInput.tsx
@@ -1,5 +1,4 @@
import { QueryClient, useQueryClient } from '@tanstack/react-query'
-import debounce from 'lodash/debounce'
import {
Dispatch,
RefObject,
@@ -628,6 +627,19 @@ const useBuildDropdownItems = (inputVal: string, history: HistoryItem[]) => {
)
}
+const debounce = (func: (...args: any[]) => void, delay?: number) => {
+ let timerId: NodeJS.Timeout
+ let shouldInvoke: boolean
+
+ return (...args: any[]) => {
+ shouldInvoke = true
+
+ clearTimeout(timerId)
+
+ timerId = setTimeout(() => shouldInvoke && func(...args), delay)
+ }
+}
+
const debouncer = debounce((setFunc: () => void) => setFunc(), 250)
export const SearchInput = ({ size = 'extraLarge' }: { size?: 'medium' | 'extraLarge' }) => {
diff --git a/src/hooks/abilities/useAbilities.test.ts b/src/hooks/abilities/useAbilities.test.ts
index 28bce8edc..77a7ef9a1 100644
--- a/src/hooks/abilities/useAbilities.test.ts
+++ b/src/hooks/abilities/useAbilities.test.ts
@@ -1,6 +1,5 @@
import { mockFunction, renderHook } from '@app/test-utils'
-import * as _ from 'lodash'
import { match, P } from 'ts-pattern'
import { Address } from 'viem'
// import { writeFileSync} from 'fs'
@@ -21,6 +20,7 @@ import { useBasicName } from '../useBasicName'
import { useHasSubnames } from '../useHasSubnames'
import { useParentBasicName } from '../useParentBasicName'
import { useAbilities } from './useAbilities'
+import { dequal } from 'dequal'
vi.mock('@app/hooks/account/useAccountSafely')
vi.mock('@app/hooks/useBasicName')
@@ -128,7 +128,7 @@ describe('useAbilities', () => {
const { result } = renderHook(() => useAbilities({ name }))
- let index = group.findIndex(([, item]) => _.isEqual(item, result.current.data))
+ let index = group.findIndex(([, item]) => dequal(item, result.current.data))
if (index == -1) group.push([[type], result.current.data])
else group[index][0].push(type)