From 2f419b3da0e1ffdebe32a303cf6fa4ebaaabd2a0 Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Sun, 4 Oct 2026 09:56:20 -0700 Subject: [PATCH] fix search input update loop --- src/components/forms/SearchInput.tsx | 19 ++-- src/components/forms/TextField.tsx | 10 +- .../SearchAutocompleteInput/index.tsx | 9 +- .../index.web.test.tsx | 100 ++++++++++++++++++ 4 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 src/screens/Search/components/SearchAutocompleteInput/index.web.test.tsx diff --git a/src/components/forms/SearchInput.tsx b/src/components/forms/SearchInput.tsx index e5da57a71..08532e212 100644 --- a/src/components/forms/SearchInput.tsx +++ b/src/components/forms/SearchInput.tsx @@ -1,4 +1,4 @@ -import {useEffect, useRef} from 'react' +import {useCallback, useEffect, useRef} from 'react' import {type TextInput, View} from 'react-native' import {useLingui} from '@lingui/react/macro' @@ -33,7 +33,7 @@ export function SearchInput({ }: Props) { const t = useTheme() const {t: l} = useLingui() - const showClear = value && value.length > 0 + const showClear = Boolean(value && value.length > 0) const internalRef = useRef>(null) useEffect(() => { @@ -43,6 +43,13 @@ export function SearchInput({ }) }, [hotkey]) + /* Sift stores its input in state, so ref reattachments can trigger a loop. */ + const inputRef = useCallback( + (node: React.ComponentRef | null) => + mergeRefs([internalRef, ref])(node), + [internalRef, ref], + ) + const enableSquareButtons = useEnableSquareButtons() return ( @@ -50,13 +57,7 @@ export function SearchInput({ mergeRefs([internalRef, ref])(node)} + inputRef={inputRef} label={label || l`Search`} value={value} placeholder={l`Search`} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 7da001032..49449e265 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -286,6 +286,14 @@ export function createInput(Component: typeof TextInput) { [rest.multiline], ) + const rootInputRef = ctx.inputRef + /* Keep stateful consumer refs attached while focus or value changes. */ + const inputRefs = useCallback( + (node: React.ComponentRef | null) => + mergeRefs([rootInputRef, inputRef, attachTabHandler])(node), + [rootInputRef, inputRef, attachTabHandler], + ) + if (!withinRoot) { return ( @@ -301,8 +309,6 @@ export function createInput(Component: typeof TextInput) { ) } - const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean)) - const inputRefs = mergeRefs([refs, attachTabHandler]) const multilineWebProps = IS_WEB && rest.multiline ? {'data-field-sizing-content': ''} : {} diff --git a/src/screens/Search/components/SearchAutocompleteInput/index.tsx b/src/screens/Search/components/SearchAutocompleteInput/index.tsx index 7d509cd5b..7aafa5dbe 100644 --- a/src/screens/Search/components/SearchAutocompleteInput/index.tsx +++ b/src/screens/Search/components/SearchAutocompleteInput/index.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from 'react' +import {useCallback, useRef, useState} from 'react' import {type TextInput, View} from 'react-native' import {useSift} from '@bsky.app/sift' @@ -79,6 +79,11 @@ export function SearchAutocompleteInput({ */ const {setAnchor} = sift.refs const {ref: inputAnchorRef, ...comboboxProps} = sift.targetProps + const mergedInputRef = useCallback( + (node: React.ComponentRef | null) => + mergeRefs([ref, inputAnchorRef, inputRef])(node), + [ref, inputAnchorRef, inputRef], + ) return ( { setDismissed(false) diff --git a/src/screens/Search/components/SearchAutocompleteInput/index.web.test.tsx b/src/screens/Search/components/SearchAutocompleteInput/index.web.test.tsx new file mode 100644 index 000000000..4830d5eb3 --- /dev/null +++ b/src/screens/Search/components/SearchAutocompleteInput/index.web.test.tsx @@ -0,0 +1,100 @@ +/** @jest-environment jsdom */ + +import {act, createRef} from 'react' +import {type TextInput} from 'react-native' +import {createRoot} from 'react-dom/client' + +import {SearchAutocompleteInput} from './index' + +/* The iOS Jest preset otherwise resolves the native autocomplete variant. */ +jest.mock('./index', () => jest.requireActual('./index.tsx')) +jest.mock('@lingui/react', () => ({ + useLingui: () => ({_: ({message}: {message: string}) => message}), +})) +jest.mock('react-native', () => jest.requireActual('react-native-web')) +jest.mock('#/env', () => ({IS_WEB: true, IS_NATIVE: false})) +jest.mock('#/lib/constants', () => ({HITSLOP_10: {}, HITSLOP_20: {}})) +jest.mock('#/state/events', () => ({listenFocusSearch: () => () => {}})) +jest.mock('#/state/preferences/enable-square-buttons', () => ({ + useEnableSquareButtons: () => false, +})) +jest.mock('#/alf', () => ({ + atoms: {text_md: {fontSize: 16}, p_sm: {padding: 8}}, + useTheme: () => ({scheme: 'light', atoms: {}, palette: {}}), + useAlf: () => ({fonts: {family: 'system', scaleMultiplier: 1}}), + flatten: jest.requireActual('react-native-web') + .StyleSheet.flatten, + applyFonts: () => {}, + utils: {alpha: () => undefined}, + tokens: {borderRadius: {}}, + web: (style: unknown) => style, + platform: () => ({}), +})) +jest.mock('#/components/Typography', () => ({ + Text: jest.requireActual('react-native-web') + .Text, +})) +jest.mock('#/components/Button', () => ({ + Button: () => null, + ButtonIcon: () => null, +})) +jest.mock('#/components/icons/MagnifyingGlass', () => ({ + MagnifyingGlass_Stroke2_Corner0_Rounded: () => null, +})) +jest.mock('#/components/icons/Times', () => ({ + TimesLarge_Stroke2_Corner0_Rounded: () => null, +})) +jest.mock('#/components/Autocomplete', () => ({ + useAutocomplete: () => ({items: []}), + Autocomplete: () => null, +})) + +const mockInputAttachments = jest.fn() +jest.mock('@bsky.app/sift', () => { + const {useCallback, useState} = jest.requireActual( + 'react', + ) + return { + useSift() { + const [, setInput] = useState(null) + const ref = useCallback((node: HTMLElement | null) => { + mockInputAttachments(node) + setInput(node) + }, []) + return { + refs: {}, + targetProps: {ref, role: 'combobox'}, + updatePosition: () => {}, + } + }, + } +}) + +it('keeps the Sift input attached through focus and query updates', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const ref = createRef>() + mockInputAttachments.mockClear() + try { + act(() => root.render()) + const input = container.querySelector('input')! + expect(ref.current).toBe(input) + expect(mockInputAttachments.mock.calls).toEqual([[input]]) + + act(() => input.focus()) + act(() => + root.render(), + ) + act(() => input.blur()) + expect(ref.current).toBe(input) + expect(mockInputAttachments.mock.calls).toEqual([[input]]) + + act(() => root.unmount()) + expect(ref.current).toBeNull() + expect(mockInputAttachments.mock.calls).toEqual([[input], [null]]) + } finally { + act(() => root.unmount()) + container.remove() + } +}) -- 2.51.2