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

This commit is contained in:
sp.wack 2024-12-02 20:46:24 +04:00 committed by GitHub
parent 5069a8700a
commit a378ff0965
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 999 additions and 9871 deletions

1
frontend/.gitignore vendored
View File

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

View File

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

View File

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

10674
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

19
frontend/src/routes.ts Normal file
View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,9 @@
/* eslint-disable import/no-extraneous-dependencies */
/// <reference types="vitest" /> /// <reference types="vitest" />
/// <reference types="vite-plugin-svgr/client" /> /// <reference types="vite-plugin-svgr/client" />
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
import viteTsconfigPaths from "vite-tsconfig-paths"; import viteTsconfigPaths from "vite-tsconfig-paths";
import svgr from "vite-plugin-svgr"; 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"; import { configDefaults } from "vitest/config";
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
@ -24,47 +23,9 @@ export default defineConfig(({ mode }) => {
const WS_URL = `${WS_PROTOCOL}://${VITE_BACKEND_HOST}/`; const WS_URL = `${WS_PROTOCOL}://${VITE_BACKEND_HOST}/`;
const FE_PORT = Number.parseInt(VITE_FRONTEND_PORT, 10); 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 { return {
plugins: [ plugins: [
!process.env.VITEST && !process.env.VITEST && reactRouter(),
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
},
appDirectory: "src",
buildEnd: unpackClientDirectory,
ssr: false,
}),
viteTsconfigPaths(), viteTsconfigPaths(),
svgr(), svgr(),
], ],
@ -87,8 +48,8 @@ export default defineConfig(({ mode }) => {
ws: true, ws: true,
changeOrigin: true, changeOrigin: true,
secure: !INSECURE_SKIP_VERIFY, secure: !INSECURE_SKIP_VERIFY,
//rewriteWsOrigin: true, // rewriteWsOrigin: true,
} },
}, },
}, },
ssr: { ssr: {