- {profileInitial}
-
+ name={displayName}
+ size="xl"
+ />
navigate(PORTAL_BASENAME) };
+}
diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx
index a0e8ba618d..809138850a 100644
--- a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx
+++ b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { renderHook, waitFor } from "@testing-library/react";
+import { renderHook as baseRenderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
const get = vi.fn();
let currentUserId: string | null = null;
@@ -20,10 +22,28 @@ function meReturning(portalAccess: boolean) {
return { data: { user: { portalAccess } } };
}
+// A fresh client per render, so one test's cached answer can't satisfy the
+// next — each case exercises a cold cache unless it deliberately shares one.
+let client: QueryClient;
+
+function renderHook(cb: () => T) {
+ return baseRenderHook(cb, {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ });
+}
+
describe("usePortalAccess", () => {
beforeEach(() => {
+ // The hook now remembers the last answer across mounts, so without this a
+ // prior test's result seeds the next one.
+ localStorage.clear();
get.mockReset();
currentUserId = null;
+ client = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
+ });
});
it("reports the backend's answer for the signed-in user", async () => {
@@ -82,12 +102,31 @@ describe("usePortalAccess", () => {
expect(first.result.current).toBe(false);
first.unmount();
- // The failure isn't sticky.
+ // The failure isn't sticky — a cold cache asks again.
+ client.clear();
get.mockResolvedValue(meReturning(true));
const second = renderHook(() => usePortalAccess());
await waitFor(() => expect(second.result.current).toBe(true));
});
+ it("shows the last known answer at first paint, then revalidates", async () => {
+ // What stops the switcher and the footer's "Open ..." row popping in a
+ // request late on every mount.
+ currentUserId = "admin-1";
+ get.mockResolvedValue(meReturning(true));
+ const first = renderHook(() => usePortalAccess());
+ await waitFor(() => expect(first.result.current).toBe(true));
+ first.unmount();
+
+ client.clear();
+ get.mockResolvedValue(meReturning(false));
+ const second = renderHook(() => usePortalAccess());
+ // Seeded from the remembered answer before the request lands...
+ expect(second.result.current).toBe(true);
+ // ...and corrected once the backend disagrees.
+ await waitFor(() => expect(second.result.current).toBe(false));
+ });
+
it("ignores a response that lands after unmount", async () => {
currentUserId = "admin-1";
let resolveMe: (v: unknown) => void = () => {};
diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.ts b/frontend/editor/src/saas/hooks/usePortalAccess.ts
index 442061cbe1..6e91f0864c 100644
--- a/frontend/editor/src/saas/hooks/usePortalAccess.ts
+++ b/frontend/editor/src/saas/hooks/usePortalAccess.ts
@@ -1,52 +1,64 @@
import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
+import {
+ readCachedOtherApp,
+ writeCachedOtherApp,
+} from "@app/services/navFooterCache";
+import { qk } from "@app/query/keys";
+
+async function fetchPortalAccess(): Promise {
+ const res = await apiClient.get<{ user?: { portalAccess?: boolean } }>(
+ "/api/v1/auth/me",
+ );
+ return res.data.user?.portalAccess === true;
+}
/**
* Whether the current user can open the processor (admin portal), straight
* from the backend (`/api/v1/auth/me` → `portalAccess`) — the same signal the
* processor's own SaasPortalGate uses. Components that must mirror processor
- * access (e.g. the sidebar's editor⇄processor switcher) ask here.
+ * access (the sidebar's editor⇄processor switcher and its footer row) ask here.
*
* The editor's Supabase auth context can't *answer* this — it never fetches
- * /me — so it is used only to identify who is asking. Keying the effect on
- * that identity is what keeps the answer per-user: the SPA can swap users
+ * /me — so it is used only to identify who is asking. That identity is the
+ * cache key, which is what keeps the answer per-user: the SPA can swap users
* without a reload (Supabase fires SIGNED_OUT/SIGNED_IN in place; only the
- * settings Logout button hard-navigates), so any answer held beyond the
- * current identity would leak to whoever signs in next.
+ * settings Logout button hard-navigates), and a keyed cache addresses each
+ * identity separately rather than holding one answer that would have to be
+ * invalidated on the swap — the bug class this hook once had.
*
- * Deliberately unmemoised beyond the mount: the one consumer (the sidebar
- * switcher) mounts once, so a cross-mount cache would only add user-scoped
- * state that has to be invalidated on identity change — the bug class this
- * hook already had once. Guests skip the request entirely.
+ * Cached through the app query client, so leaving the editor for the processor
+ * and coming back resolves from cache: the switcher is there on first paint
+ * instead of appearing a request later. Guests skip the request entirely.
*/
export function usePortalAccess(): boolean {
const { user } = useAuth();
const userId = user?.id ?? null;
- const [access, setAccess] = useState(false);
+ // The query cache is per-tree and per-load, so it can't help a cold start or
+ // the hop into the processor, which mounts its own client. Seed from the last
+ // answer this browser saw so the switcher and the footer's "Open ..." row are
+ // there at first paint. Marked ancient so it still revalidates immediately.
+ const [seed] = useState(readCachedOtherApp);
+
+ const { data, isSuccess } = useQuery({
+ queryKey: qk.portalAccess(userId),
+ queryFn: fetchPortalAccess,
+ // Signed out: nothing to ask, and any previous answer is void.
+ enabled: userId !== null,
+ // Backend unreachable or guest (401) means no access now; a later refetch
+ // asks again rather than trusting the failure.
+ retry: false,
+ initialData: seed,
+ initialDataUpdatedAt: 0,
+ });
useEffect(() => {
- // Signed out: nothing to ask, and any previous answer is void.
- if (userId === null) {
- setAccess(false);
- return;
- }
+ // Only a real answer is recorded — a failed probe is not one, so the next
+ // mount trusts the last backend response rather than a network blip.
+ if (isSuccess && data !== undefined) writeCachedOtherApp(data);
+ }, [isSuccess, data]);
- let cancelled = false;
- apiClient
- .get<{ user?: { portalAccess?: boolean } }>("/api/v1/auth/me")
- .then((res) => {
- if (!cancelled) setAccess(res.data.user?.portalAccess === true);
- })
- .catch(() => {
- // Backend unreachable or guest (401): no access now; a remount or
- // identity change asks again rather than trusting a failure.
- if (!cancelled) setAccess(false);
- });
- return () => {
- cancelled = true;
- };
- }, [userId]);
-
- return access;
+ return data === true;
}
diff --git a/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx
new file mode 100644
index 0000000000..b72eac67f8
--- /dev/null
+++ b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx
@@ -0,0 +1,158 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { expectConsole } from "@app/tests/failOnConsole";
+
+const get = vi.fn();
+vi.mock("@app/services/apiClient", () => ({
+ default: { get: (...args: unknown[]) => get(...args) },
+}));
+vi.mock("@app/hooks/walletDevPreview", () => ({
+ getWalletDevPreview: () => null,
+}));
+vi.mock("@app/services/billing", () => ({ createPortalSession: vi.fn() }));
+vi.mock("@app/platform/openExternal", () => ({ openExternal: vi.fn() }));
+
+const { useWallet } = await import("@app/hooks/useWallet");
+
+/** Full enough for the hook's deep-compare, which reads every field. */
+function walletWith(freeRemaining: number) {
+ return {
+ data: {
+ teamId: 1,
+ status: "free",
+ role: "leader",
+ billingPeriodStart: "2026-08-01",
+ billingPeriodEnd: "2026-08-31",
+ billableUsed: 500 - freeRemaining,
+ billableLimit: 500,
+ freeAllowance: 500,
+ freeRemaining,
+ pricePerDocMinor: 2,
+ bundleRatePerCreditMinor: null,
+ currency: "usd",
+ estimatedBillMinor: 0,
+ capUsd: null,
+ noCap: false,
+ stripeSubscriptionId: null,
+ spendUnitsThisPeriod: 0,
+ docsProcessedThisPeriod: 0,
+ uniquePdfsThisPeriod: 0,
+ sizeMultiplierPdfsThisPeriod: 0,
+ billingMode: "metered",
+ prepaidUnitsRemaining: 0,
+ prepaidUnitsTotal: 0,
+ prepaidExpiresAt: null,
+ recent: [],
+ members: [],
+ categoryBreakdown: { api: 0, ai: 0, automation: 0 },
+ categoryDocs: { api: 0, ai: 0, automation: 0 },
+ },
+ };
+}
+
+describe("useWallet — keeping the figures fresh", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ get.mockReset();
+ get.mockResolvedValue(walletWith(500));
+ });
+ afterEach(() => vi.useRealTimers());
+
+ it("re-reads the wallet on the poll interval", async () => {
+ const { result } = renderHook(() => useWallet());
+ await waitFor(() => expect(result.current.wallet).not.toBeNull());
+ expect(get).toHaveBeenCalledTimes(1);
+
+ get.mockResolvedValue(walletWith(480));
+ await act(async () => {
+ vi.advanceTimersByTime(30_000);
+ });
+
+ await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(480));
+ });
+
+ it("polls silently, so consumers gating on loading/error don't flicker", async () => {
+ const { result } = renderHook(() => useWallet());
+ await waitFor(() => expect(result.current.wallet).not.toBeNull());
+
+ // A poll that fails must leave the last good snapshot, and must not raise
+ // `error` — Plan swaps a working page for an alert on that.
+ get.mockRejectedValue(new Error("network blip"));
+ await act(async () => {
+ vi.advanceTimersByTime(30_000);
+ });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(result.current.wallet?.freeRemaining).toBe(500);
+ });
+
+ it("settles loading when a silent poll supersedes an in-flight visible load", async () => {
+ // The mount load raises `loading`; a poll firing before it lands cancels it.
+ // If clearing the flag were the silent load's to skip, both would decline
+ // and `loading` would stay true forever — which permanently suppresses the
+ // limit modals, since they do `if (loading || !wallet) return null`.
+ const visibility = vi.spyOn(document, "visibilityState", "get");
+ visibility.mockReturnValue("visible");
+
+ let landMount: (v: unknown) => void = () => {};
+ get.mockReturnValueOnce(
+ new Promise((resolve) => {
+ landMount = resolve;
+ }),
+ );
+ const { result } = renderHook(() => useWallet());
+ expect(result.current.loading).toBe(true);
+
+ get.mockResolvedValue(walletWith(470));
+ await act(async () => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+ await act(async () => {
+ landMount(walletWith(500));
+ });
+
+ await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(470));
+ expect(result.current.loading).toBe(false);
+ visibility.mockRestore();
+ });
+
+ it("clears a stale error once a silent poll succeeds", async () => {
+ // The visible mount load failing is meant to be logged; only the silent
+ // retries stay quiet.
+ expectConsole.warn(/\[useWallet\] fetch failed/);
+ get.mockRejectedValueOnce(new Error("network blip"));
+ const { result } = renderHook(() => useWallet());
+ await waitFor(() => expect(result.current.error).not.toBeNull());
+
+ get.mockResolvedValue(walletWith(500));
+ await act(async () => {
+ vi.advanceTimersByTime(30_000);
+ });
+
+ await waitFor(() => expect(result.current.error).toBeNull());
+ expect(result.current.wallet?.freeRemaining).toBe(500);
+ });
+
+ it("stops polling while the tab is hidden and re-reads on return", async () => {
+ const visibility = vi.spyOn(document, "visibilityState", "get");
+ visibility.mockReturnValue("visible");
+ const { result } = renderHook(() => useWallet());
+ await waitFor(() => expect(result.current.wallet).not.toBeNull());
+ const afterMount = get.mock.calls.length;
+
+ visibility.mockReturnValue("hidden");
+ await act(async () => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ vi.advanceTimersByTime(120_000);
+ });
+ expect(get).toHaveBeenCalledTimes(afterMount);
+
+ visibility.mockReturnValue("visible");
+ await act(async () => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+ await waitFor(() => expect(get.mock.calls.length).toBe(afterMount + 1));
+ visibility.mockRestore();
+ });
+});