From 9d041aaa7a1c93f50c642119d03066ef1c258c8a Mon Sep 17 00:00:00 2001
From: Trevor Farlow <trevdor@users.noreply.github.com>
Date: Fri, 23 Jun 2023 12:40:59 -0600
Subject: [PATCH] react-router 6 upgrade (#1066)

Co-authored-by: Jed Fox <git@jedfox.com>
---
 packages/desktop-client/package.json          |   4 +-
 .../src/components/FinancesApp.js             | 348 +++++++---------
 .../src/components/FloatableSidebar.js        |  11 +-
 .../src/components/GlobalKeys.js              |  12 +-
 .../src/components/LoggedInUser.js            |   9 +-
 .../desktop-client/src/components/Modals.js   | 391 +++++++++---------
 .../desktop-client/src/components/Page.js     |   6 +-
 .../src/components/SidebarWithData.js         |  28 +-
 .../desktop-client/src/components/Titlebar.js |  58 +--
 .../src/components/accounts/Account.js        |  20 +-
 .../src/components/accounts/MobileAccount.js  |   3 +-
 .../src/components/accounts/MobileAccounts.js |   2 +-
 .../components/accounts/TransactionList.js    |  13 +-
 .../src/components/budget/index.js            |   8 +-
 .../desktop-client/src/components/common.tsx  |  49 +--
 .../src/components/manager/ConfigServer.js    |  16 +-
 .../src/components/manager/ManagementApp.js   |  96 ++---
 .../src/components/manager/Modals.js          |  13 +-
 .../manager/subscribe/ChangePassword.tsx      |   8 +-
 .../components/manager/subscribe/Error.tsx    |   6 +-
 .../components/manager/subscribe/common.tsx   |  12 +-
 .../components/modals/CreateLocalAccount.js   |   6 +-
 .../src/components/modals/EditRule.js         |   1 -
 .../components/modals/MergeUnusedPayees.js    |   1 -
 .../src/components/reports/Overview.js        |   1 -
 .../src/components/reports/index.js           |  16 +-
 .../components/schedules/DiscoverSchedules.js |  16 +-
 .../src/components/schedules/EditSchedule.js  |   8 +-
 .../src/components/schedules/LinkSchedule.js  |   6 +-
 .../schedules/PostsOfflineNotification.js     |   8 +-
 .../src/components/schedules/index.js         |  10 +-
 .../desktop-client/src/components/sidebar.js  |  16 +-
 packages/desktop-client/src/global-events.js  |  12 +-
 .../desktop-client/src/util/location-state.js |  12 -
 .../desktop-client/src/util/router-tools.tsx  |  71 ++++
 packages/desktop-electron/menu.js             |   4 +-
 .../loot-core/src/client/actions/budgets.ts   |   2 +-
 packages/loot-core/typings/window.d.ts        |   5 +-
 upcoming-release-notes/1066.md                |   6 +
 yarn.lock                                     | 162 +-------
 40 files changed, 678 insertions(+), 798 deletions(-)
 delete mode 100644 packages/desktop-client/src/util/location-state.js
 create mode 100644 packages/desktop-client/src/util/router-tools.tsx
 create mode 100644 upcoming-release-notes/1066.md

diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json
index 7e14e925d..a6c58d236 100644
--- a/packages/desktop-client/package.json
+++ b/packages/desktop-client/package.json
@@ -48,9 +48,7 @@
     "react-merge-refs": "^1.1.0",
     "react-modal": "3.16.1",
     "react-redux": "7.2.1",
-    "react-router": "5.2.0",
-    "react-router-dom": "5.2.0",
-    "react-router-dom-v5-compat": "^6.4.1",
+    "react-router-dom": "6.11.2",
     "react-scripts": "^5.0.1",
     "react-spring": "^9.7.1",
     "react-virtualized-auto-sizer": "^1.0.2",
diff --git a/packages/desktop-client/src/components/FinancesApp.js b/packages/desktop-client/src/components/FinancesApp.js
index c82f1524f..8d6c3bc5c 100644
--- a/packages/desktop-client/src/components/FinancesApp.js
+++ b/packages/desktop-client/src/components/FinancesApp.js
@@ -1,18 +1,17 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useMemo } from 'react';
 import { DndProvider } from 'react-dnd';
 import Backend from 'react-dnd-html5-backend';
 import { connect } from 'react-redux';
 import {
-  Router,
   Route,
-  Redirect,
-  Switch,
-  useLocation,
+  Routes,
+  Navigate,
   NavLink,
+  useNavigate,
+  BrowserRouter,
+  useParams,
 } from 'react-router-dom';
-import { CompatRouter } from 'react-router-dom-v5-compat';
 
-import { createBrowserHistory } from 'history';
 import hotkeys from 'hotkeys-js';
 
 import * as actions from 'loot-core/src/client/actions';
@@ -20,20 +19,18 @@ import { AccountsProvider } from 'loot-core/src/client/data-hooks/accounts';
 import { PayeesProvider } from 'loot-core/src/client/data-hooks/payees';
 import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider';
 import checkForUpdateNotification from 'loot-core/src/client/update-notification';
-import * as undo from 'loot-core/src/platform/client/undo';
 
 import Cog from '../icons/v1/Cog';
 import PiggyBank from '../icons/v1/PiggyBank';
 import Wallet from '../icons/v1/Wallet';
 import { useResponsive } from '../ResponsiveProvider';
 import { colors, styles } from '../style';
-import { getLocationState, makeLocationState } from '../util/location-state';
+import { ExposeNavigate, StackedRoutes } from '../util/router-tools';
 import { getIsOutdated, getLatestVersion } from '../util/versions';
 
 import Account from './accounts/Account';
 import MobileAccount from './accounts/MobileAccount';
 import MobileAccounts from './accounts/MobileAccounts';
-import { ActiveLocationProvider } from './ActiveLocation';
 import BankSyncStatus from './BankSyncStatus';
 import Budget from './budget';
 import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext';
@@ -45,7 +42,6 @@ import { ManageRulesPage } from './ManageRulesPage';
 import Modals from './Modals';
 import NordigenLink from './nordigen/NordigenLink';
 import Notifications from './Notifications';
-import { PageTypeProvider } from './Page';
 import { ManagePayeesPage } from './payees/ManagePayeesPage';
 import Reports from './reports';
 import Schedules from './schedules';
@@ -58,125 +54,126 @@ import Titlebar, { TitlebarProvider } from './Titlebar';
 
 function NarrowNotSupported({ children, redirectTo = '/budget' }) {
   const { isNarrowWidth } = useResponsive();
-  return isNarrowWidth ? <Redirect to={redirectTo} /> : children;
+  const navigate = useNavigate();
+  useEffect(() => {
+    if (isNarrowWidth) {
+      navigate(redirectTo);
+    }
+  }, [isNarrowWidth, navigate, redirectTo]);
+  return isNarrowWidth ? null : children;
 }
 
-function Routes({ location }) {
+function StackedRoutesInner({ location }) {
   const { isNarrowWidth } = useResponsive();
   return (
-    <Switch location={location}>
-      <Route path="/" exact render={() => <Redirect to="/budget" />} />
+    <Routes location={location}>
+      <Route path="/" element={<Navigate to="/budget" replace />} />
+
+      <Route
+        path="/reports/*"
+        element={
+          <NarrowNotSupported>
+            <Reports />
+          </NarrowNotSupported>
+        }
+      />
 
-      <Route path="/reports">
-        <NarrowNotSupported>
-          <Reports />
-        </NarrowNotSupported>
-      </Route>
+      <Route
+        path="/budget"
+        element={isNarrowWidth ? <MobileBudget /> : <Budget />}
+      />
 
-      <Route path="/budget">
-        {isNarrowWidth ? <MobileBudget /> : <Budget />}
-      </Route>
+      <Route
+        path="/schedules"
+        element={
+          <NarrowNotSupported>
+            <Schedules />
+          </NarrowNotSupported>
+        }
+      />
 
-      <Route path="/schedules" exact>
-        <NarrowNotSupported>
-          <Schedules />
-        </NarrowNotSupported>
-      </Route>
-      <Route path="/schedule/edit" exact>
-        <NarrowNotSupported>
-          <EditSchedule />
-        </NarrowNotSupported>
-      </Route>
-      <Route path="/schedule/edit/:id">
-        <NarrowNotSupported>
-          <EditSchedule />
-        </NarrowNotSupported>
-      </Route>
-      <Route path="/schedule/link">
-        <NarrowNotSupported>
-          <LinkSchedule />
-        </NarrowNotSupported>
-      </Route>
-      <Route path="/schedule/discover">
-        <NarrowNotSupported>
-          <DiscoverSchedules />
-        </NarrowNotSupported>
-      </Route>
-      <Route path="/schedule/posts-offline-notification">
-        <PostsOfflineNotification />
-      </Route>
+      <Route
+        path="/schedule/edit"
+        element={
+          <NarrowNotSupported>
+            <EditSchedule />
+          </NarrowNotSupported>
+        }
+      />
+      <Route
+        path="/schedule/edit/:id"
+        element={
+          <NarrowNotSupported>
+            <EditSchedule />
+          </NarrowNotSupported>
+        }
+      />
+      <Route
+        path="/schedule/link"
+        element={
+          <NarrowNotSupported>
+            <LinkSchedule />
+          </NarrowNotSupported>
+        }
+      />
+      <Route
+        path="/schedule/discover"
+        element={
+          <NarrowNotSupported>
+            <DiscoverSchedules />
+          </NarrowNotSupported>
+        }
+      />
 
-      <Route path="/payees" exact>
-        <ManagePayeesPage />
-      </Route>
-      <Route path="/rules" exact>
-        <ManageRulesPage />
-      </Route>
-      <Route path="/settings">
-        <Settings />
-      </Route>
-      <Route path="/nordigen/link" exact>
-        <NarrowNotSupported>
-          <NordigenLink />
-        </NarrowNotSupported>
-      </Route>
+      <Route
+        path="/schedule/posts-offline-notification"
+        element={<PostsOfflineNotification />}
+      />
 
-      <Route path="/accounts/:id" exact>
-        {props => {
-          const AcctCmp = isNarrowWidth ? MobileAccount : Account;
-          return (
-            props.match && <AcctCmp key={props.match.params.id} {...props} />
-          );
-        }}
-      </Route>
-      <Route path="/accounts" exact>
-        {isNarrowWidth ? <MobileAccounts /> : <Account />}
-      </Route>
-    </Switch>
-  );
-}
+      <Route path="/payees" element={<ManagePayeesPage />} />
+      <Route path="/rules" element={<ManageRulesPage />} />
+      <Route path="/settings" element={<Settings />} />
+      <Route
+        path="/nordigen/link"
+        element={
+          <NarrowNotSupported>
+            <NordigenLink />
+          </NarrowNotSupported>
+        }
+      />
 
-function StackedRoutes() {
-  let location = useLocation();
-  let locationPtr = getLocationState(location, 'locationPtr');
+      <Route
+        path="/accounts/:id"
+        element={<AccountCmp isNarrowWidth={isNarrowWidth} />}
+      />
 
-  let locations = [location];
-  while (locationPtr) {
-    locations.unshift(locationPtr);
-    locationPtr = getLocationState(locationPtr, 'locationPtr');
-  }
+      <Route
+        path="/accounts"
+        element={isNarrowWidth ? <MobileAccounts /> : <Account />}
+      />
+    </Routes>
+  );
+}
 
