perf(panel): 除登录与首页外的页面按路由懒加载,框架依赖单独成块长期缓存,玩家首屏不再下载管理页代码
This commit is contained in:
4 files changed
+101
-19
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user