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, + }, +});