Skip to content

Commit 6ab3b57

Browse files
committed
perf: lazy-loads route components with ErrorBoundary and chunk prefetch
1 parent fbc6b74 commit 6ab3b57

File tree

4 files changed

+84
-16
lines changed

4 files changed

+84
-16
lines changed

src/app/App.tsx

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,38 @@
1-
import { createSignal, createEffect, onMount, Show, type JSX } from "solid-js";
1+
import { createSignal, createEffect, onMount, Show, ErrorBoundary, lazy, type JSX } from "solid-js";
22
import { Router, Route, Navigate, useNavigate } from "@solidjs/router";
3-
import { isAuthenticated, validateToken } from "./stores/auth";
3+
import { isAuthenticated, validateToken, AUTH_STORAGE_KEY } from "./stores/auth";
44
import { config, initConfigPersistence, resolveTheme } from "./stores/config";
55
import { initViewPersistence } from "./stores/view";
66
import { evictStaleEntries } from "./stores/cache";
77
import { initClientWatcher } from "./services/github";
88
import LoginPage from "./pages/LoginPage";
99
import OAuthCallback from "./pages/OAuthCallback";
10-
import DashboardPage from "./components/dashboard/DashboardPage";
11-
import OnboardingWizard from "./components/onboarding/OnboardingWizard";
12-
import SettingsPage from "./components/settings/SettingsPage";
1310
import PrivacyPage from "./pages/PrivacyPage";
1411

12+
const DashboardPage = lazy(() => import("./components/dashboard/DashboardPage"));
13+
const OnboardingWizard = lazy(() => import("./components/onboarding/OnboardingWizard"));
14+
const SettingsPage = lazy(() => import("./components/settings/SettingsPage"));
15+
16+
function ChunkErrorFallback() {
17+
return (
18+
<div class="min-h-screen flex items-center justify-center bg-base-200">
19+
<div class="card bg-base-100 shadow-md p-8 flex flex-col items-center gap-4 max-w-sm">
20+
<p class="text-error font-medium">Failed to load page</p>
21+
<p class="text-sm text-base-content/60 text-center">
22+
A new version may have been deployed. Reloading should fix this.
23+
</p>
24+
<button
25+
type="button"
26+
class="btn btn-neutral"
27+
onClick={() => window.location.reload()}
28+
>
29+
Reload page
30+
</button>
31+
</div>
32+
</div>
33+
);
34+
}
35+
1536
// Auth guard: redirects unauthenticated users to /login.
1637
// On page load, validates the localStorage token with GitHub API.
1738
function AuthGuard(props: { children: JSX.Element }) {
@@ -138,17 +159,25 @@ export default function App() {
138159
evictStaleEntries(24 * 60 * 60 * 1000).catch(() => {
139160
// Non-fatal — stale eviction failure is acceptable
140161
});
162+
163+
// Preload dashboard chunk in parallel with token validation to avoid
164+
// a sequential waterfall (validateToken → chunk fetch)
165+
if (localStorage.getItem?.(AUTH_STORAGE_KEY)) {
166+
void import("./components/dashboard/DashboardPage");
167+
}
141168
});
142169

143170
return (
144-
<Router>
145-
<Route path="/" component={RootRedirect} />
146-
<Route path="/login" component={LoginPage} />
147-
<Route path="/oauth/callback" component={OAuthCallback} />
148-
<Route path="/onboarding" component={() => <AuthGuard><OnboardingWizard /></AuthGuard>} />
149-
<Route path="/dashboard" component={() => <AuthGuard><DashboardPage /></AuthGuard>} />
150-
<Route path="/settings" component={() => <AuthGuard><SettingsPage /></AuthGuard>} />
151-
<Route path="/privacy" component={PrivacyPage} />
152-
</Router>
171+
<ErrorBoundary fallback={(err) => { console.error("[app] Route render failed:", err); return <ChunkErrorFallback />; }}>
172+
<Router>
173+
<Route path="/" component={RootRedirect} />
174+
<Route path="/login" component={LoginPage} />
175+
<Route path="/oauth/callback" component={OAuthCallback} />
176+
<Route path="/onboarding" component={() => <AuthGuard><OnboardingWizard /></AuthGuard>} />
177+
<Route path="/dashboard" component={() => <AuthGuard><DashboardPage /></AuthGuard>} />
178+
<Route path="/settings" component={() => <AuthGuard><SettingsPage /></AuthGuard>} />
179+
<Route path="/privacy" component={PrivacyPage} />
180+
</Router>
181+
</ErrorBoundary>
153182
);
154183
}

