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:
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
72
packages/web/src/test/app.test.tsx
Normal file
72
packages/web/src/test/app.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
26
packages/web/src/test/setup.ts
Normal file
26
packages/web/src/test/setup.ts
Normal 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();
|
||||||
|
});
|
||||||
24
packages/web/vitest.config.ts
Normal file
24
packages/web/vitest.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user