fix(ui): invite page goes blank from companies query-key collision (#6433)

## 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) <noreply@anthropic.com>
This commit is contained in:
Aron Prins
2026-05-22 22:28:49 +02:00
committed by GitHub
parent 4811d8dd33
commit e85ff094ec
3 changed files with 37 additions and 34 deletions
+25
View File
@@ -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<CompanyListResult> => {
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;
+3 -16
View File
@@ -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<CompanySelectionSource>("bootstrap");
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(null);
const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } = useQuery<CompanyListResult>({
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<CompanyListResult>(companiesListQueryOptions);
const companies = companiesResult.companies;
const companyListUnauthorized = companiesResult.unauthorized;
const sidebarCompanies = useMemo(
+9 -18
View File
@@ -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 });