src/app/lib/sentry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function scrubUrl(url: string): string {
1111

1212
/** Allowed console breadcrumb prefixes — drop everything else. */
1313
const ALLOWED_CONSOLE_PREFIXES = [
14+
"[app]",
1415
"[auth]",
1516
"[api]",
1617
"[poll]",

src/app/pages/LoginPage.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createSignal, Show } from "solid-js";
1+
import { createSignal, onMount, Show } from "solid-js";
22
import { useNavigate } from "@solidjs/router";
33
import { setAuthFromPat, type GitHubUser } from "../stores/auth";
44
import {
@@ -11,6 +11,15 @@ import { buildAuthorizeUrl } from "../lib/oauth";
1111
export default function LoginPage() {
1212
const navigate = useNavigate();
1313

14+
onMount(() => {
15+
// Speculatively prefetch the dashboard chunk while the user is on the
16+
// login page. By the time they authenticate, the chunk is cached.
17+
const prefetch = () => void import("../components/dashboard/DashboardPage");
18+
"requestIdleCallback" in window
19+
? requestIdleCallback(prefetch)
20+
: setTimeout(prefetch, 2000);
21+
});
22+
1423
const [showPatForm, setShowPatForm] = createSignal(false);
1524
const [patInput, setPatInput] = createSignal("");
1625
const [patError, setPatError] = createSignal<string | null>(null);

tests/components/App.test.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,12 @@ vi.mock("../../src/app/stores/cache", async (importOriginal) => {
5151
});
5252

5353
// Mock heavy page/component dependencies
54+
let dashboardShouldThrow = false;
5455
vi.mock("../../src/app/components/dashboard/DashboardPage", () => ({
55-
default: () => <div data-testid="dashboard-page">Dashboard</div>,
56+
default: () => {
57+
if (dashboardShouldThrow) throw new Error("chunk load failed");
58+
return <div data-testid="dashboard-page">Dashboard</div>;
59+
},
5660
}));
5761
vi.mock("../../src/app/components/onboarding/OnboardingWizard", () => ({
5862
default: () => <div data-testid="onboarding-wizard">Onboarding</div>,
@@ -73,6 +77,7 @@ describe("App", () => {
7377
vi.resetAllMocks();
7478
mockIsAuthenticated = false;
7579
mockValidateToken = async () => false;
80+
dashboardShouldThrow = false;
7681
// Re-apply default mock implementations that are needed across tests
7782
vi.mocked(cacheStore.evictStaleEntries).mockResolvedValue(0);
7883
// Reset config to defaults
@@ -159,4 +164,28 @@ describe("App", () => {
159164
it("all routes are registered: /, /login, /oauth/callback, /onboarding, /dashboard, /settings", () => {
160165
expect(() => render(() => <App />)).not.toThrow();
161166
});
167+
168+
it("shows error fallback and logs when a lazy route component throws", async () => {
169+
dashboardShouldThrow = true;
170+
mockIsAuthenticated = true;
171+
configStore.updateConfig({ onboardingComplete: true });
172+
173+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
174+
175+
render(() => <App />);
176+
177+
await waitFor(() => {
178+
screen.getByText("Failed to load page");
179+
screen.getByText("A new version may have been deployed. Reloading should fix this.");
180+
screen.getByRole("button", { name: "Reload page" });
181+
});
182+
183+
// Verify the error is logged (not silently swallowed) for observability
184+
expect(spy).toHaveBeenCalledWith(
185+
"[app] Route render failed:",
186+
expect.any(Error),
187+
);
188+
189+
spy.mockRestore();
190+
});
162191
});

0 commit comments

Comments
 (0)