Skip to content

Commit 5a66a66

Browse files
committed
perf: reduce landing page critical path
- Route-level code splitting: lazy-load app pages (VisualizerApp, panels, Monaco, Pyodide, Remotion) so the landing page ships a smaller initial bundle; Suspense fallback spinner for lazy routes - Cache GitHub repo data in localStorage (24h TTL) and defer refresh to idle, removing the render-blocking GitHub API chain from load - Extract cache-first GitHub data into githubRepoService with fallback and stale-while-revalidate handling - Target es2022 to drop legacy browser transforms - Preload the Inter latin woff2 in built index.html via a closeBundle plugin (rolldown-vite emits HTML outside generateBundle) - Mark decorative social icons aria-hidden (Google sign-in, YouTube, Instagram, TikTok) - Serve /llms.txt as markdown to LLM crawlers in the worker - Restore the URL global stub in useVideoExporter tests to fix a flaky singleFork coverage failure
1 parent e250a01 commit 5a66a66

11 files changed

Lines changed: 537 additions & 202 deletions

src/AppRoutes.jsx

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,38 @@
44
* See LICENSE for details.
55
*/
66

7+
import { lazy, Suspense } from 'react';
78
import { Routes, Route } from 'react-router-dom';
9+
import { SpinnerGap } from '@phosphor-icons/react';
810
import { useAuth } from './hooks/useAuth.js';
911
import { AUTH_CALLBACK_PATH } from './services/authService.js';
1012
import BannedScreen from './components/BannedScreen.jsx';
1113
import LandingPage from './pages/LandingPage.jsx';
12-
import VisualizerApp from './pages/VisualizerApp.jsx';
13-
import Roadmap from './pages/Roadmap.jsx';
14-
import PrivacyPolicy from './pages/PrivacyPolicy.jsx';
15-
import TermsOfUse from './pages/TermsOfUse.jsx';
16-
import GoogleAuthCallback from './pages/GoogleAuthCallback.jsx';
17-
import ProfileSettingsPage from './pages/ProfileSettingsPage.jsx';
18-
import ProComingSoonPage from './pages/ProComingSoonPage.jsx';
19-
import NotFoundPage from './pages/NotFoundPage.jsx';
2014
import RequireAuth from './components/RequireAuth.jsx';
2115

16+
const VisualizerApp = lazy(() => import('./pages/VisualizerApp.jsx'));
17+
const Roadmap = lazy(() => import('./pages/Roadmap.jsx'));
18+
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy.jsx'));
19+
const TermsOfUse = lazy(() => import('./pages/TermsOfUse.jsx'));
20+
const GoogleAuthCallback = lazy(() => import('./pages/GoogleAuthCallback.jsx'));
21+
const ProfileSettingsPage = lazy(
22+
() => import('./pages/ProfileSettingsPage.jsx')
23+
);
24+
const ProComingSoonPage = lazy(() => import('./pages/ProComingSoonPage.jsx'));
25+
const NotFoundPage = lazy(() => import('./pages/NotFoundPage.jsx'));
26+
27+
function RouteFallback() {
28+
return (
29+
<div
30+
className="flex min-h-40 items-center justify-center"
31+
role="status"
32+
aria-label="Loading page"
33+
>
34+
<SpinnerGap className="size-6 animate-spin text-text-secondary" />
35+
</div>
36+
);
37+
}
38+
2239
function AppRoutes() {
2340
const { accessBlock, isLoading } = useAuth();
2441

@@ -27,24 +44,26 @@ function AppRoutes() {
2744
}
2845

2946
return (
30-
<Routes>
31-
<Route path="/" element={<LandingPage />} />
32-
<Route path="/app" element={<VisualizerApp />} />
33-
<Route path="/roadmap" element={<Roadmap />} />
34-
<Route path="/pro" element={<ProComingSoonPage />} />
35-
<Route path={AUTH_CALLBACK_PATH} element={<GoogleAuthCallback />} />
36-
<Route path="/privacy" element={<PrivacyPolicy />} />
37-
<Route path="/terms" element={<TermsOfUse />} />
38-
<Route
39-
path="/settings/profile"
40-
element={
41-
<RequireAuth>
42-
<ProfileSettingsPage />
43-
</RequireAuth>
44-
}
45-
/>
46-
<Route path="*" element={<NotFoundPage />} />
47-
</Routes>
47+
<Suspense fallback={<RouteFallback />}>
48+
<Routes>
49+
<Route path="/" element={<LandingPage />} />
50+
<Route path="/app" element={<VisualizerApp />} />
51+
<Route path="/roadmap" element={<Roadmap />} />
52+
<Route path="/pro" element={<ProComingSoonPage />} />
53+
<Route path={AUTH_CALLBACK_PATH} element={<GoogleAuthCallback />} />
54+
<Route path="/privacy" element={<PrivacyPolicy />} />
55+
<Route path="/terms" element={<TermsOfUse />} />
56+
<Route
57+
path="/settings/profile"
58+
element={
59+
<RequireAuth>
60+
<ProfileSettingsPage />
61+
</RequireAuth>
62+
}
63+
/>
64+
<Route path="*" element={<NotFoundPage />} />
65+
</Routes>
66+
</Suspense>
4867
);
4968
}
5069

