From e85ff094ec182f046794897f745b236dabefebb7 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Fri, 22 May 2026 22:28:49 +0200 Subject: [PATCH] fix(ui): invite page goes blank from companies query-key collision (#6433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies; humans operate the board through the React UI. > - The board gates company access via `CompanyProvider` (CompanyContext) and onboards new humans through the invite landing page at `/invite/:token`. > - Reported symptom: opening an invite link and signing in works, but the page then renders completely blank (black in dark mode). > - End-to-end browser testing reproduced a client-side crash: `companiesQuery.data?.some is not a function` and `Cannot read properties of undefined (reading 'filter')`. > - Root cause: `CompanyProvider` and `InviteLandingPage` both use the React Query key `["companies"]` but return **different shapes** — `{ companies, unauthorized }` vs a bare `Company[]` — so they silently corrupt the shared cache entry; whichever component reads the other's shape calls `.some()`/`.filter()` on the wrong type and throws, unmounting the tree. > - Owners never hit it (they never mount the invite page); only invitees landing on `/invite/:token` crash. > - This PR unifies the `["companies"]` query into a single shared definition so the cache entry always has one shape and the two consumers can't drift apart again. > - The benefit is a working invite/onboarding flow and removal of a whole class of cache-shape bugs on this key. ## What Changed - Add `ui/src/api/companies-query.ts` exporting a single shared `companiesListQueryOptions` (and `CompanyListResult`) — one `queryKey` + one `queryFn` that always returns the wrapped `{ companies, unauthorized }` shape, documented with the shared-cache contract. - `ui/src/context/CompanyContext.tsx` now uses `useQuery(companiesListQueryOptions)` instead of an inline copy of that query. - `ui/src/pages/InviteLanding.tsx` uses the same `companiesListQueryOptions` (with its own `enabled` gate), reads `companiesQuery.data?.companies` for the membership checks, and uses `queryClient.fetchQuery(companiesListQueryOptions)` in the post-auth path — so it reads and writes the identical shape. ## Verification - `pnpm --filter @paperclipai/ui typecheck` — clean. - `vitest run src/pages/InviteLanding.test.tsx src/context/CompanyContext.test.tsx` — 17/17 pass, unchanged. - Manual end-to-end via a real browser against a LAN-exposed authenticated instance: - Owner creates an Owner-role invite. - New user opens the link and registers — **the "awaiting approval" screen renders** (previously blank), `POST /api/invites/:token/accept` returns `202`, no console errors. - Owner approves at Company Settings → Access (`200`); invitee becomes an active member. - Invitee signs in — full board loads; smoke test of dashboard / issues / inbox / routines / goals / company settings — all render, zero `pageerror`s. - Before: invite page `#root` empty after sign-in (blank/black). After: awaiting-approval panel renders. (Screenshots available on request.) ## Risks - Low. `CompanyProvider`'s query behavior is unchanged (same `queryFn` logic, just extracted into a shared module). `InviteLandingPage` now reads the same shape it writes. No API, schema, or migration changes. Existing tests pass unchanged. ## Model Used - Claude (Anthropic), model ID `claude-opus-4-7` (Opus 4.7), 1M-context, extended thinking + tool use; driving Claude Code with browser automation for end-to-end reproduction and verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [ ] I have added or updated tests where applicable (existing InviteLanding/CompanyContext tests cover the touched code and pass; a cross-provider regression test that mounts both consumers is a sensible follow-up) - [ ] If this change affects the UI, I have included before/after screenshots (described textually above; this is a crash/blank-page fix, screenshots available on request) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Claude Opus 4.7 (1M context) --- ui/src/api/companies-query.ts | 25 +++++++++++++++++++++++++ ui/src/context/CompanyContext.tsx | 19 +++---------------- ui/src/pages/InviteLanding.tsx | 27 +++++++++------------------ 3 files changed, 37 insertions(+), 34 deletions(-) create mode 100644 ui/src/api/companies-query.ts 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 });