diff --git a/panel/e2e/smoke.spec.ts b/panel/e2e/smoke.spec.ts index e832743..e46ba48 100644 --- a/panel/e2e/smoke.spec.ts +++ b/panel/e2e/smoke.spec.ts @@ -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("linked@mock.felis.local")).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-.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([]); +}); diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 2fd5201..6a9cf03 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -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. */} - } /> + }> + + + } + /> {/* Everything else requires a session. RequireAuth gates the whole app: no/expired session → /login, transient /me failure → still renders diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index 41a0215..a416558 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -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() {
{/* 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. */} - + }> + +
diff --git a/panel/vite.config.ts b/panel/vite.config.ts index ef6933b..ec8d77b 100644 --- a/panel/vite.config.ts +++ b/panel/vite.config.ts @@ -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 },