Skip to content

feat(code): TanStack Router architectural cleanup#2456

Closed
adamleithp wants to merge 1 commit into
feat/tanstack-routerfrom
feat/tanstack-router-full-port
Closed

feat(code): TanStack Router architectural cleanup#2456
adamleithp wants to merge 1 commit into
feat/tanstack-routerfrom
feat/tanstack-router-full-port

Conversation

@adamleithp
Copy link
Copy Markdown
Contributor

Stacks on top of #2455. Lands the high-impact cleanups from that PR's architect review; defers the navigationStore consumer migration and full store deletion to follow-up PRs that can be reviewed independently.

What's in this PR

PR 2 cleanups

  • ✅ Check in routeTree.gen.ts. .gitattributes marks it linguist-generated so reviewers can collapse regen diffs. Fresh-clone pnpm typecheck works without a prior pnpm dev/build.
  • ✅ TanStack Router DevTools moved to lazy() + <Suspense/> behind import.meta.env.DEV. The ~50KB devtools chunk no longer ships in the prod bundle (was getting included despite the conditional render).
  • ✅ Drop as string cast in syncToRouter — narrower types via early-return.
  • ✅ Settings close() now calls router.history.back() instead of hard router.navigate("/code"). Dismissing settings returns the user to their prior page (e.g. /code/inbox).
  • ✅ Settings open() no longer issues window.history.pushState — it was colliding with hashHistory's own state management.

Break the circular import

  • ✅ New apps/code/src/renderer/navigationBridge.ts owns all router.navigate calls used by Zustand stores. Stores import the bridge, not @renderer/router. Breaks the store → router → routeTree → __root → store cycle that previously worked only because of ES module live bindings.
  • SettingsCategory moved to features/settings/types.ts so the type can be shared without crossing the cycle.

