Unverified Commit 9f60178b authored by Lemon-miaow's avatar Lemon-miaow
Browse files

perf(panel): 除登录与首页外的页面按路由懒加载,框架依赖单独成块长期缓存,玩家首屏不再下载管理页代码

parent 73f4c858
Loading
Loading
Loading
Loading
+20 −0
Changes for panel/e2e/smoke.spec.ts: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -53,3 +53,23 @@ test("an admin reaches the user list", async ({ page, signIn }) => {
  await expect(page.getByRole("heading", { name: t("admin:users_title") })).toBeVisible();
  await expect(page.getByText("[email protected]")).toBeVisible();
});

// Admin pages are chunks of their own, so a player never downloads them: every
// page module is fetched by its name (/src/pages/admin/UsersPage.tsx under the
// dev server, /assets/UsersPage-<hash>.js in a build).
const ADMIN_PAGE = /\/(UsersPage|UserDetailPage|ImageAdmin|ImageBuildPage|SubmissionsPage|UpdatesPage)[.-]/;
const ACCOUNT_PAGE = /\/Account[.-]/;

test("a player's pages load on demand and never pull in the admin pages", async ({ page, signIn }) => {
  const fetched: string[] = [];
  page.on("request", (req) => fetched.push(new URL(req.url()).pathname));
  await signIn("linked");
  await page.goto("/servers");
  await expect(page.getByRole("heading", { name: t("servers:my_servers_title") })).toBeVisible();
  expect(fetched.filter((p) => ACCOUNT_PAGE.test(p))).toEqual([]);

  await page.getByRole("link", { name: t("navigation:account"), exact: true }).click();
  await expect(page).toHaveURL(/\/account$/);
  await expect.poll(() => fetched.some((p) => ACCOUNT_PAGE.test(p))).toBe(true);
  expect(fetched.filter((p) => ADMIN_PAGE.test(p))).toEqual([]);
});
+57 −16
Changes for panel/src/App.tsx: 57 added lines, 16 removed lines.
Original line number Diff line number Diff line
import { Suspense } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "@/lib/theme";
import { TierProvider } from "@/lib/tier";
@@ -6,25 +7,58 @@ import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth";
import { RequireOwner } from "@/components/RequireOwner";
import { SetupRequiredRedirect } from "@/components/SetupRequiredRedirect";
import { Loading } from "@/components/States";
import { ValidParam } from "@/components/ValidParam";
import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params";
import { Login } from "@/pages/Login";
import { Setup } from "@/pages/Setup";
import { Dashboard } from "@/pages/Dashboard";
import { ServersPage } from "@/pages/servers/ServersPage";
import { ServerConsole } from "@/pages/ServerConsole";
import { ServerPlayers } from "@/pages/ServerPlayers";
import { ServerBackups } from "@/pages/ServerBackups";
import { ServerFiles } from "@/pages/ServerFiles";
import { ServerLuckPerms } from "@/pages/ServerLuckPerms";
import { Account } from "@/pages/Account";
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
import { UsersPage } from "@/pages/admin/UsersPage";
import { UserDetailPage } from "@/pages/admin/UserDetailPage";
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
import { lazyWithReload } from "@/lib/chunk";

