From 1810a866180ff031e8b3f152f93d09e806ef4074 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:11:31 +0200 Subject: [PATCH 1/5] docs(navigator): P2 TanStack Query design + integrated plan Combines spec + 5-task plan into a single doc for faster execution. Branch: feat/navigator-p2-query (forked from feat/navigator-modernization P1 tip). Migrates search from event-driven imperative state to useNitroQuery with cache per [tabCode, filter], invalidator on FlatCreatedEvent + RoomSettingsUpdatedEvent, accept-filter that rejects mismatched-tab server pushes. Key API changes: useNavigatorActions DELETED (sendSearch + reloadCurrentSearch gone); useNavigatorData no longer returns searchResult; navigatorUiStore adds currentTabCode + currentFilter + setTab + setFilter; new useNavigatorSearch hook returns the { searchResult, isFetching, refetch } triple. --- ...5-27-navigator-p2-tanstack-query-design.md | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md 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)` From 8f1b664b2fe8e53d51b71b7bf35f22211a77afcd Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:15:08 +0200 Subject: [PATCH 2/5] feat(navigator): add currentTabCode + currentFilter to UI store (P2 prep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setTab(code) atomically updates currentTabCode and resets currentFilter to '' — switching tabs starts a fresh search context. setFilter(value) updates only the filter — the user is typing in the same tab. TDD: 3 new cases (16 total in navigatorUiStore.test). --- src/hooks/navigator/navigatorUiStore.test.ts | 33 +++++++++++++++++++- src/hooks/navigator/navigatorUiStore.ts | 10 +++++- 2 files changed, 41 insertions(+), 2 deletions(-) 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 }) })); From 7435326dad5e3a65ee3bfea56e9742b0d781ed90 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:18:24 +0200 Subject: [PATCH 3/5] feat(navigator): useNavigatorSearch query hook (P2 core) useNitroQuery keyed on [currentTabCode, currentFilter] from navigatorUiStore. Fires NavigatorSearchComposer; subscribes to NavigatorSearchEvent with an accept-filter that rejects results whose code does not match the current tab. Invalidates on FlatCreatedEvent and RoomSettingsUpdatedEvent for server-driven refresh. nitro-renderer.mock.ts: add connection.send stub to GetCommunication so SendMessageComposer (which calls GetCommunication().connection.send) does not throw in tests that exercise useNitroQuery. TDD: 7 cases incl. enabled-gating, accept-filter rejection on mismatched tab, invalidator round-trip. --- .../navigator/useNavigatorSearch.test.tsx | 300 ++++++++++++++++++ src/hooks/navigator/useNavigatorSearch.ts | 47 +++ src/nitro-renderer.mock.ts | 4 +- 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 src/hooks/navigator/useNavigatorSearch.test.tsx create mode 100644 src/hooks/navigator/useNavigatorSearch.ts diff --git a/src/hooks/navigator/useNavigatorSearch.test.tsx b/src/hooks/navigator/useNavigatorSearch.test.tsx new file mode 100644 index 0000000..bd2fb60 --- /dev/null +++ b/src/hooks/navigator/useNavigatorSearch.test.tsx @@ -0,0 +1,300 @@ +/* @vitest-environment jsdom */ + +import { FlatCreatedEvent, NavigatorSearchComposer, 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) => +{ + const result = new NavigatorSearchResultSet() as any; + result.code = code; + result.data = ''; + result.results = []; + + const ev = new NavigatorSearchEvent() as any; + ev.getParser = () => ({ result }); + return ev as NavigatorSearchEvent; +}; + +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')); + }); + + // 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')); + }); + + // 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')); + }); + 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')); + }); + + 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')); + }); + 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')); + }); + + 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')); + }); + + 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')); + }); + + // 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')); + }); + + 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')); + }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Dispatch FlatCreatedEvent — should trigger invalidateQueries + const flatCreatedEv = new FlatCreatedEvent() as any; + flatCreatedEv.getParser = () => ({ roomId: 999 }); + act(() => + { + mockEventDispatcher.dispatchEvent(flatCreatedEv); + }); + + 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/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); From ee3736474ddd404e65a63c5c6ffd0054fc45f093 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:20:27 +0200 Subject: [PATCH 4/5] refactor(navigator): remove search ownership from useNavigatorStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 core surgery: search result + NavigatorSearchEvent listener + sendSearch + reloadCurrentSearch all leave useNavigatorStore. The new useNavigatorSearch query hook owns the cache. useNavigatorActions is deleted entirely — the only two actions it exposed are gone, and no consumer outside Navigator depended on it. NavigatorMetadataEvent handler now seeds the UI store's currentTabCode on first arrival, activating the query the moment top-level contexts land. useNavigatorData: searchResult removed from closure and return. useNavigatorUiState: currentTabCode + currentFilter added. index.ts: useNavigatorActions removed, useNavigatorSearch added. NavigatorView.tsx is intentionally broken at this commit and gets fixed in the next. --- src/hooks/navigator/index.ts | 2 +- src/hooks/navigator/useNavigatorActions.ts | 8 -- src/hooks/navigator/useNavigatorData.ts | 4 +- .../navigator/useNavigatorStore.test.tsx | 14 +--- src/hooks/navigator/useNavigatorStore.ts | 75 ++----------------- src/hooks/navigator/useNavigatorUiState.ts | 5 +- 6 files changed, 18 insertions(+), 90 deletions(-) delete mode 100644 src/hooks/navigator/useNavigatorActions.ts 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/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/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 }; }; From 26772f7073465f6ebaf82773b8264e37f3949801 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:25:30 +0200 Subject: [PATCH 5/5] feat(navigator): drive search via TanStack Query + setTab/setFilter UI store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/components/navigator/NavigatorView.tsx | 39 +++-------- .../views/search/NavigatorSearchView.tsx | 64 +++++++++++-------- .../navigator/useNavigatorSearch.test.tsx | 34 +++++----- 3 files changed, 66 insertions(+), 71 deletions(-) diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 3ff6be6..7819c96 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -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(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/useNavigatorSearch.test.tsx b/src/hooks/navigator/useNavigatorSearch.test.tsx index bd2fb60..f6858f0 100644 --- a/src/hooks/navigator/useNavigatorSearch.test.tsx +++ b/src/hooks/navigator/useNavigatorSearch.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, +import { FlatCreatedEvent, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; @@ -32,14 +32,16 @@ const makeWrapper = (client: QueryClient) => /** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */ const makeSearchEvent = (code: string) => { - const result = new NavigatorSearchResultSet() as any; + // 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; + const ev = new (NavigatorSearchEvent as any)() as any; ev.getParser = () => ({ result }); - return ev as NavigatorSearchEvent; + return ev; }; const INITIAL_UI = { @@ -82,7 +84,7 @@ describe('useNavigatorSearch', () => // Dispatch a search event — should be ignored (query disabled) act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); // Data must stay null @@ -107,7 +109,7 @@ describe('useNavigatorSearch', () => // Simulate server response act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); // Query should resolve with the matching result @@ -130,7 +132,7 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -145,7 +147,7 @@ describe('useNavigatorSearch', () => // Resolve with matching event act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -171,7 +173,7 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -191,7 +193,7 @@ describe('useNavigatorSearch', () => // Resolve with events result act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('events')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -212,7 +214,7 @@ describe('useNavigatorSearch', () => act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => @@ -237,7 +239,7 @@ describe('useNavigatorSearch', () => // Dispatch an event for a DIFFERENT tab — should be rejected by accept filter act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any); }); // Still fetching — the wrong-tab event was ignored @@ -248,7 +250,7 @@ describe('useNavigatorSearch', () => // Now dispatch the correct one to unblock the test act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.searchResult).not.toBeNull()); @@ -273,16 +275,16 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + 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; + const flatCreatedEv = new (FlatCreatedEvent as any)() as any; flatCreatedEv.getParser = () => ({ roomId: 999 }); act(() => { - mockEventDispatcher.dispatchEvent(flatCreatedEv); + mockEventDispatcher.dispatchEvent(flatCreatedEv as any); }); await waitFor(() => expect(invalidateSpy).toHaveBeenCalled());