Skip to content

feat(code): migrate renderer to TanStack Router#2469

Open
adamleithp wants to merge 4 commits into
mainfrom
feat/tanstack-router-migration
Open

feat(code): migrate renderer to TanStack Router#2469
adamleithp wants to merge 4 commits into
mainfrom
feat/tanstack-router-migration

Conversation

@adamleithp
Copy link
Copy Markdown
Contributor

@adamleithp adamleithp commented Jun 2, 2026

Consolidates the TanStack Router migration stack — #2455 (file-based routing), #2456 (architectural cleanup), #2457 (navigationStore → router-derived facade) — into one PR, rebased on main and stabilized.

Why one PR instead of the stack

No layer was independently shippable: the base (#2455) failed typecheck + unit and conflicted with main, and #2457 rewrites much of what #2455/#2456 introduced. The stack gave false review granularity and 3× the CI/rebase/conflict work. Collapsing makes the broken intermediate-state failures vanish (≈2/3 of the red checks) and leaves one green CI surface + one review. Supersedes #2455, #2456, #2457.

What's in it

Migration

  • File-based routing via the TanStack Router Vite plugin (autoCodeSplitting), hash history for Electron loadFile, cold-boot last-route restore.
  • Deletes navigationStore + settingsDialogStore; URL is the source of truth. Transient state moves to small stores (settingsPageStore, taskInputPrefillStore).
  • Settings becomes a full-page route; SettingsDialog retained for the AI-consent gate via imperative openSettingsDialog().

Stabilization (defects found shaking the stack out)

  • Import cycle router → routeTree → __root → hooks → navigationBridge → router broke code-split chunks (TDZ). Added routerRef (leaf module); the bridge degrades to a no-op when the router isn't mounted instead of throwing.
  • Render storm: useAppView returned a fresh object every render → SidebarMenu's [view] effect looped (markViewed ~50×/2s), starving the UI thread. Now memoized on route primitives + stable prefill ref.
  • Loader hang: task-detail loader is synchronous + cache-only (can't hang the router on optimistic/cloud tasks); cold-deep-link fetch + spinner live in the component; openTask seeds the detail cache.

Per-route loading UX

  • Router context: { queryClient }, createRootRouteWithContext, defaultPendingMs: 0 + defaultPendingComponent (RoutePending). Navigation commits instantly with a per-route pending slot ready for skeletons.

Merge resolution vs main

  • AiApprovalScreen: kept main's in-app approve mutation + applied the store migration.
  • authStore: kept main's org-switching (orgProjectsMap/switchOrg/current*), migrated switchOrg + logout off the deleted stores.
  • Biome ignores the generated routeTree.gen.ts; marked linguist-generated.

Verification

  • typecheck
  • biome check (whole repo)
  • ✅ 1594 / 1594 renderer unit tests

Follow-ups (separate PRs, post-merge)

  • P0 Delete useAppView → primitive useRouterState selectors + per-route useParams (removes the render-storm class permanently).
  • P1 Rip out navigationBridge → typed <Link>/useNavigate() (removes the import cycle structurally; routerRef can go).
  • P1 Add defaultErrorComponent + defaultNotFoundComponent (no error boundaries today).
  • P1 Roll the shared-queryOptions loader pattern + per-route skeletons across the other data routes.
  • P2 Typed search params (validateSearch) to retire settingsPageStore/taskInputPrefillStore; auth as beforeLoad guards; tune defaultPendingMs.
  • P3 Regression test asserting the view hook returns a stable reference; lint guard for hooks returning fresh objects.

🤖 Generated with Claude Code


🧪 Manual testing checklist

Routing now drives app boot, auth gating, and every screen. Check each area on a fully reloaded build (Cmd+Shift+R, or restart pnpm dev:code) — HMR can't safely apply these graph changes.

App boot & route restore

  • Cold start lands on /code (new-task input), no flash of the wrong screen
  • Quit and relaunch restores the last route (open a task, quit, relaunch → same task)
  • Last-route restore works for a deep route (e.g. /settings/environments, /skills)
  • No markViewed / IPC-rate spam in the terminal at idle (render-storm regression)

Onboarding & auth gates (App.tsx gates the RouterProvider)

  • Fresh/logged-out: onboarding flow renders, completes through to the app
  • Auth screen → OAuth login lands in the app on /code
  • Invite-code screen shows when required and proceeds after redeeming
  • AI-consent gate (admin): "Approve AI data processing" button works in-app (mutation), success dismisses the gate
  • AI-consent gate (non-admin): shows ask-an-admin copy, no approve button
  • AI-consent gate: failure shows the error callout
  • Settings opens from within the AI-consent gate (gear / shortcut)

Auth lifecycle (authStore merge with main's org-switching)

  • Logout clears state and returns to the new-task screen (no hang, no "Router accessed before initialization")
  • Login → project selection (multi-project account) → lands in app
  • Switch org (new from main) navigates to new-task input and reloads data
    • CANT REPRO
  • Select project switches and resets session correctly

New task flow (loader / storm hotspot)

  • Create a task with a prompt → "Starting task…" advances to a live session (no infinite spinner)
  • New task appears and persists in the sidebar list
  • Create with no prompt / from a folder group works
  • Failure path returns to task input with the prompt preserved
  • Pending task view (/code/tasks/pending/$key) shows then transitions to the real task

Navigation between routes

  • Sidebar: every item navigates (tasks, inbox, archived, skills, MCP servers, command center, settings)
  • Switching between tasks is instant when cached; can navigate away mid-load (no lock)
  • Space switcher / command menu navigation works
  • Opening a task from the command center / task selector works
  • Deleting the task you're viewing navigates you away

Per-route loading

  • Cached task opens instantly (no spinner flash)
  • Uncached/cold route shows the centered spinner, then content (no full-page flicker over sidebar/header)

Settings (full-page route)

  • /settings redirects to /settings/general
  • App chrome (header/sidebar/space-switcher) is hidden on settings
  • Switching categories (general → environments → worktrees, etc.) works
  • Close settings returns to the prior route via history; falls back to /code on deep-link entry
  • Environments: create/edit flows (initialAction) open correctly

Deep links (warm + cold)

  • Open-task deep link opens the task (cold start and while running)
  • New-task deep link: new, plan, and issue actions land on task input with prefill
  • Inbox deep link navigates to the inbox report
  • Stale-folder task open redirects to /folders/$folderId

Other routes

  • Inbox loads and signals/report interactions work
  • Archived tasks load; unarchive works
  • Skills page loads
  • MCP servers page loads
  • Command center loads and assigns tasks
  • Folder settings (/folders/$folderId) loads

Regressions to watch

  • No infinite re-render / UI-thread stalls anywhere (watch dev terminal for IPC-rate warnings)
  • No blank screens on navigation (would indicate a thrown loader/render — note: error boundaries are a follow-up)
  • Browser/router devtools show clean transitions (dev only)

Collapses the three-PR stack (#2455 file-based routing, #2456 architectural
cleanup, #2457 navigationStore → router-derived facade) into a single change,
rebased on main and stabilized. No intermediate layer was independently green
(the base failed typecheck/unit and conflicted with main), so the stack was
providing false review granularity; one PR is one green CI surface and one
review.

## Migration
- File-based routing via the TanStack Router Vite plugin (autoCodeSplitting),
  hash history for Electron's loadFile, cold-boot last-route restore.
- Delete navigationStore + settingsDialogStore. URL is the source of truth;
  remaining transient state lives in small stores (settingsPageStore,
  taskInputPrefillStore).
- Settings becomes a full-page route; SettingsDialog kept for the AI-consent
  gate via an imperative openSettingsDialog().

## Stabilization (defects found shaking the stack out)
- Break the import cycle (router → routeTree → __root → hooks →
  navigationBridge → router) that broke code-split chunks via TDZ. Added
  routerRef (leaf module); navigationBridge reads it and degrades to a no-op
  when the router isn't mounted (early boot / tests) rather than throwing.
- Memoize useAppView on route primitives + stable prefill ref. It previously
  returned a fresh object every render, turning SidebarMenu's [view] effect
  into an infinite markViewed loop that starved the UI thread.
- Task-detail loader is synchronous + cache-only so it can never hang the
  router on optimistic/cloud-pending tasks; the cold-deep-link fetch + spinner
  live in the component. openTask seeds the detail cache.

## Per-route loading
Router context (queryClient), createRootRouteWithContext, defaultPendingMs: 0 +
defaultPendingComponent (RoutePending) — navigation commits instantly with a
per-route pending slot ready to become skeletons.

## Merge resolution (vs main)
- AiApprovalScreen: kept main's in-app approve mutation, applied the
  settingsDialogStore → openSettingsDialog migration on top.
- authStore: kept main's org-switching (orgProjectsMap/switchOrg/current*),
  migrated its new switchOrg + logout off the deleted stores.
- AiApprovalScreen.test: hoist spies (vi.hoisted), mock openSettingsDialog.

## Tooling
biome ignores the generated routeTree.gen.ts (lint/format/assist); marked
linguist-generated.

Typecheck, biome, and all 1594 renderer unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 2, 2026

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/code/src/renderer/navigationBridge.ts:48-50
The `COMMAND_CENTER_VIEWED` analytics event was tracked in the old `navigationStore.navigateToCommandCenter` action but is now silently dropped. After this PR, `COMMAND_CENTER_VIEWED` is never fired anywhere in the codebase — `navigationBridge.navigateToCommandCenter` does a bare router navigate, and the route component adds no tracking call. The `track` import and the constant are both still present in the analytics types, so no compiler error catches this.

```suggestion
export function navigateToCommandCenter(): void {
  void getRouterOrNull()?.navigate({ to: "/command-center" });
  track(ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED);
}
```

### Issue 2 of 3
apps/code/src/renderer/hooks/useAppView.ts:48-55
**Stale `data` field in the task-detail branch**`getCachedTask(taskId)` is called inside `deriveFromMatches`, which runs inside a `useMemo` that only re-computes when route primitives change (`routeId`, `taskId`, etc.). If the React Query cache for that task is updated by a poll, mutation, or subscription while the user stays on the same task-detail route, the memo won't re-fire and `view.data` will silently carry stale task data. Any consumer reading `view.data` for live content (rather than using `useTasks()` / `useQuery()` directly) will render out-of-date information. The PR description acknowledges deleting `useAppView` in a P0 follow-up; in the meantime callers should prefer `view.taskId` and their own query hooks over `view.data`.

### Issue 3 of 3
apps/code/src/renderer/hooks/useAppView.ts:22-25
**Duplicate `TaskInputReportAssociation` type** — the same interface is declared here and again in `taskInputPrefillStore.ts`. The two definitions are structurally identical today but can drift. Consider re-exporting the single canonical type from `taskInputPrefillStore.ts` (or `types.ts`) and importing it here instead.

Reviews (1): Last reviewed commit: "feat(code): migrate renderer to TanStack..." | Re-trigger Greptile

Comment thread apps/code/src/renderer/navigationBridge.ts
Comment thread apps/code/src/renderer/hooks/useAppView.ts
Comment thread apps/code/src/renderer/hooks/useAppView.ts Outdated
adamleithp and others added 2 commits June 2, 2026 23:29
Switching settings categories pushed a history entry each time, so
closeSettings' single history.back() walked back through previously-visited
categories (environments → general → app) instead of leaving settings. Make
in-settings category switches replace the history entry; opening settings still
pushes one entry, so "Back to app" returns directly to the route you came from.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…a, dedup type

- Restore COMMAND_CENTER_VIEWED analytics: the pre-router navigationStore
  emitted it on navigate; navigationBridge.navigateToCommandCenter now does too
  (TASK_VIEWED parity is already covered by openTask).
- Remove the memoized `AppView.data` snapshot, which could go stale while the
  user stayed on a task (the memo only recomputes on route-primitive changes).
  HeaderRow now reads the live task via useTasks() keyed on view.taskId, and
  useSidebarData uses view.taskId — both equivalent cache coverage but live.
- Dedup TaskInputReportAssociation: import the canonical type from
  taskInputPrefillStore instead of redeclaring it in useAppView.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@adamleithp
Copy link
Copy Markdown
Contributor Author

Addressed the review comments in 4aec5f4f9:

  • P1 — COMMAND_CENTER_VIEWED dropped: restored. navigationBridge.navigateToCommandCenter now emits it, matching the old navigationStore action. (The other event the old store fired, TASK_VIEWED, is already preserved in openTask.)
  • P2 — stale AppView.data: removed the data snapshot entirely rather than carry a value that goes stale while staying on a task. HeaderRow now reads the live task via useTasks() keyed on view.taskId; useSidebarData uses view.taskId. Same cache coverage, but reactive. This also shrinks the P0 useAppView follow-up.
  • P2 — duplicate TaskInputReportAssociation: useAppView now imports the canonical type from taskInputPrefillStore instead of redeclaring it.

typecheck, biome, and all 1594 unit tests green.

handleTaskClick looked the task up in taskMap (the full-list query) and
silently did nothing on a miss. Sidebar rows come from the summaries path,
which can include tasks the list query doesn't carry. Fall back to navigating
by id — the task-detail route resolves the task from its own query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant