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/
/blob-report/
/playwright/.cache/
.react-router/

View File

@ -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" })),
}));

View File

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

10674
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

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 { 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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
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 { useDispatch } from "react-redux";
import { setImportedProjectZip } from "#/state/initial-query-slice";

View File

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

View File

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

View File

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

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 React from "react";
import OpenHands from "#/api/open-hands";

View File

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

View File

@ -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: {