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.
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) for searchResult
useMessageEvent<NavigatorSearchEvent> 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:
src/hooks/navigator/useNavigatorSearch.ts exists and exports useNavigatorSearch
useNavigatorStore.ts no longer owns searchResult, no longer subscribes to NavigatorSearchEvent, no longer exposes sendSearch or reloadCurrentSearch
navigatorUiStore.ts has currentTabCode + currentFilter state and setTab + setFilter actions
useNavigatorActions.ts is deleted; barrel no longer exports useNavigatorActions
useNavigatorData.ts no longer returns searchResult
useNavigatorUiState.ts returns currentTabCode + currentFilter
NavigatorView.tsx reads searchResult from useNavigatorSearch(), uses isFetching for the loading flag, calls setTab on tab clicks
NavigatorSearchView.tsx debounces setFilter calls
yarn typecheck clean (same pre-existing floorplan errors)
yarn test --run green; smoke test updated; new useNavigatorSearch.test.tsx with 7 cases
yarn lint:hooks clean
- 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
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.
Task 3: Strip search ownership from useNavigatorStore + useNavigatorData + remove useNavigatorActions
Files: useNavigatorStore.ts, useNavigatorData.ts, useNavigatorActions.ts (DELETE), useNavigatorUiState.ts, index.ts
Task 4: Migrate NavigatorView.tsx + NavigatorSearchView.tsx
Files: src/components/navigator/NavigatorView.tsx, src/components/navigator/views/search/NavigatorSearchView.tsx
Task 5: PR
Navigator Modernization — P2: TanStack Query for Search
Branch:
feat/navigator-p2-query(forked fromfeat/navigator-modernization@1148c0a6) Date: 2026-05-27 Depends on: P1 (hook split) — merged or pending merge1. Goal
Migrate Navigator's search request/response from event-driven imperative state to TanStack Query. The user gets:
useNitroEventInvalidatoronFlatCreatedEventandRoomSettingsUpdatedEvent(and possiblyFavouriteChangedEventif the active tab isfavorites_view)isFetching— no separateisLoadingflag to manage2. Architecture changes
2.1 New file:
src/hooks/navigator/useNavigatorSearch.tsThe query hook. Reads
currentTabCode+currentFilterfromnavigatorUiStore, firesNavigatorSearchComposer, waits forNavigatorSearchEvent, returns the parsedNavigatorSearchResultSet.2.2
navigatorUiStore.tsadditionsAdd 2 new state fields + 2 new actions:
setTab(code)resetscurrentFilterto''because switching tabs starts a fresh search.setFilterupdates only the filter — the user is typing in the same tab.2.3
useNavigatorStore.ts— remove search state ownershipRemove:
useState<NavigatorSearchResultSet>(null)forsearchResultuseMessageEvent<NavigatorSearchEvent>listenersendSearchandreloadCurrentSearchactionsuseNavigatorUiStore.getState().setLoading(...)calls (no longer needed)topLevelContextRefandsearchResultRef(only consumed insidereloadCurrentSearch)Keep:
topLevelContext+topLevelContexts(these still come fromNavigatorMetadataEventand drive the tab list)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— removesearchResultfrom return shapeuseNavigatorData()no longer returnssearchResult. Consumers that need it calluseNavigatorSearch()instead.2.5
useNavigatorActions.ts— empty or removedBoth
sendSearchandreloadCurrentSearchare gone. Either:useNavigatorUiStore.getState().setTab(...)/setFilter(...)directly2.6
useNavigatorUiState.ts— add the 2 new flagsAdd
currentTabCodeandcurrentFilterto the per-key selector list and return shape.2.7
useNavigatorSearch.test.tsx— newTest cases:
setTab('public')→ query fires NavigatorSearchComposer('public', '')setFilter('cocco')→ query fires NavigatorSearchComposer('public', 'cocco')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 data2.8
NavigatorView.tsx— major rewriteReplace:
useNavigatorActionsimport → goneuseNavigatorDatano longer destructuressearchResult— get it fromuseNavigatorSearchinsteaduseEffectblocks driving the imperative search flow (needsSearch,needsInitlifecycle,reloadCurrentSearchorchestration) → goneonClick={ () => 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 setFilterRead the file. The component currently exposes a search input that, on enter or button click, calls
sendSearch(value, currentTabCode). After P2 it:currentFilterfromuseNavigatorUiStateuseNavigatorUiStore.getState().setFilter(value)(debounced 300ms)sendSearchreferenceDebounce: use a local
useStatefor the input text + auseEffectthat callssetFilter(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.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)
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:hooksclean6. Risk register
NavigatorSearchEventarrives unsolicited (server-side push) — query wouldn't updateacceptfilter 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).useNavigatorActionsbreaks an import we missedisLoading/isReady/needsInit/needsSearchflags fromnavigatorUiStore(they're now derivable from query state) — too aggressive?searchResultownership moves. Future cleanup can remove the obsolete lifecycle flags once we're sure nothing reads them.7. Plan (executable)
Task 1: Add UI store state + actions (TDD)
Files:
src/hooks/navigator/navigatorUiStore.ts,src/hooks/navigator/navigatorUiStore.test.tscurrentTabCode: string(initial'') andcurrentFilter: string(initial'') toNavigatorUiStatesetTab(code: string): voidandsetFilter(value: string): voidtoNavigatorUiActionssetTab(code)sets{ currentTabCode: code, currentFilter: '' }(atomic reset on tab change)setFilter(value)sets{ currentFilter: value }(no tab side-effect)setTabupdates tab and resets filter;setFilterupdates filter without touching tab; idempotentsetTabon same code resets filter to '' regardlessyarn test --run src/hooks/navigator/navigatorUiStore.test.ts→ greenfeat(navigator): add currentTabCode + currentFilter to UI store (P2 prep)Task 2: Create
useNavigatorSearchquery hook (TDD)Files:
src/hooks/navigator/useNavigatorSearch.ts,src/hooks/navigator/useNavigatorSearch.test.tsxImplement per §2.1 + §2.7 above. 7 test cases.
The test will need:
QueryClientProviderwrapper, mock forNavigatorSearchComposer(probably already in mock),NavigatorSearchEventdispatch with parser.result.code matching/non-matching.feat(navigator): useNavigatorSearch query hook (P2 core)Task 3: Strip search ownership from
useNavigatorStore+useNavigatorData+ removeuseNavigatorActionsFiles:
useNavigatorStore.ts,useNavigatorData.ts,useNavigatorActions.ts(DELETE),useNavigatorUiState.ts,index.tssearchResultstate +setSearchResultfromuseNavigatorStoreNavigatorSearchEventlistener fromuseNavigatorStoresendSearchandreloadCurrentSearchfromuseNavigatorStorereturnsetLoadingcalls insideuseNavigatorStoretopLevelContextRefandsearchResultRef(no longer used after sendSearch/reload removal)NavigatorMetadataEventhandler, adduseNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')aftersetTopLevelContext(...)— seeds the query when contexts arrivesearchResultfromuseNavigatorDatadestructure + returnsrc/hooks/navigator/useNavigatorActions.tsuseNavigatorUiState.tsto exposecurrentTabCode+currentFilterper-key selectorssrc/hooks/navigator/index.tsto removeuseNavigatorActionsexport, adduseNavigatorSearchexportuseNavigatorStore.test.tsxsmoke test: 2 cases that expectedsearchResultin data shape orsendSearch/reloadCurrentSearchin actions shape — update accordingly (or just remove the "useNavigatorActions returns ..." test entirely)refactor(navigator): remove search ownership from useNavigatorStoreTask 4: Migrate
NavigatorView.tsx+NavigatorSearchView.tsxFiles:
src/components/navigator/NavigatorView.tsx,src/components/navigator/views/search/NavigatorSearchView.tsxNavigatorView:useNavigatorSearchuseNavigatorDatadestructure ofsearchResultwithuseNavigatorSearch()call returning{ searchResult, isFetching }useNavigatorActionsimport + destructure (it's gone)useEffectblocks (needsSearch / needsInit-init / markReady / reloadCurrentSearch); the new flow:NavigatorInitComposeron firstisVisible— still needed for metadatauseNavigatorUiStore.getState().setTab(context.code)case 'search':store.setTab(parts[2]); store.setFilter(parts[3] ?? ''); store.show();(no morependingSearchref)<NitroCard.Content isLoading={ isLoading }>withisFetchingfrom the querypendingSearchrefNavigatorSearchView:currentFilterfromuseNavigatorUiStatefor the initial input valueuseStatefor the text being typed (mirrors the store value)useEffectwith 300ms timer callinguseNavigatorUiStore.getState().setFilter(text)useNavigatorActionsreferences — the search submit happens via store, query refires automaticallyyarn typecheckcleanyarn test --rungreenyarn lint:hookscleanfeat(navigator): drive search via TanStack Query + setTab/setFilter UI store actionsTask 5: PR
duckietm:Dev:feat(navigator): TanStack Query for search (P2)