diff --git a/stats/src/App.tsx b/stats/src/App.tsx
index d1fe215..9d93a57 100644
--- a/stats/src/App.tsx
+++ b/stats/src/App.tsx
@@ -1,12 +1,6 @@
-import { useState, useCallback } from 'react';
+import { Suspense, lazy, useCallback, useState } from 'react';
import { TabBar } from './components/layout/TabBar';
import { OverviewTab } from './components/overview/OverviewTab';
-import { TrendsTab } from './components/trends/TrendsTab';
-import { AnimeTab } from './components/anime/AnimeTab';
-import { MediaDetailView } from './components/library/MediaDetailView';
-import { VocabularyTab } from './components/vocabulary/VocabularyTab';
-import { SessionsTab } from './components/sessions/SessionsTab';
-import { WordDetailPanel } from './components/vocabulary/WordDetailPanel';
import { useExcludedWords } from './hooks/useExcludedWords';
import type { TabId } from './components/layout/TabBar';
import {
@@ -19,6 +13,52 @@ import {
switchTab,
} from './lib/stats-navigation';
+const AnimeTab = lazy(() =>
+ import('./components/anime/AnimeTab').then((module) => ({
+ default: module.AnimeTab,
+ })),
+);
+const TrendsTab = lazy(() =>
+ import('./components/trends/TrendsTab').then((module) => ({
+ default: module.TrendsTab,
+ })),
+);
+const VocabularyTab = lazy(() =>
+ import('./components/vocabulary/VocabularyTab').then((module) => ({
+ default: module.VocabularyTab,
+ })),
+);
+const SessionsTab = lazy(() =>
+ import('./components/sessions/SessionsTab').then((module) => ({
+ default: module.SessionsTab,
+ })),
+);
+const MediaDetailView = lazy(() =>
+ import('./components/library/MediaDetailView').then((module) => ({
+ default: module.MediaDetailView,
+ })),
+);
+const WordDetailPanel = lazy(() =>
+ import('./components/vocabulary/WordDetailPanel').then((module) => ({
+ default: module.WordDetailPanel,
+ })),
+);
+
+function LoadingSurface({ label, overlay = false }: { label: string; overlay?: boolean }) {
+ return (
+
+ {label}
+
+ );
+}
+
export function App() {
const [viewState, setViewState] = useState(createInitialStatsView);
const [mountedTabs, setMountedTabs] = useState>(() => new Set(['overview']));
@@ -96,27 +136,29 @@ export function App() {
{mediaDetail ? (
-
- setViewState((prev) =>
- prev.mediaDetail
- ? {
- ...prev,
- mediaDetail: {
- ...prev.mediaDetail,
- initialSessionId: null,
- },
- }
- : prev,
- )
- }
- onBack={() => setViewState((prev) => closeMediaDetail(prev))}
- backLabel={
- mediaDetail.origin.type === 'overview' ? 'Back to Overview' : 'Back to Library'
- }
- />
+ }>
+
+ setViewState((prev) =>
+ prev.mediaDetail
+ ? {
+ ...prev,
+ mediaDetail: {
+ ...prev.mediaDetail,
+ initialSessionId: null,
+ },
+ }
+ : prev,
+ )
+ }
+ onBack={() => setViewState((prev) => closeMediaDetail(prev))}
+ backLabel={
+ mediaDetail.origin.type === 'overview' ? 'Back to Overview' : 'Back to Library'
+ }
+ />
+
) : (
<>
{mountedTabs.has('overview') ? (
@@ -141,14 +183,16 @@ export function App() {
hidden={activeTab !== 'anime'}
className="animate-fade-in"
>
-
- setViewState((prev) => ({ ...prev, selectedAnimeId: null }))
- }
- onNavigateToWord={openWordDetail}
- onOpenEpisodeDetail={navigateToEpisodeDetail}
- />
+ }>
+
+ setViewState((prev) => ({ ...prev, selectedAnimeId: null }))
+ }
+ onNavigateToWord={openWordDetail}
+ onOpenEpisodeDetail={navigateToEpisodeDetail}
+ />
+
) : null}
{mountedTabs.has('trends') ? (
@@ -159,7 +203,9 @@ export function App() {
hidden={activeTab !== 'trends'}
className="animate-fade-in"
>
-
+ }>
+
+
) : null}
{mountedTabs.has('vocabulary') ? (
@@ -170,14 +216,16 @@ export function App() {
hidden={activeTab !== 'vocabulary'}
className="animate-fade-in"
>
-
+ }>
+
+
) : null}
{mountedTabs.has('sessions') ? (
@@ -188,25 +236,31 @@ export function App() {
hidden={activeTab !== 'sessions'}
className="animate-fade-in"
>
-
- setViewState((prev) => ({ ...prev, focusedSessionId: null }))
- }
- />
+ }>
+
+ setViewState((prev) => ({ ...prev, focusedSessionId: null }))
+ }
+ />
+
) : null}
>
)}
- setGlobalWordId(null)}
- onSelectWord={openWordDetail}
- onNavigateToAnime={navigateToAnime}
- isExcluded={isExcluded}
- onToggleExclusion={toggleExclusion}
- />
+ {globalWordId !== null ? (
+ }>
+ setGlobalWordId(null)}
+ onSelectWord={openWordDetail}
+ onNavigateToAnime={navigateToAnime}
+ isExcluded={isExcluded}
+ onToggleExclusion={toggleExclusion}
+ />
+
+ ) : null}
);
}
diff --git a/stats/src/lib/app-lazy-loading.test.ts b/stats/src/lib/app-lazy-loading.test.ts
new file mode 100644
index 0000000..407264f
--- /dev/null
+++ b/stats/src/lib/app-lazy-loading.test.ts
@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const APP_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../App.tsx');
+
+test('App lazy-loads non-overview tabs and detail surfaces behind Suspense boundaries', () => {
+ const source = fs.readFileSync(APP_PATH, 'utf8');
+
+ assert.match(source, /\bSuspense\b/, 'expected Suspense boundary in App');
+ assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/anime\/AnimeTab'\)/);
+ assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/);
+ assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/VocabularyTab'\)/);
+ assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/sessions\/SessionsTab'\)/);
+ assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
+ assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/);
+
+ assert.doesNotMatch(source, /import \{ AnimeTab \} from '\.\/components\/anime\/AnimeTab';/);
+ assert.doesNotMatch(source, /import \{ TrendsTab \} from '\.\/components\/trends\/TrendsTab';/);
+ assert.doesNotMatch(
+ source,
+ /import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
+ );
+ assert.doesNotMatch(
+ source,
+ /import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
+ );
+ assert.doesNotMatch(
+ source,
+ /import \{ MediaDetailView \} from '\.\/components\/library\/MediaDetailView';/,
+ );
+ assert.doesNotMatch(
+ source,
+ /import \{ WordDetailPanel \} from '\.\/components\/vocabulary\/WordDetailPanel';/,
+ );
+});
diff --git a/stats/vite.config.ts b/stats/vite.config.ts
index 7fb4d95..6b74cfc 100644
--- a/stats/vite.config.ts
+++ b/stats/vite.config.ts
@@ -8,5 +8,25 @@ export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
+ rollupOptions: {
+ output: {
+ manualChunks(id) {
+ const normalized = id.replaceAll('\\', '/');
+
+ if (
+ normalized.includes('/node_modules/react-dom/') ||
+ normalized.includes('/node_modules/react/')
+ ) {
+ return 'react-vendor';
+ }
+
+ if (normalized.includes('/node_modules/recharts/')) {
+ return 'charts-vendor';
+ }
+
+ return undefined;
+ },
+ },
+ },
},
});