-  let base = locations[0];
-  let stack = locations.slice(1);
+// Needed to re-mount the component for each account ID change
+function AccountCmp(props) {
+  const { id } = useParams();
+  const Component = props.isNarrowWidth ? MobileAccount : Account;
 
-  return (
-    <ActiveLocationProvider location={locations[locations.length - 1]}>
-      <Routes location={base} />
-      {stack.map((location, idx) => (
-        <PageTypeProvider
-          key={location.key}
-          type="modal"
-          current={idx === stack.length - 1}
-        >
-          <Routes location={location} />
-        </PageTypeProvider>
-      ))}
-    </ActiveLocationProvider>
-  );
+  return <Component key={id} {...props} />;
 }
 
 function NavTab({ icon: TabIcon, name, path }) {
   return (
     <NavLink
       to={path}
-      style={{
+      style={({ isActive }) => ({
         alignItems: 'center',
-        color: '#8E8E8F',
+        color: isActive ? colors.p5 : '#8E8E8F',
         display: 'flex',
         flexDirection: 'column',
         textDecoration: 'none',
-      }}
-      activeStyle={{ color: colors.p5 }}
+      })}
     >
       <TabIcon
         width={22}
@@ -211,49 +208,22 @@ function MobileNavTabs() {
   );
 }
 
-function FinancesApp(props) {
-  const [patchedHistory] = useState(() => createBrowserHistory());
-
+function Redirector({ getAccounts }) {
+  let navigate = useNavigate();
   useEffect(() => {
-    let oldPush = patchedHistory.push;
-    patchedHistory.push = (to, state) => {
-      let newState = makeLocationState(to.state || state);
-      if (typeof to === 'object') {
-        return oldPush.call(patchedHistory, { ...to, state: newState });
-      } else {
-        return oldPush.call(patchedHistory, to, newState);
-      }
-    };
-
-    // I'm not sure if this is the best approach but we need this to
-    // globally. We could instead move various workflows inside global
-    // React components, but that's for another day.
-    window.__history = patchedHistory;
-
-    undo.setUndoState('url', window.location.href);
-
-    const cleanup = patchedHistory.listen(location => {
-      undo.setUndoState('url', window.location.href);
-    });
-
-    return cleanup;
-  }, []);
-
-  useEffect(() => {
-    // TODO: quick hack fix for showing the demo
-    if (patchedHistory.location.pathname === '/subscribe') {
-      patchedHistory.push('/');
-    }
-
     // Get the accounts and check if any exist. If there are no
     // accounts, we want to redirect the user to the All Accounts
     // screen which will prompt them to add an account
-    props.getAccounts().then(accounts => {
+    getAccounts().then(accounts => {
       if (accounts.length === 0) {
-        patchedHistory.push('/accounts');
+        navigate('/accounts');
       }
     });
+  }, []);
+}
 
+function FinancesApp(props) {
+  useEffect(() => {
     // The default key handler scope
     hotkeys.setScope('app');
 
@@ -273,61 +243,59 @@ function FinancesApp(props) {
   }, []);
 
   return (
-    <Router history={patchedHistory}>
-      <CompatRouter>
-        <View style={{ height: '100%', backgroundColor: colors.n10 }}>
-          <GlobalKeys />
-
-          <View style={{ flexDirection: 'row', flex: 1 }}>
-            <FloatableSidebar />
-
-            <View
+    <BrowserRouter>
+      <Redirector getAccounts={props.getAccounts} />
+      <ExposeNavigate />
+
+      <View style={{ height: '100%', backgroundColor: colors.n10 }}>
+        <GlobalKeys />
+
+        <View style={{ flexDirection: 'row', flex: 1 }}>
+          <FloatableSidebar />
+
+          <View
+            style={{
+              flex: 1,
+              overflow: 'hidden',
+              width: '100%',
+            }}
+          >
+            <Titlebar
+              style={{
+                WebkitAppRegion: 'drag',
+                position: 'absolute',
+                top: 0,
+                left: 0,
+                right: 0,
+                zIndex: 1000,
+              }}
+            />
+            <div
               style={{
                 flex: 1,
-                overflow: 'hidden',
-                width: '100%',
+                display: 'flex',
+                overflow: 'auto',
+                position: 'relative',
               }}
             >
-              <Titlebar
-                style={{
-                  WebkitAppRegion: 'drag',
-                  position: 'absolute',
-                  top: 0,
-                  left: 0,
-                  right: 0,
-                  zIndex: 1000,
-                }}
+              <Notifications />
+              <BankSyncStatus />
+              <StackedRoutes
+                render={location => <StackedRoutesInner location={location} />}
               />
-              <div
-                style={{
-                  flex: 1,
-                  display: 'flex',
-                  overflow: 'auto',
-                  position: 'relative',
-                }}
-              >
-                <Notifications />
-                <BankSyncStatus />
-                <StackedRoutes />
-                <Modals history={patchedHistory} />
-              </div>
-
-              <Switch>
-                <Route path="/budget">
-                  <MobileNavTabs />
-                </Route>
-                <Route path="/accounts">
-                  <MobileNavTabs />
-                </Route>
-                <Route path="/settings">
-                  <MobileNavTabs />
-                </Route>
-              </Switch>
-            </View>
+              <Modals />
+            </div>
+
+            <Routes>
+              <Route path="/budget" element={<MobileNavTabs />} />
+              <Route path="/accounts" element={<MobileNavTabs />} />
+              <Route path="/settings" element={<MobileNavTabs />} />
+              <Route path="*" element={null} />
+            </Routes>
           </View>
         </View>
-      </CompatRouter>
-    </Router>
+      </View>
+    </BrowserRouter>
   );
 }
 
diff --git a/packages/desktop-client/src/components/FloatableSidebar.js b/packages/desktop-client/src/components/FloatableSidebar.js
index 444ee866e..bdc9583c5 100644
--- a/packages/desktop-client/src/components/FloatableSidebar.js
+++ b/packages/desktop-client/src/components/FloatableSidebar.js
@@ -1,6 +1,5 @@
 import React, { createContext, useState, useContext, useMemo } from 'react';
 import { connect, useSelector } from 'react-redux';
-import { withRouter } from 'react-router-dom';
 
 import * as actions from 'loot-core/src/client/actions';
 
@@ -84,9 +83,7 @@ function Sidebar({ floatingSidebar }) {
   );
 }
 
-export default withRouter(
-  connect(
-    state => ({ floatingSidebar: state.prefs.global.floatingSidebar }),
-    actions,
-  )(Sidebar),
-);
+export default connect(
+  state => ({ floatingSidebar: state.prefs.global.floatingSidebar }),
+  actions,
+)(Sidebar);
diff --git a/packages/desktop-client/src/components/GlobalKeys.js b/packages/desktop-client/src/components/GlobalKeys.js
index 63b6a58fc..a75c07635 100644
--- a/packages/desktop-client/src/components/GlobalKeys.js
+++ b/packages/desktop-client/src/components/GlobalKeys.js
@@ -1,10 +1,10 @@
 import { useEffect } from 'react';
-import { useHistory } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
 
 import * as Platform from 'loot-core/src/client/platform';
 
 export default function GlobalKeys() {
-  let history = useHistory();
+  let navigate = useNavigate();
   useEffect(() => {
     const handleKeys = e => {
       if (Platform.isBrowser) {
@@ -14,17 +14,17 @@ export default function GlobalKeys() {
       if (e.metaKey) {
         switch (e.key) {
           case '1':
-            history.push('/budget');
+            navigate('/budget');
             break;
           case '2':
-            history.push('/reports');
+            navigate('/reports');
             break;
           case '3':
-            history.push('/accounts');
+            navigate('/accounts');
             break;
           case ',':
             if (Platform.OS === 'mac') {
-              history.push('/settings');
+              navigate('/settings');
             }
             break;
           default:
diff --git a/packages/desktop-client/src/components/LoggedInUser.js b/packages/desktop-client/src/components/LoggedInUser.js
index 9b9b68aaa..ba41e9cbe 100644
--- a/packages/desktop-client/src/components/LoggedInUser.js
+++ b/packages/desktop-client/src/components/LoggedInUser.js
@@ -1,6 +1,5 @@
 import React, { useState, useEffect } from 'react';
 import { connect } from 'react-redux';
-import { withRouter } from 'react-router';
 
 import { css } from 'glamor';
 
@@ -35,7 +34,7 @@ function LoggedInUser({
 
   async function onChangePassword() {
     await closeBudget();
-    window.__history.push('/change-password');
+    window.__navigate('/change-password');
   }
 
   async function onMenuSelect(type) {
@@ -47,14 +46,14 @@ function LoggedInUser({
         break;
       case 'sign-in':
         await closeBudget();
-        window.__history.push('/login');
+        window.__navigate('/login');
         break;
       case 'sign-out':
         signOut();
         break;
       case 'config-server':
         await closeBudget();
-        window.__history.push('/config-server');
+        window.__navigate('/config-server');
         break;
       default:
     }
@@ -132,4 +131,4 @@ function LoggedInUser({
 export default connect(
   state => ({ userData: state.user.data }),
   actions,
-)(withRouter(LoggedInUser));
+)(LoggedInUser);
diff --git a/packages/desktop-client/src/components/Modals.js b/packages/desktop-client/src/components/Modals.js
index c18eeb859..c09a581d1 100644
--- a/packages/desktop-client/src/components/Modals.js
+++ b/packages/desktop-client/src/components/Modals.js
@@ -1,8 +1,6 @@
 import React from 'react';
 import { connect } from 'react-redux';
-import { Route, Switch } from 'react-router-dom';
 
-import { createLocation } from 'history';
 import { bindActionCreators } from 'redux';
 
 import * as actions from 'loot-core/src/client/actions';
@@ -31,13 +29,11 @@ import PlaidExternalMsg from './modals/PlaidExternalMsg';
 import SelectLinkedAccounts from './modals/SelectLinkedAccounts';
 
 function Modals({
-  history,
   modalStack,
   isHidden,
   accounts,
   categoryGroups,
   categories,
-  payees,
   budgetId,
   actions,
 }) {
@@ -45,190 +41,208 @@ function Modals({
 
   const syncServerStatus = useSyncServerStatus();
 
-  return modalStack.map(({ name, options = {} }, idx) => {
-    const modalProps = {
-      onClose: actions.popModal,
-      onBack: actions.popModal,
-      showBack: idx > 0,
-      isCurrent: idx === modalStack.length - 1,
-      isHidden,
-      stackIndex: idx,
-    };
-
-    let location = createLocation('/' + name);
-    return (
-      <Switch key={name} location={location}>
-        <Route path="/import-transactions">
-          <ImportTransactions modalProps={modalProps} options={options} />
-        </Route>
-
-        <Route path="/add-account">
-          <CreateAccount
-            modalProps={modalProps}
-            actions={actions}
-            syncServerStatus={syncServerStatus}
-          />
-        </Route>
-
-        <Route path="/add-local-account">
-          <CreateLocalAccount
-            modalProps={modalProps}
-            actions={actions}
-            history={history}
-          />
-        </Route>
-
-        <Route path="/close-account">
-          <CloseAccount
-            modalProps={modalProps}
-            account={options.account}
-            balance={options.balance}
-            canDelete={options.canDelete}
-            accounts={accounts.filter(acct => acct.closed === 0)}
-            categoryGroups={categoryGroups}
-            actions={actions}
-          />
-        </Route>
-
-        <Route path="/select-linked-accounts">
-          <SelectLinkedAccounts
-            modalProps={modalProps}
-            externalAccounts={options.accounts}
-            requisitionId={options.requisitionId}
-            localAccounts={accounts.filter(acct => acct.closed === 0)}
-            upgradingAccountId={options.upgradingAccountId}
-            actions={actions}
-          />
-        </Route>
-
-        <Route path="/configure-linked-accounts">
-          <ConfigureLinkedAccounts
-            modalProps={modalProps}
-            institution={options.institution}
-            publicToken={options.publicToken}
-            accounts={options.accounts}
-            upgradingId={options.upgradingId}
-            actions={actions}
-          />
-        </Route>
-
-        <Route path="/confirm-category-delete">
-          <ConfirmCategoryDelete
-            modalProps={modalProps}
-            actions={actions}
-            category={categories.find(c => c.id === options.category)}
-            group={categoryGroups.find(g => g.id === options.group)}
-            categoryGroups={categoryGroups}
-            onDelete={options.onDelete}
-          />
-        </Route>
-
-        <Route path="/load-backup">
-          <LoadBackup
-            watchUpdates
-            budgetId={budgetId}
-            modalProps={modalProps}
-            actions={actions}
-          />
-        </Route>
-
-        <Route path="/manage-rules">
-          <ManageRulesModal
-            history={history}
-            modalProps={modalProps}
-            payeeId={options.payeeId}
-          />
-        </Route>
-
-        <Route path="/edit-rule">
-          <EditRule
-            history={history}
-            modalProps={modalProps}
-            defaultRule={options.rule}
-            onSave={options.onSave}
-          />
-        </Route>
-
-        <Route path="/merge-unused-payees">
-          <MergeUnusedPayees
-            history={history}
-            modalProps={modalProps}
-            payeeIds={options.payeeIds}
-            targetPayeeId={options.targetPayeeId}
-          />
-        </Route>
-
-        <Route path="/plaid-external-msg">
-          <PlaidExternalMsg
-            modalProps={modalProps}
-            actions={actions}
-            onMoveExternal={options.onMoveExternal}
-            onClose={() => {
-              options.onClose && options.onClose();
-              send('poll-web-token-stop');
-            }}
-            onSuccess={options.onSuccess}
-          />
-        </Route>
-        <Route path="/nordigen-init">
-          <NordigenInitialise
-            modalProps={modalProps}
-            onSuccess={options.onSuccess}
-          />
-        </Route>
-        <Route path="/nordigen-external-msg">
-          <NordigenExternalMsg
-            modalProps={modalProps}
-            actions={actions}
-            onMoveExternal={options.onMoveExternal}
-            onClose={() => {
-              options.onClose && options.onClose();
-              send('nordigen-poll-web-token-stop');
-            }}
-            onSuccess={options.onSuccess}
-          />
-        </Route>
-
-        <Route path="/create-encryption-key">
-          <CreateEncryptionKey
-            key={name}
-            modalProps={modalProps}
-            actions={actions}
-            options={options}
-          />
-        </Route>
-
-        <Route path="/fix-encryption-key">
-          <FixEncryptionKey
-            key={name}
-            modalProps={modalProps}
-            actions={actions}
-            options={options}
-          />
-        </Route>
-
-        <Route path="/edit-field">
-          <EditField
-            key={name}
-            modalProps={modalProps}
-            actions={actions}
-            name={options.name}
-            onSubmit={options.onSubmit}
-          />
-        </Route>
-
-        <Route path="/budget-summary">
-          <BudgetSummary
-            key={name}
-            modalProps={modalProps}
-            month={options.month}
-            actions={actions}
-            isGoalTemplatesEnabled={isGoalTemplatesEnabled}
-          />
-        </Route>
-      </Switch>
-    );
-  });
+  return modalStack
+    .map(({ name, options = {} }, idx) => {
+      const modalProps = {
+        onClose: actions.popModal,
+        onBack: actions.popModal,
+        showBack: idx > 0,
+        isCurrent: idx === modalStack.length - 1,
+        isHidden,
+        stackIndex: idx,
+      };
+
+      switch (name) {
+        case 'import-transactions':
+          return (
+            <ImportTransactions modalProps={modalProps} options={options} />
+          );
+
+        case 'add-account':
+          return (
+            <CreateAccount
+              modalProps={modalProps}
+              actions={actions}
+              syncServerStatus={syncServerStatus}
+            />
+          );
+
+        case 'add-local-account':
+          return (
+            <CreateLocalAccount modalProps={modalProps} actions={actions} />
+          );
+
+        case 'close-account':
+          return (
+            <CloseAccount
+              modalProps={modalProps}
+              account={options.account}
+              balance={options.balance}
+              canDelete={options.canDelete}
+              accounts={accounts.filter(acct => acct.closed === 0)}
+              categoryGroups={categoryGroups}
+              actions={actions}
+            />
+          );
+
+        case 'select-linked-accounts':
+          return (
+            <SelectLinkedAccounts
+              modalProps={modalProps}
+              externalAccounts={options.accounts}
+              requisitionId={options.requisitionId}
+              localAccounts={accounts.filter(acct => acct.closed === 0)}
+              upgradingAccountId={options.upgradingAccountId}
+              actions={actions}
+            />
+          );
+
+        case 'configure-linked-accounts':
+          return (
+            <ConfigureLinkedAccounts
+              modalProps={modalProps}
+              institution={options.institution}
+              publicToken={options.publicToken}
+              accounts={options.accounts}
+              upgradingId={options.upgradingId}
+              actions={actions}
+            />
+          );
+
+        case 'confirm-category-delete':
+          return (
+            <ConfirmCategoryDelete
+              modalProps={modalProps}
+              actions={actions}
+              category={categories.find(c => c.id === options.category)}
+              group={categoryGroups.find(g => g.id === options.group)}
+              categoryGroups={categoryGroups}
+              onDelete={options.onDelete}
+            />
+          );
+
+        case 'load-backup':
+          return (
+            <LoadBackup
+              watchUpdates
+              budgetId={budgetId}
+              modalProps={modalProps}
+              actions={actions}
+            />
+          );
+
+        case 'manage-rules':
+          return (
+            <ManageRulesModal
+              modalProps={modalProps}
+              payeeId={options.payeeId}
+            />
+          );
+
+        case 'edit-rule':
+          return (
+            <EditRule
+              modalProps={modalProps}
+              defaultRule={options.rule}
+              onSave={options.onSave}
+            />
+          );
+
+        case 'merge-unused-payees':
+          return (
+            <MergeUnusedPayees
+              modalProps={modalProps}
+              payeeIds={options.payeeIds}
+              targetPayeeId={options.targetPayeeId}
+            />
+          );
+
+        case 'plaid-external-msg':
+          return (
+            <PlaidExternalMsg
+              modalProps={modalProps}
+              actions={actions}
+              onMoveExternal={options.onMoveExternal}
+              onClose={() => {
+                options.onClose && options.onClose();
+                send('poll-web-token-stop');
+              }}
+              onSuccess={options.onSuccess}
+            />
+          );
+
+        case 'nordigen-init':
+          return (
+            <NordigenInitialise
+              modalProps={modalProps}
+              onSuccess={options.onSuccess}
+            />
+          );
+
+        case 'nordigen-external-msg':
+          return (
+            <NordigenExternalMsg
+              modalProps={modalProps}
+              actions={actions}
+              onMoveExternal={options.onMoveExternal}
+              onClose={() => {
+                options.onClose && options.onClose();
+                send('nordigen-poll-web-token-stop');
+              }}
+              onSuccess={options.onSuccess}
+            />
+          );
+
+        case 'create-encryption-key':
+          return (
+            <CreateEncryptionKey
+              key={name}
+              modalProps={modalProps}
+              actions={actions}
+              options={options}
+            />
+          );
+
+        case 'fix-encryption-key':
+          return (
+            <FixEncryptionKey
+              key={name}
+              modalProps={modalProps}
+              actions={actions}
+              options={options}
+            />
+          );
+
+        case 'edit-field':
+          return (
+            <EditField
+              key={name}
+              modalProps={modalProps}
+              actions={actions}
+              name={options.name}
+              onSubmit={options.onSubmit}
+            />
+          );
+
+        case 'budget-summary':
+          return (
+            <BudgetSummary
+              key={name}
+              modalProps={modalProps}
+              month={options.month}
+              actions={actions}
+              isGoalTemplatesEnabled={isGoalTemplatesEnabled}
+            />
+          );
+
+        default:
+          console.error('Unknown modal:', name);
+          return null;
+      }
+    })
+    .map((modal, idx) => (
+      <React.Fragment key={modalStack[idx].name}>{modal}</React.Fragment>
+    ));
 }
 
 export default connect(
@@ -238,7 +252,6 @@ export default connect(
     accounts: state.queries.accounts,
     categoryGroups: state.queries.categories.grouped,
     categories: state.queries.categories.list,
-    payees: state.queries.payees,
     budgetId: state.prefs.local && state.prefs.local.id,
   }),
   dispatch => ({ actions: bindActionCreators(actions, dispatch) }),
diff --git a/packages/desktop-client/src/components/Page.js b/packages/desktop-client/src/components/Page.js
index ccacf653b..8038a87db 100644
--- a/packages/desktop-client/src/components/Page.js
+++ b/packages/desktop-client/src/components/Page.js
@@ -1,5 +1,5 @@
 import React, { createContext, useContext } from 'react';
-import { useHistory } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
 
 import { useResponsive } from '../ResponsiveProvider';
 import { colors, styles } from '../style';
@@ -65,7 +65,7 @@ function PageTitle({ name, style }) {
 
 export function Page({ title, modalSize, children, titleStyle }) {
   let { type, current } = usePageType();
-  let history = useHistory();
+  let navigate = useNavigate();
   let { isNarrowWidth } = useResponsive();
   let HORIZONTAL_PADDING = isNarrowWidth ? 10 : 20;
 
@@ -81,7 +81,7 @@ export function Page({ title, modalSize, children, titleStyle }) {
         title={title}
         isCurrent={current}
         size={size}
-        onClose={() => history.goBack()}
+        onClose={() => navigate(-1)}
       >
         {children}
       </Modal>
diff --git a/packages/desktop-client/src/components/SidebarWithData.js b/packages/desktop-client/src/components/SidebarWithData.js
index d38be20d0..0acca9e5c 100644
--- a/packages/desktop-client/src/components/SidebarWithData.js
+++ b/packages/desktop-client/src/components/SidebarWithData.js
@@ -1,6 +1,6 @@
 import React, { useState, useEffect } from 'react';
 import { connect, useDispatch } from 'react-redux';
-import { withRouter, useHistory } from 'react-router';
+import { useNavigate } from 'react-router';
 
 import { bindActionCreators } from 'redux';
 
@@ -18,7 +18,7 @@ import { Sidebar } from './sidebar';
 
 function EditableBudgetName({ prefs, savePrefs }) {
   let dispatch = useDispatch();
-  let history = useHistory();
+  let navigate = useNavigate();
   const [editing, setEditing] = useState(false);
   const [menuOpen, setMenuOpen] = useState(false);
 
@@ -30,7 +30,7 @@ function EditableBudgetName({ prefs, savePrefs }) {
         setEditing(true);
         break;
       case 'settings':
-        history.push('/settings');
+        navigate('/settings');
         break;
       case 'help':
         window.open('https://actualbudget.org/docs/', '_blank');
@@ -155,15 +155,13 @@ function SidebarWithData({
   );
 }
 
-export default withRouter(
-  connect(
-    state => ({
-      accounts: state.queries.accounts,
-      failedAccounts: state.account.failedAccounts,
-      updatedAccounts: state.queries.updatedAccounts,
-      prefs: state.prefs.local,
-      floatingSidebar: state.prefs.global.floatingSidebar,
-    }),
-    dispatch => bindActionCreators(actions, dispatch),
-  )(SidebarWithData),
-);
+export default connect(
+  state => ({
+    accounts: state.queries.accounts,
+    failedAccounts: state.account.failedAccounts,
+    updatedAccounts: state.queries.updatedAccounts,
+    prefs: state.prefs.local,
+    floatingSidebar: state.prefs.global.floatingSidebar,
+  }),
+  dispatch => bindActionCreators(actions, dispatch),
+)(SidebarWithData);
diff --git a/packages/desktop-client/src/components/Titlebar.js b/packages/desktop-client/src/components/Titlebar.js
index 35093383e..4bd3af0f2 100644
--- a/packages/desktop-client/src/components/Titlebar.js
+++ b/packages/desktop-client/src/components/Titlebar.js
@@ -6,7 +6,7 @@ import React, {
   useContext,
 } from 'react';
 import { connect } from 'react-redux';
-import { Switch, Route, useLocation, useHistory } from 'react-router-dom';
+import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
 
 import { css, media } from 'glamor';
 
@@ -268,7 +268,7 @@ function Titlebar({
   style,
   sync,
 }) {
-  let history = useHistory();
+  let navigate = useNavigate();
   let location = useLocation();
   let sidebar = useSidebar();
   let { isNarrowWidth } = useResponsive();
@@ -320,32 +320,38 @@ function Titlebar({
         </Button>
       )}
 
-      <Switch>
-        <Route path="/accounts" exact>
-          {location.state?.goBack ? (
-            <Button onClick={() => history.goBack()} bare>
-              <ArrowLeft
-                width={10}
-                height={10}
-                style={{ marginRight: 5, color: 'currentColor' }}
-              />{' '}
-              Back
-            </Button>
-          ) : null}
-        </Route>
+      <Routes>
+        <Route
+          path="/accounts"
+          element={
+            location.state?.goBack ? (
+              <Button onClick={() => navigate(-1)} bare>
+                <ArrowLeft
+                  width={10}
+                  height={10}
+                  style={{ marginRight: 5, color: 'currentColor' }}
+                />{' '}
+                Back
+              </Button>
+            ) : null
+          }
+        />
 
-        <Route path="/accounts/:id" exact>
-          <AccountSyncCheck />
-        </Route>
+        <Route path="/accounts/:id" element={<AccountSyncCheck />} />
 
-        <Route path="/budget" exact>
-          <BudgetTitlebar
-            globalPrefs={globalPrefs}
-            saveGlobalPrefs={saveGlobalPrefs}
-            localPrefs={localPrefs}
-          />
-        </Route>
-      </Switch>
+        <Route
+          path="/budget"
+          element={
+            <BudgetTitlebar
+              globalPrefs={globalPrefs}
+              saveGlobalPrefs={saveGlobalPrefs}
+              localPrefs={localPrefs}
+            />
+          }
+        />
+
+        <Route path="*" element={null} />
+      </Routes>
       <View style={{ flex: 1 }} />
       <UncategorizedButton />
       {serverURL ? (
diff --git a/packages/desktop-client/src/components/accounts/Account.js b/packages/desktop-client/src/components/accounts/Account.js
index 26c60b416..23ee740d6 100644
--- a/packages/desktop-client/src/components/accounts/Account.js
+++ b/packages/desktop-client/src/components/accounts/Account.js
@@ -7,7 +7,12 @@ import React, {
   useMemo,
 } from 'react';
 import { useSelector, useDispatch } from 'react-redux';
-import { Redirect, useParams, useHistory, useLocation } from 'react-router-dom';
+import {
+  Navigate,
+  useParams,
+  useNavigate,
+  useLocation,
+} from 'react-router-dom';
 
 import { debounce } from 'debounce';
 import { bindActionCreators } from 'redux';
@@ -521,7 +526,8 @@ function SelectedTransactionsButton({
   onScheduleAction,
 }) {
   let selectedItems = useSelectedItems();
-  let history = useHistory();
+  let navigate = useNavigate();
+  let location = useLocation();
 
   let types = useMemo(() => {
     let items = [...selectedItems];
@@ -633,14 +639,14 @@ function SelectedTransactionsButton({
             }
 
             if (scheduleId) {
-              history.push(`/schedule/edit/${scheduleId}`, {
-                locationPtr: history.location,
+              navigate(`/schedule/edit/${scheduleId}`, {
+                locationPtr: location,
               });
             }
             break;
           case 'link-schedule':
-            history.push(`/schedule/link`, {
-              locationPtr: history.location,
+            navigate(`/schedule/link`, {
+              locationPtr: location,
               transactionIds: [...selectedItems],
             });
             break;
@@ -1841,7 +1847,7 @@ class AccountInternal extends PureComponent {
     if (!accountName && !loading) {
       // This is probably an account that was deleted, so redirect to
       // all accounts
-      return <Redirect to="/accounts" />;
+      return <Navigate to="/accounts" replace />;
     }
 
     let showEmptyMessage = !loading && !accountId && accounts.length === 0;
diff --git a/packages/desktop-client/src/components/accounts/MobileAccount.js b/packages/desktop-client/src/components/accounts/MobileAccount.js
index 2db1f2ca3..212e397b5 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccount.js
+++ b/packages/desktop-client/src/components/accounts/MobileAccount.js
@@ -1,7 +1,6 @@
 import React, { useEffect, useMemo, useState } from 'react';
 import { connect, useDispatch, useSelector } from 'react-redux';
-import { useParams } from 'react-router-dom';
-import { useNavigate } from 'react-router-dom-v5-compat';
+import { useParams, useNavigate } from 'react-router-dom';
 
 import debounce from 'debounce';
 import memoizeOne from 'memoize-one';
diff --git a/packages/desktop-client/src/components/accounts/MobileAccounts.js b/packages/desktop-client/src/components/accounts/MobileAccounts.js
index d76baefee..2aba8819c 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccounts.js
+++ b/packages/desktop-client/src/components/accounts/MobileAccounts.js
@@ -1,6 +1,6 @@
 import React, { Component, useEffect, useState } from 'react';
 import { connect } from 'react-redux';
-import { useNavigate } from 'react-router-dom-v5-compat';
+import { useNavigate } from 'react-router-dom';
 
 import * as actions from 'loot-core/src/client/actions';
 import * as queries from 'loot-core/src/client/queries';
diff --git a/packages/desktop-client/src/components/accounts/TransactionList.js b/packages/desktop-client/src/components/accounts/TransactionList.js
index 96c1e3096..28cd4d657 100644
--- a/packages/desktop-client/src/components/accounts/TransactionList.js
+++ b/packages/desktop-client/src/components/accounts/TransactionList.js
@@ -1,5 +1,5 @@
 import React, { useRef, useCallback, useLayoutEffect } from 'react';
-import { useHistory } from 'react-router';
+import { useNavigate } from 'react-router-dom';
 
 import { send } from 'loot-core/src/platform/client/fetch';
 import {
@@ -80,7 +80,7 @@ export default function TransactionList({
   onCreatePayee,
 }) {
   let transactionsLatest = useRef();
-  let history = useHistory();
+  let navigate = useNavigate();
 
   useLayoutEffect(() => {
     transactionsLatest.current = transactions;
@@ -145,12 +145,9 @@ export default function TransactionList({
     return newTransaction;
   }, []);
 
-  let onManagePayees = useCallback(
-    id => {
-      history.push('/payees', { selectedPayee: id });
-    },
-    [history],
-  );
+  let onManagePayees = useCallback(id => {
+    navigate('/payees', { selectedPayee: id });
+  });
 
   return (
     <TransactionTable
diff --git a/packages/desktop-client/src/components/budget/index.js b/packages/desktop-client/src/components/budget/index.js
index 7c5e5d3af..c14af8eae 100644
--- a/packages/desktop-client/src/components/budget/index.js
+++ b/packages/desktop-client/src/components/budget/index.js
@@ -1,6 +1,6 @@
 import React, { memo, PureComponent, useContext, useMemo } from 'react';
 import { connect } from 'react-redux';
-import { useHistory } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
 
 import * as actions from 'loot-core/src/client/actions';
 import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
@@ -332,7 +332,7 @@ class Budget extends PureComponent {
   };
 
   onShowActivity = (categoryName, categoryId, month) => {
-    this.props.history.push({
+    this.props.navigate({
       pathname: '/accounts',
       state: {
         goBack: true,
@@ -520,7 +520,7 @@ const RolloverBudgetSummary = memo(props => {
 function BudgetWrapper(props) {
   let spreadsheet = useSpreadsheet();
   let titlebar = useContext(TitlebarContext);
-  let history = useHistory();
+  let navigate = useNavigate();
 
   let reportComponents = useMemo(
     () => ({
@@ -565,7 +565,7 @@ function BudgetWrapper(props) {
         rolloverComponents={rolloverComponents}
         spreadsheet={spreadsheet}
         titlebar={titlebar}
-        history={history}
+        navigate={navigate}
       />
     </View>
   );
diff --git a/packages/desktop-client/src/components/common.tsx b/packages/desktop-client/src/components/common.tsx
index 183994882..255e88fb9 100644
--- a/packages/desktop-client/src/components/common.tsx
+++ b/packages/desktop-client/src/components/common.tsx
@@ -13,8 +13,7 @@ import React, {
   createElement,
   cloneElement,
 } from 'react';
-import type { RouteComponentProps } from 'react-router';
-import { Route, NavLink, withRouter, useRouteMatch } from 'react-router-dom';
+import { NavLink, useMatch, useNavigate } from 'react-router-dom';
 
 import {
   ListboxInput,
@@ -156,7 +155,6 @@ export function Link({ style, children, ...nativeProps }: LinkProps) {
 
 type AnchorLinkProps = {
   to: string;
-  exact: boolean;
   style?: CSSProperties;
   activeStyle?: CSSProperties;
   children?: ReactNode;
@@ -164,17 +162,15 @@ type AnchorLinkProps = {
 
 export function AnchorLink({
   to,
-  exact,
   style,
   activeStyle,
   children,
 }: AnchorLinkProps) {
-  let match = useRouteMatch({ path: to, exact: true });
+  let match = useMatch({ path: to });
 
   return (
     <NavLink
       to={to}
-      exact={exact}
       {...css([styles.smallText, style, match ? activeStyle : null])}
     >
       {children}
@@ -221,40 +217,33 @@ export const ExternalLink = forwardRef<HTMLElement, ExternalLinkProps>(
   },
 );
 
-type ButtonLinkProps = ComponentProps<typeof Button> &
-  RouteComponentProps & {
-    to: string;
-    activeStyle?: CSSProperties;
-  };
-function ButtonLink_({
-  history,
-  staticContext,
+type ButtonLinkProps = ComponentProps<typeof Button> & {
+  to: string;
+  activeStyle?: CSSProperties;
+};
+export function ButtonLink({
   to,
   style,
   activeStyle,
-  match,
-  location,
   ...props
 }: ButtonLinkProps) {
+  const navigate = useNavigate();
+  const match = useMatch({ path: to });
   return (
-    <Route
-      path={to}
-      children={({ match }) => (
-        <Button
-          style={[style, match ? activeStyle : null]}
-          {...props}
-          onClick={e => {
-            props.onClick && props.onClick(e);
-            history.push(to);
-          }}
-        />
-      )}
+    <Button
+      style={{
+        ...style,
+        ...(match ? activeStyle : {}),
+      }}
+      {...props}
+      onClick={e => {
+        props.onClick && props.onClick(e);
+        navigate(to);
+      }}
     />
   );
 }
 
-export const ButtonLink = withRouter(ButtonLink_);
-
 type InputWithContentProps = ComponentProps<typeof Input> & {
   leftContent: ReactNode;
   rightContent: ReactNode;
diff --git a/packages/desktop-client/src/components/manager/ConfigServer.js b/packages/desktop-client/src/components/manager/ConfigServer.js
index a4e7a1235..50f08f6a4 100644
--- a/packages/desktop-client/src/components/manager/ConfigServer.js
+++ b/packages/desktop-client/src/components/manager/ConfigServer.js
@@ -1,6 +1,6 @@
 import React, { useState, useEffect } from 'react';
 import { useDispatch } from 'react-redux';
-import { useHistory } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
 
 import { createBudget } from 'loot-core/src/client/actions/budgets';
 import { signOut, loggedIn } from 'loot-core/src/client/actions/user';
@@ -19,7 +19,7 @@ import { Title, Input } from './subscribe/common';
 export default function ConfigServer() {
   useSetThemeColor(colors.p5);
   let dispatch = useDispatch();
-  let history = useHistory();
+  let navigate = useNavigate();
   let [url, setUrl] = useState('');
   let currentUrl = useServerURL();
   let setServerUrl = useSetServerURL();
@@ -58,7 +58,7 @@ export default function ConfigServer() {
         setError(error);
       } else {
         await dispatch(signOut());
-        history.push('/');
+        navigate('/');
       }
       setLoading(false);
     } else if (error) {
@@ -67,7 +67,7 @@ export default function ConfigServer() {
     } else {
       setLoading(false);
       await dispatch(signOut());
-      history.push('/');
+      navigate('/');
     }
   }
 
@@ -78,13 +78,13 @@ export default function ConfigServer() {
   async function onSkip() {
     await setServerUrl(null);
     await dispatch(loggedIn());
-    history.push('/');
+    navigate('/');
   }
 
   async function onCreateTestFile() {
     await setServerUrl(null);
     await dispatch(createBudget({ testMode: true }));
-    window.__history.push('/');
+    window.__navigate('/');
   }
 
   return (
@@ -135,7 +135,7 @@ export default function ConfigServer() {
         <Input
           autoFocus={true}
           placeholder={'https://example.com'}
-          value={url}
+          value={url || ''}
           onChange={e => setUrl(e.target.value)}
           style={{ flex: 1, marginRight: 10 }}
         />
@@ -147,7 +147,7 @@ export default function ConfigServer() {
             bare
             type="button"
             style={{ fontSize: 15, marginLeft: 10 }}
-            onClick={() => history.goBack()}
+            onClick={() => navigate(-1)}
           >
             Cancel
           </Button>
diff --git a/packages/desktop-client/src/components/manager/ManagementApp.js b/packages/desktop-client/src/components/manager/ManagementApp.js
index 6e8d52e5f..4d7038f26 100644
--- a/packages/desktop-client/src/components/manager/ManagementApp.js
+++ b/packages/desktop-client/src/components/manager/ManagementApp.js
@@ -1,13 +1,12 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect } from 'react';
 import { connect } from 'react-redux';
-import { Switch, Redirect, Router, Route } from 'react-router-dom';
-
-import { createBrowserHistory } from 'history';
+import { Navigate, BrowserRouter, Route, Routes } from 'react-router-dom';
 
 import * as actions from 'loot-core/src/client/actions';
 
 import { colors } from '../../style';
 import tokens from '../../tokens';
+import { ExposeNavigate } from '../../util/router-tools';
 import { View, Text } from '../common';
 import LoggedInUser from '../LoggedInUser';
 import Notifications from '../Notifications';
@@ -58,9 +57,6 @@ function ManagementApp({
   getUserData,
   loadAllFiles,
 }) {
-  const [history] = useState(createBrowserHistory);
-  window.__history = history;
-
   // runs on mount only
   useEffect(() => {
     // An action may have been triggered from outside, and we don't
@@ -107,7 +103,8 @@ function ManagementApp({
   }
 
   return (
-    <Router history={history}>
+    <BrowserRouter>
+      <ExposeNavigate />
       <View style={{ height: '100%' }}>
         <View
           style={{
@@ -150,25 +147,18 @@ function ManagementApp({
           >
             {userData && files ? (
               <>
-                <Switch>
-                  <Route exact path="/config-server">
-                    <ConfigServer />
-                  </Route>
-                  <Route exact path="/change-password">
-                    <ChangePassword />
-                  </Route>
+                <Routes>
+                  <Route path="/config-server" element={<ConfigServer />} />
+
+                  <Route path="/change-password" element={<ChangePassword />} />
                   {files && files.length > 0 ? (
-                    <Route exact path="/">
-                      <BudgetList />
-                    </Route>
+                    <Route path="/" element={<BudgetList />} />
                   ) : (
-                    <Route exact path="/">
-                      <WelcomeScreen />
-                    </Route>
+                    <Route path="/" element={<WelcomeScreen />} />
                   )}
                   {/* Redirect all other pages to this route */}
-                  <Route path="/" render={() => <Redirect to="/" />} />
-                </Switch>
+                  <Route path="/*" element={<Navigate to="/" />} />
+                </Routes>
 
                 <View
                   style={{
@@ -179,48 +169,44 @@ function ManagementApp({
                     zIndex: 4000,
                   }}
                 >
-                  <Switch>
-                    <Route exact path="/config-server" children={null} />
-                    <Route exact path="/">
-                      <LoggedInUser
-                        hideIfNoServer
-                        style={{ padding: '4px 7px' }}
-                      />
-                    </Route>
-                  </Switch>
+                  <Routes>
+                    <Route path="/config-server" element={null} />
+                    <Route
+                      path="/*"
+                      element={
+                        <LoggedInUser
+                          hideIfNoServer
+                          style={{ padding: '4px 7px' }}
+                        />
+                      }
+                    />
+                  </Routes>
                 </View>
               </>
             ) : (
-              <Switch>
-                <Route exact path="/login">
-                  <Login />
-                </Route>
-                <Route exact path="/error">
-                  <Error />
-                </Route>
-                <Route exact path="/config-server">
-                  <ConfigServer />
-                </Route>
-                <Route exact path="/bootstrap">
-                  <Bootstrap />
-                </Route>
+              <Routes>
+                <Route path="/login" element={<Login />} />
+                <Route path="/error" element={<Error />} />
+                <Route path="/config-server" element={<ConfigServer />} />
+                <Route path="/bootstrap" element={<Bootstrap />} />
                 {/* Redirect all other pages to this route */}
-                <Route path="/" render={() => <Redirect to="/bootstrap" />} />
-              </Switch>
+                <Route
+                  path="/*"
+                  element={<Navigate to="/bootstrap" replace />}
+                />
+              </Routes>
             )}
           </View>
         )}
 
-        <Switch>
-          <Route exact path="/config-server" children={null} />
-          <Route path="/">
-            <ServerURL />
-          </Route>
-        </Switch>
+        <Routes>
+          <Route path="/config-server" element={null} />
+          <Route path="/*" element={<ServerURL />} />
+        </Routes>
         <Version />
       </View>
-      <Modals history={history} />
-    </Router>
+      <Modals />
+    </BrowserRouter>
   );
 }
 
diff --git a/packages/desktop-client/src/components/manager/Modals.js b/packages/desktop-client/src/components/manager/Modals.js
index bbc467122..d945e7369 100644
--- a/packages/desktop-client/src/components/manager/Modals.js
+++ b/packages/desktop-client/src/components/manager/Modals.js
@@ -16,15 +16,7 @@ import ImportActual from './ImportActual';
 import ImportYNAB4 from './ImportYNAB4';
 import ImportYNAB5 from './ImportYNAB5';
 
-function Modals({
-  modalStack,
-  isHidden,
-  allFiles,
-  availableImports,
-  globalPrefs,
-  isLoggedIn,
-  actions,
-}) {
+function Modals({ modalStack, isHidden, availableImports, actions }) {
   let stack = modalStack.map(({ name, options }, idx) => {
     const modalProps = {
       onClose: actions.popModal,
@@ -111,9 +103,6 @@ export default connect(
     isHidden: state.modals.isHidden,
     budgets: state.budgets.budgets,
     availableImports: state.budgets.availableImports,
-    globalPrefs: state.prefs.global,
-    allFiles: state.budgets.allFiles,
-    isLoggedIn: !!state.user.data,
   }),
   dispatch => ({ actions: bindActionCreators(actions, dispatch) }),
 )(Modals);
diff --git a/packages/desktop-client/src/components/manager/subscribe/ChangePassword.tsx b/packages/desktop-client/src/components/manager/subscribe/ChangePassword.tsx
index 36a7965b4..b8ffeb0df 100644
--- a/packages/desktop-client/src/components/manager/subscribe/ChangePassword.tsx
+++ b/packages/desktop-client/src/components/manager/subscribe/ChangePassword.tsx
@@ -1,5 +1,5 @@
 import React, { useState } from 'react';
-import { useHistory } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
 
 import { send } from 'loot-core/src/platform/client/fetch';
 
@@ -10,7 +10,7 @@ import { Title } from './common';
 import { ConfirmPasswordForm } from './ConfirmPasswordForm';
 
 export default function ChangePassword() {
-  let history = useHistory();
+  let navigate = useNavigate();
   let [error, setError] = useState(null);
   let [msg, setMessage] = useState(null);
 
@@ -36,7 +36,7 @@ export default function ChangePassword() {
     } else {
       setMessage('Password successfully changed');
       await send('subscribe-sign-in', { password });
-      history.push('/');
+      navigate('/');
     }
   }
 
@@ -86,7 +86,7 @@ export default function ChangePassword() {
             bare
             type="button"
             style={{ fontSize: 15, marginRight: 10 }}
-            onClick={() => history.push('/')}
+            onClick={() => navigate('/')}
           >
             Cancel
           </Button>
diff --git a/packages/desktop-client/src/components/manager/subscribe/Error.tsx b/packages/desktop-client/src/components/manager/subscribe/Error.tsx
index b83a3f5e0..6a4ff3b05 100644
--- a/packages/desktop-client/src/components/manager/subscribe/Error.tsx
+++ b/packages/desktop-client/src/components/manager/subscribe/Error.tsx
@@ -1,5 +1,5 @@
 import React from 'react';
-import { useHistory, useLocation } from 'react-router-dom';
+import { useNavigate, useLocation } from 'react-router-dom';
 
 import { colors } from '../../../style';
 import { View, Text, Button } from '../../common';
@@ -14,12 +14,12 @@ function getErrorMessage(reason) {
 }
 
 export default function Error() {
-  let history = useHistory();
+  let navigate = useNavigate();
   let location = useLocation();
   let { error } = (location.state || {}) as { error? };
 
   function onTryAgain() {
-    history.push('/');
+    navigate('/');
   }
 
   return (
diff --git a/packages/desktop-client/src/components/manager/subscribe/common.tsx b/packages/desktop-client/src/components/manager/subscribe/common.tsx
index 73b2c58b0..f7fbc44f1 100644
--- a/packages/desktop-client/src/components/manager/subscribe/common.tsx
+++ b/packages/desktop-client/src/components/manager/subscribe/common.tsx
@@ -4,7 +4,7 @@ import React, {
   useEffect,
   useState,
 } from 'react';
-import { useHistory, useLocation } from 'react-router-dom';
+import { useNavigate, useLocation } from 'react-router-dom';
 
 import { send } from 'loot-core/src/platform/client/fetch';
 
@@ -23,7 +23,7 @@ import { useSetServerURL } from '../../ServerContext';
 // do any checks.
 export function useBootstrapped() {
   let [checked, setChecked] = useState(false);
-  let history = useHistory();
+  let navigate = useNavigate();
   let location = useLocation();
   let setServerURL = useSetServerURL();
 
@@ -31,7 +31,7 @@ export function useBootstrapped() {
     async function run() {
       let ensure = url => {
         if (location.pathname !== url) {
-          history.push(url);
+          navigate(url);
         } else {
           setChecked(true);
         }
@@ -47,7 +47,7 @@ export function useBootstrapped() {
         });
         if ('error' in result || !result.hasServer) {
           console.log('error' in result && result.error);
-          history.push('/config-server');
+          navigate('/config-server');
           return;
         }
 
@@ -61,7 +61,7 @@ export function useBootstrapped() {
       } else {
         let result = await send('subscribe-needs-bootstrap');
         if ('error' in result) {
-          history.push('/error', { error: result.error });
+          navigate('/error', { state: { error: result.error } });
         } else if (result.bootstrapped) {
           ensure('/login');
         } else {
@@ -70,7 +70,7 @@ export function useBootstrapped() {
       }
     }
     run();
-  }, [history, location]);
+  }, [location]);
 
   return { checked };
 }
diff --git a/packages/desktop-client/src/components/modals/CreateLocalAccount.js b/packages/desktop-client/src/components/modals/CreateLocalAccount.js
index 2e4ec4a25..d4b8efcdf 100644
--- a/packages/desktop-client/src/components/modals/CreateLocalAccount.js
+++ b/packages/desktop-client/src/components/modals/CreateLocalAccount.js
@@ -1,4 +1,5 @@
 import React from 'react';
+import { useNavigate } from 'react-router-dom';
 
 import { Formik } from 'formik';
 
@@ -17,7 +18,8 @@ import {
   Text,
 } from '../common';
 
-function CreateLocalAccount({ modalProps, actions, history }) {
+function CreateLocalAccount({ modalProps, actions }) {
+  let navigate = useNavigate();
   return (
     <Modal title="Create Local Account" {...modalProps} showBack={false}>
       {() => (
@@ -43,7 +45,7 @@ function CreateLocalAccount({ modalProps, actions, history }) {
                   toRelaxedNumber(values.balance),
                   values.offbudget,
                 );
-                history.push('/accounts/' + id);
+                navigate('/accounts/' + id);
               }
             }}
             render={({
diff --git a/packages/desktop-client/src/components/modals/EditRule.js b/packages/desktop-client/src/components/modals/EditRule.js
index 06cc775e6..f0ce39494 100644
--- a/packages/desktop-client/src/components/modals/EditRule.js
+++ b/packages/desktop-client/src/components/modals/EditRule.js
@@ -581,7 +581,6 @@ let conditionFields = [
   ]);
 
 export default function EditRule({
-  history,
   modalProps,
   defaultRule,
   onSave: originalOnSave,
diff --git a/packages/desktop-client/src/components/modals/MergeUnusedPayees.js b/packages/desktop-client/src/components/modals/MergeUnusedPayees.js
index bbccf33a9..62cd244ef 100644
--- a/packages/desktop-client/src/components/modals/MergeUnusedPayees.js
+++ b/packages/desktop-client/src/components/modals/MergeUnusedPayees.js
@@ -11,7 +11,6 @@ import { View, Text, Modal, ModalButtons, Button, P } from '../common';
 let highlightStyle = { color: colors.p5 };
 
 export default function MergeUnusedPayees({
-  history,
   modalProps,
   payeeIds,
   targetPayeeId,
diff --git a/packages/desktop-client/src/components/reports/Overview.js b/packages/desktop-client/src/components/reports/Overview.js
index 264180009..31a5f14bd 100644
--- a/packages/desktop-client/src/components/reports/Overview.js
+++ b/packages/desktop-client/src/components/reports/Overview.js
@@ -49,7 +49,6 @@ function Card({ flex, to, style, children }) {
     return (
       <AnchorLink
         to={to}
-        exact
         style={[{ textDecoration: 'none', flex }, containerProps]}
       >
         {content}
diff --git a/packages/desktop-client/src/components/reports/index.js b/packages/desktop-client/src/components/reports/index.js
index 75af78499..b0ccfe63c 100644
--- a/packages/desktop-client/src/components/reports/index.js
+++ b/packages/desktop-client/src/components/reports/index.js
@@ -1,5 +1,5 @@
 import React from 'react';
-import { Route } from 'react-router-dom';
+import { Route, Routes } from 'react-router-dom';
 
 import { View } from '../common';
 
@@ -10,15 +10,11 @@ import Overview from './Overview';
 export default function Reports() {
   return (
     <View style={{ flex: 1 }} data-testid="reports-page">
-      <Route path="/reports" exact>
-        <Overview />
-      </Route>
-      <Route path="/reports/net-worth" exact>
-        <NetWorth />
-      </Route>
-      <Route path="/reports/cash-flow" exact>
-        <CashFlow />
-      </Route>
+      <Routes>
+        <Route path="/" element={<Overview />} />
+        <Route path="/net-worth" element={<NetWorth />} />
+        <Route path="/cash-flow" element={<CashFlow />} />
+      </Routes>
     </View>
   );
 }
diff --git a/packages/desktop-client/src/components/schedules/DiscoverSchedules.js b/packages/desktop-client/src/components/schedules/DiscoverSchedules.js
index 4978e64bc..bea585f1e 100644
--- a/packages/desktop-client/src/components/schedules/DiscoverSchedules.js
+++ b/packages/desktop-client/src/components/schedules/DiscoverSchedules.js
@@ -1,5 +1,5 @@
 import React, { useState } from 'react';
-import { useHistory } from 'react-router-dom';
+import { Navigate, useLocation, useNavigate } from 'react-router-dom';
 
 import q, { runQuery } from 'loot-core/src/client/query-helpers';
 import { send } from 'loot-core/src/platform/client/fetch';
@@ -12,6 +12,7 @@ import useSelected, {
 } from '../../hooks/useSelected';
 import useSendPlatformRequest from '../../hooks/useSendPlatformRequest';
 import { colors } from '../../style';
+import { getParent } from '../../util/router-tools';
 import { View, Stack, ButtonWithLoading, P } from '../common';
 import { Page, usePageType } from '../Page';
 import { Table, TableHeader, Row, Field, SelectCell } from '../table';
@@ -109,13 +110,20 @@ function DiscoverSchedulesTable({ schedules, loading }) {
 
 export default function DiscoverSchedules() {
   let pageType = usePageType();
-  let history = useHistory();
-  let { data: schedules = [], isLoading } =
+  let navigate = useNavigate();
+  let { data: schedules, isLoading } =
     useSendPlatformRequest('schedule/discover');
+  if (!schedules) schedules = [];
+
   let [creating, setCreating] = useState(false);
 
   let selectedInst = useSelected('discover-schedules', schedules, []);
 
+  let location = useLocation();
+  if (!getParent(location)) {
+    return <Navigate to="/schedules" replace />;
+  }
+
   async function onCreate() {
     let selected = schedules.filter(s => selectedInst.items.has(s.id));
     setCreating(true);
@@ -144,7 +152,7 @@ export default function DiscoverSchedules() {
     }
 
     setCreating(false);
-    history.goBack();
+    navigate(-1);
   }
 
   return (
diff --git a/packages/desktop-client/src/components/schedules/EditSchedule.js b/packages/desktop-client/src/components/schedules/EditSchedule.js
index 23c6c4b89..32bd37231 100644
--- a/packages/desktop-client/src/components/schedules/EditSchedule.js
+++ b/packages/desktop-client/src/components/schedules/EditSchedule.js
@@ -1,6 +1,6 @@
 import React, { useEffect, useReducer } from 'react';
 import { useDispatch, useSelector } from 'react-redux';
-import { useParams, useHistory } from 'react-router-dom';
+import { useParams, useNavigate } from 'react-router-dom';
 
 import { pushModal } from 'loot-core/src/client/actions/modals';
 import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
@@ -81,7 +81,7 @@ export default function ScheduleDetails() {
   let { id, initialFields } = useParams();
   let adding = id == null;
   let payees = useCachedPayees({ idKey: true });
-  let history = useHistory();
+  let navigate = useNavigate();
   let globalDispatch = useDispatch();
   let dateFormat = useSelector(state => {
     return state.prefs.local.dateFormat || 'MM/dd/yyyy';
@@ -379,7 +379,7 @@ export default function ScheduleDetails() {
       if (adding) {
         await onLinkTransactions([...selectedInst.items], res.data);
       }
-      history.goBack();
+      navigate(-1);
     }
   }
 
@@ -769,7 +769,7 @@ export default function ScheduleDetails() {
         style={{ marginTop: 20 }}
       >
         {state.error && <Text style={{ color: colors.r4 }}>{state.error}</Text>}
-        <Button style={{ marginRight: 10 }} onClick={() => history.goBack()}>
+        <Button style={{ marginRight: 10 }} onClick={() => navigate(-1)}>
           Cancel
         </Button>
         <Button primary onClick={onSave}>
diff --git a/packages/desktop-client/src/components/schedules/LinkSchedule.js b/packages/desktop-client/src/components/schedules/LinkSchedule.js
index e27d20df6..0ab090f6c 100644
--- a/packages/desktop-client/src/components/schedules/LinkSchedule.js
+++ b/packages/desktop-client/src/components/schedules/LinkSchedule.js
@@ -1,5 +1,5 @@
 import React, { useCallback, useState } from 'react';
-import { useLocation, useHistory } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
 
 import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
 import { send } from 'loot-core/src/platform/client/fetch';
@@ -11,7 +11,7 @@ import { SchedulesTable } from './SchedulesTable';
 
 export default function ScheduleLink() {
   let location = useLocation();
-  let history = useHistory();
+  let navigate = useNavigate();
   let scheduleData = useSchedules(
     useCallback(query => query.filter({ completed: false }), []),
   );
@@ -32,7 +32,7 @@ export default function ScheduleLink() {
         updated: ids.map(id => ({ id, schedule: scheduleId })),
       });
     }
-    history.goBack();
+    navigate(-1);
   }
 
   return (
diff --git a/packages/desktop-client/src/components/schedules/PostsOfflineNotification.js b/packages/desktop-client/src/components/schedules/PostsOfflineNotification.js
index e770b0a18..43a6b19f2 100644
--- a/packages/desktop-client/src/components/schedules/PostsOfflineNotification.js
+++ b/packages/desktop-client/src/components/schedules/PostsOfflineNotification.js
@@ -1,5 +1,5 @@
 import React from 'react';
-import { useLocation, useHistory } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
 
 import { send } from 'loot-core/src/platform/client/fetch';
 
@@ -10,18 +10,18 @@ import DisplayId from '../util/DisplayId';
 
 export default function PostsOfflineNotification() {
   let location = useLocation();
-  let history = useHistory();
+  let navigate = useNavigate();
 
   let payees = (location.state && location.state.payees) || [];
   let plural = payees.length > 1;
 
   function onClose() {
-    history.goBack();
+    navigate(-1);
   }
 
   async function onPost() {
     await send('schedule/force-run-service');
-    history.goBack();
+    navigate(-1);
   }
 
   return (
diff --git a/packages/desktop-client/src/components/schedules/index.js b/packages/desktop-client/src/components/schedules/index.js
index e54490884..90bc38270 100644
--- a/packages/desktop-client/src/components/schedules/index.js
+++ b/packages/desktop-client/src/components/schedules/index.js
@@ -1,16 +1,16 @@
 import React, { useState } from 'react';
-import { useHistory } from 'react-router-dom';
 
 import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
 import { send } from 'loot-core/src/platform/client/fetch';
 
+import { usePushModal } from '../../util/router-tools';
 import { View, Button, Search } from '../common';
 import { Page } from '../Page';
 
 import { SchedulesTable, ROW_HEIGHT } from './SchedulesTable';
 
 export default function Schedules() {
-  let history = useHistory();
+  let pushModal = usePushModal();
 
   let [filter, setFilter] = useState('');
 
@@ -23,15 +23,15 @@ export default function Schedules() {
   let { schedules, statuses } = scheduleData;
 
   function onEdit(id) {
-    history.push(`/schedule/edit/${id}`, { locationPtr: history.location });
+    pushModal(`/schedule/edit/${id}`);
   }
 
   function onAdd() {
-    history.push(`/schedule/edit`, { locationPtr: history.location });
+    pushModal('/schedule/edit');
   }
 
   function onDiscover() {
-    history.push(`/schedule/discover`, { locationPtr: history.location });
+    pushModal('/schedule/discover');
   }
 
   async function onAction(name, id) {
diff --git a/packages/desktop-client/src/components/sidebar.js b/packages/desktop-client/src/components/sidebar.js
index db1cd3c12..5c3efed88 100644
--- a/packages/desktop-client/src/components/sidebar.js
+++ b/packages/desktop-client/src/components/sidebar.js
@@ -30,7 +30,6 @@ const fontWeight = 600;
 function ItemContent({
   style,
   to,
-  exact,
   onClick,
   activeStyle,
   forceActive,
@@ -57,7 +56,6 @@ function ItemContent({
   ) : (
     <AnchorLink
       to={to}
-      exact={exact}
       style={style}
       activeStyle={activeStyle}
       forceActive={forceActive}
@@ -74,7 +72,6 @@ function Item({
   style,
   indent = 0,
   to,
-  exact,
   onClick,
   button,
   forceHover = false,
@@ -122,7 +119,6 @@ function Item({
       <ItemContent
         style={linkStyle}
         to={to}
-        exact={exact}
         onClick={onClick}
         activeStyle={activeStyle}
         forceActive={forceActive}
@@ -134,16 +130,7 @@ function Item({
   );
 }
 
-function SecondaryItem({
-  Icon,
-  title,
-  style,
-  to,
-  exact,
-  onClick,
-  bold,
-  indent = 0,
-}) {
+function SecondaryItem({ Icon, title, style, to, onClick, bold, indent = 0 }) {
   const hoverStyle = {
     backgroundColor: colors.n2,
   };
@@ -183,7 +170,6 @@ function SecondaryItem({
       <ItemContent
         style={linkStyle}
         to={to}
-        exact={exact}
         onClick={onClick}
         activeStyle={activeStyle}
       >
diff --git a/packages/desktop-client/src/global-events.js b/packages/desktop-client/src/global-events.js
index d50a41b39..059745440 100644
--- a/packages/desktop-client/src/global-events.js
+++ b/packages/desktop-client/src/global-events.js
@@ -36,11 +36,13 @@ export function handleGlobalEvents(actions, store) {
   });
 
   listen('schedules-offline', ({ payees }) => {
-    let history = window.__history;
-    if (history) {
-      history.push(`/schedule/posts-offline-notification`, {
-        locationPtr: history.location,
-        payees,
+    let navigate = window.__navigate;
+    if (navigate) {
+      navigate(`/schedule/posts-offline-notification`, {
+        state: {
+          locationPtr: navigate.location,
+          payees,
+        },
       });
     }
   });
diff --git a/packages/desktop-client/src/util/location-state.js b/packages/desktop-client/src/util/location-state.js
deleted file mode 100644
index 246d034df..000000000
--- a/packages/desktop-client/src/util/location-state.js
+++ /dev/null
@@ -1,12 +0,0 @@
-let VERSION = Date.now();
-
-export function makeLocationState(state) {
-  return { ...state, _version: VERSION };
-}
-
-export function getLocationState(location, subfield) {
-  if (location.state && location.state._version === VERSION) {
-    return subfield ? location.state[subfield] : location.state;
-  }
-  return null;
-}
diff --git a/packages/desktop-client/src/util/router-tools.tsx b/packages/desktop-client/src/util/router-tools.tsx
new file mode 100644
index 000000000..a8fc7ea7d
--- /dev/null
+++ b/packages/desktop-client/src/util/router-tools.tsx
@@ -0,0 +1,71 @@
+import { type ReactNode, useCallback, useLayoutEffect } from 'react';
+import {
+  type Location,
+  type To,
+  useLocation,
+  useNavigate,
+} from 'react-router-dom';
+
+import { ActiveLocationProvider } from '../components/ActiveLocation';
+import { PageTypeProvider } from '../components/Page';
+
+let VERSION = Date.now();
+
+export function ExposeNavigate() {
+  let navigate = useNavigate();
+  useLayoutEffect(() => {
+    window.__navigate = navigate;
+  }, [navigate]);
+  return null;
+}
+
+export function usePushModal() {
+  let navigate = useNavigate();
+  let location = useLocation();
+
+  return useCallback(
+    (path: To) =>
+      navigate(path, { state: { parent: location, _version: VERSION } }),
+    [navigate, location],
+  );
+}
+
+export function getParent(location: Location): Location | null {
+  if (location.state?._version !== VERSION) {
+    return null;
+  }
+  return location.state?.parent || null;
+}
+
+export function StackedRoutes({
+  render,
+}: {
+  render: (loc: Location) => ReactNode;
+}) {
+  let location = useLocation();
+  let parent = getParent(location);
+
+  let locations = [location];
+  while (parent) {
+    locations.unshift(parent);
+    parent = getParent(parent);
+  }
+
+  let base = locations[0];
+  let stack = locations.slice(1);
+
+  return (
+    <ActiveLocationProvider location={locations[locations.length - 1]}>
+      {render(base)}
+      {stack.map((location, idx) => (
+        <PageTypeProvider
+          key={location.key}
+          type="modal"
+          current={idx === stack.length - 1}
+        >
+          {render(location)}
+        </PageTypeProvider>
+      ))}
+    </ActiveLocationProvider>
+  );
+}
diff --git a/packages/desktop-electron/menu.js b/packages/desktop-electron/menu.js
index f6a23e037..f47249599 100644
--- a/packages/desktop-electron/menu.js
+++ b/packages/desktop-electron/menu.js
@@ -131,7 +131,9 @@ function getMenu(isDev, createWindow) {
           enabled: false,
           click: function (menuItem, focusedWin) {
             focusedWin.webContents.executeJavaScript(
-              '__history && __history.push("/schedule/discover", { locationPtr: __history.location })',
+              // TODO: fix
+              // '__navigate && __history.push("/schedule/discover", { locationPtr: __history.location })',
+              'alert("Not implemented")',
             );
           },
         },
diff --git a/packages/loot-core/src/client/actions/budgets.ts b/packages/loot-core/src/client/actions/budgets.ts
index 5eff493d1..d9b6f18df 100644
--- a/packages/loot-core/src/client/actions/budgets.ts
+++ b/packages/loot-core/src/client/actions/budgets.ts
@@ -165,7 +165,7 @@ export function importBudget(filepath, type) {
     dispatch(closeModal());
 
     await dispatch(loadPrefs());
-    window.__history.push('/budget');
+    window.__navigate('/budget');
   };
 }
 
diff --git a/packages/loot-core/typings/window.d.ts b/packages/loot-core/typings/window.d.ts
index 57e0efae4..3eea72bc6 100644
--- a/packages/loot-core/typings/window.d.ts
+++ b/packages/loot-core/typings/window.d.ts
@@ -8,9 +8,6 @@ declare global {
       openURLInBrowser: (url: string) => void;
     };
 
-    __history?: {
-      location;
-      push(url: string, opts?: unknown): void;
-    };
+    __navigate?: ReturnType<import('react-router')['useNavigate']>;
   }
 }
diff --git a/upcoming-release-notes/1066.md b/upcoming-release-notes/1066.md
new file mode 100644
index 000000000..d4af039d4
--- /dev/null
+++ b/upcoming-release-notes/1066.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [trevdor]
+---
+
+Upgrade to react-router v6 and adopt v6 routing conventions.
diff --git a/yarn.lock b/yarn.lock
index ecc5f02a1..0bfe01471 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -111,9 +111,7 @@ __metadata:
     react-merge-refs: ^1.1.0
     react-modal: 3.16.1
     react-redux: 7.2.1
-    react-router: 5.2.0
-    react-router-dom: 5.2.0
-    react-router-dom-v5-compat: ^6.4.1
+    react-router-dom: 6.11.2
     react-scripts: ^5.0.1
     react-spring: ^9.7.1
     react-virtualized-auto-sizer: ^1.0.2
@@ -1723,7 +1721,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.8, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
+"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.8, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
   version: 7.22.5
   resolution: "@babel/runtime@npm:7.22.5"
   dependencies:
@@ -3276,10 +3274,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@remix-run/router@npm:1.6.1":
-  version: 1.6.1
-  resolution: "@remix-run/router@npm:1.6.1"
-  checksum: 4ca65d9c7d6fa277227ad8fd4ef53bebab99460b714d835b609c998f9a7e7c33a964ce2b8af853b50025a60d9113968f256abc5f71f451939ff14a5187d327fe
+"@remix-run/router@npm:1.6.2":
+  version: 1.6.2
+  resolution: "@remix-run/router@npm:1.6.2"
+  checksum: 5969d313bff6ba5c75917910090cebafda84b9d3b4b453fae6b3d60fea9f938078578ffca769c532ab7ce252cd4a207b78d1024d7c727ab80dd572e62fd3b3f2
   languageName: node
   linkType: hard
 
@@ -9851,30 +9849,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"history@npm:^4.9.0":
-  version: 4.10.1
-  resolution: "history@npm:4.10.1"
-  dependencies:
-    "@babel/runtime": ^7.1.2
-    loose-envify: ^1.2.0
-    resolve-pathname: ^3.0.0
-    tiny-invariant: ^1.0.2
-    tiny-warning: ^1.0.0
-    value-equal: ^1.0.1
-  checksum: addd84bc4683929bae4400419b5af132ff4e4e9b311a0d4e224579ea8e184a6b80d7f72c55927e4fa117f69076a9e47ce082d8d0b422f1a9ddac7991490ca1d0
-  languageName: node
-  linkType: hard
-
-"history@npm:^5.3.0":
-  version: 5.3.0
-  resolution: "history@npm:5.3.0"
-  dependencies:
-    "@babel/runtime": ^7.7.6
-  checksum: d73c35df49d19ac172f9547d30a21a26793e83f16a78386d99583b5bf1429cc980799fcf1827eb215d31816a6600684fba9686ce78104e23bd89ec239e7c726f
-  languageName: node
-  linkType: hard
-
-"hoist-non-react-statics@npm:^3.1.0, hoist-non-react-statics@npm:^3.3.0":
+"hoist-non-react-statics@npm:^3.3.0":
   version: 3.3.2
   resolution: "hoist-non-react-statics@npm:3.3.2"
   dependencies:
@@ -10768,13 +10743,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"isarray@npm:0.0.1":
-  version: 0.0.1
-  resolution: "isarray@npm:0.0.1"
-  checksum: 49191f1425681df4a18c2f0f93db3adb85573bcdd6a4482539d98eac9e705d8961317b01175627e860516a2fc45f8f9302db26e5a380a97a520e272e2a40a8d4
-  languageName: node
-  linkType: hard
-
 "isarray@npm:^2.0.5":
   version: 2.0.5
   resolution: "isarray@npm:2.0.5"
@@ -12175,7 +12143,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.2.0, loose-envify@npm:^1.3.1, loose-envify@npm:^1.4.0":
+"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
   version: 1.4.0
   resolution: "loose-envify@npm:1.4.0"
   dependencies:
@@ -12562,19 +12530,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"mini-create-react-context@npm:^0.4.0":
-  version: 0.4.1
-  resolution: "mini-create-react-context@npm:0.4.1"
-  dependencies:
-    "@babel/runtime": ^7.12.1
-    tiny-warning: ^1.0.3
-  peerDependencies:
-    prop-types: ^15.0.0
-    react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
-  checksum: f8cb2c7738aac355fe9ce7e8425f371b7fa90daddd5133edda4ccfdc18c49043b2ec04be6f3abf09b60a0f52549d54f158d5bfd81cdfb1a658531e5b9fe7bc6a
-  languageName: node
-  linkType: hard
-
 "mini-css-extract-plugin@npm:^2.4.5":
   version: 2.7.5
   resolution: "mini-css-extract-plugin@npm:2.7.5"
@@ -13591,15 +13546,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"path-to-regexp@npm:^1.7.0":
-  version: 1.8.0
-  resolution: "path-to-regexp@npm:1.8.0"
-  dependencies:
-    isarray: 0.0.1
-  checksum: 709f6f083c0552514ef4780cb2e7e4cf49b0cc89a97439f2b7cc69a608982b7690fb5d1720a7473a59806508fc2dae0be751ba49f495ecf89fd8fbc62abccbcd
-  languageName: node
-  linkType: hard
-
 "path-type@npm:^2.0.0":
   version: 2.0.0
   resolution: "path-type@npm:2.0.0"
@@ -14783,7 +14729,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
+"prop-types@npm:^15.5.10, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
   version: 15.8.1
   resolution: "prop-types@npm:15.8.1"
   dependencies:
@@ -15046,7 +14992,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-is@npm:^16.13.1, react-is@npm:^16.6.0, react-is@npm:^16.7.0, react-is@npm:^16.9.0":
+"react-is@npm:^16.13.1, react-is@npm:^16.7.0, react-is@npm:^16.9.0":
   version: 16.13.1
   resolution: "react-is@npm:16.13.1"
   checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f
@@ -15131,65 +15077,27 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-router-dom-v5-compat@npm:^6.4.1":
-  version: 6.11.1
-  resolution: "react-router-dom-v5-compat@npm:6.11.1"
+"react-router-dom@npm:6.11.2":
+  version: 6.11.2
+  resolution: "react-router-dom@npm:6.11.2"
   dependencies:
-    history: ^5.3.0
-    react-router: 6.11.1
+    "@remix-run/router": 1.6.2
+    react-router: 6.11.2
   peerDependencies:
     react: ">=16.8"
     react-dom: ">=16.8"
-    react-router-dom: 4 || 5
-  checksum: 56d79c3a14c82f57bdc564f425a48311a91cbb4bcf8e6cdfa7adb59a260074606cf59073d830381111464e345642323063c25a800e287ff527b1035381811e13
-  languageName: node
-  linkType: hard
-
-"react-router-dom@npm:5.2.0":
-  version: 5.2.0
-  resolution: "react-router-dom@npm:5.2.0"
-  dependencies:
-    "@babel/runtime": ^7.1.2
-    history: ^4.9.0
-    loose-envify: ^1.3.1
-    prop-types: ^15.6.2
-    react-router: 5.2.0
-    tiny-invariant: ^1.0.2
-    tiny-warning: ^1.0.0
-  peerDependencies:
-    react: ">=15"
-  checksum: 98d2d35f9540ac4a3c14dc023623fc8411a6a6338e95d726370e07b27c3bc6e854516537c8e3f9ad2483c4bbd579ba28cce9aff843a19fe8ebff663318886335
-  languageName: node
-  linkType: hard
-
-"react-router@npm:5.2.0":
-  version: 5.2.0
-  resolution: "react-router@npm:5.2.0"
-  dependencies:
-    "@babel/runtime": ^7.1.2
-    history: ^4.9.0
-    hoist-non-react-statics: ^3.1.0
-    loose-envify: ^1.3.1
-    mini-create-react-context: ^0.4.0
-    path-to-regexp: ^1.7.0
-    prop-types: ^15.6.2
-    react-is: ^16.6.0
-    tiny-invariant: ^1.0.2
-    tiny-warning: ^1.0.0
-  peerDependencies:
-    react: ">=15"
-  checksum: 6fc908729110a65a5676a9e41333e0f511a3c0ff84c93c0dc704330cf3e02124c93aaeab8031b0e2c71712390d9278fff848eeebfbdda36dca3201142f309973
+  checksum: ba44ff37f2956bc18991f2eedb32a3fa46d35832f61ded6c5d167e853ca289868fca6635467866280c73bc3da00dce8437dbbec57da100c0a3e3e3850af00b83
   languageName: node
   linkType: hard
 
-"react-router@npm:6.11.1":
-  version: 6.11.1
-  resolution: "react-router@npm:6.11.1"
+"react-router@npm:6.11.2":
+  version: 6.11.2
+  resolution: "react-router@npm:6.11.2"
   dependencies:
-    "@remix-run/router": 1.6.1
+    "@remix-run/router": 1.6.2
   peerDependencies:
     react: ">=16.8"
-  checksum: c5cafbaac13564d0e325f84ce6e4cbc42de5c381b0f619209f3b101d2b6eae4a8f9ee87b492875e869909dd9bb549d05d2f677085708f79622b872bd45d14bbb
+  checksum: e47f875dca70033a3b42704cb5ec076b60f9629a5cdc3be613707f3d5a5706123fb80301037455c285c6d5a1011b443e1784e0103969ebfac7071648d360c413
   languageName: node
   linkType: hard
 
@@ -15600,13 +15508,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"resolve-pathname@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "resolve-pathname@npm:3.0.0"
-  checksum: 6147241ba42c423dbe83cb067a2b4af4f60908c3af57e1ea567729cc71416c089737fe2a73e9e79e7a60f00f66c91e4b45ad0d37cd4be2d43fec44963ef14368
-  languageName: node
-  linkType: hard
-
 "resolve-url-loader@npm:^4.0.0":
   version: 4.0.0
   resolution: "resolve-url-loader@npm:4.0.0"
@@ -17143,20 +17044,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tiny-invariant@npm:^1.0.2":
-  version: 1.3.1
-  resolution: "tiny-invariant@npm:1.3.1"
-  checksum: 872dbd1ff20a21303a2fd20ce3a15602cfa7fcf9b228bd694a52e2938224313b5385a1078cb667ed7375d1612194feaca81c4ecbe93121ca1baebe344de4f84c
-  languageName: node
-  linkType: hard
-
-"tiny-warning@npm:^1.0.0, tiny-warning@npm:^1.0.3":
-  version: 1.0.3
-  resolution: "tiny-warning@npm:1.0.3"
-  checksum: da62c4acac565902f0624b123eed6dd3509bc9a8d30c06e017104bedcf5d35810da8ff72864400ad19c5c7806fc0a8323c68baf3e326af7cb7d969f846100d71
-  languageName: node
-  linkType: hard
-
 "tmp-promise@npm:^3.0.2":
   version: 3.0.3
   resolution: "tmp-promise@npm:3.0.3"
@@ -17817,13 +17704,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"value-equal@npm:^1.0.1":
-  version: 1.0.1
-  resolution: "value-equal@npm:1.0.1"
-  checksum: bb7ae1facc76b5cf8071aeb6c13d284d023fdb370478d10a5d64508e0e6e53bb459c4bbe34258df29d82e6f561f874f0105eba38de0e61fe9edd0bdce07a77a2
-  languageName: node
-  linkType: hard
-
 "vary@npm:~1.1.2":
   version: 1.1.2
   resolution: "vary@npm:1.1.2"
-- 
GitLab