diff --git a/docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md b/docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md new file mode 100644 index 0000000..b6f3965 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md @@ -0,0 +1,243 @@ +# Navigator Modernization — P2: TanStack Query for Search + +**Branch**: `feat/navigator-p2-query` (forked from `feat/navigator-modernization` @ `1148c0a6`) +**Date**: 2026-05-27 +**Depends on**: P1 (hook split) — merged or pending merge + +## 1. Goal + +Migrate Navigator's search request/response from event-driven imperative state to TanStack Query. The user gets: +- **Instant tab switching** when the same tab/filter was visited before in the session (cache hit, no round-trip) +- **Stale-while-revalidate** on revisit (shows cached results while refetching in background) +- **Server-driven refresh** via `useNitroEventInvalidator` on `FlatCreatedEvent` and `RoomSettingsUpdatedEvent` (and possibly `FavouriteChangedEvent` if the active tab is `favorites_view`) +- **Single source of truth** for `isFetching` — no separate `isLoading` flag to manage + +## 2. Architecture changes + +### 2.1 New file: `src/hooks/navigator/useNavigatorSearch.ts` + +The query hook. Reads `currentTabCode` + `currentFilter` from `navigatorUiStore`, fires `NavigatorSearchComposer`, waits for `NavigatorSearchEvent`, returns the parsed `NavigatorSearchResultSet`. + +```ts +import { NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; +import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorSearch = () => +{ + const tabCode = useNavigatorUiStore(s => s.currentTabCode); + const filter = useNavigatorUiStore(s => s.currentFilter); + + const query = useNitroQuery({ + key: [ 'navigator', 'search', tabCode, filter ], + request: () => new NavigatorSearchComposer(tabCode, filter), + parser: NavigatorSearchEvent, + select: e => e.getParser()?.result ?? null, + accept: e => { + const result = e.getParser()?.result; + // accept-filter: only this query's matching tab code + return !!result && result.code === tabCode; + }, + enabled: !!tabCode, + staleTime: 30_000 // re-fetch after 30s of staleness on revisit + }); + + useNitroEventInvalidator(FlatCreatedEvent, [ 'navigator', 'search' ]); + useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ 'navigator', 'search' ]); + + return { + searchResult: query.data, + isFetching: query.isFetching, + refetch: query.refetch + }; +}; +``` + +### 2.2 `navigatorUiStore.ts` additions + +Add 2 new state fields + 2 new actions: + +```ts +type NavigatorUiState = { + // ...existing 9 flags... + currentTabCode: string; // '' until NavigatorMetadataEvent arrives, then first top-level context code + currentFilter: string; // '' by default +}; + +type NavigatorUiActions = { + // ...existing 15 actions... + setTab(code: string): void; // also clears currentFilter + setFilter(value: string): void; +}; +``` + +`setTab(code)` resets `currentFilter` to `''` because switching tabs starts a fresh search. `setFilter` updates only the filter — the user is typing in the same tab. + +### 2.3 `useNavigatorStore.ts` — remove search state ownership + +Remove: +- `useState(null)` for `searchResult` +- `useMessageEvent` listener +- `sendSearch` and `reloadCurrentSearch` actions +- The `useNavigatorUiStore.getState().setLoading(...)` calls (no longer needed) +- The `topLevelContextRef` and `searchResultRef` (only consumed inside `reloadCurrentSearch`) + +Keep: +- `topLevelContext` + `topLevelContexts` (these still come from `NavigatorMetadataEvent` and drive the tab list) +- The `NavigatorMetadataEvent` listener — but it now ALSO calls `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` on first arrival, to seed the initial tab. The query then activates because `currentTabCode` becomes non-empty (`enabled: !!tabCode`). + +### 2.4 `useNavigatorData.ts` — remove `searchResult` from return shape + +`useNavigatorData()` no longer returns `searchResult`. Consumers that need it call `useNavigatorSearch()` instead. + +### 2.5 `useNavigatorActions.ts` — empty or removed + +Both `sendSearch` and `reloadCurrentSearch` are gone. Either: +- Remove the file + the export — consumers use `useNavigatorUiStore.getState().setTab(...)` / `setFilter(...)` directly +- Or keep the file as an empty re-export for forward compat. (Decision: REMOVE — minimize dead API). + +### 2.6 `useNavigatorUiState.ts` — add the 2 new flags + +Add `currentTabCode` and `currentFilter` to the per-key selector list and return shape. + +### 2.7 `useNavigatorSearch.test.tsx` — new + +Test cases: +- Initial mount with empty tabCode → query is disabled, no request fired +- After `setTab('public')` → query fires NavigatorSearchComposer('public', '') +- After `setFilter('cocco')` → query fires NavigatorSearchComposer('public', 'cocco') +- After `setTab('events')` → currentFilter resets to '', query fires NavigatorSearchComposer('events', '') +- `FlatCreatedEvent` invalidates the cache → refetch +- `RoomSettingsUpdatedEvent` invalidates the cache → refetch +- `NavigatorSearchEvent` with WRONG tabCode (e.g. server pushes an unsolicited result) is REJECTED by `accept` filter — does NOT update query data + +### 2.8 `NavigatorView.tsx` — major rewrite + +Replace: +- `useNavigatorActions` import → gone +- `useNavigatorData` no longer destructures `searchResult` — get it from `useNavigatorSearch` instead +- 4 `useEffect` blocks driving the imperative search flow (`needsSearch`, `needsInit` lifecycle, `reloadCurrentSearch` orchestration) → gone +- Tab `onClick={ () => sendSearch('', context.code) }` → `onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }` +- `isLoading` from `useNavigatorUiState()` → `isFetching` from `useNavigatorSearch()` query +- `NavigatorInitComposer` initial dispatch on first `isVisible` — KEEP (still need it to get `topLevelContexts` populated) +- `pendingSearch` ref — gone (linkTracker `case 'search'` directly does `setTab(code); setFilter(value)`) + +Major simplification: the file shrinks ~30 lines. + +### 2.9 `NavigatorSearchView.tsx` — drive setFilter + +Read the file. The component currently exposes a search input that, on enter or button click, calls `sendSearch(value, currentTabCode)`. After P2 it: +- Reads `currentFilter` from `useNavigatorUiState` +- onChange → `useNavigatorUiStore.getState().setFilter(value)` (debounced 300ms) +- No more `sendSearch` reference + +Debounce: use a local `useState` for the input text + a `useEffect` that calls `setFilter(text)` 300ms after the last keystroke. Standard pattern. + +## 3. Backward-compat considerations + +- `useNavigatorActions.sendSearch` and `useNavigatorActions.reloadCurrentSearch` are REMOVED. No consumer outside Navigator depends on them — verified by grepping the previous P1 consumer migration. +- `useNavigatorData.searchResult` is REMOVED. Only `NavigatorView` reads it currently — easy to migrate. +- The `useNavigatorActions` filter itself becomes empty — consider whether to delete the file entirely. **Decision: delete the file** to minimize the API surface. Tasks 5-8 of P1 migrated `NavigatorSearchView` to use `useNavigatorActions` — that's the only consumer; it migrates to `useNavigatorUiStore` directly. + +## 4. Out of scope (each gets its own future spec) + +- Reactive favourite stars on cards (P3) +- Visual rework: empty states, virtualization, chip-based UI (P4) +- Form Action on search input (P6) + +## 5. Acceptance criteria + +P2 is complete when: + +1. `src/hooks/navigator/useNavigatorSearch.ts` exists and exports `useNavigatorSearch` +2. `useNavigatorStore.ts` no longer owns `searchResult`, no longer subscribes to `NavigatorSearchEvent`, no longer exposes `sendSearch` or `reloadCurrentSearch` +3. `navigatorUiStore.ts` has `currentTabCode` + `currentFilter` state and `setTab` + `setFilter` actions +4. `useNavigatorActions.ts` is deleted; barrel no longer exports `useNavigatorActions` +5. `useNavigatorData.ts` no longer returns `searchResult` +6. `useNavigatorUiState.ts` returns `currentTabCode` + `currentFilter` +7. `NavigatorView.tsx` reads `searchResult` from `useNavigatorSearch()`, uses `isFetching` for the loading flag, calls `setTab` on tab clicks +8. `NavigatorSearchView.tsx` debounces `setFilter` calls +9. `yarn typecheck` clean (same pre-existing floorplan errors) +10. `yarn test --run` green; smoke test updated; new `useNavigatorSearch.test.tsx` with 7 cases +11. `yarn lint:hooks` clean +12. Manual smoke: switch tabs rapidly → results cached, no flicker. Type filter → debounced refetch. Create a room → list refreshes. + +## 6. Risk register + +| Risk | Mitigation | +|---|---| +| `NavigatorSearchEvent` arrives unsolicited (server-side push) — query wouldn't update | The `accept` filter checks the result's code matches the current tabCode, so only matching events update the query. Unsolicited results to a non-active tab are ignored (acceptable — when the user switches to that tab, the cache is empty and a fresh query fires). | +| Removing `useNavigatorActions` breaks an import we missed | Type-checker catches it. The P1 grep showed only Navigator-internal consumers use it. | +| Removing the `isLoading`/`isReady`/`needsInit`/`needsSearch` flags from `navigatorUiStore` (they're now derivable from query state) — too aggressive? | KEEP them in P2. Only `searchResult` ownership moves. Future cleanup can remove the obsolete lifecycle flags once we're sure nothing reads them. | +| Debounce timing on search input | 300ms is standard; if it feels laggy the user can lower it later — pure UX tune | + +## 7. Plan (executable) + +### Task 1: Add UI store state + actions (TDD) + +**Files**: `src/hooks/navigator/navigatorUiStore.ts`, `src/hooks/navigator/navigatorUiStore.test.ts` + +- [ ] Add `currentTabCode: string` (initial `''`) and `currentFilter: string` (initial `''`) to `NavigatorUiState` +- [ ] Add `setTab(code: string): void` and `setFilter(value: string): void` to `NavigatorUiActions` +- [ ] `setTab(code)` sets `{ currentTabCode: code, currentFilter: '' }` (atomic reset on tab change) +- [ ] `setFilter(value)` sets `{ currentFilter: value }` (no tab side-effect) +- [ ] Update test file: 3 new cases — `setTab` updates tab and resets filter; `setFilter` updates filter without touching tab; idempotent `setTab` on same code resets filter to '' regardless +- [ ] `yarn test --run src/hooks/navigator/navigatorUiStore.test.ts` → green +- [ ] Commit: `feat(navigator): add currentTabCode + currentFilter to UI store (P2 prep)` + +### Task 2: Create `useNavigatorSearch` query hook (TDD) + +**Files**: `src/hooks/navigator/useNavigatorSearch.ts`, `src/hooks/navigator/useNavigatorSearch.test.tsx` + +Implement per §2.1 + §2.7 above. 7 test cases. + +The test will need: `QueryClientProvider` wrapper, mock for `NavigatorSearchComposer` (probably already in mock), `NavigatorSearchEvent` dispatch with parser.result.code matching/non-matching. + +- [ ] Commit: `feat(navigator): useNavigatorSearch query hook (P2 core)` + +### Task 3: Strip search ownership from `useNavigatorStore` + `useNavigatorData` + remove `useNavigatorActions` + +**Files**: `useNavigatorStore.ts`, `useNavigatorData.ts`, `useNavigatorActions.ts` (DELETE), `useNavigatorUiState.ts`, `index.ts` + +- [ ] Remove `searchResult` state + `setSearchResult` from `useNavigatorStore` +- [ ] Remove `NavigatorSearchEvent` listener from `useNavigatorStore` +- [ ] Remove `sendSearch` and `reloadCurrentSearch` from `useNavigatorStore` return +- [ ] Remove `setLoading` calls inside `useNavigatorStore` +- [ ] Remove `topLevelContextRef` and `searchResultRef` (no longer used after sendSearch/reload removal) +- [ ] In `NavigatorMetadataEvent` handler, add `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` after `setTopLevelContext(...)` — seeds the query when contexts arrive +- [ ] Remove `searchResult` from `useNavigatorData` destructure + return +- [ ] DELETE `src/hooks/navigator/useNavigatorActions.ts` +- [ ] Update `useNavigatorUiState.ts` to expose `currentTabCode` + `currentFilter` per-key selectors +- [ ] Update `src/hooks/navigator/index.ts` to remove `useNavigatorActions` export, add `useNavigatorSearch` export +- [ ] Update `useNavigatorStore.test.tsx` smoke test: 2 cases that expected `searchResult` in data shape or `sendSearch/reloadCurrentSearch` in actions shape — update accordingly (or just remove the "useNavigatorActions returns ..." test entirely) +- [ ] Verify typecheck: ONLY consumer-side errors expected (NavigatorView still references the old API). Hook files clean. +- [ ] Commit: `refactor(navigator): remove search ownership from useNavigatorStore` + +### Task 4: Migrate `NavigatorView.tsx` + `NavigatorSearchView.tsx` + +**Files**: `src/components/navigator/NavigatorView.tsx`, `src/components/navigator/views/search/NavigatorSearchView.tsx` + +- [ ] In `NavigatorView`: + - Import `useNavigatorSearch` + - Replace `useNavigatorData` destructure of `searchResult` with `useNavigatorSearch()` call returning `{ searchResult, isFetching }` + - Drop `useNavigatorActions` import + destructure (it's gone) + - Drop the 4 lifecycle `useEffect` blocks (needsSearch / needsInit-init / markReady / reloadCurrentSearch); the new flow: + - Keep the `NavigatorInitComposer` on first `isVisible` — still needed for metadata + - Tab clicks call `useNavigatorUiStore.getState().setTab(context.code)` + - linkTracker `case 'search'`: `store.setTab(parts[2]); store.setFilter(parts[3] ?? ''); store.show();` (no more `pendingSearch` ref) + - Replace `` with `isFetching` from the query + - Drop the `pendingSearch` ref +- [ ] In `NavigatorSearchView`: + - Read `currentFilter` from `useNavigatorUiState` for the initial input value + - Local `useState` for the text being typed (mirrors the store value) + - Debounce: `useEffect` with 300ms timer calling `useNavigatorUiStore.getState().setFilter(text)` + - Remove all `useNavigatorActions` references — the search submit happens via store, query refires automatically +- [ ] `yarn typecheck` clean +- [ ] `yarn test --run` green +- [ ] `yarn lint:hooks` clean +- [ ] Commit: `feat(navigator): drive search via TanStack Query + setTab/setFilter UI store actions` + +### Task 5: PR + +- [ ] Push branch +- [ ] Open PR against `duckietm:Dev`: `feat(navigator): TanStack Query for search (P2)` diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 16c76cb..10203ef 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -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(null); useNitroEvent(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 => sendSearch('', context.code) }> + onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }> { LocalizeText('navigator.toplevelview.' + context.code) } ) } = props => - + { !isCreatorOpen &&
{ isOpenSavesSearches && diff --git a/src/components/navigator/views/search/NavigatorSearchView.tsx b/src/components/navigator/views/search/NavigatorSearchView.tsx index 18980b6..a6b3185 100644 --- a/src/components/navigator/views/search/NavigatorSearchView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchView.tsx @@ -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) => - { - 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) => + { + if(event.key !== 'Enter') return; + + processSearch(); + }; + return (
@@ -69,7 +81,7 @@ export const NavigatorSearchView: FC<{}> = props =>
- setSearchValue(event.target.value) } onKeyDown={ event => handleKeyDown(event) } /> + setInputText(event.target.value) } onKeyDown={ event => handleKeyDown(event) } /> diff --git a/src/hooks/navigator/index.ts b/src/hooks/navigator/index.ts index c621851..67660d0 100644 --- a/src/hooks/navigator/index.ts +++ b/src/hooks/navigator/index.ts @@ -1,5 +1,5 @@ -export { useNavigatorActions } from './useNavigatorActions'; export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorSearch } from './useNavigatorSearch'; export { useNavigatorUiState } from './useNavigatorUiState'; export { useNavigatorUiStore } from './navigatorUiStore'; export { useDoorState } from '../rooms/widgets/useDoorState'; diff --git a/src/hooks/navigator/navigatorUiStore.test.ts b/src/hooks/navigator/navigatorUiStore.test.ts index ebc41fa..36a7450 100644 --- a/src/hooks/navigator/navigatorUiStore.test.ts +++ b/src/hooks/navigator/navigatorUiStore.test.ts @@ -10,7 +10,9 @@ const INITIAL = { isOpenSavesSearches: false, isLoading: false, needsInit: true, - needsSearch: false + needsSearch: false, + currentTabCode: '', + currentFilter: '' }; describe('useNavigatorUiStore', () => @@ -32,6 +34,8 @@ describe('useNavigatorUiStore', () => expect(s.isLoading).toBe(false); expect(s.needsInit).toBe(true); expect(s.needsSearch).toBe(false); + expect(s.currentTabCode).toBe(''); + expect(s.currentFilter).toBe(''); }); describe('show / hide / toggle', () => @@ -141,4 +145,31 @@ describe('useNavigatorUiStore', () => expect(useNavigatorUiStore.getState().needsSearch).toBe(false); }); }); + + describe('tab + filter', () => + { + it("setTab('public') sets currentTabCode and clears currentFilter", () => + { + useNavigatorUiStore.setState({ currentTabCode: 'events', currentFilter: 'habbo' }); + useNavigatorUiStore.getState().setTab('public'); + expect(useNavigatorUiStore.getState().currentTabCode).toBe('public'); + expect(useNavigatorUiStore.getState().currentFilter).toBe(''); + }); + + it("setFilter('cocco') sets currentFilter without touching tab", () => + { + useNavigatorUiStore.getState().setTab('events'); + useNavigatorUiStore.getState().setFilter('cocco'); + expect(useNavigatorUiStore.getState().currentTabCode).toBe('events'); + expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco'); + }); + + it('setTab on same code still resets currentFilter', () => + { + useNavigatorUiStore.setState({ currentTabCode: 'public', currentFilter: 'test' }); + useNavigatorUiStore.getState().setTab('public'); + expect(useNavigatorUiStore.getState().currentTabCode).toBe('public'); + expect(useNavigatorUiStore.getState().currentFilter).toBe(''); + }); + }); }); diff --git a/src/hooks/navigator/navigatorUiStore.ts b/src/hooks/navigator/navigatorUiStore.ts index 527aab7..709207a 100644 --- a/src/hooks/navigator/navigatorUiStore.ts +++ b/src/hooks/navigator/navigatorUiStore.ts @@ -10,6 +10,8 @@ export type NavigatorUiState = { isLoading: boolean; needsInit: boolean; needsSearch: boolean; + currentTabCode: string; + currentFilter: string; }; export type NavigatorUiActions = { @@ -28,6 +30,8 @@ export type NavigatorUiActions = { markInitDone(): void; requestSearch(): void; consumeSearchRequest(): void; + setTab(code: string): void; + setFilter(value: string): void; }; export const useNavigatorUiStore = createNitroStore()((set) => ({ @@ -40,6 +44,8 @@ export const useNavigatorUiStore = createNitroStore set({ isVisible: true, needsSearch: true }), hide: () => set({ isVisible: false }), @@ -57,5 +63,7 @@ export const useNavigatorUiStore = createNitroStore set({ isReady: true }), markInitDone: () => set({ needsInit: false }), requestSearch: () => set({ needsSearch: true }), - consumeSearchRequest: () => set({ needsSearch: false }) + consumeSearchRequest: () => set({ needsSearch: false }), + setTab: (code) => set({ currentTabCode: code, currentFilter: '' }), + setFilter: (value) => set({ currentFilter: value }) })); diff --git a/src/hooks/navigator/useNavigatorActions.ts b/src/hooks/navigator/useNavigatorActions.ts deleted file mode 100644 index 6a88e43..0000000 --- a/src/hooks/navigator/useNavigatorActions.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useBetween } from 'use-between'; -import { useNavigatorStore } from './useNavigatorStore'; - -export const useNavigatorActions = () => -{ - const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); - return { sendSearch, reloadCurrentSearch }; -}; diff --git a/src/hooks/navigator/useNavigatorData.ts b/src/hooks/navigator/useNavigatorData.ts index aeb05b7..500fb9e 100644 --- a/src/hooks/navigator/useNavigatorData.ts +++ b/src/hooks/navigator/useNavigatorData.ts @@ -6,12 +6,12 @@ export const useNavigatorData = () => const { categories, eventCategories, favouriteRoomIds, topLevelContext, topLevelContexts, - searchResult, navigatorSearches, navigatorData + navigatorSearches, navigatorData } = useBetween(useNavigatorStore); return { categories, eventCategories, favouriteRoomIds, topLevelContext, topLevelContexts, - searchResult, navigatorSearches, navigatorData + navigatorSearches, navigatorData }; }; diff --git a/src/hooks/navigator/useNavigatorSearch.test.tsx b/src/hooks/navigator/useNavigatorSearch.test.tsx new file mode 100644 index 0000000..f6858f0 --- /dev/null +++ b/src/hooks/navigator/useNavigatorSearch.test.tsx @@ -0,0 +1,302 @@ +/* @vitest-environment jsdom */ + +import { FlatCreatedEvent, NavigatorSearchEvent, + NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockEventDispatcher } from '../../nitro-renderer.mock'; +import { useNavigatorUiStore } from './navigatorUiStore'; +import { useNavigatorSearch } from './useNavigatorSearch'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a fresh QueryClient with retries off so failures are immediate. */ +const makeQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 } + } + }); + +/** Wrapper factory — each test gets its own QueryClient instance. */ +const makeWrapper = (client: QueryClient) => + ({ children }: { children: React.ReactNode }) => ( + + { children } + + ); + +/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */ +const makeSearchEvent = (code: string) => +{ + // Cast constructors as `any` so tsgo doesn't check required args against + // the real renderer SDK types (the mock stubs have no required args). + const result = new (NavigatorSearchResultSet as any)() as any; + result.code = code; + result.data = ''; + result.results = []; + + const ev = new (NavigatorSearchEvent as any)() as any; + ev.getParser = () => ({ result }); + return ev; +}; + +const INITIAL_UI = { + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false, + currentTabCode: '', + currentFilter: '' +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useNavigatorSearch', () => +{ + beforeEach(() => + { + // Reset UI store state before each test + useNavigatorUiStore.setState(INITIAL_UI); + }); + + afterEach(() => + { + cleanup(); + vi.clearAllMocks(); + }); + + it('1. with empty tabCode query is disabled — NavigatorSearchEvent does not update data', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // Dispatch a search event — should be ignored (query disabled) + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); + }); + + // Data must stay null + expect(result.current.searchResult).toBeNull(); + expect(result.current.isFetching).toBe(false); + }); + + it('2. after setTab("public"), NavigatorSearchComposer is fired and NavigatorSearchEvent resolves query', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // Activate the query + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + + // Hook should start fetching + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // Simulate server response + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); + }); + + // Query should resolve with the matching result + await waitFor(() => expect(result.current.searchResult).not.toBeNull()); + expect((result.current.searchResult as any).code).toBe('public'); + expect(result.current.isFetching).toBe(false); + }); + + it('3. after setFilter("cocco"), a new query fires and NavigatorSearchEvent resolves it', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // First establish a tab + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + // Resolve the initial query + await waitFor(() => expect(result.current.isFetching).toBe(true)); + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); + }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Now set a filter — triggers new query + act(() => + { + useNavigatorUiStore.getState().setFilter('cocco'); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // Resolve with matching event + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect((result.current.searchResult as any).code).toBe('public'); + + // Confirm filter is set + expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco'); + }); + + it('4. after setTab("events"), currentFilter resets to "" and new query fires for events', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // Establish public tab with a filter + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + useNavigatorUiStore.getState().setFilter('some-filter'); + }); + + // Resolve the public+filter query + await waitFor(() => expect(result.current.isFetching).toBe(true)); + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); + }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Switch to events tab — should atomically reset filter + act(() => + { + useNavigatorUiStore.getState().setTab('events'); + }); + + // Filter must be cleared + expect(useNavigatorUiStore.getState().currentFilter).toBe(''); + expect(useNavigatorUiStore.getState().currentTabCode).toBe('events'); + + // New query for 'events' fires + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // Resolve with events result + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect((result.current.searchResult as any).code).toBe('events'); + }); + + it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); + }); + + await waitFor(() => + { + expect(result.current.searchResult).not.toBeNull(); + expect((result.current.searchResult as any).code).toBe('public'); + }); + }); + + it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // Dispatch an event for a DIFFERENT tab — should be rejected by accept filter + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any); + }); + + // Still fetching — the wrong-tab event was ignored + // (the query promise stays pending until it times out or a matching event arrives) + // After the wrong-tab dispatch, data should NOT be updated + expect(result.current.searchResult).toBeNull(); + + // Now dispatch the correct one to unblock the test + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); + }); + + await waitFor(() => expect(result.current.searchResult).not.toBeNull()); + // Only the correct-tab result is stored + expect((result.current.searchResult as any).code).toBe('public'); + }); + + it('7. dispatching FlatCreatedEvent triggers query invalidation (refetch starts)', async () => + { + const client = makeQueryClient(); + + // Spy on invalidateQueries to confirm the invalidator calls it + const invalidateSpy = vi.spyOn(client, 'invalidateQueries'); + + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // Establish a resolved query so there is something to invalidate + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + await waitFor(() => expect(result.current.isFetching).toBe(true)); + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); + }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Dispatch FlatCreatedEvent — should trigger invalidateQueries + const flatCreatedEv = new (FlatCreatedEvent as any)() as any; + flatCreatedEv.getParser = () => ({ roomId: 999 }); + act(() => + { + mockEventDispatcher.dispatchEvent(flatCreatedEv as any); + }); + + await waitFor(() => expect(invalidateSpy).toHaveBeenCalled()); + + // The invalidation should target the 'navigator', 'search' key prefix + const calls = invalidateSpy.mock.calls; + const calledWithSearchKey = calls.some(call => + { + const opts = call[0] as any; + const key: string[] = opts?.queryKey ?? []; + return key[0] === 'navigator' && key[1] === 'search'; + }); + expect(calledWithSearchKey).toBe(true); + }); +}); diff --git a/src/hooks/navigator/useNavigatorSearch.ts b/src/hooks/navigator/useNavigatorSearch.ts new file mode 100644 index 0000000..b120001 --- /dev/null +++ b/src/hooks/navigator/useNavigatorSearch.ts @@ -0,0 +1,47 @@ +import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, + NavigatorSearchResultSet, RoomSettingsUpdatedEvent } from '@nitrots/nitro-renderer'; +import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +const SEARCH_BASE_KEY = [ 'navigator', 'search' ] as const; + +/** + * TanStack Query wrapper for navigator search. + * + * Cache key: ['navigator', 'search', tabCode, filter] + * - Fires NavigatorSearchComposer(tabCode, filter) on miss. + * - Listens for NavigatorSearchEvent and resolves with the result. + * - accept-filter: rejects events whose result.code !== tabCode (defends + * against server-side cross-tab pushes resolving the wrong query slot). + * - Disabled when tabCode is '' (initial state, before metadata arrives). + * - Invalidates on FlatCreatedEvent (new room created) and + * RoomSettingsUpdatedEvent (room renamed / settings changed). + */ +export const useNavigatorSearch = () => +{ + const tabCode = useNavigatorUiStore(s => s.currentTabCode); + const filter = useNavigatorUiStore(s => s.currentFilter); + + const query = useNitroQuery({ + key: [ ...SEARCH_BASE_KEY, tabCode, filter ], + request: () => new NavigatorSearchComposer(tabCode, filter), + parser: NavigatorSearchEvent, + select: e => e.getParser()?.result ?? null, + accept: e => + { + const result = e.getParser()?.result; + return !!result && result.code === tabCode; + }, + enabled: !!tabCode, + staleTime: 30_000 + }); + + useNitroEventInvalidator(FlatCreatedEvent, [ ...SEARCH_BASE_KEY ]); + useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ ...SEARCH_BASE_KEY ]); + + return { + searchResult: query.data ?? null, + isFetching: query.isFetching, + refetch: query.refetch + }; +}; diff --git a/src/hooks/navigator/useNavigatorStore.test.tsx b/src/hooks/navigator/useNavigatorStore.test.tsx index a9040f4..2e09036 100644 --- a/src/hooks/navigator/useNavigatorStore.test.tsx +++ b/src/hooks/navigator/useNavigatorStore.test.tsx @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index'; +import { useNavigatorData, useNavigatorUiState } from './index'; describe('navigator filter shapes (smoke)', () => { @@ -10,24 +10,18 @@ describe('navigator filter shapes (smoke)', () => expect(Object.keys(result.current).sort()).toEqual([ 'categories', 'eventCategories', 'favouriteRoomIds', 'navigatorData', 'navigatorSearches', - 'searchResult', 'topLevelContext', 'topLevelContexts' + 'topLevelContext', 'topLevelContexts' ].sort()); }); - it('useNavigatorUiState returns the 9 documented flags', () => + it('useNavigatorUiState returns the 11 documented flags', () => { const { result } = renderHook(() => useNavigatorUiState()); expect(Object.keys(result.current).sort()).toEqual([ + 'currentFilter', 'currentTabCode', 'isCreatorOpen', 'isLoading', 'isOpenSavesSearches', 'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible', 'needsInit', 'needsSearch' ].sort()); }); - - it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () => - { - const { result } = renderHook(() => useNavigatorActions()); - expect(typeof result.current.sendSearch).toBe('function'); - expect(typeof result.current.reloadCurrentSearch).toBe('function'); - }); }); diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts index e3165d9..bf1a513 100644 --- a/src/hooks/navigator/useNavigatorStore.ts +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -6,13 +6,13 @@ import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, - NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent, - NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, + NavigatorSearchesEvent, + NavigatorTopLevelContext, NitroEventType, RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, - RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, + RoomForwardEvent, RoomScoreEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { CreateRoomSession, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api'; @@ -27,7 +27,6 @@ export const useNavigatorStore = () => const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); const [ topLevelContext, setTopLevelContext ] = useState(null); const [ topLevelContexts, setTopLevelContexts ] = useState(null); - const [ searchResult, setSearchResult ] = useState(null); const [ navigatorSearches, setNavigatorSearches ] = useState(null); const [ navigatorData, setNavigatorData ] = useState({ settingsReceived: false, @@ -44,41 +43,8 @@ export const useNavigatorStore = () => canRate: true }); - // Refs let handlers stay [] deps without losing access to fresh state. - const topLevelContextsRef = useRef(topLevelContexts); - topLevelContextsRef.current = topLevelContexts; - const topLevelContextRef = useRef(topLevelContext); - topLevelContextRef.current = topLevelContext; - const searchResultRef = useRef(searchResult); - searchResultRef.current = searchResult; - const { simpleAlert = null } = useNotification(); - const sendSearch = useCallback((searchValue: string, contextCode: string) => - { - useNavigatorUiStore.getState().closeCreator(); - SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); - useNavigatorUiStore.getState().setLoading(true); - }, []); - - const reloadCurrentSearch = useCallback(() => - { - if(!useNavigatorUiStore.getState().isReady) - { - useNavigatorUiStore.getState().requestSearch(); - return; - } - const sr = searchResultRef.current; - if(sr) - { - sendSearch(sr.data, sr.code); - return; - } - const ctx = topLevelContextRef.current; - if(!ctx) return; - sendSearch('', ctx.code); - }, [ sendSearch ]); - useMessageEvent(FavouritesEvent, useCallback(event => { const parser = event.getParser(); @@ -99,12 +65,6 @@ export const useNavigatorStore = () => }); }, [])); - useMessageEvent(RoomSettingsUpdatedEvent, useCallback(event => - { - const parser = event.getParser(); - SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); - }, [])); - useMessageEvent(CanCreateRoomEventEvent, useCallback(event => { const parser = event.getParser(); @@ -223,28 +183,8 @@ export const useNavigatorStore = () => const parser = event.getParser(); setTopLevelContexts(parser.topLevelContexts); setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); - }, [])); - - useMessageEvent(NavigatorSearchEvent, useCallback(event => - { - const parser = event.getParser(); - const contexts = topLevelContextsRef.current; - setTopLevelContext(prev => - { - let next = prev; - if(!next) next = (contexts && contexts.length && contexts[0]) || null; - if(!next) return null; - if(contexts && contexts.length) - { - for(const ctx of contexts) - { - if(ctx.code === parser.result.code) next = ctx; - } - } - return next; - }); - setSearchResult(parser.result); - useNavigatorUiStore.getState().setLoading(false); + // Seed the query's tab code so useNavigatorSearch activates immediately + useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? ''); }, [])); useMessageEvent(UserFlatCatsEvent, useCallback(event => @@ -342,7 +282,6 @@ export const useNavigatorStore = () => return { categories, eventCategories, favouriteRoomIds, topLevelContext, topLevelContexts, - searchResult, navigatorSearches, navigatorData, - sendSearch, reloadCurrentSearch + navigatorSearches, navigatorData }; }; diff --git a/src/hooks/navigator/useNavigatorUiState.ts b/src/hooks/navigator/useNavigatorUiState.ts index 3c0868a..d811f0c 100644 --- a/src/hooks/navigator/useNavigatorUiState.ts +++ b/src/hooks/navigator/useNavigatorUiState.ts @@ -11,8 +11,11 @@ export const useNavigatorUiState = () => const isLoading = useNavigatorUiStore(s => s.isLoading); const needsInit = useNavigatorUiStore(s => s.needsInit); const needsSearch = useNavigatorUiStore(s => s.needsSearch); + const currentTabCode = useNavigatorUiStore(s => s.currentTabCode); + const currentFilter = useNavigatorUiStore(s => s.currentFilter); return { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, - isOpenSavesSearches, isLoading, needsInit, needsSearch + isOpenSavesSearches, isLoading, needsInit, needsSearch, + currentTabCode, currentFilter }; }; diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 28abd00..d80b851 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -533,7 +533,9 @@ export const GetCommunication = vi.fn(() => ({ { const wrapper = _msgEventWrappers.get(event); if(wrapper) msgListeners.get(event.type)?.delete(wrapper); - } + }, + // Stub for SendMessageComposer which calls GetCommunication().connection.send(...) + connection: { send: vi.fn() } })); export const GetConfiguration = vi.fn(stubManager); export const GetLocalizationManager = vi.fn(stubManager);