// Sign-in and the landing page ship in the entry bundle; every other page is
// its own chunk, fetched the first time it is opened, so a phone opening the
// panel never downloads the admin pages it cannot use. Vite names each chunk
// after its page, and panel.go caches the hashed files for a year.
const Setup = lazyWithReload(() => import("@/pages/Setup").then((m) => ({ default: m.Setup })));
const ServersPage = lazyWithReload(() =>
  import("@/pages/servers/ServersPage").then((m) => ({ default: m.ServersPage })),
);
const ServerConsole = lazyWithReload(() =>
  import("@/pages/ServerConsole").then((m) => ({ default: m.ServerConsole })),
);
const ServerPlayers = lazyWithReload(() =>
  import("@/pages/ServerPlayers").then((m) => ({ default: m.ServerPlayers })),
);
const ServerBackups = lazyWithReload(() =>
  import("@/pages/ServerBackups").then((m) => ({ default: m.ServerBackups })),
);
const ServerFiles = lazyWithReload(() =>
  import("@/pages/ServerFiles").then((m) => ({ default: m.ServerFiles })),
);
const ServerLuckPerms = lazyWithReload(() =>
  import("@/pages/ServerLuckPerms").then((m) => ({ default: m.ServerLuckPerms })),
);
const Account = lazyWithReload(() => import("@/pages/Account").then((m) => ({ default: m.Account })));
const MySubmissionsPage = lazyWithReload(() =>
  import("@/pages/MySubmissionsPage").then((m) => ({ default: m.MySubmissionsPage })),
);
const ImageAdmin = lazyWithReload(() =>
  import("@/pages/admin/ImageAdmin").then((m) => ({ default: m.ImageAdmin })),
);
const ImageBuildPage = lazyWithReload(() =>
  import("@/pages/admin/ImageBuildPage").then((m) => ({ default: m.ImageBuildPage })),
);
const SubmissionsPage = lazyWithReload(() =>
  import("@/pages/admin/SubmissionsPage").then((m) => ({ default: m.SubmissionsPage })),
);
const UsersPage = lazyWithReload(() =>
  import("@/pages/admin/UsersPage").then((m) => ({ default: m.UsersPage })),
);
const UserDetailPage = lazyWithReload(() =>
  import("@/pages/admin/UserDetailPage").then((m) => ({ default: m.UserDetailPage })),
);
const UpdatesPage = lazyWithReload(() =>
  import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
);

// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
//   /        User-Side    — app-tier, every authenticated principal
@@ -46,7 +80,14 @@ export default function App() {
          {/* Owner first-run onboarding. Like /login it sits OUTSIDE RequireAuth:
              the visitor arrives from the `felis setup` link with no session, and
              redeeming the one-time token is what mints one. */}
          <Route path="/setup" element={<Setup />} />
          <Route
            path="/setup"
            element={
              <Suspense fallback={<Loading />}>
                <Setup />
              </Suspense>
            }
          />

          {/* Everything else requires a session. RequireAuth gates the whole app:
              no/expired session → /login, transient /me failure → still renders
+7 −2
Changes for panel/src/components/AppShell.tsx: 7 added lines, 2 removed lines.
Original line number Diff line number Diff line
import { useEffect, useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { Cat, Globe, Sun, Moon, LogOut, WifiOff, Menu, X } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -13,6 +13,7 @@ import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { ConfigBanner, VersionBadge } from "@/components/RuntimeStatus";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { Loading } from "@/components/States";

function SectionGroup({
  section,
@@ -305,9 +306,13 @@ export function AppShell() {
        <main className="flex flex-1 flex-col p-4 md:p-6">
          <div className="mx-auto flex w-full max-w-8xl flex-1 flex-col gap-6">
            {/* A crash on one page leaves the navigation usable; moving to
                another route clears it. */}
                another route clears it. Pages load as their own chunks, so the
                first visit to one shows a spinner here with the shell in place,
                and a chunk gone after a deploy lands in the boundary. */}
            <ErrorBoundary resetKey={pathname}>
              <Suspense fallback={<Loading />}>
                <Outlet />
              </Suspense>
            </ErrorBoundary>
          </div>
        </main>
+16 −0
Changes for panel/vite.config.ts: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,9 @@ import { defineConfig, type PluginOption } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";

const FRAMEWORK =
  /[\\/]node_modules[\\/](react|react-dom|scheduler|react-router|react-router-dom|@remix-run[\\/]router|i18next|react-i18next|i18next-browser-languagedetector)[\\/]/;

// The panel is served by felis-api behind Zero-Trust; the API lives under the
// same origin in production, so /api proxies there in dev.
export default defineConfig(async ({ mode }) => {
@@ -16,6 +19,19 @@ export default defineConfig(async ({ mode }) => {
    resolve: {
      alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
    },
    build: {
      rollupOptions: {
        output: {
          // The framework changes far less often than the panel, so it gets a
          // chunk of its own that stays cached across panel releases. Listed by
          // name: a blanket node_modules rule would pull the 3D fleet's three.js
          // out of its lazy chunk into the first load.
          manualChunks(id: string) {
            if (FRAMEWORK.test(id)) return "framework";
          },
        },
      },
    },
    server: {
      proxy: {
        "/api": { target: "http://localhost:8080", changeOrigin: true },