diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx
index 6dc7e15..65fe499 100644
--- a/packages/web/src/main.tsx
+++ b/packages/web/src/main.tsx
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { DemoProvider } from "./context/DemoContext";
import { routeTree } from "./routeTree.gen";
import "./index.css";
@@ -32,7 +33,9 @@ if (!root) throw new Error("Missing #root element");
createRoot(root).render(
-
+
+
+
,
);
diff --git a/packages/web/src/routes/__root.tsx b/packages/web/src/routes/__root.tsx
index 024c1bc..07b902f 100644
--- a/packages/web/src/routes/__root.tsx
+++ b/packages/web/src/routes/__root.tsx
@@ -1,46 +1,43 @@
-import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router";
+import { createRootRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
import { useEffect } from "react";
import { Sidebar } from "@/components/layout/Sidebar";
-import { DemoProvider } from "@/context/DemoContext";
import { loadConfig } from "@/lib/config";
import { applyTheme, getStoredTheme } from "@/lib/theme";
+const SETTINGS_PATH = "/settings";
+
function RootLayout() {
- const config = loadConfig();
const router = useRouter();
- const isSettings = router.state.location.pathname === "/settings";
+ const isSettings = router.state.location.pathname === SETTINGS_PATH;
useEffect(() => {
applyTheme(getStoredTheme());
}, []);
- useEffect(() => {
- if (!config && !isSettings) {
- router.navigate({ to: "/settings" as never });
- }
- }, [config, isSettings, router]);
-
if (isSettings) {
return ;
}
- if (!config) return null;
-
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
);
}
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,
});
diff --git a/packages/web/src/test/app.test.tsx b/packages/web/src/test/app.test.tsx
new file mode 100644
index 0000000..28e0160
--- /dev/null
+++ b/packages/web/src/test/app.test.tsx
@@ -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(
+
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
+
+
+ ,
+ );
+}
+
+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 {String(demo)};
+ }
+ // 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(
+
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
+
+
+
+ ,
+ );
+ }).not.toThrow();
+ expect(screen.getByTestId("demo-flag")).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/test/setup.ts b/packages/web/src/test/setup.ts
new file mode 100644
index 0000000..95266bc
--- /dev/null
+++ b/packages/web/src/test/setup.ts
@@ -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();
+});
diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts
new file mode 100644
index 0000000..e154f77
--- /dev/null
+++ b/packages/web/vitest.config.ts
@@ -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,
+ },
+});