Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 88 additions & 27 deletions frontend/src/routes/AppRoutes.jsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,74 @@
import React from 'react';
import React, { Suspense, lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Login from '../features/auth/pages/Login';
import Register from '../features/auth/pages/Register';
import Dashboard from '../features/dashboard/pages/Dashboard';
import LandingPage from '../features/landing/pages/LandingPage';
import CloudflareTestPage from './CloudflareTestPage';
import GroupAvatarTestPage from '../../tests/GroupAvatarTestPage';
import VerifyEmail from '../features/auth/pages/VerifyEmail';
import { Loader2 } from 'lucide-react';
import { useAuthContext } from '../context/useAuthContext';

// Lazy-loaded production routes
const LandingPage = lazy(() => import('../features/landing/pages/LandingPage'));
const Login = lazy(() => import('../features/auth/pages/Login'));
const Register = lazy(() => import('../features/auth/pages/Register'));
const VerifyEmail = lazy(() => import('../features/auth/pages/VerifyEmail'));
const Dashboard = lazy(() => import('../features/dashboard/pages/Dashboard'));

// Internal test routes: only loaded in local development environments
let GroupAvatarTestPage = null;
let CloudflareTestPage = null;

if (import.meta.env.DEV) {
GroupAvatarTestPage = lazy(() => import('../../tests/GroupAvatarTestPage'));
CloudflareTestPage = lazy(() => import('./CloudflareTestPage'));
}

// Accessible, responsive route loading fallback
export const RouteLoadingFallback = () => (
<div
style={{
minHeight: '100vh',
width: '100vw',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--bg-primary, #ffffff)',
color: 'var(--text-primary, #0f172a)',
}}
role="status"
aria-label="Loading page"
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
}}
>
<Loader2
size={36}
className="animate-spin"
style={{
color: '#6366f1',
animation: 'spin 1s linear infinite',
}}
/>
<span
style={{
fontSize: '0.875rem',
color: 'var(--text-secondary, #64748b)',
}}
>
Loading...
</span>
</div>
</div>
);

// Simple check to safeguard secure UI containers
const ProtectedRoute = ({ children }) => {
export const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuthContext();

if (loading) {
return null;
return <RouteLoadingFallback />;
}

const token = user?.token || (typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null);
Expand All @@ -27,25 +81,32 @@ const ProtectedRoute = ({ children }) => {

export default function AppRoutes() {
return (
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />


<Route path="/test" element={<GroupAvatarTestPage />} />
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify-email" element={<VerifyEmail />} />

<Route path="/verify-email" element={<VerifyEmail />} />
{/* Internal test routes excluded from production bundles */}
{import.meta.env.DEV && GroupAvatarTestPage && (
<Route path="/test" element={<GroupAvatarTestPage />} />
)}
{import.meta.env.DEV && CloudflareTestPage && (
<Route path="/test/cloudflare" element={<CloudflareTestPage />} />
)}

<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>

<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
110 changes: 110 additions & 0 deletions frontend/tests/routeLoading.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const frontendDir = path.resolve(__dirname, '..');

test('FE-007: Route loading optimization and test route isolation', async (t) => {
const appRoutesPath = path.join(frontendDir, 'src/routes/AppRoutes.jsx');

await t.test('verifies AppRoutes.jsx uses React.lazy for production pages', () => {
assert.ok(fs.existsSync(appRoutesPath), 'AppRoutes.jsx must exist');
const content = fs.readFileSync(appRoutesPath, 'utf8');

// Verify lazy loading of main routes
assert.ok(
content.includes("lazy(() => import('../features/landing/pages/LandingPage'))"),
'LandingPage must be lazy loaded'
);
assert.ok(
content.includes("lazy(() => import('../features/auth/pages/Login'))"),
'Login must be lazy loaded'
);
assert.ok(
content.includes("lazy(() => import('../features/auth/pages/Register'))"),
'Register must be lazy loaded'
);
assert.ok(
content.includes("lazy(() => import('../features/auth/pages/VerifyEmail'))"),
'VerifyEmail must be lazy loaded'
);
assert.ok(
content.includes("lazy(() => import('../features/dashboard/pages/Dashboard'))"),
'Dashboard must be lazy loaded'
);
});

await t.test('verifies internal test routes are strictly gated by import.meta.env.DEV', () => {
const content = fs.readFileSync(appRoutesPath, 'utf8');

assert.ok(
content.includes('if (import.meta.env.DEV)'),
'Test page dynamic imports must be enclosed in import.meta.env.DEV check'
);
assert.ok(
content.includes('import.meta.env.DEV && GroupAvatarTestPage'),
'/test route must be gated by import.meta.env.DEV'
);
assert.ok(
content.includes('import.meta.env.DEV && CloudflareTestPage'),
'/test/cloudflare route must be gated by import.meta.env.DEV'
);
});

await t.test('verifies Suspense and RouteLoadingFallback are configured', () => {
const content = fs.readFileSync(appRoutesPath, 'utf8');

assert.ok(
content.includes('<Suspense fallback={<RouteLoadingFallback />}>'),
'Routes must be wrapped with Suspense fallback'
);
assert.ok(
content.includes('role="status"'),
'RouteLoadingFallback must include accessible role="status"'
);
});

await t.test('verifies production build dist directory excludes test page chunks and splits code', () => {
const distAssetsDir = path.join(frontendDir, 'dist/assets');
if (!fs.existsSync(distAssetsDir)) {
// If dist has not been built yet, skip asset inspection
return;
}

const distFiles = fs.readdirSync(distAssetsDir);

// Ensure no GroupAvatarTestPage or CloudflareTestPage chunks exist in dist
const hasGroupAvatarChunk = distFiles.some((f) => f.includes('GroupAvatarTestPage'));
const hasCloudflareChunk = distFiles.some((f) => f.includes('CloudflareTestPage'));

assert.strictEqual(
hasGroupAvatarChunk,
false,
'GroupAvatarTestPage must not be emitted in production dist assets'
);
assert.strictEqual(
hasCloudflareChunk,
false,
'CloudflareTestPage must not be emitted in production dist assets'
);

// Verify index chunk is significantly smaller than the original 708kB single-bundle
const indexChunkFile = distFiles.find((f) => f.startsWith('index-') && f.endsWith('.js'));
assert.ok(indexChunkFile, 'Production index-*.js chunk must exist');

const indexStats = fs.statSync(path.join(distAssetsDir, indexChunkFile));
const indexSizeKb = indexStats.size / 1024;
assert.ok(
indexSizeKb < 400,
`Expected entry chunk to be < 400 kB due to code splitting, actual: ${indexSizeKb.toFixed(2)} kB`
);

// Verify separate Dashboard chunk exists
const dashboardChunkFile = distFiles.find((f) => f.startsWith('Dashboard-') && f.endsWith('.js'));
assert.ok(dashboardChunkFile, 'Dashboard must be split into its own chunk');
});
});
Loading