Skip to content

Commit c0a330b

Browse files
authored
fix(billing): re-clear usage cache on org switch
The UI usage-cache clear keyed on authIdentity (region + project), but usage is org-scoped — the host UsageMonitorService keys its snapshot on currentOrgId. Switching between two orgs that both have no selected project leaves authIdentity at "region:none", so the effect never re-ran and a failed post-switch refresh could leave the previous org's spend cached. Depend on currentOrgId too, matching the host. Addresses the Greptile P1 review comment on #3507. Generated-By: PostHog Code Task-Id: 90f5b50d-504e-433d-82d9-2b9ead0bb1e5
1 parent b2e2375 commit c0a330b

2 files changed

Lines changed: 91 additions & 7 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { USAGE_QUERY_KEY } from "@posthog/ui/features/billing/useUsage";
2+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3+
import { renderHook } from "@testing-library/react";
4+
import type { ReactNode } from "react";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
import { useUsageIdentitySync } from "./useAuthSession";
7+
8+
let queryClient: QueryClient;
9+
function wrapper({ children }: { children: ReactNode }) {
10+
return (
11+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
12+
);
13+
}
14+
15+
describe("useUsageIdentitySync", () => {
16+
beforeEach(() => {
17+
queryClient = new QueryClient({
18+
defaultOptions: { queries: { retry: false } },
19+
});
20+
});
21+
22+
it("drops the cached usage snapshot on mount", () => {
23+
const removeSpy = vi.spyOn(queryClient, "removeQueries");
24+
25+
renderHook(() => useUsageIdentitySync("us:none", "org-a"), { wrapper });
26+
27+
expect(removeSpy).toHaveBeenCalledWith({ queryKey: USAGE_QUERY_KEY });
28+
expect(removeSpy).toHaveBeenCalledTimes(1);
29+
});
30+
31+
it("re-clears the snapshot when the org changes even if authIdentity is unchanged", () => {
32+
// Regression: usage is org-scoped, but authIdentity keys on region + project.
33+
// Switching between two orgs that both have no selected project leaves
34+
// authIdentity at "region:none", so keying only on it would leave the
35+
// previous org's spend cached after a failed refresh.
36+
const removeSpy = vi.spyOn(queryClient, "removeQueries");
37+
38+
const { rerender } = renderHook(
39+
({ authIdentity, orgId }) => useUsageIdentitySync(authIdentity, orgId),
40+
{ wrapper, initialProps: { authIdentity: "us:none", orgId: "org-a" } },
41+
);
42+
removeSpy.mockClear();
43+
44+
rerender({ authIdentity: "us:none", orgId: "org-b" });
45+
46+
expect(removeSpy).toHaveBeenCalledWith({ queryKey: USAGE_QUERY_KEY });
47+
expect(removeSpy).toHaveBeenCalledTimes(1);
48+
});
49+
50+
it("re-clears the snapshot when authIdentity changes", () => {
51+
const removeSpy = vi.spyOn(queryClient, "removeQueries");
52+
53+
const { rerender } = renderHook(
54+
({ authIdentity, orgId }) => useUsageIdentitySync(authIdentity, orgId),
55+
{ wrapper, initialProps: { authIdentity: "us:1", orgId: "org-a" } },
56+
);
57+
removeSpy.mockClear();
58+
59+
rerender({ authIdentity: "us:2", orgId: "org-a" });
60+
61+
expect(removeSpy).toHaveBeenCalledTimes(1);
62+
});
63+
64+
it("does not re-clear when neither authIdentity nor org changes", () => {
65+
const removeSpy = vi.spyOn(queryClient, "removeQueries");
66+
67+
const { rerender } = renderHook(
68+
({ authIdentity, orgId }) => useUsageIdentitySync(authIdentity, orgId),
69+
{ wrapper, initialProps: { authIdentity: "us:1", orgId: "org-a" } },
70+
);
71+
removeSpy.mockClear();
72+
73+
rerender({ authIdentity: "us:1", orgId: "org-a" });
74+
75+
expect(removeSpy).not.toHaveBeenCalled();
76+
});
77+
});

packages/ui/src/features/auth/useAuthSession.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,22 @@ function useAuthAnalyticsIdentity(
105105
]);
106106
}
107107

108-
function useUsageIdentitySync(authIdentity: string | null): void {
108+
export function useUsageIdentitySync(
109+
authIdentity: string | null,
110+
orgId: string | null,
111+
): void {
109112
const queryClient = useQueryClient();
110-
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on identity change
113+
// Usage is org-scoped billing data — drop the cached snapshot on any sign-in,
114+
// sign-out, region, or org switch so a new org never renders the previous
115+
// org's spend. authIdentity keys on region + project, which can stay constant
116+
// across an org switch (e.g. between two orgs with no selected project), so
117+
// depend on currentOrgId too — matching the host UsageMonitorService, which
118+
// keys its snapshot on currentOrgId. Without it, a post-switch refresh that
119+
// fails would leave the stale value cached.
120+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on identity/org change
111121
useEffect(() => {
112-
// Usage is identity-scoped billing data — drop the cached snapshot on any
113-
// sign-in, sign-out, or org/project switch so a new identity never renders
114-
// the previous account's spend.
115122
queryClient.removeQueries({ queryKey: USAGE_QUERY_KEY });
116-
}, [authIdentity, queryClient]);
123+
}, [authIdentity, orgId, queryClient]);
117124
}
118125

119126
export function useAuthSession() {
@@ -125,7 +132,7 @@ export function useAuthSession() {
125132
useAuthSubscriptionSync();
126133
useAuthIdentitySync(authState);
127134
useAuthAnalyticsIdentity(authIdentity, authState, currentUser);
128-
useUsageIdentitySync(authIdentity);
135+
useUsageIdentitySync(authIdentity, authState.currentOrgId);
129136

130137
return {
131138
authState,

0 commit comments

Comments
 (0)