feat(navigator): empty-state + skeleton views, fix double search fetch (P4 wave 1a)

Visual polish, first wave:
- NavigatorEmptyStateView: replaces the bare "No rooms found" text with a
  centered icon + message + a Create-room CTA. Reuses existing i18n keys
  (navigator.search.returned.no.results / .roomsettings.moderation.none /
  .createroom.create) so no new localization entries are needed.
- NavigatorSearchSkeletonView: animate-pulse placeholder rows shown while a
  search is in flight and no result is cached yet (matches the HK dashboard
  skeleton pattern). Replaces the NitroCard.Content spinner overlay for the
  result list.

Bug fix bundled in: NavigatorSearchView called useNavigatorSearch() a second
time purely to read searchResult for its input-sync effect. Since the hook is
not a useBetween singleton, that registered a duplicate NavigatorSearchEvent
listener AND fired a duplicate NavigatorSearchComposer on every search.
NavigatorView now owns the single useNavigatorSearch() call and passes
searchResult to NavigatorSearchView via prop.

Test maintenance: useNavigatorSearch.test.tsx was written for the original
useNitroQuery implementation, which upstream reverted (05d71dd1) to
useMessageEvent + useState. Removed the dead QueryClient scaffolding, fixed
case 1 (assert no fetch starts with empty tab), dropped case 7 (the query
invalidator no longer exists). 6 cases, all green.

Full suite 471/471. Typecheck: only the environmental renderer-mismatch
errors (soundboard / rare-values / floorplan APIs absent from the linked
renderer), none in navigator files.
This commit is contained in:
simoleo89
2026-05-28 18:02:48 +02:00
parent 772b6dd632
commit 3bce0c0191
5 changed files with 93 additions and 116 deletions
+19 -108
View File
@@ -1,8 +1,6 @@
/* @vitest-environment jsdom */
import { FlatCreatedEvent, NavigatorSearchEvent,
NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mockEventDispatcher } from '../../nitro-renderer.mock';
@@ -13,23 +11,12 @@ 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 }
}
});
// NOTE: useNavigatorSearch uses useMessageEvent + useState (NOT useNitroQuery).
// The one-shot query pattern was reverted upstream (05d71dd1) because it left
// the UI blank when the listener never matched. These tests exercise the
// event-driven implementation directly — no QueryClient scaffolding.
/** Wrapper factory — each test gets its own QueryClient instance. */
const makeWrapper = (client: QueryClient) =>
({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={ client }>
{ children }
</QueryClientProvider>
);
/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */
/** Build a fake NavigatorSearchEvent whose getParser() returns a result with `code`. */
const makeSearchEvent = (code: string) =>
{
// Cast constructors as `any` so tsgo doesn't check required args against
@@ -66,7 +53,6 @@ describe('useNavigatorSearch', () =>
{
beforeEach(() =>
{
// Reset UI store state before each test
useNavigatorUiStore.setState(INITIAL_UI);
});
@@ -76,59 +62,44 @@ describe('useNavigatorSearch', () =>
vi.clearAllMocks();
});
it('1. with empty tabCode query is disabled — NavigatorSearchEvent does not update data', async () =>
it('1. with empty tabCode no fetch starts (the request effect is gated)', () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
// 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();
// No tab selected → the request effect short-circuits, nothing fetches.
expect(result.current.isFetching).toBe(false);
expect(result.current.searchResult).toBeNull();
});
it('2. after setTab("public"), NavigatorSearchComposer is fired and NavigatorSearchEvent resolves query', async () =>
it('2. after setTab("public"), the hook starts fetching and a matching event resolves it', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
// 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 () =>
it('3. after setFilter("cocco"), a new fetch fires and a matching event resolves it', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
// First establish a tab
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
});
// Resolve the initial query
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
@@ -136,7 +107,6 @@ describe('useNavigatorSearch', () =>
});
await waitFor(() => expect(result.current.isFetching).toBe(false));
// Now set a filter — triggers new query
act(() =>
{
useNavigatorUiStore.getState().setFilter('cocco');
@@ -144,7 +114,6 @@ describe('useNavigatorSearch', () =>
await waitFor(() => expect(result.current.isFetching).toBe(true));
// Resolve with matching event
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
@@ -152,24 +121,19 @@ describe('useNavigatorSearch', () =>
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 () =>
it('4. after setTab("events"), currentFilter resets to "" and a new fetch fires for events', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
// 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(() =>
{
@@ -177,20 +141,16 @@ describe('useNavigatorSearch', () =>
});
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);
@@ -202,8 +162,7 @@ describe('useNavigatorSearch', () =>
it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
act(() =>
{
@@ -226,8 +185,7 @@ describe('useNavigatorSearch', () =>
it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
act(() =>
{
@@ -236,67 +194,20 @@ describe('useNavigatorSearch', () =>
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
// The wrong-tab event is filtered out by the accept guard.
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);
});
});