Skip to content

Commit 8f5a6aa

Browse files
fix(web): show settings on first load and hoist DemoProvider globally
Bug 1: On a fresh load with no saved config, RootLayout returned `null` while a useEffect-driven `router.navigate()` fired, leaving a blank screen until the user manually refreshed. Move the redirect into the root route's `beforeLoad` so it happens synchronously during route resolution and the settings form renders on first paint. Bug 2: `DemoProvider` was mounted inside `RootLayout` only on the non-settings branch, so any component reading `useDemo()` outside that branch would throw "useDemoContext must be used within DemoProvider". Hoist `<DemoProvider>` to `main.tsx` so the context is available app-wide. Adds vitest + RTL setup with regression tests for both behaviours.
1 parent 3fa4d59 commit 8f5a6aa

5 files changed

Lines changed: 146 additions & 24 deletions

File tree

packages/web/src/main.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
22
import { createRouter, RouterProvider } from "@tanstack/react-router";
33
import { StrictMode } from "react";
44
import { createRoot } from "react-dom/client";
5+
import { DemoProvider } from "./context/DemoContext";
56
import { routeTree } from "./routeTree.gen";
67
import "./index.css";
78

@@ -32,7 +33,9 @@ if (!root) throw new Error("Missing #root element");
3233
createRoot(root).render(
3334
<StrictMode>
3435
<QueryClientProvider client={queryClient}>
35-
<RouterProvider router={router} />
36+
<DemoProvider>
37+
<RouterProvider router={router} />
38+
</DemoProvider>
3639
</QueryClientProvider>
3740
</StrictMode>,
3841
);

packages/web/src/routes/__root.tsx

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,43 @@
1-
import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router";
1+
import { createRootRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
22
import { useEffect } from "react";
33
import { Sidebar } from "@/components/layout/Sidebar";
4-
import { DemoProvider } from "@/context/DemoContext";
54
import { loadConfig } from "@/lib/config";
65
import { applyTheme, getStoredTheme } from "@/lib/theme";
76

7+
const SETTINGS_PATH = "/settings";
8+
89
function RootLayout() {
9-
const config = loadConfig();
1010
const router = useRouter();
11-
const isSettings = router.state.location.pathname === "/settings";
11+
const isSettings = router.state.location.pathname === SETTINGS_PATH;
1212

1313
useEffect(() => {
1414
applyTheme(getStoredTheme());
1515
}, []);
1616

17-
useEffect(() => {
18-
if (!config && !isSettings) {
19-
router.navigate({ to: "/settings" as never });
20-
}
21-
}, [config, isSettings, router]);
22-
2317
if (isSettings) {
2418
return <Outlet />;
2519
}
2620

27-
if (!config) return null;
28-
2921
return (
30-
<DemoProvider>
31-
<div
32-
className="flex h-screen w-full overflow-hidden"
33-
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
34-
>
35-
<Sidebar />
36-
<main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}>
37-
<Outlet />
38-
</main>
39-
</div>
40-
</DemoProvider>
22+
<div
23+
className="flex h-screen w-full overflow-hidden"
24+
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
25+
>
26+
<Sidebar />
27+
<main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}>
28+
<Outlet />
29+
</main>
30+
</div>
4131
);
4232
}
4333

4434
export const Route = createRootRoute({
35+
beforeLoad: ({ location }) => {
36+
// Redirect to settings synchronously when no config is present, so the
37+
// first paint already shows the settings form instead of a blank screen.
38+
if (location.pathname !== SETTINGS_PATH && !loadConfig()) {
39+
throw redirect({ to: SETTINGS_PATH as never });
40+
}
41+
},
4542
component: RootLayout,
4643
});

