forked from farhoodlabs/paperclip
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:
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user