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.
14 KiB
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
useNitroEventInvalidatoronFlatCreatedEventandRoomSettingsUpdatedEvent(and possiblyFavouriteChangedEventif the active tab isfavorites_view) - Single source of truth for
isFetching— no separateisLoadingflag 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.
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<typeof NavigatorSearchEvent, NavigatorSearchResultSet>({
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:
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<NavigatorSearchResultSet>(null)forsearchResultuseMessageEvent<NavigatorSearchEvent>listenersendSearchandreloadCurrentSearchactions- The
useNavigatorUiStore.getState().setLoading(...)calls (no longer needed) - The
topLevelContextRefandsearchResultRef(only consumed insidereloadCurrentSearch)
Keep:
topLevelContext+topLevelContexts(these still come fromNavigatorMetadataEventand drive the tab list)- The
NavigatorMetadataEventlistener — but it now ALSO callsuseNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')on first arrival, to seed the initial tab. The query then activates becausecurrentTabCodebecomes 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', '') FlatCreatedEventinvalidates the cache → refetchRoomSettingsUpdatedEventinvalidates the cache → refetchNavigatorSearchEventwith WRONG tabCode (e.g. server pushes an unsolicited result) is REJECTED byacceptfilter — does NOT update query data
2.8 NavigatorView.tsx — major rewrite
Replace:
useNavigatorActionsimport → goneuseNavigatorDatano longer destructuressearchResult— get it fromuseNavigatorSearchinstead- 4
useEffectblocks driving the imperative search flow (needsSearch,needsInitlifecycle,reloadCurrentSearchorchestration) → gone - Tab
onClick={ () => sendSearch('', context.code) }→onClick={ () => useNavigatorUiStore.getState().setTab(context.code) } isLoadingfromuseNavigatorUiState()→isFetchingfromuseNavigatorSearch()queryNavigatorInitComposerinitial dispatch on firstisVisible— KEEP (still need it to gettopLevelContextspopulated)pendingSearchref — gone (linkTrackercase 'search'directly doessetTab(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
currentFilterfromuseNavigatorUiState - onChange →
useNavigatorUiStore.getState().setFilter(value)(debounced 300ms) - No more
sendSearchreference
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.sendSearchanduseNavigatorActions.reloadCurrentSearchare REMOVED. No consumer outside Navigator depends on them — verified by grepping the previous P1 consumer migration.useNavigatorData.searchResultis REMOVED. OnlyNavigatorViewreads it currently — easy to migrate.- The
useNavigatorActionsfilter itself becomes empty — consider whether to delete the file entirely. Decision: delete the file to minimize the API surface. Tasks 5-8 of P1 migratedNavigatorSearchViewto useuseNavigatorActions— that's the only consumer; it migrates touseNavigatorUiStoredirectly.
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:
src/hooks/navigator/useNavigatorSearch.tsexists and exportsuseNavigatorSearchuseNavigatorStore.tsno longer ownssearchResult, no longer subscribes toNavigatorSearchEvent, no longer exposessendSearchorreloadCurrentSearchnavigatorUiStore.tshascurrentTabCode+currentFilterstate andsetTab+setFilteractionsuseNavigatorActions.tsis deleted; barrel no longer exportsuseNavigatorActionsuseNavigatorData.tsno longer returnssearchResultuseNavigatorUiState.tsreturnscurrentTabCode+currentFilterNavigatorView.tsxreadssearchResultfromuseNavigatorSearch(), usesisFetchingfor the loading flag, callssetTabon tab clicksNavigatorSearchView.tsxdebouncessetFiltercallsyarn typecheckclean (same pre-existing floorplan errors)yarn test --rungreen; smoke test updated; newuseNavigatorSearch.test.tsxwith 7 casesyarn lint:hooksclean- 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'') andcurrentFilter: string(initial'') toNavigatorUiState - Add
setTab(code: string): voidandsetFilter(value: string): voidtoNavigatorUiActions 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 —
setTabupdates tab and resets filter;setFilterupdates filter without touching tab; idempotentsetTabon 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
searchResultstate +setSearchResultfromuseNavigatorStore - Remove
NavigatorSearchEventlistener fromuseNavigatorStore - Remove
sendSearchandreloadCurrentSearchfromuseNavigatorStorereturn - Remove
setLoadingcalls insideuseNavigatorStore - Remove
topLevelContextRefandsearchResultRef(no longer used after sendSearch/reload removal) - In
NavigatorMetadataEventhandler, adduseNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')aftersetTopLevelContext(...)— seeds the query when contexts arrive - Remove
searchResultfromuseNavigatorDatadestructure + return - DELETE
src/hooks/navigator/useNavigatorActions.ts - Update
useNavigatorUiState.tsto exposecurrentTabCode+currentFilterper-key selectors - Update
src/hooks/navigator/index.tsto removeuseNavigatorActionsexport, adduseNavigatorSearchexport - Update
useNavigatorStore.test.tsxsmoke test: 2 cases that expectedsearchResultin data shape orsendSearch/reloadCurrentSearchin 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
useNavigatorDatadestructure ofsearchResultwithuseNavigatorSearch()call returning{ searchResult, isFetching } - Drop
useNavigatorActionsimport + destructure (it's gone) - Drop the 4 lifecycle
useEffectblocks (needsSearch / needsInit-init / markReady / reloadCurrentSearch); the new flow:- Keep the
NavigatorInitComposeron firstisVisible— 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 morependingSearchref)
- Keep the
- Replace
<NitroCard.Content isLoading={ isLoading }>withisFetchingfrom the query - Drop the
pendingSearchref
- Import
- In
NavigatorSearchView:- Read
currentFilterfromuseNavigatorUiStatefor the initial input value - Local
useStatefor the text being typed (mirrors the store value) - Debounce:
useEffectwith 300ms timer callinguseNavigatorUiStore.getState().setFilter(text) - Remove all
useNavigatorActionsreferences — the search submit happens via store, query refires automatically
- Read
yarn typecheckcleanyarn test --rungreenyarn lint:hooksclean- 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)