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

This commit is contained in:
Lemon-miaow committed 2026-09-25 13:04:22 +08:00
1 parent 73f4c858bf
commit 9f60178ba8
4 files changed
+101 -19

No files matched your search

+20
View File
@@ -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
View File
@@ -1,3 +1,4 @@
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
+8 -3
View File
@@ -1,4 +1,4 @@
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}>
<Outlet />
<Suspense fallback={<Loading />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</div>
</main>
+16
View File
@@ -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 },