Explorar el Código

chore(frontend): Migrate from Remix to React Router 7 (#5304)

sp.wack hace 1 año
padre
commit
a378ff0965

+ 1 - 0
frontend/.gitignore

@@ -7,3 +7,4 @@ node_modules/
 /playwright-report/
 /blob-report/
 /playwright/.cache/
+.react-router/

+ 6 - 6
frontend/__tests__/components/chat/chat-interface.test.tsx

@@ -26,8 +26,8 @@ describe("Empty state", () => {
   }));
 
   beforeAll(() => {
-    vi.mock("@remix-run/react", async (importActual) => ({
-      ...(await importActual<typeof import("@remix-run/react")>()),
+    vi.mock("react-router", async (importActual) => ({
+      ...(await importActual<typeof import("react-router")>()),
       useRouteLoaderData: vi.fn(() => ({})),
     }));
 
@@ -290,8 +290,8 @@ describe.skip("ChatInterface", () => {
   });
 
   it("should render both GitHub buttons initially when ghToken is available", () => {
-    vi.mock("@remix-run/react", async (importActual) => ({
-      ...(await importActual<typeof import("@remix-run/react")>()),
+    vi.mock("react-router", async (importActual) => ({
+      ...(await importActual<typeof import("react-router")>()),
       useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
     }));
 
@@ -315,8 +315,8 @@ describe.skip("ChatInterface", () => {
   });
 
   it("should render only 'Push changes to PR' button after PR is created", async () => {
-    vi.mock("@remix-run/react", async (importActual) => ({
-      ...(await importActual<typeof import("@remix-run/react")>()),
+    vi.mock("react-router", async (importActual) => ({
+      ...(await importActual<typeof import("react-router")>()),
       useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
     }));
 

+ 14 - 14
frontend/__tests__/routes/_oh.test.tsx

@@ -1,5 +1,5 @@
 import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
-import { createRemixStub } from "@remix-run/testing";
+import { createRoutesStub } from "react-router";
 import { screen, waitFor, within } from "@testing-library/react";
 import { renderWithProviders } from "test-utils";
 import userEvent from "@testing-library/user-event";
@@ -8,7 +8,7 @@ import * as CaptureConsent from "#/utils/handle-capture-consent";
 import i18n from "#/i18n";
 
 describe("frontend/routes/_oh", () => {
-  const RemixStub = createRemixStub([{ Component: MainApp, path: "/" }]);
+  const RouteStub = createRoutesStub([{ Component: MainApp, path: "/" }]);
 
   const { userIsAuthenticatedMock, settingsAreUpToDateMock } = vi.hoisted(
     () => ({
@@ -34,26 +34,26 @@ describe("frontend/routes/_oh", () => {
   });
 
   it("should render", async () => {
-    renderWithProviders(<RemixStub />);
+    renderWithProviders(<RouteStub />);
     await screen.findByTestId("root-layout");
   });
 
   it("should render the AI config modal if the user is authed", async () => {
     // Our mock return value is true by default
-    renderWithProviders(<RemixStub />);
+    renderWithProviders(<RouteStub />);
     await screen.findByTestId("ai-config-modal");
   });
 
   it("should render the AI config modal if settings are not up-to-date", async () => {
     settingsAreUpToDateMock.mockReturnValue(false);
-    renderWithProviders(<RemixStub />);
+    renderWithProviders(<RouteStub />);
 
     await screen.findByTestId("ai-config-modal");
   });
 
   it("should not render the AI config modal if the settings are up-to-date", async () => {
     settingsAreUpToDateMock.mockReturnValue(true);
-    renderWithProviders(<RemixStub />);
+    renderWithProviders(<RouteStub />);
 
     await waitFor(() => {
       expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
@@ -67,7 +67,7 @@ describe("frontend/routes/_oh", () => {
       "handleCaptureConsent",
     );
 
-    renderWithProviders(<RemixStub />);
+    renderWithProviders(<RouteStub />);
 
     // The user has not consented to tracking
     const consentForm = await screen.findByTestId("user-capture-consent-form");
@@ -89,7 +89,7 @@ describe("frontend/routes/_oh", () => {
 
   it("should not render the user consent form if the user has already made a decision", async () => {
     localStorage.setItem("analytics-consent", "true");
-    renderWithProviders(<RemixStub />);
+    renderWithProviders(<RouteStub />);
 
     await waitFor(() => {
       expect(
@@ -101,12 +101,12 @@ describe("frontend/routes/_oh", () => {
   // TODO: Likely failing due to how tokens are now handled in context. Move to e2e tests
   it.skip("should render a new project button if a token is set", async () => {
     localStorage.setItem("token", "test-token");
-    const { rerender } = renderWithProviders(<RemixStub />);
+    const { rerender } = renderWithProviders(<RouteStub />);
 
     await screen.findByTestId("new-project-button");
 
     localStorage.removeItem("token");
-    rerender(<RemixStub />);
+    rerender(<RouteStub />);
 
     await waitFor(() => {
       expect(
@@ -118,17 +118,17 @@ describe("frontend/routes/_oh", () => {
   // TODO: Move to e2e tests
   it.skip("should update the i18n language when the language settings change", async () => {
     const changeLanguageSpy = vi.spyOn(i18n, "changeLanguage");
-    const { rerender } = renderWithProviders(<RemixStub />);
+    const { rerender } = renderWithProviders(<RouteStub />);
 
     // The default language is English
     expect(changeLanguageSpy).toHaveBeenCalledWith("en");
 
     localStorage.setItem("LANGUAGE", "es");
 
-    rerender(<RemixStub />);
+    rerender(<RouteStub />);
     expect(changeLanguageSpy).toHaveBeenCalledWith("es");
 
-    rerender(<RemixStub />);
+    rerender(<RouteStub />);
     // The language has not changed, so the spy should not have been called again
     expect(changeLanguageSpy).toHaveBeenCalledTimes(2);
   });
@@ -139,7 +139,7 @@ describe("frontend/routes/_oh", () => {
     localStorage.setItem("ghToken", "test-token");
 
     // const logoutCleanupSpy = vi.spyOn(LogoutCleanup, "logoutCleanup");
-    renderWithProviders(<RemixStub />);
+    renderWithProviders(<RouteStub />);
 
     const userActions = await screen.findByTestId("user-actions");
     const userAvatar = within(userActions).getByTestId("user-avatar");

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 221 - 493
frontend/package-lock.json


+ 9 - 10
frontend/package.json

@@ -9,11 +9,10 @@
   "dependencies": {
     "@monaco-editor/react": "^4.6.0",
     "@nextui-org/react": "^2.4.8",
+    "@react-router/node": "^7.0.1",
+    "@react-router/serve": "^7.0.1",
     "@react-types/shared": "^3.25.0",
     "@reduxjs/toolkit": "^2.3.0",
-    "@remix-run/node": "^2.11.2",
-    "@remix-run/react": "^2.11.2",
-    "@remix-run/serve": "^2.11.2",
     "@tanstack/react-query": "^5.60.5",
     "@vitejs/plugin-react": "^4.3.2",
     "@xterm/addon-fit": "^0.10.0",
@@ -36,7 +35,7 @@
     "react-icons": "^5.3.0",
     "react-markdown": "^9.0.1",
     "react-redux": "^9.1.2",
-    "react-router-dom": "^6.26.1",
+    "react-router": "^7.0.1",
     "react-syntax-highlighter": "^15.6.1",
     "react-textarea-autosize": "^8.5.4",
     "remark-gfm": "^4.0.0",
@@ -48,9 +47,9 @@
     "ws": "^8.18.0"
   },
   "scripts": {
-    "dev": "npm run make-i18n && cross-env VITE_MOCK_API=false remix vite:dev",
-    "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true remix vite:dev",
-    "build": "npm run make-i18n && tsc && remix vite:build",
+    "dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
+    "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev",
+    "build": "npm run make-i18n && tsc && react-router build",
     "start": "npx sirv-cli build/ --single",
     "test": "vitest run",
     "test:e2e": "playwright test",
@@ -61,7 +60,8 @@
     "prelint": "npm run make-i18n",
     "lint": "eslint src --ext .ts,.tsx,.js && prettier --check src/**/*.{ts,tsx}",
     "lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
-    "prepare": "cd .. && husky frontend/.husky"
+    "prepare": "cd .. && husky frontend/.husky",
+    "typecheck": "react-router typegen && tsc"
   },
   "husky": {
     "hooks": {
@@ -76,8 +76,7 @@
   },
   "devDependencies": {
     "@playwright/test": "^1.48.2",
-    "@remix-run/dev": "^2.11.2",
-    "@remix-run/testing": "^2.11.2",
+    "@react-router/dev": "^7.0.1",
     "@tailwindcss/typography": "^0.5.15",
     "@tanstack/eslint-plugin-query": "^5.60.1",
     "@testing-library/jest-dom": "^6.6.1",

+ 35 - 0
frontend/react-router.config.ts

@@ -0,0 +1,35 @@
+import type { Config } from "@react-router/dev/config";
+
+/**
+ * This script is used to unpack the client directory from the frontend build directory.
+ * Remix SPA mode builds the client directory into the build directory. This function
+ * moves the contents of the client directory to the build directory and then removes the
+ * client directory.
+ *
+ * This script is used in the buildEnd function of the Vite config.
+ */
+const unpackClientDirectory = async () => {
+  const fs = await import("fs");
+  const path = await import("path");
+
+  const buildDir = path.resolve(__dirname, "build");
+  const clientDir = path.resolve(buildDir, "client");
+
+  const files = await fs.promises.readdir(clientDir);
+  await Promise.all(
+    files.map((file) =>
+      fs.promises.rename(
+        path.resolve(clientDir, file),
+        path.resolve(buildDir, file),
+      ),
+    ),
+  );
+
+  await fs.promises.rmdir(clientDir);
+};
+
+export default {
+  appDirectory: "src",
+  buildEnd: unpackClientDirectory,
+  ssr: false,
+} satisfies Config;

+ 1 - 1
frontend/src/components/features/sidebar/sidebar.tsx

@@ -1,5 +1,5 @@
 import React from "react";
-import { useLocation } from "react-router-dom";
+import { useLocation } from "react-router";
 import { useAuth } from "#/context/auth-context";
 import { useUserPrefs } from "#/context/user-prefs-context";
 import { useGitHubUser } from "#/hooks/query/use-github-user";

+ 1 - 1
frontend/src/components/layout/nav-tab.tsx

@@ -1,4 +1,4 @@
-import { NavLink } from "react-router-dom";
+import { NavLink } from "react-router";
 import { cn } from "#/utils/utils";
 import { BetaBadge } from "./beta-badge";
 

+ 1 - 1
frontend/src/components/shared/modals/settings/settings-form.tsx

@@ -1,4 +1,4 @@
-import { useLocation } from "@remix-run/react";
+import { useLocation } from "react-router";
 import { useTranslation } from "react-i18next";
 import React from "react";
 import posthog from "posthog-js";

+ 1 - 1
frontend/src/components/shared/task-form.tsx

@@ -1,5 +1,5 @@
 import React from "react";
-import { useNavigate, useNavigation } from "@remix-run/react";
+import { useNavigate, useNavigation } from "react-router";
 import { useDispatch, useSelector } from "react-redux";
 import posthog from "posthog-js";
 import { RootState } from "#/store";

+ 2 - 2
frontend/src/entry.client.tsx

@@ -5,7 +5,7 @@
  * For more information, see https://remix.run/file-conventions/entry.client
  */
 
-import { RemixBrowser } from "@remix-run/react";
+import { HydratedRouter } from "react-router/dom";
 import React, { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 import { Provider } from "react-redux";
@@ -74,7 +74,7 @@ prepareApp().then(() =>
           <UserPrefsProvider>
             <AuthProvider>
               <QueryClientProvider client={queryClient}>
-                <RemixBrowser />
+                <HydratedRouter />
                 <PosthogInit />
               </QueryClientProvider>
             </AuthProvider>

+ 1 - 1
frontend/src/hooks/use-end-session.ts

@@ -1,5 +1,5 @@
 import { useDispatch } from "react-redux";
-import { useNavigate } from "@remix-run/react";
+import { useNavigate } from "react-router";
 import { useAuth } from "#/context/auth-context";
 import {
   initialState as browserInitialState,

+ 1 - 1
frontend/src/root.tsx

@@ -5,7 +5,7 @@ import {
   Outlet,
   Scripts,
   ScrollRestoration,
-} from "@remix-run/react";
+} from "react-router";
 import "./tailwind.css";
 import "./index.css";
 import React from "react";

+ 19 - 0
frontend/src/routes.ts

@@ -0,0 +1,19 @@
+import {
+  type RouteConfig,
+  layout,
+  index,
+  route,
+} from "@react-router/dev/routes";
+
+export default [
+  layout("routes/_oh/route.tsx", [
+    index("routes/_oh._index/route.tsx"),
+    route("app", "routes/_oh.app/route.tsx", [
+      index("routes/_oh.app._index/route.tsx"),
+      route("browser", "routes/_oh.app.browser.tsx"),
+      route("jupyter", "routes/_oh.app.jupyter.tsx"),
+    ]),
+  ]),
+
+  route("oauth", "routes/oauth.github.callback.tsx"),
+] satisfies RouteConfig;

+ 1 - 1
frontend/src/routes/_oh._index/route.tsx

@@ -1,4 +1,4 @@
-import { useLocation, useNavigate } from "@remix-run/react";
+import { useLocation, useNavigate } from "react-router";
 import React from "react";
 import { useDispatch } from "react-redux";
 import { setImportedProjectZip } from "#/state/initial-query-slice";

+ 1 - 1
frontend/src/routes/_oh.app._index/route.tsx

@@ -1,6 +1,6 @@
 import React from "react";
 import { useSelector } from "react-redux";
-import { useRouteError } from "@remix-run/react";
+import { useRouteError } from "react-router";
 import { editor } from "monaco-editor";
 import { EditorProps } from "@monaco-editor/react";
 import { RootState } from "#/store";

+ 1 - 1
frontend/src/routes/_oh.app/route.tsx

@@ -1,6 +1,6 @@
 import { useDisclosure } from "@nextui-org/react";
 import React from "react";
-import { Outlet } from "@remix-run/react";
+import { Outlet } from "react-router";
 import { useDispatch, useSelector } from "react-redux";
 import { Controls } from "#/components/features/controls/controls";
 import { RootState } from "#/store";

+ 1 - 1
frontend/src/routes/_oh/route.tsx

@@ -1,5 +1,5 @@
 import React from "react";
-import { useRouteError, isRouteErrorResponse, Outlet } from "@remix-run/react";
+import { useRouteError, isRouteErrorResponse, Outlet } from "react-router";
 import i18n from "#/i18n";
 import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
 import { useIsAuthed } from "#/hooks/query/use-is-authed";

+ 1 - 1
frontend/src/routes/oauth.github.callback.tsx

@@ -1,4 +1,4 @@
-import { useNavigate, useSearchParams } from "@remix-run/react";
+import { useNavigate, useSearchParams } from "react-router";
 import { useQuery } from "@tanstack/react-query";
 import React from "react";
 import OpenHands from "#/api/open-hands";

+ 7 - 2
frontend/tsconfig.json

@@ -5,7 +5,8 @@
     "**/.server/**/*.ts",
     "**/.server/**/*.tsx",
     "**/.client/**/*.ts",
-    "**/.client/**/*.tsx"
+    "**/.client/**/*.tsx",
+    ".react-router/types/**/*",
   ],
   "compilerOptions": {
     "lib": [
@@ -15,9 +16,13 @@
     ],
     "target": "es2022",
     "types": [
-      "@remix-run/node",
+      "@react-router/node",
       "vite/client",
     ],
+    "rootDirs": [
+      ".",
+      "./.react-router/types"
+    ],
     "allowJs": true,
     "skipLibCheck": true,
     "esModuleInterop": true,

+ 4 - 43
frontend/vite.config.ts

@@ -1,10 +1,9 @@
-/* eslint-disable import/no-extraneous-dependencies */
 /// <reference types="vitest" />
 /// <reference types="vite-plugin-svgr/client" />
 import { defineConfig, loadEnv } from "vite";
 import viteTsconfigPaths from "vite-tsconfig-paths";
 import svgr from "vite-plugin-svgr";
-import { vitePlugin as remix } from "@remix-run/dev";
+import { reactRouter } from "@react-router/dev/vite";
 import { configDefaults } from "vitest/config";
 
 export default defineConfig(({ mode }) => {
@@ -24,47 +23,9 @@ export default defineConfig(({ mode }) => {
   const WS_URL = `${WS_PROTOCOL}://${VITE_BACKEND_HOST}/`;
   const FE_PORT = Number.parseInt(VITE_FRONTEND_PORT, 10);
 
-  /**
-   * This script is used to unpack the client directory from the frontend build directory.
-   * Remix SPA mode builds the client directory into the build directory. This function
-   * moves the contents of the client directory to the build directory and then removes the
-   * client directory.
-   *
-   * This script is used in the buildEnd function of the Vite config.
-   */
-  const unpackClientDirectory = async () => {
-    const fs = await import("fs");
-    const path = await import("path");
-
-    const buildDir = path.resolve(__dirname, "build");
-    const clientDir = path.resolve(buildDir, "client");
-
-    const files = await fs.promises.readdir(clientDir);
-    await Promise.all(
-      files.map((file) =>
-        fs.promises.rename(
-          path.resolve(clientDir, file),
-          path.resolve(buildDir, file),
-        ),
-      ),
-    );
-
-    await fs.promises.rmdir(clientDir);
-  };
-
   return {
     plugins: [
-      !process.env.VITEST &&
-        remix({
-          future: {
-            v3_fetcherPersist: true,
-            v3_relativeSplatPath: true,
-            v3_throwAbortReason: true,
-          },
-          appDirectory: "src",
-          buildEnd: unpackClientDirectory,
-          ssr: false,
-        }),
+      !process.env.VITEST && reactRouter(),
       viteTsconfigPaths(),
       svgr(),
     ],
@@ -87,8 +48,8 @@ export default defineConfig(({ mode }) => {
           ws: true,
           changeOrigin: true,
           secure: !INSECURE_SKIP_VERIFY,
-          //rewriteWsOrigin: true,
-        }
+          // rewriteWsOrigin: true,
+        },
       },
     },
     ssr: {

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio