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)