feat: add validated browser-tab browsing preferences scoped to the signed-in user and workspace, restore destination filters before rendering, preserve explicit navigation intent, and clear saved browsing state on logout or session expiry

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Matthew Meszaros
2026-10-08 07:17:34 +00:00
co-authored by Devin AI
parent c7144c1502
commit acc3d62ba3
4 changed files with 183 additions and 0 deletions
+123
View File
@@ -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<typeof UserContext.Provider>["value"] : null;
return <UserContext.Provider value={profile}>{children}</UserContext.Provider>;
}
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");
});
});
+48
View File
@@ -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<T>(
name: string,
initial: T | (() => T),
schema: z.ZodType<T>,
options?: { initialOverride: T },
): [T, Dispatch<SetStateAction<T>>] {
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<Dispatch<SetStateAction<T>>>((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];
}
+10
View File
@@ -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. */ }
}
+2
View File
@@ -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();