perf: route- and modal-level code splitting (React.lazy)

The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
  public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
  Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
  the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
  DownloadDialog (DownloadButton — kept out of the feed chunk), and the
  VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
  callers can pre-select the admin tab without statically importing the now
  lazy-loaded AdminUsers page (which would defeat the split).

Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
This commit is contained in:
npeter83 2026-07-04 19:43:50 +02:00
parent 18a33b96c8
commit 2344902a7f
10 changed files with 167 additions and 117 deletions

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -40,36 +40,43 @@ import {
} from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed";
import ChannelPage from "./components/ChannelPage";
import BackToTop from "./components/BackToTop";
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
import Playlists from "./components/Playlists";
import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler";
import ConfigPanel from "./components/ConfigPanel";
import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers";
import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel";
import Messages from "./components/Messages";
import DownloadCenter from "./components/DownloadCenter";
import { setNavigator } from "./lib/nav";
import ChatDock from "./components/ChatDock";
import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog";
import About from "./components/About";
import ReleaseNotes from "./components/ReleaseNotes";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import { setNavigator } from "./lib/nav";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import { CURRENT_VERSION } from "./lib/releaseNotes";
// Route-level code splitting: each module page (and the heavier modals) is its own lazy chunk,
// so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
const Feed = lazy(() => import("./components/Feed"));
const ChannelPage = lazy(() => import("./components/ChannelPage"));
const Channels = lazy(() => import("./components/Channels"));
const Playlists = lazy(() => import("./components/Playlists"));
const Stats = lazy(() => import("./components/Stats"));
const Scheduler = lazy(() => import("./components/Scheduler"));
const ConfigPanel = lazy(() => import("./components/ConfigPanel"));
const AdminUsers = lazy(() => import("./components/AdminUsers"));
const SettingsPanel = lazy(() => import("./components/SettingsPanel"));
const NotificationsPanel = lazy(() => import("./components/NotificationsPanel"));
const Messages = lazy(() => import("./components/Messages"));
const DownloadCenter = lazy(() => import("./components/DownloadCenter"));
const OnboardingWizard = lazy(() => import("./components/OnboardingWizard"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagMode: "or",
@ -656,6 +663,11 @@ export default function App() {
);
if (!meQuery.data) return <Welcome />;
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
const pageFallback = (
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
);
return (
<div className="h-screen flex bg-bg text-fg">
<NavSidebar
@ -690,6 +702,7 @@ export default function App() {
)}
<div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<Suspense fallback={pageFallback}>
<ChannelPage
key={channelView.id}
channelId={channelView.id}
@ -699,6 +712,7 @@ export default function App() {
onBack={closeChannel}
onOpenChannel={openChannel}
/>
</Suspense>
) : (
<>
<Header
@ -711,6 +725,7 @@ export default function App() {
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<main className="flex-1 min-w-0 overflow-y-auto">
<Suspense fallback={pageFallback}>
{page === "channels" ? (
<Channels
canWrite={meQuery.data!.can_write}
@ -768,6 +783,7 @@ export default function App() {
onOpenChannel={openChannel}
/>
)}
</Suspense>
</main>
</>
)}
@ -777,6 +793,7 @@ export default function App() {
<Toaster />
</div>
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
<Suspense fallback={null}>
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}
@ -792,6 +809,7 @@ export default function App() {
{notesOpen && (
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)}
</Suspense>
<ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />

View file

@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications";
import { LS, setAccountRaw } from "../lib/storage";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import Tooltip from "./Tooltip";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider";
@ -13,15 +13,8 @@ import Tabs, { usePersistedTab } from "./Tabs";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
// carries a badge with the count of pending requests so it's visible without switching to it.
// The persisted-tab storage key + the access-requests tab id, exported so a notification's
// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key
// on mount, and this page mounts fresh on navigation).
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
export const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void {
setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
}
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
// pre-select the tab without importing this lazy-loaded page — see that file.)
export default function AdminUsers({ me }: { me: Me }) {
const { t } = useTranslation();

View file

@ -1,9 +1,11 @@
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Download } from "lucide-react";
import clsx from "clsx";
import DownloadDialog from "./DownloadDialog";
// Lazy: the download dialog (+ its profile editor) loads only when a card's download button is
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api, type Me } from "../lib/api";
// Self-contained download affordance for a video card / the player. Hidden for the demo account
@ -59,7 +61,9 @@ export default function DownloadButton({
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
</button>
{open && (
<Suspense fallback={null}>
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
</Suspense>
)}
</>
);

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { lazy, Suspense, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -16,10 +16,12 @@ import {
import clsx from "clsx";
import Tabs, { usePersistedTab } from "./Tabs";
import Modal from "./Modal";
import DownloadDialog from "./DownloadDialog";
import ProfileEditor from "./ProfileEditor";
import VideoEditor from "./VideoEditor";
import ShareDialog from "./ShareDialog";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
// used (the video editor in particular is heavy — filmstrip + scrubber).
const DownloadDialog = lazy(() => import("./DownloadDialog"));
const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob, type Me } from "../lib/api";
@ -582,6 +584,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
{tab === "system" && me.role === "admin" && <AdminSystem />}
<Suspense fallback={null}>
{addSource && (
<DownloadDialog
source={addSource}
@ -595,6 +598,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense>
</div>
);
}

View file

@ -1,9 +1,11 @@
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./Modal";
import ProfileEditor from "./ProfileEditor";
import { api } from "../lib/api";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications";
import { navigateTo } from "../lib/nav";
@ -105,12 +107,14 @@ export default function DownloadDialog({
</div>
{manage && (
<Suspense fallback={null}>
<ProfileEditor
onClose={() => {
setManage(false);
profilesQ.refetch();
}}
/>
</Suspense>
)}
</Modal>
);

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
@ -7,7 +7,8 @@ import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import VirtualFeed from "./VirtualFeed";
import PlayerModal from "./PlayerModal";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
const PAGE = 60;
@ -441,6 +442,7 @@ export default function Feed({
)}
{activeVideo && (
<Suspense fallback={null}>
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
@ -448,6 +450,7 @@ export default function Feed({
onState={onState}
onOpenChannel={onOpenChannel}
/>
</Suspense>
)}
</div>
);
@ -647,6 +650,7 @@ export default function Feed({
/>
)}
{activeVideo && (
<Suspense fallback={null}>
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
@ -654,6 +658,7 @@ export default function Feed({
onState={onState}
onOpenChannel={onOpenChannel}
/>
</Suspense>
)}
{isFetchingNextPage && (
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>

View file

@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "./AdminUsers";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
import {
clearAll as clearClient,
dismiss as dismissClient,

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -39,7 +39,7 @@ import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable";
import PlayerModal from "./PlayerModal";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
import { useConfirm } from "./ConfirmProvider";
@ -850,6 +850,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
</main>
{playingIndex != null && items[playingIndex] && (
<Suspense fallback={null}>
<PlayerModal
video={items[playingIndex]}
queue={items}
@ -858,6 +859,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
onClose={() => setPlayingIndex(null)}
onState={playerState}
/>
</Suspense>
)}
</div>
);

View file

@ -0,0 +1,15 @@
import { LS, setAccountRaw } from "./storage";
// The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to
// pre-select that tab before navigating there (a notification's "Review" link uses it —
// usePersistedTab reads the key on mount, and the page mounts fresh on navigation).
//
// Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
// it back into the main bundle and defeat the code-split.
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
export const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void {
setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
}

View file

@ -1,22 +1,25 @@
import React from "react";
import React, { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
import Terms from "./components/legal/Terms";
import WatchPage from "./components/WatchPage";
import "./i18n";
import "./index.css";
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a
// legal page never downloads the authenticated app bundle, and the app never carries them either.
// The legal pages live outside the App tree (no /api/me) so Google's consent screen and reviewers
// can fetch them directly; the watch page is a login-free public player. Reached by full-page
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
const App = lazy(() => import("./App"));
const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
const Terms = lazy(() => import("./components/legal/Terms"));
const WatchPage = lazy(() => import("./components/WatchPage"));
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
});
// Public, login-free legal pages. They live outside the authenticated App tree (no /api/me)
// so Google's consent screen and reviewers can fetch them directly. Reached by full-page
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
const path = window.location.pathname;
const root =
path === "/privacy" ? <PrivacyPolicy /> :
@ -32,5 +35,7 @@ const root =
);
createRoot(document.getElementById("root")!).render(
<React.StrictMode>{root}</React.StrictMode>
<React.StrictMode>
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>{root}</Suspense>
</React.StrictMode>
);