diff --git a/components/Providers.tsx b/components/Providers.tsx index 7567e0a7..0247fbe6 100644 --- a/components/Providers.tsx +++ b/components/Providers.tsx @@ -13,29 +13,9 @@ import { ShortcutHelpProvider } from "@/lib/context/ShortcutHelpContext"; import LayoutWrapper from "@/components/LayoutWrapper"; import ToastRegion from "@/components/ToastRegion"; import SessionExpiryProvider from "@/components/SessionExpiryProvider"; -import ShortcutHelpModal from "@/components/ShortcutHelpModal"; -import UnhandledRejectionListener from "@/components/UnhandledRejectionListener"; -import { apiClient } from "@/lib/client/apiClient"; - -/** - * Lazy-loaded: CommandPalette pulls in its own icon set (13 lucide-react - * icons) and renders `null` until the user opens it (Cmd/Ctrl+K), but was - * previously a static import here -- mounted on every route via `Providers`, - * its icons shipped in the initial bundle on every page load regardless of - * whether the palette was ever opened. - */ -const CommandPalette = lazy(() => import("@/components/CommandPalette")); - -/** Keeps the API client's authorization header aligned with wallet state. */ -function ApiClientAuthBridge() { - const { account, isConnected } = useWallet(); - - useEffect(() => { - apiClient.setAuthToken(isConnected ? account?.address : null); - }, [account?.address, isConnected]); - - return null; -} +import CommandPalette from "@/components/CommandPalette"; +import DevRequestIdDisplay from "@/components/DevRequestIdDisplay"; +import DevResetHandler from "@/components/dev/DevResetHandler"; /** * Client-side provider boundary for the app. @@ -56,14 +36,11 @@ export default function Providers({ children }: { children: ReactNode }) { - - {children} - - - - - - + {children} + + + + diff --git a/components/dev/DevResetHandler.tsx b/components/dev/DevResetHandler.tsx new file mode 100644 index 00000000..bc602bea --- /dev/null +++ b/components/dev/DevResetHandler.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, Suspense } from "react"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { DEV_RESET_QUERY_PARAM, resetLocalStorage } from "@/lib/dev/resetLocalStorage"; + +/** Watches for `?dev-reset` (added by visiting the URL the `npm run + * dev:reset` script prints), clears every app-owned `localStorage` key, + * then strips the param so a page refresh doesn't clear storage again. */ +function DevResetHandlerInner() { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + if (!searchParams.has(DEV_RESET_QUERY_PARAM)) return; + + resetLocalStorage(); + + const remaining = new URLSearchParams(searchParams); + remaining.delete(DEV_RESET_QUERY_PARAM); + const query = remaining.toString(); + router.replace(query ? `${pathname}?${query}` : pathname); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + return null; +} + +export default function DevResetHandler() { + return ( + + + + ); +} diff --git a/lib/dev/resetLocalStorage.ts b/lib/dev/resetLocalStorage.ts new file mode 100644 index 00000000..79b0fd45 --- /dev/null +++ b/lib/dev/resetLocalStorage.ts @@ -0,0 +1,31 @@ +import { THEME_STORAGE_KEY } from "@/lib/config/theme"; +import { DEV_MODE_STORAGE_KEY, DEV_MODE_LATEST_REQUEST_ID_KEY } from "@/lib/config/developer"; + +/** Query param that triggers `resetLocalStorage()` on load -- see + * `components/dev/DevResetHandler.tsx`. Paired with the `npm run dev:reset` + * script, which just prints the URL to visit (a Node script has no way to + * reach into a running browser's `localStorage` directly). */ +export const DEV_RESET_QUERY_PARAM = "dev-reset"; + +/** Every `localStorage` key this app owns. Kept as one list so `dev:reset` + * clears the whole set instead of whichever ones someone remembered -- + * add new app-level persisted keys here when they're introduced. */ +export const DEV_RESET_LOCAL_STORAGE_KEYS = [ + THEME_STORAGE_KEY, + "display-density", + "remitwise_whats_new_last_seen", + DEV_MODE_STORAGE_KEY, + DEV_MODE_LATEST_REQUEST_ID_KEY, +] as const; + +/** Clears every key in {@link DEV_RESET_LOCAL_STORAGE_KEYS} so a developer + * can get back to a fresh first-visit client state (theme, density, + * "what's new" seen-state, dev mode) without manually clearing browser + * storage. Safe to call on the server -- it's a no-op without `window`. */ +export function resetLocalStorage(): void { + if (typeof window === "undefined") return; + + for (const key of DEV_RESET_LOCAL_STORAGE_KEYS) { + window.localStorage.removeItem(key); + } +} diff --git a/package.json b/package.json index 1978f836..a357d0e7 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "generate:types": "openapi-typescript ./openapi.yaml --output ./src/api/types.ts", "prebuild": "npm run generate:types", "dev": "next dev", - "mock:anchor": "node scripts/mock-anchor-server.mjs", + "dev:reset": "node scripts/dev-reset.mjs", "build": "next build --webpack", "start": "next start", "check:img-alt": "node scripts/check-img-alt.js", diff --git a/scripts/dev-reset.mjs b/scripts/dev-reset.mjs new file mode 100644 index 00000000..0dcc1489 --- /dev/null +++ b/scripts/dev-reset.mjs @@ -0,0 +1,9 @@ +#!/usr/bin/env node +// Prints the URL that clears the app's localStorage. A Node script has no +// way to reach into a running browser's storage directly, so `dev:reset` +// points you at the client-side handler in components/dev/DevResetHandler.tsx +// instead of pretending to do the clearing itself. +const port = process.env.PORT || 3000; +const url = `http://localhost:${port}/?dev-reset`; + +console.log(`\nVisit this URL in your browser to clear the app's localStorage:\n\n ${url}\n`); diff --git a/tests/unit/dev/resetLocalStorage.test.ts b/tests/unit/dev/resetLocalStorage.test.ts new file mode 100644 index 00000000..066bd9ae --- /dev/null +++ b/tests/unit/dev/resetLocalStorage.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + DEV_RESET_LOCAL_STORAGE_KEYS, + resetLocalStorage, +} from "@/lib/dev/resetLocalStorage"; + +describe("resetLocalStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("clears every app-owned localStorage key", () => { + for (const key of DEV_RESET_LOCAL_STORAGE_KEYS) { + localStorage.setItem(key, "some-value"); + } + + resetLocalStorage(); + + for (const key of DEV_RESET_LOCAL_STORAGE_KEYS) { + expect(localStorage.getItem(key)).toBeNull(); + } + }); + + it("leaves keys it doesn't own untouched", () => { + localStorage.setItem("some-other-apps-key", "keep-me"); + + resetLocalStorage(); + + expect(localStorage.getItem("some-other-apps-key")).toBe("keep-me"); + }); + + it("does nothing when localStorage already has no matching keys", () => { + expect(() => resetLocalStorage()).not.toThrow(); + }); +});