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);