src/AppRoutes.test.jsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ describe('AppRoutes', () => {
9090
expect(screen.queryByTestId('banned-screen')).not.toBeInTheDocument();
9191
});
9292

93-
it('does not show BannedScreen when no access block', () => {
93+
it('does not show BannedScreen when no access block', async () => {
9494
vi.mocked(useAuth).mockReturnValue({
9595
accessBlock: null,
9696
isLoading: false,
@@ -99,7 +99,7 @@ describe('AppRoutes', () => {
9999
renderRoutes('/app');
100100

101101
expect(screen.queryByTestId('banned-screen')).not.toBeInTheDocument();
102-
expect(screen.getByTestId('visualizer-app')).toBeInTheDocument();
102+
await screen.findByTestId('visualizer-app');
103103
});
104104

105105
it('renders LandingPage on /', () => {
@@ -113,48 +113,48 @@ describe('AppRoutes', () => {
113113
expect(screen.getByTestId('landing-page')).toBeInTheDocument();
114114
});
115115

116-
it('renders ProComingSoonPage on /pro', () => {
116+
it('renders ProComingSoonPage on /pro', async () => {
117117
vi.mocked(useAuth).mockReturnValue({
118118
accessBlock: null,
119119
isLoading: false,
120120
});
121121

122122
renderRoutes('/pro');
123123

124-
expect(screen.getByTestId('pro-page')).toBeInTheDocument();
124+
await screen.findByTestId('pro-page');
125125
});
126126

127-
it('renders PrivacyPolicy on /privacy', () => {
127+
it('renders PrivacyPolicy on /privacy', async () => {
128128
vi.mocked(useAuth).mockReturnValue({
129129
accessBlock: null,
130130
isLoading: false,
131131
});
132132

133133
renderRoutes('/privacy');
134134

135-
expect(screen.getByTestId('privacy')).toBeInTheDocument();
135+
await screen.findByTestId('privacy');
136136
});
137137

138-
it('renders TermsOfUse on /terms', () => {
138+
it('renders TermsOfUse on /terms', async () => {
139139
vi.mocked(useAuth).mockReturnValue({
140140
accessBlock: null,
141141
isLoading: false,
142142
});
143143

144144
renderRoutes('/terms');
145145

146-
expect(screen.getByTestId('terms')).toBeInTheDocument();
146+
await screen.findByTestId('terms');
147147
});
148148

149-
it('wraps /settings/profile in RequireAuth', () => {
149+
it('wraps /settings/profile in RequireAuth', async () => {
150150
vi.mocked(useAuth).mockReturnValue({
151151
accessBlock: null,
152152
isLoading: false,
153153
});
154154

155155
renderRoutes('/settings/profile');
156156

157-
expect(screen.getByTestId('require-auth')).toBeInTheDocument();
157+
await screen.findByTestId('require-auth');
158158
expect(screen.getByTestId('profile-settings')).toBeInTheDocument();
159159
});
160160
});

src/components/Footer.jsx

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,42 +10,34 @@ import { SiYoutube, SiInstagram, SiTiktok } from 'react-icons/si';
1010
import { useState, useEffect } from 'react';
1111
import { useTranslation } from 'react-i18next';
1212
import { useNavigate, Link } from 'react-router-dom';
13+
import { GITHUB_REPO_URL } from '../constants/githubRepo';
1314
import {
14-
GITHUB_REPO_NAME,
15-
GITHUB_REPO_OWNER,
16-
GITHUB_REPO_URL,
17-
} from '../constants/githubRepo';
15+
loadGitHubRepoData,
16+
readCachedGitHubRepo,
17+
runWhenIdle,
18+
} from '../services/githubRepoService';
1819
import { useConsent } from '../hooks/useConsent.js';
1920

2021
function Footer() {
2122
const { t } = useTranslation();
2223
const navigate = useNavigate();
2324
const { resetConsent } = useConsent();
24-
const [version, setVersion] = useState(null);
25+
const [version, setVersion] = useState(() => {
26+
const cached = readCachedGitHubRepo();
27+
return cached ? cached.data.versionTag : null;
28+
});
2529
const currentYear = new Date().getFullYear();
2630

2731
useEffect(() => {
28-
const fetchLatestVersion = async () => {
29-
try {
30-
const response = await fetch(
31-
`https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/releases/latest`
32-
);
33-
if (!response.ok) {
34-
throw new Error('Failed to fetch release data');
35-
}
36-
const data = await response.json();
37-
const rawTag = data.tag_name;
38-
if (typeof rawTag !== 'string' || !rawTag.trim()) {
39-
throw new Error('Missing release tag');
40-
}
41-
const versionTag = rawTag.replace(/^v/, '');
42-
setVersion(versionTag);
43-
} catch (error) {
44-
console.error('Failed to fetch latest version:', error);
45-
}
46-
};
47-
48-
fetchLatestVersion();
32+
const cached = readCachedGitHubRepo();
33+
if (cached && !cached.isStale) {
34+
return undefined;
35+
}
36+
return runWhenIdle(() => {
37+
loadGitHubRepoData()
38+
.then(({ data }) => setVersion(data.versionTag))
39+
.catch(() => {});
40+
});
4941
}, []);
5042

5143
const links = [
@@ -71,21 +63,21 @@ function Footer() {
7163
label: t('footer.youtube'),
7264
href: 'https://www.youtube.com/@bayan-flow',
7365
ariaLabel: t('footer.youtubeAria'),
74-
icon: <SiYoutube className="w-5 h-5" />,
66+
icon: <SiYoutube className="w-5 h-5" aria-hidden="true" />,
7567
hoverClass: 'hover:text-[#FF0000]',
7668
},
7769
{
7870
label: t('footer.instagram'),
7971
href: 'https://www.instagram.com/bayanflow.app',
8072
ariaLabel: t('footer.instagramAria'),
81-
icon: <SiInstagram className="w-5 h-5" />,
73+
icon: <SiInstagram className="w-5 h-5" aria-hidden="true" />,
8274
hoverClass: 'hover:text-[#d62976]',
8375
},
8476
{
8577
label: t('footer.tikTok'),
8678
href: 'https://www.tiktok.com/@bayanflow.app',
8779
ariaLabel: t('footer.tikTokAria'),
88-
icon: <SiTiktok className="w-5 h-5" />,
80+
icon: <SiTiktok className="w-5 h-5" aria-hidden="true" />,
8981
hoverClass: 'hover:text-[#8B5CF6]',
9082
},
9183
];

src/components/GitHubRepoBadge.jsx

Lines changed: 22 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,13 @@ import { useState, useEffect } from 'react';
77
import { Star, Tag, GitFork } from '@phosphor-icons/react';
88
import { useTranslation } from 'react-i18next';
99
import {
10-
GITHUB_REPO_FULL_NAME,
11-
GITHUB_REPO_NAME,
12-
GITHUB_REPO_OWNER,
13-
GITHUB_REPO_PACKAGE_VERSION,
14-
GITHUB_REPO_URL,
15-
} from '../constants/githubRepo';
10+
loadGitHubRepoData,
11+
readCachedGitHubRepo,
12+
runWhenIdle,
13+
} from '../services/githubRepoService';
1614
import { formatGitHubCount } from '../utils/formatGitHubCount';
1715
import Tooltip from './ui/Tooltip';
1816

19-
const parseReleaseTag = tagName => {
20-
if (typeof tagName !== 'string' || !tagName.trim()) {
21-
return null;
22-
}
23-
return tagName.trim().replace(/^v/i, '');
24-
};
25-
2617
const GitHubIcon = ({ className }) => (
2718
<svg
2819
className={className}
@@ -53,60 +44,26 @@ const Metric = ({ icon, value }) => {
5344

5445
function GitHubRepoBadge() {
5546
const { t } = useTranslation();
56-
const [repoData, setRepoData] = useState(null);
57-
const [loading, setLoading] = useState(true);
47+
const [repoData, setRepoData] = useState(() => {
48+
const cached = readCachedGitHubRepo();
49+
return cached ? cached.data : null;
50+
});
51+
const [loading, setLoading] = useState(() => {
52+
const cached = readCachedGitHubRepo();
53+
return !cached;
54+
});
5855

5956
useEffect(() => {
60-
const fetchGitHubData = async () => {
61-
const fallback = {
62-
url: GITHUB_REPO_URL,
63-
fullName: GITHUB_REPO_FULL_NAME,
64-
stars: 0,
65-
forks: 0,
66-
versionTag: GITHUB_REPO_PACKAGE_VERSION,
67-
};
68-
69-
try {
70-
const [repoResponse, releaseResponse] = await Promise.all([
71-
fetch(
72-
`https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}`
73-
),
74-
fetch(
75-
`https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/releases/latest`
76-
),
77-
]);
78-
79-
let next = { ...fallback };
80-
81-
if (repoResponse.ok) {
82-
const repoJson = await repoResponse.json();
83-
next = {
84-
url: repoJson.html_url || fallback.url,
85-
fullName: repoJson.full_name || fallback.fullName,
86-
stars: repoJson.stargazers_count ?? 0,
87-
forks: repoJson.forks_count ?? 0,
88-
versionTag: fallback.versionTag,
89-
};
90-
}
91-
92-
if (releaseResponse.ok) {
93-
const releaseJson = await releaseResponse.json();
94-
const parsed = parseReleaseTag(releaseJson.tag_name);
95-
if (parsed) {
96-
next.versionTag = parsed;
97-
}
98-
}
99-
100-
setRepoData(next);
101-
} catch (error) {
102-
console.error('Failed to fetch GitHub data:', error);
103-
setRepoData(fallback);
104-
} finally {
105-
setLoading(false);
106-
}
107-
};
108-
109-
fetchGitHubData();
57+
const cached = readCachedGitHubRepo();
58+
if (cached && !cached.isStale) {
59+
return undefined;
60+
}
61+
return runWhenIdle(() => {
62+
loadGitHubRepoData()
63+
.then(({ data }) => setRepoData(data))
64+
.catch(() => {})
65+
.finally(() => setLoading(false));
66+
});
11067
}, []);
11168

11269
if (loading) {

0 commit comments

Comments
 (0)