From 1810a866180ff031e8b3f152f93d09e806ef4074 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:11:31 +0200 Subject: [PATCH] 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)`