perf: split stats app bundles by route

This commit is contained in:
2026-03-18 00:05:51 -07:00
parent a5b1c0509d
commit 61e1621137
3 changed files with 171 additions and 59 deletions

View File

@@ -1,12 +1,6 @@
import { useState, useCallback } from 'react'; import { Suspense, lazy, useCallback, useState } from 'react';
import { TabBar } from './components/layout/TabBar'; import { TabBar } from './components/layout/TabBar';
import { OverviewTab } from './components/overview/OverviewTab'; 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 { useExcludedWords } from './hooks/useExcludedWords';
import type { TabId } from './components/layout/TabBar'; import type { TabId } from './components/layout/TabBar';
import { import {
@@ -19,6 +13,52 @@ import {
switchTab, switchTab,
} from './lib/stats-navigation'; } 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 (
<div
aria-busy="true"
className={
overlay
? 'fixed inset-x-4 bottom-4 z-50 rounded-xl border border-ctp-surface1 bg-ctp-mantle/95 p-4 text-sm text-ctp-overlay2 shadow-2xl backdrop-blur'
: 'rounded-xl border border-ctp-surface1 bg-ctp-surface0/70 p-4 text-sm text-ctp-overlay2'
}
>
{label}
</div>
);
}
export function App() { export function App() {
const [viewState, setViewState] = useState(createInitialStatsView); const [viewState, setViewState] = useState(createInitialStatsView);
const [mountedTabs, setMountedTabs] = useState<Set<TabId>>(() => new Set(['overview'])); const [mountedTabs, setMountedTabs] = useState<Set<TabId>>(() => new Set(['overview']));
@@ -96,27 +136,29 @@ export function App() {
</header> </header>
<main className="flex-1 overflow-y-auto p-4"> <main className="flex-1 overflow-y-auto p-4">
{mediaDetail ? ( {mediaDetail ? (
<MediaDetailView <Suspense fallback={<LoadingSurface label="Loading media detail..." />}>
videoId={mediaDetail.videoId} <MediaDetailView
initialExpandedSessionId={mediaDetail.initialSessionId} videoId={mediaDetail.videoId}
onConsumeInitialExpandedSession={() => initialExpandedSessionId={mediaDetail.initialSessionId}
setViewState((prev) => onConsumeInitialExpandedSession={() =>
prev.mediaDetail setViewState((prev) =>
? { prev.mediaDetail
...prev, ? {
mediaDetail: { ...prev,
...prev.mediaDetail, mediaDetail: {
initialSessionId: null, ...prev.mediaDetail,
}, initialSessionId: null,
} },
: prev, }
) : prev,
} )
onBack={() => setViewState((prev) => closeMediaDetail(prev))} }
backLabel={ onBack={() => setViewState((prev) => closeMediaDetail(prev))}
mediaDetail.origin.type === 'overview' ? 'Back to Overview' : 'Back to Library' backLabel={
} mediaDetail.origin.type === 'overview' ? 'Back to Overview' : 'Back to Library'
/> }
/>
</Suspense>
) : ( ) : (
<> <>
{mountedTabs.has('overview') ? ( {mountedTabs.has('overview') ? (
@@ -141,14 +183,16 @@ export function App() {
hidden={activeTab !== 'anime'} hidden={activeTab !== 'anime'}
className="animate-fade-in" className="animate-fade-in"
> >
<AnimeTab <Suspense fallback={<LoadingSurface label="Loading library..." />}>
initialAnimeId={selectedAnimeId} <AnimeTab
onClearInitialAnime={() => initialAnimeId={selectedAnimeId}
setViewState((prev) => ({ ...prev, selectedAnimeId: null })) onClearInitialAnime={() =>
} setViewState((prev) => ({ ...prev, selectedAnimeId: null }))
onNavigateToWord={openWordDetail} }
onOpenEpisodeDetail={navigateToEpisodeDetail} onNavigateToWord={openWordDetail}
/> onOpenEpisodeDetail={navigateToEpisodeDetail}
/>
</Suspense>
</section> </section>
) : null} ) : null}
{mountedTabs.has('trends') ? ( {mountedTabs.has('trends') ? (
@@ -159,7 +203,9 @@ export function App() {
hidden={activeTab !== 'trends'} hidden={activeTab !== 'trends'}
className="animate-fade-in" className="animate-fade-in"
> >
<TrendsTab /> <Suspense fallback={<LoadingSurface label="Loading trends..." />}>
<TrendsTab />
</Suspense>
</section> </section>
) : null} ) : null}
{mountedTabs.has('vocabulary') ? ( {mountedTabs.has('vocabulary') ? (
@@ -170,14 +216,16 @@ export function App() {
hidden={activeTab !== 'vocabulary'} hidden={activeTab !== 'vocabulary'}
className="animate-fade-in" className="animate-fade-in"
> >
<VocabularyTab <Suspense fallback={<LoadingSurface label="Loading vocabulary..." />}>
onNavigateToAnime={navigateToAnime} <VocabularyTab
onOpenWordDetail={openWordDetail} onNavigateToAnime={navigateToAnime}
excluded={excluded} onOpenWordDetail={openWordDetail}
isExcluded={isExcluded} excluded={excluded}
onRemoveExclusion={removeExclusion} isExcluded={isExcluded}
onClearExclusions={clearAll} onRemoveExclusion={removeExclusion}
/> onClearExclusions={clearAll}
/>
</Suspense>
</section> </section>
) : null} ) : null}
{mountedTabs.has('sessions') ? ( {mountedTabs.has('sessions') ? (
@@ -188,25 +236,31 @@ export function App() {
hidden={activeTab !== 'sessions'} hidden={activeTab !== 'sessions'}
className="animate-fade-in" className="animate-fade-in"
> >
<SessionsTab <Suspense fallback={<LoadingSurface label="Loading sessions..." />}>
initialSessionId={focusedSessionId} <SessionsTab
onClearInitialSession={() => initialSessionId={focusedSessionId}
setViewState((prev) => ({ ...prev, focusedSessionId: null })) onClearInitialSession={() =>
} setViewState((prev) => ({ ...prev, focusedSessionId: null }))
/> }
/>
</Suspense>
</section> </section>
) : null} ) : null}
</> </>
)} )}
</main> </main>
<WordDetailPanel {globalWordId !== null ? (
wordId={globalWordId} <Suspense fallback={<LoadingSurface label="Loading word detail..." overlay />}>
onClose={() => setGlobalWordId(null)} <WordDetailPanel
onSelectWord={openWordDetail} wordId={globalWordId}
onNavigateToAnime={navigateToAnime} onClose={() => setGlobalWordId(null)}
isExcluded={isExcluded} onSelectWord={openWordDetail}
onToggleExclusion={toggleExclusion} onNavigateToAnime={navigateToAnime}
/> isExcluded={isExcluded}
onToggleExclusion={toggleExclusion}
/>
</Suspense>
) : null}
</div> </div>
); );
} }

View File

@@ -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';/,
);
});

View File

@@ -8,5 +8,25 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
emptyOutDir: true, 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;
},
},
},
}, },
}); });