packages/web/src/test/app.test.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect } from "vitest";
2+
import { render, screen } from "@testing-library/react";
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4+
import {
5+
createMemoryHistory,
6+
createRouter,
7+
RouterProvider,
8+
} from "@tanstack/react-router";
9+
import { routeTree } from "@/routeTree.gen";
10+
import { DemoProvider } from "@/context/DemoContext";
11+
import { useDemo } from "@/hooks/useDemo";
12+
13+
function renderAt(initialPath: string) {
14+
const router = createRouter({
15+
routeTree,
16+
history: createMemoryHistory({ initialEntries: [initialPath] }),
17+
});
18+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
19+
return render(
20+
<QueryClientProvider client={qc}>
21+
<DemoProvider>
22+
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
23+
<RouterProvider router={router as any} />
24+
</DemoProvider>
25+
</QueryClientProvider>,
26+
);
27+
}
28+
29+
describe("first load with no config", () => {
30+
it("renders the settings form on first paint when no config exists", async () => {
31+
localStorage.clear();
32+
renderAt("/");
33+
// Should be visible immediately — bug 1: RootLayout returns null while
34+
// a useEffect-driven navigate fires, leaving a blank screen.
35+
expect(
36+
await screen.findByText(/Connect to your self-hosted Honcho instance/i),
37+
).toBeInTheDocument();
38+
});
39+
});
40+
41+
describe("Sidebar/useDemo availability across routes", () => {
42+
it("does not throw when a useDemo consumer mounts alongside the routed app", () => {
43+
function DemoConsumer() {
44+
const { demo } = useDemo();
45+
return <span data-testid="demo-flag">{String(demo)}</span>;
46+
}
47+
// After the fix, DemoProvider wraps the app at the root (main.tsx /
48+
// __root.tsx) so consumers anywhere in the tree resolve. This test
49+
// renders a consumer as a sibling of the router under the same provider
50+
// the production wiring uses.
51+
localStorage.clear();
52+
expect(() => {
53+
const router = createRouter({
54+
routeTree,
55+
history: createMemoryHistory({ initialEntries: ["/settings"] }),
56+
});
57+
const qc = new QueryClient({
58+
defaultOptions: { queries: { retry: false } },
59+
});
60+
render(
61+
<QueryClientProvider client={qc}>
62+
<DemoProvider>
63+
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
64+
<RouterProvider router={router as any} />
65+
<DemoConsumer />
66+
</DemoProvider>
67+
</QueryClientProvider>,
68+
);
69+
}).not.toThrow();
70+
expect(screen.getByTestId("demo-flag")).toBeInTheDocument();
71+
});
72+
});

packages/web/src/test/setup.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import "@testing-library/jest-dom/vitest";
2+
import { afterEach, vi } from "vitest";
3+
import { cleanup } from "@testing-library/react";
4+
5+
// jsdom doesn't implement matchMedia; theme code reads it on mount.
6+
if (!window.scrollTo) {
7+
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
8+
}
9+
10+
if (!window.matchMedia) {
11+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
12+
matches: false,
13+
media: query,
14+
onchange: null,
15+
addListener: vi.fn(),
16+
removeListener: vi.fn(),
17+
addEventListener: vi.fn(),
18+
removeEventListener: vi.fn(),
19+
dispatchEvent: vi.fn(),
20+
}));
21+
}
22+
23+
afterEach(() => {
24+
cleanup();
25+
localStorage.clear();
26+
});

packages/web/vitest.config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { defineConfig } from "vitest/config";
2+
import react from "@vitejs/plugin-react";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
8+
export default defineConfig({
9+
plugins: [react()],
10+
resolve: {
11+
alias: {
12+
"@": path.resolve(__dirname, "./src"),
13+
},
14+
},
15+
define: {
16+
__APP_VERSION__: JSON.stringify("0.0.0-test"),
17+
},
18+
test: {
19+
environment: "jsdom",
20+
globals: true,
21+
setupFiles: ["./src/test/setup.ts"],
22+
css: false,
23+
},
24+
});

0 commit comments

Comments
 (0)