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.
This commit is contained in:
Offending Commit
2026-05-03 16:41:59 -05:00
parent 3fa4d599fe
commit 8f5a6aa7e9
5 changed files with 146 additions and 24 deletions

View File

@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router"; import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { DemoProvider } from "./context/DemoContext";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import "./index.css"; import "./index.css";
@@ -32,7 +33,9 @@ if (!root) throw new Error("Missing #root element");
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<DemoProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</DemoProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,
); );

View File

@@ -1,33 +1,24 @@
import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router"; import { createRootRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { Sidebar } from "@/components/layout/Sidebar"; import { Sidebar } from "@/components/layout/Sidebar";
import { DemoProvider } from "@/context/DemoContext";
import { loadConfig } from "@/lib/config"; import { loadConfig } from "@/lib/config";
import { applyTheme, getStoredTheme } from "@/lib/theme"; import { applyTheme, getStoredTheme } from "@/lib/theme";
const SETTINGS_PATH = "/settings";
function RootLayout() { function RootLayout() {
const config = loadConfig();
const router = useRouter(); const router = useRouter();
const isSettings = router.state.location.pathname === "/settings"; const isSettings = router.state.location.pathname === SETTINGS_PATH;
useEffect(() => { useEffect(() => {
applyTheme(getStoredTheme()); applyTheme(getStoredTheme());
}, []); }, []);
useEffect(() => {
if (!config && !isSettings) {
router.navigate({ to: "/settings" as never });
}
}, [config, isSettings, router]);
if (isSettings) { if (isSettings) {
return <Outlet />; return <Outlet />;
} }
if (!config) return null;
return ( return (
<DemoProvider>
<div <div
className="flex h-screen w-full overflow-hidden" className="flex h-screen w-full overflow-hidden"
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }} style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
@@ -37,10 +28,16 @@ function RootLayout() {
<Outlet /> <Outlet />
</main> </main>
</div> </div>
</DemoProvider>
); );
} }
export const Route = createRootRoute({ export const Route = createRootRoute({
beforeLoad: ({ location }) => {
// Redirect to settings synchronously when no config is present, so the
// first paint already shows the settings form instead of a blank screen.
if (location.pathname !== SETTINGS_PATH && !loadConfig()) {
throw redirect({ to: SETTINGS_PATH as never });
}
},
component: RootLayout, component: RootLayout,
}); });

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMemoryHistory,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import { routeTree } from "@/routeTree.gen";
import { DemoProvider } from "@/context/DemoContext";
import { useDemo } from "@/hooks/useDemo";
function renderAt(initialPath: string) {
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [initialPath] }),
});
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<DemoProvider>
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
<RouterProvider router={router as any} />
</DemoProvider>
</QueryClientProvider>,
);
}
describe("first load with no config", () => {
it("renders the settings form on first paint when no config exists", async () => {
localStorage.clear();
renderAt("/");
// Should be visible immediately — bug 1: RootLayout returns null while
// a useEffect-driven navigate fires, leaving a blank screen.
expect(
await screen.findByText(/Connect to your self-hosted Honcho instance/i),
).toBeInTheDocument();
});
});
describe("Sidebar/useDemo availability across routes", () => {
it("does not throw when a useDemo consumer mounts alongside the routed app", () => {
function DemoConsumer() {
const { demo } = useDemo();
return <span data-testid="demo-flag">{String(demo)}</span>;
}
// After the fix, DemoProvider wraps the app at the root (main.tsx /
// __root.tsx) so consumers anywhere in the tree resolve. This test
// renders a consumer as a sibling of the router under the same provider
// the production wiring uses.
localStorage.clear();
expect(() => {
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: ["/settings"] }),
});
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={qc}>
<DemoProvider>
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
<RouterProvider router={router as any} />
<DemoConsumer />
</DemoProvider>
</QueryClientProvider>,
);
}).not.toThrow();
expect(screen.getByTestId("demo-flag")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,26 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
// jsdom doesn't implement matchMedia; theme code reads it on mount.
if (!window.scrollTo) {
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
}
if (!window.matchMedia) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
afterEach(() => {
cleanup();
localStorage.clear();
});

View File

@@ -0,0 +1,24 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
define: {
__APP_VERSION__: JSON.stringify("0.0.0-test"),
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
css: false,
},
});