feat(navigator): drive search via TanStack Query + setTab/setFilter UI store

NavigatorView reads searchResult/isFetching from useNavigatorSearch
instead of useNavigatorData/useNavigatorUiState. Tab clicks call
setTab(code) on the UI store, which atomically updates the query key
and triggers refetch. The 4 lifecycle useEffect blocks driving the
old imperative flow (needsSearch / reloadCurrentSearch / markReady)
are removed — the query handles all of it now.

NavigatorSearchView has a debounced (300ms) onChange -> setFilter
that drives the same query refetch. Explicit submit (Enter / button)
skips the debounce and calls setFilter immediately.

linkTracker case 'search' now setTab + setFilter + show — no more
pendingSearch ref.

useNavigatorSearch.test.tsx: cast constructors as any to satisfy tsgo
against real renderer types while keeping runtime stubs no-arg-safe.

yarn typecheck / test / lint:hooks all clean (only pre-existing
floorplan environmental failures).
This commit is contained in:
simoleo89
2026-05-27 19:25:30 +02:00
parent ee3736474d
commit 26772f7073
3 changed files with 66 additions and 71 deletions
+10 -29
View File
@@ -8,7 +8,7 @@ import randomRoomImg from '../../assets/images/navigator/random_room.png';
import promoteRoomImg from '../../assets/images/navigator/promote_room.png';
import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api';
import { Flex, Text } from '../../common';
import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks';
import { useNavigatorData, useNavigatorSearch, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks';
import { NavigatorDoorStateView } from './views/NavigatorDoorStateView';
import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView';
import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView';
@@ -20,10 +20,9 @@ import { NavigatorSearchView } from './views/search/NavigatorSearchView';
export const NavigatorView: FC<{}> = props =>
{
const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData();
const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState();
const { sendSearch, reloadCurrentSearch } = useNavigatorActions();
const pendingSearch = useRef<{ value: string, code: string }>(null);
const { topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData();
const { searchResult, isFetching } = useNavigatorSearch();
const { isVisible, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, needsInit } = useNavigatorUiState();
const elementRef = useRef<HTMLDivElement>(null);
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event =>
@@ -72,7 +71,10 @@ export const NavigatorView: FC<{}> = props =>
return;
case 'search':
if(parts.length <= 2) return;
pendingSearch.current = { value: parts.length > 3 ? parts[3] : '', code: parts[2] };
const code = parts[2];
const value = parts.length > 3 ? parts[3] : '';
store.setTab(code);
if(value) store.setFilter(value);
store.show();
return;
}
@@ -89,27 +91,6 @@ export const NavigatorView: FC<{}> = props =>
if(elementRef.current) elementRef.current.scrollTop = 0;
}, [ searchResult ]);
useEffect(() =>
{
if(!isVisible || !isReady || !needsSearch) return;
if(pendingSearch.current)
{
sendSearch(pendingSearch.current.value, pendingSearch.current.code);
pendingSearch.current = null;
}
else
{
reloadCurrentSearch();
}
useNavigatorUiStore.getState().consumeSearchRequest();
}, [ isVisible, isReady, needsSearch, sendSearch, reloadCurrentSearch ]);
useEffect(() =>
{
if(isReady || !topLevelContext) return;
useNavigatorUiStore.getState().markReady();
}, [ isReady, topLevelContext ]);
useEffect(() =>
{
if(!isVisible || !needsInit) return;
@@ -142,7 +123,7 @@ export const NavigatorView: FC<{}> = props =>
<NitroCard.TabItem
key={ index }
isActive={ topLevelContext === context && !isCreatorOpen }
onClick={ () => sendSearch('', context.code) }>
onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }>
{ LocalizeText('navigator.toplevelview.' + context.code) }
</NitroCard.TabItem>) }
<NitroCard.TabItem
@@ -151,7 +132,7 @@ export const NavigatorView: FC<{}> = props =>
<FaPlus className="fa-icon" />
</NitroCard.TabItem>
</NitroCard.Tabs>
<NitroCard.Content isLoading={ isLoading }>
<NitroCard.Content isLoading={ isFetching }>
{ !isCreatorOpen &&
<div className="flex h-full overflow-hidden gap-2">
{ isOpenSavesSearches &&
@@ -2,35 +2,17 @@ import { FC, KeyboardEvent, useEffect, useState } from 'react';
import { FaSearch } from 'react-icons/fa';
import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api';
import { Button } from '../../../../common';
import { useNavigatorActions, useNavigatorData } from '../../../../hooks';
import { useNavigatorData, useNavigatorSearch, useNavigatorUiStore } from '../../../../hooks';
export const NavigatorSearchView: FC<{}> = props =>
{
const [ searchFilterIndex, setSearchFilterIndex ] = useState(0);
const [ searchValue, setSearchValue ] = useState('');
const { topLevelContext, searchResult } = useNavigatorData();
const { sendSearch } = useNavigatorActions();
const processSearch = () =>
{
if(!topLevelContext) return;
let searchFilter = SearchFilterOptions[searchFilterIndex];
if(!searchFilter) searchFilter = SearchFilterOptions[0];
const searchQuery = ((searchFilter.query ? (searchFilter.query + ':') : '') + searchValue);
sendSearch((searchQuery || ''), topLevelContext.code);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) =>
{
if(event.key !== 'Enter') return;
processSearch();
};
const [ inputText, setInputText ] = useState('');
const { topLevelContext } = useNavigatorData();
const { searchResult } = useNavigatorSearch();
// Sync the input text display when a server result arrives (e.g. on tab switch
// or deep-link navigation that sets the filter through the store directly).
useEffect(() =>
{
if(!searchResult) return;
@@ -55,9 +37,39 @@ export const NavigatorSearchView: FC<{}> = props =>
if(!filter) filter = SearchFilterOptions[0];
setSearchFilterIndex(SearchFilterOptions.findIndex(option => (option === filter)));
setSearchValue(value);
setInputText(value);
}, [ searchResult ]);
// Debounced filter — 300ms after the user stops typing, push to the store
// which updates the query key and triggers a refetch.
useEffect(() =>
{
const timer = setTimeout(() =>
{
const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0];
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText;
useNavigatorUiStore.getState().setFilter(searchQuery);
}, 300);
return () => clearTimeout(timer);
}, [ inputText, searchFilterIndex ]);
const processSearch = () =>
{
if(!topLevelContext) return;
// Immediate submit — skip the debounce timer
const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0];
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText;
useNavigatorUiStore.getState().setFilter(searchQuery);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) =>
{
if(event.key !== 'Enter') return;
processSearch();
};
return (
<div className="flex w-full gap-1">
<div className="flex shrink-0">
@@ -69,7 +81,7 @@ export const NavigatorSearchView: FC<{}> = props =>
</select>
</div>
<div className="flex w-full gap-1">
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ searchValue } onChange={ event => setSearchValue(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ inputText } onChange={ event => setInputText(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
<Button variant="primary" onClick={ processSearch }>
<FaSearch className="fa-icon" />
</Button>