Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions console-ui/src/components/providers/verification-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { VerificationModeProvider, useVerificationMode } from "./verification-mode";
import { STORAGE_KEYS } from "@/lib/constants";

// Records the mode on every render so a test can assert the FIRST render is
// deterministic (server-safe) even when localStorage diverges. Reading
// localStorage in the useState initializer was what diverged the first client
// render from the server HTML and caused the hydration mismatch that broke
// client-side navigation app-wide.
let renders: string[] = [];

function Probe() {
const { mode, toggle } = useVerificationMode();
renders.push(mode);
return (
<button data-testid="mode" onClick={toggle}>
{mode}
</button>
);
}

beforeEach(() => {
localStorage.clear();
renders = [];
});

describe("VerificationModeProvider hydration determinism", () => {
it("first render is 'normal' even when localStorage says technical, then applies it after mount", () => {
localStorage.setItem(STORAGE_KEYS.verificationMode, "technical");

render(
<VerificationModeProvider>
<Probe />
</VerificationModeProvider>,
);

// The first client render must match the server (which has no localStorage):
// "normal". A divergent first render is the hydration mismatch we're fixing.
expect(renders[0]).toBe("normal");
// After the mount effect, the persisted preference is applied.
expect(screen.getByTestId("mode").textContent).toBe("technical");
});

it("stays 'normal' on first render and after mount when nothing is persisted", () => {
render(
<VerificationModeProvider>
<Probe />
</VerificationModeProvider>,
);

expect(renders[0]).toBe("normal");
expect(screen.getByTestId("mode").textContent).toBe("normal");
});

it("toggle flips the mode and persists it", () => {
render(
<VerificationModeProvider>
<Probe />
</VerificationModeProvider>,
);

expect(screen.getByTestId("mode").textContent).toBe("normal");
fireEvent.click(screen.getByTestId("mode"));
expect(screen.getByTestId("mode").textContent).toBe("technical");
expect(localStorage.getItem(STORAGE_KEYS.verificationMode)).toBe("technical");
});
});
27 changes: 18 additions & 9 deletions console-ui/src/components/providers/verification-mode.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { createContext, useContext, useState, useCallback } from "react";
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import { STORAGE_KEYS } from "@/lib/constants";

type VerificationMode = "normal" | "technical";
Expand All @@ -17,15 +17,24 @@ const VerificationModeContext = createContext<VerificationModeContextValue>({

const STORAGE_KEY = STORAGE_KEYS.verificationMode;

function getInitialMode(): VerificationMode {
if (typeof window === "undefined") return "normal";
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "technical" || stored === "normal") return stored;
return "normal";
}

export function VerificationModeProvider({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<VerificationMode>(getInitialMode);
// Start "normal" on the server AND the first client render, then load the
// persisted preference in an effect. Reading localStorage during render (in a
// useState initializer) diverged the first client render from the server HTML
// whenever the user had toggled "technical", causing a React hydration
// mismatch. On a mismatch React discards the server DOM and regenerates the
// tree on the client, which breaks App Router client navigation app-wide —
// links render but router.push silently no-ops (the symptom #458 band-aided
// with native <a>). Same hydration-determinism fix #457 applied to the store
// and InviteCodeBanner; this provider (added in #450) was missed.
const [mode, setMode] = useState<VerificationMode>("normal");

useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "technical" || stored === "normal") {
setMode(stored);
}
}, []);

const toggle = useCallback(() => {
setMode((prev) => {
Expand Down
Loading