From 254bea22d82b23282f1321336d19ccc763f06194 Mon Sep 17 00:00:00 2001 From: Kazama Date: Thu, 30 Jul 2026 02:32:03 +0530 Subject: [PATCH] feat(dx): add a dev:reset script that clears local storage --- components/Providers.tsx | 2 ++ components/dev/DevResetHandler.tsx | 36 ++++++++++++++++++++++++ lib/dev/resetLocalStorage.ts | 31 ++++++++++++++++++++ package.json | 1 + scripts/dev-reset.mjs | 9 ++++++ tests/unit/dev/resetLocalStorage.test.ts | 35 +++++++++++++++++++++++ 6 files changed, 114 insertions(+) create mode 100644 components/dev/DevResetHandler.tsx create mode 100644 lib/dev/resetLocalStorage.ts create mode 100644 scripts/dev-reset.mjs create mode 100644 tests/unit/dev/resetLocalStorage.test.ts diff --git a/components/Providers.tsx b/components/Providers.tsx index 0d96f28e..db10c324 100644 --- a/components/Providers.tsx +++ b/components/Providers.tsx @@ -11,6 +11,7 @@ import ToastRegion from "@/components/ToastRegion"; import SessionExpiryProvider from "@/components/SessionExpiryProvider"; import CommandPalette from "@/components/CommandPalette"; import DevRequestIdDisplay from "@/components/DevRequestIdDisplay"; +import DevResetHandler from "@/components/dev/DevResetHandler"; /** * Client-side provider boundary for the app. @@ -33,6 +34,7 @@ export default function Providers({ children }: { children: ReactNode }) { + 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 dcaa5e0a..6bcc1a9a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "generate:types": "openapi-typescript ./openapi.yaml --output ./src/api/types.ts", "prebuild": "npm run generate:types", "dev": "next dev", + "dev:reset": "node scripts/dev-reset.mjs", "build": "next build --webpack", "start": "next start", "lint": "eslint .", 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(); + }); +});