Settings as a real route (PR 3)

  • ✅ New SettingsPanel.tsx holds the visual content (left nav + sections). Identical UI to the previous SettingsDialog content, minus the fixed inset-0 z-[100] overlay positioning.
  • /settings/$category route renders <SettingsPanel/> as a full-screen page.
  • __root.tsx detects /settings/* matches via useRouterState and renders without app chrome for those routes (no HeaderRow, no MainSidebar, no SpaceSwitcher, no TourOverlay, no HedgehogMode). Settings takes the full window.
  • SettingsDialog.tsx is now a thin overlay wrapper around SettingsPanel, used only in pre-router shells (AiApprovalScreen) where RouterProvider isn't mounted yet.

Cold-boot URL restore (partial PR 5)

  • router.ts writes window.location.hash to localStorage on every router resolution and restores it synchronously before createTanStackRouter reads location. Eliminates the TaskInput flash users saw on cold start when their last route was a task or inbox view. (Electron's BrowserWindow.loadFile resets the URL hash on relaunch, so this is the workaround.)

Scaffolding for PR 6

  • New taskInputPrefillStore.ts (unused yet). Will hold transient TaskInput prefill state (initialPrompt, reportAssociation, etc.) when navigationStore is deleted. Shipped as a separate file now so PR 6's diff is purely consumer migration.

Deferred to follow-up PRs

PR 6 — Delete navigationStore

Why deferred: 91 call sites. ~25 read view.data (the full Task object, not just the id). Safely porting requires a queryClient module ref so view.data can be populated from React Query's task cache when the URL is the navigation source (deep links, back/forward).

Plan:

  1. Add apps/code/src/renderer/queryClientRef.tsProviders populates a module-level ref at mount.
  2. Rewrite navigationStore.ts as a derived facade: view comes from router state + the query-cache lookup; actions call navigationBridge. Drop persistence, history stack, hydrateTask.
  3. Sweep imperative .getState() / .setState() callers (~6 files).
  4. Delete syncToRouter (dead code once the store no longer sets view).

PR 7 — Delete settingsDialogStore

Why deferred: depends on PR 6 landing so the patterns are consistent. ~20 call sites; cleanly maps to useNavigate/<Link to=\"/settings/\$category\"/>.

Polish (PR 8 or distributed)

  • Route loaders for TaskDetail / Inbox (currently each route does useTasks().find in render).
  • Wire Cmd+[ / Cmd+] menu shortcuts to router.history.go(±1).
  • Re-evaluate autoCodeSplitting: true once nav is stable (Electron loads from local disk — 12 tiny chunks may net negative on parse).

Test plan

Automated (passing):

  • Renderer typecheck (pnpm exec tsc -p tsconfig.web.json --noEmit)
  • Vitest suite — 131 files / 1598 tests pass
  • Biome lint clean

Manual (please verify):

  • App boots to last route (cold-boot URL restore working)
  • All previous flows from feat(code): introduce TanStack Router file-based routing #2455 still work
  • Settings opens as a full-screen page (no sidebar/header visible)
  • Settings "Back to app" button returns to prior route (e.g. /code/inbox), not always /code
  • Escape from settings → returns to prior route
  • Auto-open from billing limit (registerBillingSubscriptions) still works — note this now yanks user to /settings/plan-usage; verify the UX is acceptable or that we add a "silent" flag in PR 6
  • DevTools button visible in bottom-right in dev mode
  • Prod bundle does NOT include @tanstack/react-router-devtools (check dist/assets/index-*.js for the absence of TanStackRouterDevtools)
  • AiApprovalScreen settings modal still works (this still uses the SettingsDialog overlay, not the route)

🤖 Generated with Claude Code

Follow-up to #2455. Lands the high-impact cleanups from that PR's review;
defers the navigationStore consumer migration (~91 call sites) and full store
deletion to follow-up PRs that can be reviewed independently.

PR 2 cleanups:
- Check in routeTree.gen.ts; .gitattributes marks it as linguist-generated
  so reviewers don't get noise from regen diffs. Fresh-clone typecheck now
  works without a prior dev/build run.
- TanStack Router DevTools moved to lazy() + Suspense behind
  import.meta.env.DEV so the ~50KB devtools chunk is excluded from the prod
  bundle (was getting bundled despite the conditional render).
- Drop `as string` cast in syncToRouter; cleaner narrowing via early return.
- settings close() now uses router.history.back() instead of hard
  router.navigate("/code"), so dismissing settings returns to the prior
  page (e.g. /code/inbox) rather than always landing at /code.
- settings open() no longer issues window.history.pushState — it was
  fighting hashHistory's own state management.

Circular import fix:
- New apps/code/src/renderer/navigationBridge.ts holds all router.navigate
  calls used by Zustand stores. Stores import the bridge, not the router.
  Breaks the store → router → routeTree → __root → store cycle that was
  working only because ES module bindings are live.
- SettingsCategory moved to features/settings/types.ts to keep type
  imports cycle-free.

Settings as a real route (PR 3):
- New SettingsPanel.tsx holds the visual content (sidebar + sections).
- /settings/$category route renders SettingsPanel as a full-screen page.
- __root.tsx detects /settings/* matches and skips the app chrome
  (HeaderRow, MainSidebar, SpaceSwitcher, TourOverlay, HedgehogMode) for
  those routes — settings takes the full window.
- SettingsDialog.tsx becomes a thin overlay wrapper around SettingsPanel,
  used only in pre-router shells (AiApprovalScreen) where RouterProvider
  isn't mounted yet.

Cold-boot URL restore (partial PR 5):
- router.ts writes window.location.hash to localStorage on every router
  resolution and restores it synchronously before createTanStackRouter
  reads location. Eliminates the TaskInput flash users saw on cold start
  when their last route was a task or inbox view.

New taskInputPrefillStore scaffold (unused yet, prep for PR 6) that will
hold transient TaskInput prefill (initialPrompt, reportAssociation, etc.)
when navigationStore is deleted.

Deferred to follow-up PRs (documented in the PR body):
- PR 6: full navigationStore deletion. 91 call sites, ~25 read view.data
  (the Task object), need queryClient module ref to populate view.data
  from cache when URL is the navigation source.
- PR 7: settingsDialogStore consumer migration to useNavigate/<Link/>.
- Route loaders for TaskDetail/Inbox, menu Cmd+[/] hotkeys.

Co-Authored-By: Claude Opus 4.7 (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 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
apps/code/src/renderer/features/settings/components/SettingsDialog.tsx:14-23
**Popstate condition is always-false guard — dead code**

`open()` no longer calls `window.history.pushState({ settingsOpen: true })`, so `window.history.state?.settingsOpen` is always `undefined` / falsy. The condition `!window.history.state?.settingsOpen` is therefore always `true`, meaning any `popstate` event while the overlay is open (e.g. the user pressing Electron's back button in `AiApprovalScreen`) will unconditionally close the dialog — which was never the original intent.

Either remove this handler entirely (the overlay is now the only remaining caller and there's no pushed state to guard against), or replace the condition with one that reflects the current behaviour.

### Issue 2 of 4
apps/code/src/renderer/features/settings/components/SettingsDialog.tsx:25-35
**Redundant Escape handler — `SettingsPanel` already owns it**

`SettingsPanel` now registers `useHotkeys("escape", close, { enabled: true, ... })` unconditionally while it is mounted. When the overlay is open, `SettingsPanel` is rendered inside `SettingsDialog`, so Escape already fires `close()` via the hotkey. This second listener on `window` fires a duplicate `close()` call immediately after. The guard `if (!wasOpen) return` in `close()` makes it harmless, but it violates the OnceAndOnlyOnce rule. This `keydown` effect can be dropped from `SettingsDialog`.

### Issue 3 of 4
apps/code/src/renderer/routes/__root.tsx:46-48
**Import declarations after module-level `const`**

The `import` statements for `GlobalEventHandlers`, `useNewTaskDeepLink`, and `useTaskDeepLink` (lines 46–48) appear after the `const TanStackRouterDevtools = ...` declaration at line 38. ES module imports are hoisted so this is functionally correct, but placing `import` declarations after non-import code is unconventional and may trip static analysis. Moving the three imports above line 35 (joining the existing import block) would keep the file idiomatic.

### Issue 4 of 4
apps/code/src/renderer/features/task-detail/stores/taskInputPrefillStore.ts:32-39
The team convention is to use `null` rather than `undefined` to express an intentional absence. Both fields being cleared here (`reportAssociation`, `initialCloudRepository`) represent a deliberate removal of a value, not a missing optional. Using `null` makes that intent explicit and avoids potential confusion with fields that are simply absent from a partial update.

```suggestion
  clearReportAssociation: () =>
    set((s) => ({
      prefill: {
        ...s.prefill,
        reportAssociation: null,
        initialCloudRepository: null,
      },
    })),
```

Reviews (1): Last reviewed commit: "feat(code): TanStack Router architectura..." | Re-trigger Greptile

Comment on lines 14 to 23
useEffect(() => {
if (!isOpen) return;
const handlePopState = () => {
if (isOpen && !window.history.state?.settingsOpen) {
if (!window.history.state?.settingsOpen) {
useSettingsDialogStore.setState({ isOpen: false });
}
};

window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [isOpen]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Popstate condition is always-false guard — dead code

open() no longer calls window.history.pushState({ settingsOpen: true }), so window.history.state?.settingsOpen is always undefined / falsy. The condition !window.history.state?.settingsOpen is therefore always true, meaning any popstate event while the overlay is open (e.g. the user pressing Electron's back button in AiApprovalScreen) will unconditionally close the dialog — which was never the original intent.

Either remove this handler entirely (the overlay is now the only remaining caller and there's no pushed state to guard against), or replace the condition with one that reflects the current behaviour.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/settings/components/SettingsDialog.tsx
Line: 14-23

Comment:
**Popstate condition is always-false guard — dead code**

`open()` no longer calls `window.history.pushState({ settingsOpen: true })`, so `window.history.state?.settingsOpen` is always `undefined` / falsy. The condition `!window.history.state?.settingsOpen` is therefore always `true`, meaning any `popstate` event while the overlay is open (e.g. the user pressing Electron's back button in `AiApprovalScreen`) will unconditionally close the dialog — which was never the original intent.

Either remove this handler entirely (the overlay is now the only remaining caller and there's no pushed state to guard against), or replace the condition with one that reflects the current behaviour.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +25 to +35
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
close();
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [isOpen, close]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant Escape handler — SettingsPanel already owns it

SettingsPanel now registers useHotkeys("escape", close, { enabled: true, ... }) unconditionally while it is mounted. When the overlay is open, SettingsPanel is rendered inside SettingsDialog, so Escape already fires close() via the hotkey. This second listener on window fires a duplicate close() call immediately after. The guard if (!wasOpen) return in close() makes it harmless, but it violates the OnceAndOnlyOnce rule. This keydown effect can be dropped from SettingsDialog.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/settings/components/SettingsDialog.tsx
Line: 25-35

Comment:
**Redundant Escape handler — `SettingsPanel` already owns it**

`SettingsPanel` now registers `useHotkeys("escape", close, { enabled: true, ... })` unconditionally while it is mounted. When the overlay is open, `SettingsPanel` is rendered inside `SettingsDialog`, so Escape already fires `close()` via the hotkey. This second listener on `window` fires a duplicate `close()` call immediately after. The guard `if (!wasOpen) return` in `close()` makes it harmless, but it violates the OnceAndOnlyOnce rule. This `keydown` effect can be dropped from `SettingsDialog`.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 46 to 48
import { GlobalEventHandlers } from "../components/GlobalEventHandlers";
import { useNewTaskDeepLink } from "../hooks/useNewTaskDeepLink";
import { useTaskDeepLink } from "../hooks/useTaskDeepLink";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Import declarations after module-level const

The import statements for GlobalEventHandlers, useNewTaskDeepLink, and useTaskDeepLink (lines 46–48) appear after the const TanStackRouterDevtools = ... declaration at line 38. ES module imports are hoisted so this is functionally correct, but placing import declarations after non-import code is unconventional and may trip static analysis. Moving the three imports above line 35 (joining the existing import block) would keep the file idiomatic.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/routes/__root.tsx
Line: 46-48

Comment:
**Import declarations after module-level `const`**

The `import` statements for `GlobalEventHandlers`, `useNewTaskDeepLink`, and `useTaskDeepLink` (lines 46–48) appear after the `const TanStackRouterDevtools = ...` declaration at line 38. ES module imports are hoisted so this is functionally correct, but placing `import` declarations after non-import code is unconventional and may trip static analysis. Moving the three imports above line 35 (joining the existing import block) would keep the file idiomatic.

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!

Comment on lines +32 to +39
clearReportAssociation: () =>
set((s) => ({
prefill: {
...s.prefill,
reportAssociation: undefined,
initialCloudRepository: undefined,
},
})),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The team convention is to use null rather than undefined to express an intentional absence. Both fields being cleared here (reportAssociation, initialCloudRepository) represent a deliberate removal of a value, not a missing optional. Using null makes that intent explicit and avoids potential confusion with fields that are simply absent from a partial update.

Suggested change
clearReportAssociation: () =>
set((s) => ({
prefill: {
...s.prefill,
reportAssociation: undefined,
initialCloudRepository: undefined,
},
})),
clearReportAssociation: () =>
set((s) => ({
prefill: {
...s.prefill,
reportAssociation: null,
initialCloudRepository: null,
},
})),

Rule Used: Use 'null' instead of 'undefined' to indicate an i... (source)

Learned From
PostHog/posthog#32556

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/task-detail/stores/taskInputPrefillStore.ts
Line: 32-39

Comment:
The team convention is to use `null` rather than `undefined` to express an intentional absence. Both fields being cleared here (`reportAssociation`, `initialCloudRepository`) represent a deliberate removal of a value, not a missing optional. Using `null` makes that intent explicit and avoids potential confusion with fields that are simply absent from a partial update.

```suggestion
  clearReportAssociation: () =>
    set((s) => ({
      prefill: {
        ...s.prefill,
        reportAssociation: null,
        initialCloudRepository: null,
      },
    })),
```

**Rule Used:** Use 'null' instead of 'undefined' to indicate an i... ([source](https://app.greptile.com/posthog-org-19734/-/custom-context?memory=0631667e-2260-42be-a589-0d8da9ef007d))

**Learned From**
[PostHog/posthog#32556](https://github.com/PostHog/posthog/pull/32556)

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!

@adamleithp
Copy link
Copy Markdown
Contributor Author

Superseded by #2469 — the three-PR stack is collapsed into a single PR (rebased on main, conflicts resolved, CI green: typecheck + biome + 1594 unit tests). No intermediate layer was independently green, so the stack was collapsed rather than fixed three times. Closing in favor of #2469.

@adamleithp adamleithp closed this Jun 2, 2026
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