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; + }, + }, + }, }, });