feat(code): convert navigationStore to router-derived facade#2457
feat(code): convert navigationStore to router-derived facade#2457adamleithp wants to merge 9 commits into
Conversation
Removes the dual source of truth between Zustand and TanStack Router. `useNavigationStore` keeps the same public API surface for the ~91 existing consumers, but internally has no state of its own — `view` is derived from router state + the task query cache on every render. Actions delegate to `navigationBridge`. Persistence, history stack, and `hydrateTask` are gone (URL is the source of truth, hashHistory + cold-boot restore handle the rest). Notable changes: - `navigationStore.ts` rewritten as a custom hook (~200 lines vs ~400). Keeps `useNavigationStore()`, `.getState()`, and a no-op `.setState()` so consumers don't change. - `view.data` (the full Task object) now populates from `getCachedTask(taskId)` against the React Query cache. Consumers that guarded on `view.data` (HeaderRow, GlobalEventHandlers, ArchivedTasksView, etc.) keep working; the value is just undefined until tasks load, same as before for deep-link cases. - Transient TaskInput state (initialPrompt, reportAssociation, initialCloudRepository, etc.) moves to the existing `useTaskInputPrefillStore`. `navigateToTaskInput(options)` writes prefill then navigates to `/code`; the route reads prefill via the derived view. - `syncToRouter` deleted — no internal state to mirror. - Async side effects in `navigateToTask` (workspace + folder reconciliation) preserved unchanged. - `setActiveTaskAnalyticsContext` now fires from a `router.subscribe( "onResolved")` listener at module init, replacing the per-action call. - New `navigationBridge` accessors (`getCurrentMatches`, `getCurrentLocation`, `subscribeToRouterResolved`, `goForwardInHistory`) keep the store's router access cycle-free. - `routes/code/tasks/$taskId.tsx`: removed the URL→store sync effect; with the derived view, the sync is automatic. - `navigationStore.test.ts` rewritten — old tests targeted Zustand internals (persistence, history stack) that no longer exist. New 12 tests exercise view derivation per route and bridge delegation per action. - `notifications.test.ts` mocks `useNavigationStore.getState` directly instead of driving it via `setState` (which is now no-op). All 131 test files / 1594 tests pass. Typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/code/src/renderer/stores/navigationStore.ts:259-261
`hydrateTask` is defined as an inline arrow function inside `getSnapshot()`, which means a new function reference is created on every call. `__root.tsx` destructures `hydrateTask` from `useNavigationStore()` and places it in a `useEffect` dependency array (`[tasks, hydrateTask]`). Because the reference is never the same across renders, that effect fires on every render of `RootLayout` instead of only when `tasks` changes. The effect body is harmless today (calls a no-op), but the fire frequency is a regression from the old store where Zustand guaranteed stable function references.
```suggestion
hydrateTask,
```
### Issue 2 of 2
apps/code/src/renderer/stores/navigationStore.ts:257-258
`canGoBack` and `canGoForward` are also inline functions, giving them the same unstable-reference problem as `hydrateTask`. They currently have no call sites, but any future consumer that puts them in a `useCallback` or `useEffect` dependency array would get unexpected re-runs. Lifting them to module-level stubs makes the snapshot fully stable and consistent with the module-level action functions.
```suggestion
canGoBack,
canGoForward,
```
Reviews (1): Last reviewed commit: "feat(code): convert navigationStore to r..." | Re-trigger Greptile |
| hydrateTask: () => { | ||
| /* No-op: the URL is the source of truth now. */ | ||
| }, |
There was a problem hiding this comment.
hydrateTask is defined as an inline arrow function inside getSnapshot(), which means a new function reference is created on every call. __root.tsx destructures hydrateTask from useNavigationStore() and places it in a useEffect dependency array ([tasks, hydrateTask]). Because the reference is never the same across renders, that effect fires on every render of RootLayout instead of only when tasks changes. The effect body is harmless today (calls a no-op), but the fire frequency is a regression from the old store where Zustand guaranteed stable function references.
| hydrateTask: () => { | |
| /* No-op: the URL is the source of truth now. */ | |
| }, | |
| hydrateTask, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/stores/navigationStore.ts
Line: 259-261
Comment:
`hydrateTask` is defined as an inline arrow function inside `getSnapshot()`, which means a new function reference is created on every call. `__root.tsx` destructures `hydrateTask` from `useNavigationStore()` and places it in a `useEffect` dependency array (`[tasks, hydrateTask]`). Because the reference is never the same across renders, that effect fires on every render of `RootLayout` instead of only when `tasks` changes. The effect body is harmless today (calls a no-op), but the fire frequency is a regression from the old store where Zustand guaranteed stable function references.
```suggestion
hydrateTask,
```
How can I resolve this? If you propose a fix, please make it concise.| canGoBack: () => true, | ||
| canGoForward: () => true, |
There was a problem hiding this comment.
canGoBack and canGoForward are also inline functions, giving them the same unstable-reference problem as hydrateTask. They currently have no call sites, but any future consumer that puts them in a useCallback or useEffect dependency array would get unexpected re-runs. Lifting them to module-level stubs makes the snapshot fully stable and consistent with the module-level action functions.
| canGoBack: () => true, | |
| canGoForward: () => true, | |
| canGoBack, | |
| canGoForward, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/stores/navigationStore.ts
Line: 257-258
Comment:
`canGoBack` and `canGoForward` are also inline functions, giving them the same unstable-reference problem as `hydrateTask`. They currently have no call sites, but any future consumer that puts them in a `useCallback` or `useEffect` dependency array would get unexpected re-runs. Lifting them to module-level stubs makes the snapshot fully stable and consistent with the module-level action functions.
```suggestion
canGoBack,
canGoForward,
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
The pure-hook facade had no per-selector memoization — every nav consumer
re-rendered on every router event AND every prefill change, regardless of
whether their selected slice changed. With ~91 consumers, this manifested
as sluggish navigation and broken interactions (settings stopped opening
correctly, task switching crawled).
Fix: back the facade with `create(() => snapshot)` so Zustand's selector
memoization kicks in. Update the store via subscriptions to:
- router.subscribe("onResolved") — for URL changes
- useTaskInputPrefillStore.subscribe — for transient prefill
- queryClient.getQueryCache().subscribe — for view.data populating
once useTasks resolves
Updates are batched via queueMicrotask to coalesce burst events. Test
fixture exposes a hoisted `_fireRouterResolved` shim so tests can drive
the same subscriber path the runtime uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er migration This completes the TanStack Router migration. The renderer no longer has parallel routing state — every navigation flows through the router. ## navigationStore deletion (~36 files) - New `apps/code/src/renderer/hooks/useAppView.ts` exposes the URL-derived view as `useAppView()` (subscribes to router state) and `getAppViewSnapshot()` (for non-React reads). Replaces every `useNavigationStore((s) => s.view)` consumer. - New `apps/code/src/renderer/hooks/useOpenTask.ts` exports `openTask(task)` (router.navigate + workspace/folder reconciliation side effects) and `openTaskInput(opts)` (writes prefill, navigates to /code). Replaces every `useNavigationStore((s) => s.navigateToTask / .navigateToTaskInput)` consumer. - Direct navigation callers (`navigateToInbox`, `navigateToArchived`, etc.) import from `@renderer/navigationBridge` directly — no hook needed. - Imperative `.getState().view` callers (`useArchiveTask`, `useSessionCallbacks`, `useAppBridge`, `notifications`, etc.) use `getAppViewSnapshot()`. - `navigationStore.ts` and its test are deleted. ## settingsDialogStore deletion (~22 files) - New `apps/code/src/renderer/features/settings/stores/settingsPageStore.ts` holds the UI-only state the dialog store used to own — `context`, `initialAction`, `formMode`. No routing in this store. - New `useOpenSettings.ts` exposes `openSettings(category, contextOrAction)`, `closeSettings()`, `useCloseSettings()`, `useIsSettingsOpen()`. These wrap the `navigationBridge` so consumers can stay router-unaware. - `SettingsDialog.tsx` is now a self-contained modal driver for the pre-router `AiApprovalScreen` shell only — exports `openSettingsDialog` / `closeSettingsDialog` for that case. Inside the main app, the `/settings/$category` route renders `SettingsPanel` directly. - `SettingsPanel` takes optional `activeCategory`, `onClose`, `onCategoryChange` props so it can be reused in both the route and the pre-router dialog shell. - `EnvironmentsSettings` reads its segment (local vs cloud) from the URL param via `useRouterState`. - `settingsDialogStore.ts` and its test are deleted; replaced by `settingsPageStore.test.ts`. ## Bridge expansion `navigationBridge.ts` gains `getCurrentMatches()`, `getCurrentLocation()`, `subscribeToRouterResolved()`, `goForwardInHistory()` so all non-React router access stays inside the bridge. ## Notes - Route loaders for TaskDetail/Inbox are deferred — they need the task-fetch pipeline extracted from `useAuthenticatedQuery` into plain query options that `loader` can call. Worthwhile but a separate refactor. - Cmd+[ / Cmd+] back/forward shortcuts now go through `router.history.back/forward` via the bridge (was the nav store's own history stack). - 1582 tests pass; renderer typecheck and biome lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Peter Kirkham <peter@posthog.com>
…delete-navstore # Conflicts: # apps/code/package.json # pnpm-lock.yaml
…l render storm
Three independent defects surfaced after the TanStack Router migration; all
manifested as "routes stuck loading / navigation locked".
1. Circular import (router → routeTree → __root → hooks → navigationBridge →
router) broke code-split route chunks via TDZ ("Cannot access
'rootRouteImport' before initialization"). Introduce routerRef, a leaf module
holding the router singleton; navigationBridge reads it via getRouter()
instead of importing the router directly, severing the only route-tree edge
back to router.ts.
2. Infinite render storm: useAppView returned a fresh object every render (the
old navigationStore.view was a stable ref), so SidebarMenu's [view] effect
refired forever → markViewed mutation → cache write → re-render (~50x/2s),
starving the UI thread and blocking navigation + session start. Memoize
useAppView on the route's primitive values + stable prefill ref. SidebarMenu
reads view.taskId (new shape; view.data may be undefined).
3. Blocking loader could hang the router: ensureQueryData(getTask) never
resolves for optimistic/cloud-pending tasks, leaving the route pending and
un-navigable. Make the task-detail loader synchronous + cache-only; move the
cold-deep-link fetch + spinner into the component so a hang only affects that
view. openTask seeds the detail cache so in-app opens never fetch.
Also adds the per-route loading slot: router context (queryClient),
defaultPendingMs: 0 + defaultPendingComponent (RoutePending), and
createRootRouteWithContext. Navigation now commits instantly with a per-route
pending UI that can later become skeletons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🩹 Stabilization pass (commit
|
Stacks on top of #2456. Closes out the dual-source-of-truth concern raised in #2455's architect review: the Zustand
navigationStoreno longer holds its ownviewstate — it's now a thin facade that derivesviewfrom the router and the React Query task cache on every render.What changed
useNavigationStorekeeps the same public API surface ((selector?),.getState(),.setState()) so the ~91 existing call sites don't change. Internally:useRouterState+useTaskInputPrefillStoreto know when to re-render. Snapshot is rebuilt from those two sources on every call.view.datapopulates from cache.getCachedTask(taskId)in@utils/queryClientwalks the React Query["tasks", "list"]queries. Consumers readingview.data?.idkeep working; the value is justundefineduntiluseTasks()resolves (same as the deep-link case in the old store).navigateTo*action calls anavigationBridgefunction.goBack/goForwarduserouter.history. No moresyncToRouter(no state to mirror).initialPrompt,reportAssociation,initialCloudRepository,initialModel,initialMode,folderId,taskInputRequestIdnow live inuseTaskInputPrefillStore(introduced in feat(code): TanStack Router architectural cleanup #2456 for exactly this).navigateToTaskInput(options)writes prefill → navigates to/code. The derived view reads prefill and surfaces those fields underview.*exactly like before.navigateToTaskstill does the workspace/folder reconciliation it always did — that logic is unchanged.router.subscribe("onResolved")listener at module init instead of from inside each action.Persistence, the history stack, and
hydrateTaskare deleted. Persistence is replaced by router URL + the cold-boot localStorage restore from #2456. The history stack is replaced byrouter.history.hydrateTaskbecomes a no-op — the URL is the source of truth, no rehydration needed.New helpers in
navigationBridgegetCurrentMatches(),getCurrentLocation()— non-React router state accessors.subscribeToRouterResolved(handler)— hook into route resolution from outside React.goForwardInHistory()— for symmetry withgoBackInHistory.These keep the store cycle-free (
navigationStorenever imports@renderer/routerdirectly).Files touched
stores/navigationStore.tsstores/navigationStore.test.tsroutes/code/tasks/$taskId.tsxnavigationBridge.tsutils/notifications.test.tsuseNavigationStore.getStatedirectlyWhat's NOT in this PR
settingsDialogStoreis still alive. Its consumers can move touseNavigate/<Link/>directly once this lands.navigationStore.tsstill exists as a hook export. Removing the file means migrating all 91 call sites touseNavigate/useParams/useMatch. Worth doing, but the diff would dwarf the architectural change in this PR. I'd schedule it as PR 8 once the facade has run in production for a release cycle.Test plan
Automated (passing):
pnpm exec tsc -p tsconfig.web.json --noEmit)Manual (please verify):
view.datapopulates correctly inHeaderRowtask section (skill buttons, diff stats badge, etc.) when viewing a taskGlobalEventHandlersthat passview.datato handoff buttons)useArchiveTaskstill navigates away from an archived task correctly (it readsview.datato detect the current task)view.dataisundefinedbriefly, then populates onceuseTasksresolves (same behavior as before, this case existed previously)🤖 Generated with Claude Code