diff --git a/ui/src/api/companies-query.ts b/ui/src/api/companies-query.ts new file mode 100644 index 00000000..c01c481a --- /dev/null +++ b/ui/src/api/companies-query.ts @@ -0,0 +1,25 @@ +import type { Company } from "@paperclipai/shared"; +import { companiesApi } from "./companies"; +import { ApiError } from "./client"; +import { queryKeys } from "../lib/queryKeys"; + +export type CompanyListResult = { companies: Company[]; unauthorized: boolean }; + +// Single source of truth for the `["companies"]` query. Both CompanyProvider and +// the invite landing page read this cache entry, so they must agree on the shape — +// returning a bare `Company[]` from one and this wrapped object from the other +// silently corrupts the shared cache and crashes whichever reads the other's shape. +export const companiesListQueryOptions = { + queryKey: queryKeys.companies.all, + queryFn: async (): Promise => { + try { + return { companies: await companiesApi.list(), unauthorized: false }; + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + return { companies: [], unauthorized: true }; + } + throw err; + } + }, + retry: false, +} as const; diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index 18a043f0..140d2c83 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -10,11 +10,10 @@ import { import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { Company } from "@paperclipai/shared"; import { companiesApi } from "../api/companies"; -import { ApiError } from "../api/client"; +import { companiesListQueryOptions, type CompanyListResult } from "../api/companies-query"; import { queryKeys } from "../lib/queryKeys"; import type { CompanySelectionSource } from "../lib/company-selection"; type CompanySelectionOptions = { source?: CompanySelectionSource }; -type CompanyListResult = { companies: Company[]; unauthorized: boolean }; interface CompanyContextValue { companies: Company[]; @@ -69,20 +68,8 @@ export function CompanyProvider({ children }: { children: ReactNode }) { const [selectionSource, setSelectionSource] = useState("bootstrap"); const [selectedCompanyId, setSelectedCompanyIdState] = useState(null); - const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } = useQuery({ - queryKey: queryKeys.companies.all, - queryFn: async () => { - try { - return { companies: await companiesApi.list(), unauthorized: false }; - } catch (err) { - if (err instanceof ApiError && err.status === 401) { - return { companies: [], unauthorized: true }; - } - throw err; - } - }, - retry: false, - }); + const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } = + useQuery(companiesListQueryOptions); const companies = companiesResult.companies; const companyListUnauthorized = companiesResult.unauthorized; const sidebarCompanies = useMemo( diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index cb394505..15642ecc 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -8,7 +8,7 @@ import { useCompany } from "@/context/CompanyContext"; import { Link, useNavigate, useParams } from "@/lib/router"; import { accessApi } from "../api/access"; import { authApi } from "../api/auth"; -import { companiesApi } from "../api/companies"; +import { companiesListQueryOptions } from "../api/companies-query"; import { healthApi } from "../api/health"; import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { clearPendingInviteToken, rememberPendingInviteToken } from "../lib/invite-memory"; @@ -248,11 +248,10 @@ export function InviteLandingPage() { }); const companiesQuery = useQuery({ - queryKey: queryKeys.companies.all, - queryFn: () => companiesApi.list(), + ...companiesListQueryOptions, enabled: !!sessionQuery.data && !!inviteQuery.data?.companyId, - retry: false, }); + const companyList = companiesQuery.data?.companies ?? []; useEffect(() => { if (token) rememberPendingInviteToken(token); @@ -263,11 +262,9 @@ export function InviteLandingPage() { }, [token]); useEffect(() => { - if (!companiesQuery.data || !inviteQuery.data?.companyId) return; - const isMember = companiesQuery.data.some( - (c) => c.id === inviteQuery.data!.companyId - ); - if (isMember) { + const list = companiesQuery.data?.companies; + if (!list || !inviteQuery.data?.companyId) return; + if (list.some((c) => c.id === inviteQuery.data!.companyId)) { clearPendingInviteToken(token); navigate("/", { replace: true }); } @@ -280,9 +277,7 @@ export function InviteLandingPage() { companiesQuery.isLoading; const isCurrentMember = Boolean(invite?.companyId) && - Boolean( - companiesQuery.data?.some((company) => company.id === invite?.companyId), - ); + companyList.some((company) => company.id === invite?.companyId); const companyName = invite?.companyName?.trim() || null; const companyDisplayName = companyName || "this Paperclip company"; const companyLogoUrl = invite?.companyLogoUrl?.trim() || null; @@ -375,13 +370,9 @@ export function InviteLandingPage() { setAuthFeedback(null); rememberPendingInviteToken(token); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); - const companies = await queryClient.fetchQuery({ - queryKey: queryKeys.companies.all, - queryFn: () => companiesApi.list(), - retry: false, - }); + const { companies: freshCompanies } = await queryClient.fetchQuery(companiesListQueryOptions); - if (invite?.companyId && companies.some((company) => company.id === invite.companyId)) { + if (invite?.companyId && freshCompanies.some((company) => company.id === invite.companyId)) { clearPendingInviteToken(token); setSelectedCompanyId(invite.companyId, { source: "manual" }); navigate("/", { replace: true });