From ccc5ffcc6c16dafc43d2c89ef6ddc1486dd2cfab Mon Sep 17 00:00:00 2001 From: Bram Kaashoek Date: Wed, 10 Sep 2025 15:31:59 +0200 Subject: [PATCH] fix: focus being lost when input value is set from external state --- .changeset/custom-components-story.md | 5 + pnpm-lock.yaml | 106 --------------------- src/index.test.tsx | 58 ++++++++++++ src/index.tsx | 12 ++- src/stories/AddressSearch.stories.tsx | 4 +- src/stories/CustomInput.stories.tsx | 128 ++++++++++++++++++++++++++ src/utils/usePreserveFocus.test.tsx | 56 +++++++++++ src/utils/usePreserveFocus.ts | 34 +++++++ 8 files changed, 291 insertions(+), 112 deletions(-) create mode 100644 .changeset/custom-components-story.md create mode 100644 src/stories/CustomInput.stories.tsx create mode 100644 src/utils/usePreserveFocus.test.tsx create mode 100644 src/utils/usePreserveFocus.ts diff --git a/.changeset/custom-components-story.md b/.changeset/custom-components-story.md new file mode 100644 index 0000000..54159bd --- /dev/null +++ b/.changeset/custom-components-story.md @@ -0,0 +1,5 @@ +--- +"react-loqate": patch +--- + +- Fix focus being lost when input value is set from external state \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf88664..e1cfc24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@mui/base': - specifier: 5.0.0-alpha.114 - version: 5.0.0-alpha.114(@types/react@18.0.27)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) clsx: specifier: 1.2.1 version: 1.2.1 @@ -759,12 +756,6 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@emotion/is-prop-valid@1.4.0': - resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} - - '@emotion/memoize@0.9.0': - resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} - '@emotion/use-insertion-effect-with-fallbacks@1.2.0': resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} peerDependencies: @@ -1303,44 +1294,6 @@ packages: resolution: {integrity: sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==} engines: {node: '>=18'} - '@mui/base@5.0.0-alpha.114': - resolution: {integrity: sha512-ZpsG2I+zTOAnVTj3Un7TxD2zKRA2OhEPGMcWs/9ylPlS6VuGQSXowPooZiqarjT7TZ0+1bOe8titk/t8dLFiGw==} - engines: {node: '>=12.0.0'} - deprecated: This package has been replaced by @base-ui-components/react - peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@mui/types@7.2.24': - resolution: {integrity: sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==} - peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@mui/types@7.4.6': - resolution: {integrity: sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==} - peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - '@mui/utils@5.17.1': - resolution: {integrity: sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==} - engines: {node: '>=12.0.0'} - peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@ndelangen/get-tarball@3.0.9': resolution: {integrity: sha512-9JKTEik4vq+yGosHYhZ1tiH/3WpUS0Nh0kej4Agndhox8pAdWhEx5knFVRcb/ya9knCRCs1rPxNrSXTDdfVqpA==} @@ -1369,9 +1322,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -2482,10 +2432,6 @@ packages: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -4567,9 +4513,6 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.1.1: - resolution: {integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==} - react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -6510,12 +6453,6 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@emotion/is-prop-valid@1.4.0': - dependencies: - '@emotion/memoize': 0.9.0 - - '@emotion/memoize@0.9.0': {} - '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.2.0)': dependencies: react: 18.2.0 @@ -6877,43 +6814,6 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@mui/base@5.0.0-alpha.114(@types/react@18.0.27)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': - dependencies: - '@babel/runtime': 7.28.4 - '@emotion/is-prop-valid': 1.4.0 - '@mui/types': 7.4.6(@types/react@18.0.27) - '@mui/utils': 5.17.1(@types/react@18.0.27)(react@18.2.0) - '@popperjs/core': 2.11.8 - clsx: 1.2.1 - prop-types: 15.8.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-is: 18.3.1 - optionalDependencies: - '@types/react': 18.0.27 - - '@mui/types@7.2.24(@types/react@18.0.27)': - optionalDependencies: - '@types/react': 18.0.27 - - '@mui/types@7.4.6(@types/react@18.0.27)': - dependencies: - '@babel/runtime': 7.28.4 - optionalDependencies: - '@types/react': 18.0.27 - - '@mui/utils@5.17.1(@types/react@18.0.27)(react@18.2.0)': - dependencies: - '@babel/runtime': 7.28.4 - '@mui/types': 7.2.24(@types/react@18.0.27) - '@types/prop-types': 15.7.15 - clsx: 2.1.1 - prop-types: 15.8.1 - react: 18.2.0 - react-is: 19.1.1 - optionalDependencies: - '@types/react': 18.0.27 - '@ndelangen/get-tarball@3.0.9': dependencies: gunzip-maybe: 1.4.2 @@ -6944,8 +6844,6 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@popperjs/core@2.11.8': {} - '@radix-ui/react-compose-refs@1.1.2(@types/react@18.0.27)(react@18.2.0)': dependencies: react: 18.2.0 @@ -8532,8 +8430,6 @@ snapshots: clsx@1.2.1: {} - clsx@2.1.1: {} - color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -10982,8 +10878,6 @@ snapshots: react-is@18.3.1: {} - react-is@19.1.1: {} - react@18.2.0: dependencies: loose-envify: 1.4.0 diff --git a/src/index.test.tsx b/src/index.test.tsx index 5c9ef64..50af00c 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -377,3 +377,61 @@ it('accepts origin and bias options', async () => { text: 'a', }); }); + +it('preserves focus when using custom Input with external state management', async () => { + function TestComponent() { + const [, setExternalState] = React.useState(''); + + return ( + {}} + components={{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Input: React.forwardRef( + ({ value, onChange, ...rest }, ref) => { + React.useEffect(() => { + setExternalState(value || ''); + }, [value]); + + return ( + { + onChange?.(event); + setExternalState(event.target.value); + }} + {...rest} + data-testid="external-state-input" + /> + ); + } + ), + }} + /> + ); + } + + render(); + + const input = screen.getByTestId('external-state-input') as HTMLInputElement; + input.focus(); + expect(document.activeElement).toBe(input); + + fireEvent.change(input, { target: { value: 'a' } }); + + await screen.findByRole('list'); + + await waitFor( + () => { + const currentInput = screen.getByTestId('external-state-input'); + expect(document.activeElement).toBe(currentInput); + }, + { timeout: 1000 } + ); + + const suggestions = screen.getAllByRole('listitem'); + expect(suggestions.length).toBeGreaterThan(0); +}); diff --git a/src/index.tsx b/src/index.tsx index 3d04d3e..fff8aa0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,8 @@ import clsx from 'clsx'; import React, { - ChangeEvent, - ComponentType, + type ChangeEvent, + type ComponentType, useMemo, - useRef, useState, } from 'react'; import DefaultInput from './components/DefaultInput'; @@ -13,6 +12,7 @@ import ClickAwayListener from './utils/ClickAwayListener'; import Loqate from './utils/Loqate'; import Portal from './utils/Portal'; import useDebounceEffect from './utils/useDebounceEffect'; +import usePreserveFocus from './utils/usePreserveFocus'; export interface Props { locale: string; @@ -141,7 +141,9 @@ function AddressSearch(props: Props): JSX.Element { const [value, setValue] = useState(''); const [, setError] = useState(null); - const anchorRef = useRef(null); + const { elementRef: anchorRef, preserveFocus } = + usePreserveFocus(); + const rect = anchorRef.current?.getBoundingClientRect(); async function find(text: string, containerId?: string): Promise { @@ -200,6 +202,8 @@ function AddressSearch(props: Props): JSX.Element { }: ChangeEvent): Promise { const { value: search } = target; + // Custom Input components with external state management can cause DOM reconciliation issues that lose focus + preserveFocus(); setValue(search); } diff --git a/src/stories/AddressSearch.stories.tsx b/src/stories/AddressSearch.stories.tsx index 75a8bb6..95ac9a5 100644 --- a/src/stories/AddressSearch.stories.tsx +++ b/src/stories/AddressSearch.stories.tsx @@ -1,6 +1,6 @@ -import { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import React, { useState } from 'react'; -import AddressSearch, { Address, Props } from '..'; +import AddressSearch, { type Address, type Props } from '..'; const meta: Meta = { title: 'Loqate Address Search', diff --git a/src/stories/CustomInput.stories.tsx b/src/stories/CustomInput.stories.tsx new file mode 100644 index 0000000..a0bddb7 --- /dev/null +++ b/src/stories/CustomInput.stories.tsx @@ -0,0 +1,128 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React, { forwardRef, useState } from 'react'; +import AddressSearch, { type Address, type Item } from '..'; + +const meta: Meta = { + title: 'Custom Components', + component: AddressSearch, +}; + +export default meta; + +type Story = StoryObj; + +const CustomInput = forwardRef>( + ({ value, onChange, ...rest }, ref) => { + return ( + + ); + } +); + +const CustomList = forwardRef>( + (props, ref) => { + return ( +
    + ); + } +); + +const CustomListItem = forwardRef< + HTMLLIElement, + React.ComponentProps<'li'> & { suggestion: Item; value?: string } +>((props, ref) => { + const { + suggestion, + onClick, + onKeyDown, + onMouseEnter, + onMouseLeave, + style, + ...rest + } = props; + return ( +
  • { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(e as unknown as React.MouseEvent); + } + onKeyDown?.(e); + }} + style={{ + padding: '12px 16px', + borderBottom: '1px solid #e2e8f0', + cursor: 'pointer', + backgroundColor: 'white', + ...style, + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = '#eff6ff'; + onMouseEnter?.(e); + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'white'; + onMouseLeave?.(e); + }} + > + {suggestion.Text} {suggestion.Description} +
  • + ); +}); + +const Template = (props: React.ComponentProps) => { + const [result, setResult] = useState
    (); + + return ( + <> + +
    {JSON.stringify(result, null, 2)}
    + + ); +}; + +export const CustomComponents: Story = { + args: { + // @ts-expect-error: env does exist + // We need to prefix with STORYBOOK, otherwise Storybook will ignore the variable + apiKey: import.meta.env.STORYBOOK_API_KEY ?? '', + countries: ['US', 'GB'], + locale: 'en_US', + inline: true, + components: { + Input: CustomInput, + List: CustomList, + ListItem: CustomListItem, + }, + }, + render: Template, +}; diff --git a/src/utils/usePreserveFocus.test.tsx b/src/utils/usePreserveFocus.test.tsx new file mode 100644 index 0000000..e672853 --- /dev/null +++ b/src/utils/usePreserveFocus.test.tsx @@ -0,0 +1,56 @@ +import { act, render, waitFor } from '@testing-library/react'; +import React, { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import usePreserveFocus from './usePreserveFocus'; + +describe('usePreserveFocus', () => { + it('calls focus() asynchronously via Promise.resolve when element is focused', async () => { + const mockFocus = vi.fn(); + + function TestComponent() { + const [count, setCount] = useState(0); + const { elementRef, preserveFocus } = + usePreserveFocus(); + + React.useLayoutEffect(() => { + if (elementRef.current) { + elementRef.current.focus = mockFocus; + Object.defineProperty(document, 'activeElement', { + value: elementRef.current, + writable: true, + configurable: true, + }); + } + }); + + const handleClick = () => { + preserveFocus(); + setCount(count + 1); + }; + + return ( +
    + + +
    + ); + } + + const { getByTestId } = render(); + const button = getByTestId('trigger-action'); + + act(() => { + button.click(); + }); + + await waitFor(() => { + expect(mockFocus).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/utils/usePreserveFocus.ts b/src/utils/usePreserveFocus.ts new file mode 100644 index 0000000..fa6c5a8 --- /dev/null +++ b/src/utils/usePreserveFocus.ts @@ -0,0 +1,34 @@ +import { useLayoutEffect, useRef, useState } from 'react'; + +function usePreserveFocus(): { + elementRef: React.RefObject; + preserveFocus: () => void; +} { + const elementRef = useRef(null); + const [shouldRestoreFocus, setShouldRestoreFocus] = useState(false); + + const preserveFocus = () => { + if (elementRef.current && document.activeElement === elementRef.current) { + setShouldRestoreFocus(true); + } + }; + + useLayoutEffect(() => { + if (shouldRestoreFocus && elementRef.current) { + // Use a microtask to ensure this runs after any DOM updates + Promise.resolve().then(() => { + if (elementRef.current) { + elementRef.current.focus(); + } + }); + setShouldRestoreFocus(false); + } + }); + + return { + elementRef, + preserveFocus, + }; +} + +export default usePreserveFocus;