diff --git a/web/src/hooks/useBrowseState.test.tsx b/web/src/hooks/useBrowseState.test.tsx new file mode 100644 index 000000000..31e4a80ed --- /dev/null +++ b/web/src/hooks/useBrowseState.test.tsx @@ -0,0 +1,123 @@ +import { act, renderHook } from "@testing-library/react"; +import { type ComponentProps, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { UserContext } from "./context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import { BROWSE_STORAGE_PREFIX, clearBrowseState } from "@/lib/browseState"; +import useBrowseState from "./useBrowseState"; + +let userID = "user"; +const key = (name = "tasks", owner = "user", organization = "workspace") => `${BROWSE_STORAGE_PREFIX}${owner}:${organization}:${name}`; +const schema = z.object({ query: z.string(), status: z.enum(["all", "pending", "done"]), reverse: z.boolean() }); +const defaults = { query: "", status: "all" as const, reverse: false }; + +function Owner({ children }: { children: ReactNode }) { + const profile = userID ? { user: { id: userID } } as ComponentProps["value"] : null; + return {children}; +} + +describe("workspace-scoped browse state", () => { + beforeEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + userID = "user"; + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + + it("restores filters and sort on a fresh mount without first rendering defaults", () => { + const page = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner }); + act(() => page.result.current[1]({ query: "alice", status: "pending", reverse: true })); + page.unmount(); + const refreshed = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner }); + expect(refreshed.result.current[0]).toEqual({ query: "alice", status: "pending", reverse: true }); + }); + + it("supports consecutive functional updates and remembers explicit clearing", () => { + const page = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner }); + act(() => { + page.result.current[1]((previous) => ({ ...previous, query: "alice" })); + page.result.current[1]((previous) => ({ ...previous, reverse: true })); + }); + expect(page.result.current[0]).toEqual({ query: "alice", status: "all", reverse: true }); + act(() => page.result.current[1](defaults)); + page.unmount(); + const refreshed = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner }); + expect(refreshed.result.current[0]).toEqual(defaults); + }); + + it("gives an explicit navigation intent priority over remembered filters", () => { + sessionStorage.setItem(key(), JSON.stringify({ value: { ...defaults, query: "remembered" } })); + const intent = { ...defaults, query: "linked" }; + const page = renderHook(() => useBrowseState("tasks", defaults, schema, { initialOverride: intent }), { wrapper: Owner }); + expect(page.result.current[0].query).toBe("linked"); + act(() => page.result.current[1](defaults)); + expect(page.result.current[0]).toEqual(defaults); + }); + + it("isolates pages, workspaces and users while the view stays mounted", () => { + sessionStorage.setItem(key("tasks", "user", "another"), JSON.stringify({ value: { ...defaults, query: "receiving" } })); + const page = renderHook(({ name }) => useBrowseState(name, defaults, schema), { wrapper: Owner, initialProps: { name: "tasks" } }); + act(() => page.result.current[1]({ ...defaults, query: "sending" })); + const staleSetter = page.result.current[1]; + act(() => useAppStore.setState({ currentOrganization: { id: "another", name: "Another", role: "owner" } })); + expect(page.result.current[0].query).toBe("receiving"); + act(() => staleSetter(defaults)); + expect(page.result.current[0].query).toBe("receiving"); + expect(JSON.parse(sessionStorage.getItem(key())!).value.query).toBe("sending"); + userID = "another-user"; + page.rerender({ name: "tasks" }); + expect(page.result.current[0]).toEqual(defaults); + userID = "user"; + page.rerender({ name: "tasks" }); + expect(page.result.current[0].query).toBe("receiving"); + page.rerender({ name: "campaigns" }); + expect(page.result.current[0]).toEqual(defaults); + page.rerender({ name: "tasks" }); + expect(page.result.current[0].query).toBe("receiving"); + }); + + it.each(["{broken", JSON.stringify({ value: { query: 123, status: "pending", reverse: false } }), JSON.stringify({ value: { ...defaults, status: "unknown" } })])("ignores malformed or stale stored filters: %s", (saved) => { + sessionStorage.setItem(key(), saved); + const page = renderHook(() => useBrowseState("tasks", () => defaults, schema), { wrapper: Owner }); + expect(page.result.current[0]).toEqual(defaults); + }); + + it("revives typed dates and keeps an explicitly cleared optional field", () => { + const dateSchema = z.object({ since: z.coerce.date().optional() }); + const initial = { since: new Date("2026-10-01T00:00:00Z") }; + const page = renderHook(() => useBrowseState("history", initial, dateSchema), { wrapper: Owner }); + page.unmount(); + const refreshed = renderHook(() => useBrowseState("history", initial, dateSchema), { wrapper: Owner }); + expect(refreshed.result.current[0].since).toEqual(initial.since); + act(() => refreshed.result.current[1]({ since: undefined })); + refreshed.unmount(); + const cleared = renderHook(() => useBrowseState("history", initial, dateSchema), { wrapper: Owner }); + expect(cleared.result.current[0].since).toBeUndefined(); + }); + + it("continues working with blocked or full storage", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("full"); }); + const page = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner }); + act(() => page.result.current[1]({ ...defaults, query: "still works" })); + expect(page.result.current[0].query).toBe("still works"); + }); + + it("does not save anonymous browsing state", () => { + userID = ""; + const page = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner }); + act(() => page.result.current[1]({ ...defaults, query: "anonymous" })); + expect(sessionStorage.getItem(key())).toBeNull(); + }); + + it("removes browsing preferences and legacy tags on logout without clearing other storage", () => { + sessionStorage.setItem(key(), "private search"); + sessionStorage.setItem("warmbly:mailbox-tag:user:workspace", "tag"); + sessionStorage.setItem("other-feature", "preserve"); + clearBrowseState(); + expect(sessionStorage.getItem(key())).toBeNull(); + expect(sessionStorage.getItem("warmbly:mailbox-tag:user:workspace")).toBeNull(); + expect(sessionStorage.getItem("other-feature")).toBe("preserve"); + }); +}); diff --git a/web/src/hooks/useBrowseState.ts b/web/src/hooks/useBrowseState.ts new file mode 100644 index 000000000..794f27db0 --- /dev/null +++ b/web/src/hooks/useBrowseState.ts @@ -0,0 +1,48 @@ +import { useCallback, useContext, useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import type { z } from "zod"; +import { UserContext } from "./context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState"; + +export default function useBrowseState( + name: string, + initial: T | (() => T), + schema: z.ZodType, + options?: { initialOverride: T }, +): [T, Dispatch>] { + const userID = useContext(UserContext)?.user.id; + const organizationID = useAppStore((state) => state.currentOrganization?.id ?? "personal"); + const key = userID ? `${BROWSE_STORAGE_PREFIX}${userID}:${organizationID}:${name}` : null; + const read = () => { + if (options) return options.initialOverride; + if (key) { + try { + const saved = sessionStorage.getItem(key); + if (saved !== null) { + const result = schema.safeParse(JSON.parse(saved)?.value); + if (result.success) return result.data; + } + } catch { /* Storage may be unavailable or malformed. */ } + } + return typeof initial === "function" ? (initial as () => T)() : initial; + }; + const [memory, setMemory] = useState(() => ({ key, value: read() })); + let current = memory; + // Restore before committing, so a workspace switch never queries or saves the old filters. + if (memory.key !== key) { + current = { key, value: read() }; + setMemory(current); + } + const setValue = useCallback>>((next) => { + setMemory((previous) => previous.key !== key ? previous : ({ + key: previous.key, + value: typeof next === "function" ? (next as (value: T) => T)(previous.value) : next, + })); + }, [key]); + useEffect(() => { + if (memory.key === null || memory.key !== key) return; + try { sessionStorage.setItem(memory.key, JSON.stringify({ value: memory.value })); } + catch { /* Browsing must work when storage is disabled or full. */ } + }, [key, memory]); + return [current.value, setValue]; +} diff --git a/web/src/lib/browseState.ts b/web/src/lib/browseState.ts new file mode 100644 index 000000000..a07940c7b --- /dev/null +++ b/web/src/lib/browseState.ts @@ -0,0 +1,10 @@ +export const BROWSE_STORAGE_PREFIX = "warmbly:browse:v1:"; + +export function clearBrowseState() { + try { + for (let index = sessionStorage.length - 1; index >= 0; index--) { + const key = sessionStorage.key(index); + if (key?.startsWith(BROWSE_STORAGE_PREFIX) || key?.startsWith("warmbly:mailbox-tag:")) sessionStorage.removeItem(key); + } + } catch { /* Storage may be disabled. */ } +} diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts index 62669cb9d..112e03bbc 100644 --- a/web/src/lib/session.ts +++ b/web/src/lib/session.ts @@ -3,6 +3,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { clearTokens } from "./auth"; import { useAppStore } from "@/stores/useAppStore"; import { resetBoot } from "./boot"; +import { clearBrowseState } from "./browseState"; // clearClientSession drops everything the signed-in person left in this // browser: tokens, drafts, the persisted workspace selection, and the fetched @@ -15,6 +16,7 @@ import { resetBoot } from "./boot"; // queryClient is optional: some callers run outside the provider. export function clearClientSession(queryClient?: QueryClient) { clearTokens(); + clearBrowseState(); queryClient?.clear(); resetBoot();