Files
PaperClipAI/ui/src/context/CompanyContext.test.tsx
T
DottaandPaperclip b40e6edaea perf(inbox): combine startup reads and reduce refetch pressure (#18790)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Mine shows the work and failures that need the viewing user's
attention.
> - A reload waits for account access, task rows, parent captions and
failure state.
> - Those reads form several browser request stages before useful rows
appear.
> - Live invalidations can also restart the same task-list request more
than once.
> - This change supplies startup data together and removes redundant
refresh work.
> - It preserves current authority, failure presentation, archive state
and row stability.

## Linked Issues or Issue Description

Refs #18645. Related: #18642. This change implements the read-receipt
behavior independently on current master. It does not use that PR's
branch or patch.

**What happened?**

Mine starts its primary request after the session, company and
board-access reads. It then requests parent captions and rich failure
context. Four observed loads spent another 0.45–0.70 seconds after the
primary rows arrived. One slower load waited 1.71 seconds for parent
context. The same run observed two HTTP 429 responses. These timings
identify request waits; they do not attribute the delay to SQL or Node
CPU.

Read receipts also invalidate general task collections and dashboards.
Overlapping parent and child invalidations can start two requests and
abort the first.

**Expected behavior**

The initial page should use one account-safe startup projection and one
complete Mine response. A read receipt should refresh the affected
viewer's inbox state. Each invalidation batch should refresh a covered
query once. Required captions and current failure state must be ready
before first reveal. Existing rows should remain usable during a
transient refresh error.

**Steps to reproduce**

1. Open a populated Mine inbox with parent tasks outside the visible
index and a current failed run.
2. Reload with browser HTTP cache retained and application query state
empty.
3. Inspect the session, company, board access, task-index and context
request sequence.
4. Open or mark a task read and inspect the unrelated collection
refetches.

**Paperclip version or commit**

Baseline: `44a9a043b80c18314d8dd89556f444dae696d3d6`.

**Deployment mode**

Authenticated board UI. Local proof uses the real APIs and database with
a synthetic session resolver. No hosted candidate deployment is claimed.

## What Changed

- Add optional, same-user `boardBootstrap` data to the existing session
response. Seed account-keyed company and board-access reads from it.
Keep ordinary endpoint reads for fallback, retry and live
reconciliation. A returning account cannot reuse revoked cached access.
Cache collection and later real refetches remain effective.
- Add the board-only Mine snapshot endpoint. Reuse the canonical issue
selector, private-task filtering, run-read decisions, secret redaction
and interruption/recovery presentation. Assemble the fixed 100-task
page, parent captions and bounded 200-run failure window on the server.
Start independent reads together. Optional source errors keep successful
task rows usable.
- Keep array-shaped source caches for archive and undo. Key Mine source,
context and reveal state by account. Validate the response's user stamp
before committing it. Remove array-only cross-tab Mine broadcasts, which
cannot carry its paired failure context. The server retains
authenticated in-flight coalescing.
- Scope read-receipt invalidation to the affected viewer's inbox, badges
and chat unread state. Keep company and task audit views fresh. Preserve
genuine task mutations, run updates and subscription-gap reconciliation.
- Remove covered scalar-prefix invalidations within a batch. Preserve
predicates, exact matches and different query/refetch constraints. A
later event can still refresh again.
- Retire Mine in-flight reuse after local company activity or run
lifecycle changes. A post-archive read starts fresh. Old work keeps its
admission slot until it settles, even if every HTTP client aborts. The
eight-compute limit stays in place.
- Add API, component and browser regressions. Extend the production
timing harness with matching API/UI artifact receipts, retained-cache
reloads, request status counts, layout checks and optional controlled
network latency. Update API and verification documentation.

## Verification

- Full `pnpm -r typecheck` passes.
- Full `pnpm build` passes.
- Token gates, map checks and diff whitespace checks pass.
- 72 Inbox component tests pass. Startup-specific checks pass, including
account switching, revoked cached access, malformed/absent bootstrap
data and cache collection. The canonical authenticated bootstrap route
also passes against real PostgreSQL.
- Six real PostgreSQL Mine route cases pass, including full equivalence
with legacy authorized projections, privacy, fixed bounds, membership
revocation, coalescing, archive freshness and aborted-client admission.
Four focused legacy coordinator controls pass. Six assembly tests pass.
- Refetch and invalidation regressions pass. Meaningful pre-fix tests
observed two request starts and one abort for overlapping invalidations;
the fix produces one start and no abort. The archive race test observed
the post-archive GET joining the old body before the fix.
- All 11 existing startup browser checks pass, including Tasks/Mine code
loading, desktop/mobile bounds, non-streamlined Coordinator, task
navigation, failed preloading and Back position. The nine focused
startup, fallback, partial-source error, rate-limit recovery,
archive/undo and geometry cases also pass. The 429 cases use the
inherited retry window. Archive and keyboard undo are corroborated
through the real API and reload. Authentication and failure fault
injection in these cases are explicitly simulated.
- Matched production before/after timings are complete. Full aggregate
Vitest coverage is left to CI. At 15:15 UTC, the final-head runner gate
and `review` workflow pass. The 45 build/test/policy jobs are queued;
the Build job has no `ubuntu-latest` runner assigned and has executed no
steps, so remote aggregate results remain unverified. Paperclip Review
and Superagent Security Scan are in progress. Superseded runs for this
PR were cancelled. No current-head check has reported a failure; the CI
checklist remains open. The remaining step is runner allocation and
completion of those external checks; persistent queueing needs the
repository CI owner's attention.
- Greptile completed on final head
`ecde295565309275c4f2fae903caf6892f1eaafd` at 5/5. The map-scope finding
is fixed and resolved; there are no open review threads.

### Matched browser timings

Five alternating baseline/candidate pairs per network scenario, measured
serially
in headful Chrome 156.0.8078.80 on Apple silicon with Node 24.22.2 and a
1440×900
viewport. Both production UIs use their matching real API revision
against one
fixed disposable PostgreSQL fixture: 34 tasks, 31 visible task rows, one
paused
agent and one terminal failure. No active agent/provider work. HTTP
cache is
retained; each initial sample reloads the document and clears
application query
state. Warm navigation and Back retain query state. CPU and throughput
are native.
The 100ms scenario uses CDP minimum request-to-response-header latency
without
request interception. It is a controlled request-dependency experiment,
not a
staging reproduction.

Values are median milliseconds with min–max in parentheses. Paired
saving is
baseline minus candidate for each alternating pair; its median differs
from the
difference of arm medians. Positive saving favors the candidate.

| Network / journey | Baseline | Candidate | Median paired saving |
| --- | ---: | ---: | ---: |
| Native / initial rows | 187.6 (128.9–208.5) | 191.7 (130.3–210.9) |
−23.3 |
| Native / Recent → Mine | 100.17 (99.90–101.87) | 100.07 (99.88–101.37)
| −0.16 |
| Native / task → Back | 70.45 (56.63–72.02) | 66.64 (56.52–67.71) |
+4.94 |
| 100ms / initial rows | 930.3 (914.1–1015.7) | 799.0 (697.1–826.0) |
+211.8 |
| 100ms / Recent → Mine | 100.09 (97.73–100.16) | 100.18 (96.65–100.98)
| −0.02 |
| 100ms / task → Back | 74.97 (62.77–75.82) | 75.47 (61.51–76.19) |
+0.35 |

The controlled-latency initial pairs all improve, saving 108.3–217.0ms.
Native
initial pairs have mixed signs (−67.5 to +78.1ms), so they establish no
reliable
native page-speed improvement. Warm and Back timings are effectively
unchanged.
Median primary-request start is 110.4→135.2ms natively and 497.8→480.9ms
with
100ms latency; these runs do not establish a large startup-only timing
gain.

API requests through the two-second post-reveal window fall from median
32
(32–37) to 27 (25–28) natively, and 37 (33–38) to 30 (25–32) with 100ms
latency.
All 20 samples preserve the same ordered 31 task links, first-row DOM
identity
and geometry. All recorded API responses are HTTP 200; zero unfinished
requests,
429s or JavaScript errors. Lower 429 incidence on staging remains
unproven.

Measured source: baseline `44a9a043b80c18314d8dd89556f444dae696d3d6`,
candidate
`4c12b2ef0ecf0754676e99bd6b9399e84408646c`. Final head
`ecde295565309275c4f2fae903caf6892f1eaafd` changes only two feature-map
files
and existing browser test expectations since that measured candidate;
production executable inputs are identical to the measured candidate.
Asset hashes, API
health revision receipts and every raw sample were retained. These
results do
not claim hosted-page improvement or deployment of this PR.

## Risks

- Startup enrichment adds work to the session read. Failure of this
optional projection falls back to the ordinary company and access
endpoints. It does not invalidate a valid session or replace per-request
server authorization.
- Account changes, stale cache entries and same-company navigation can
expose old presentation data. Account keys, principal stamps, reveal
scopes and canonical bootstrap replacement have dedicated negative
cases. Permission failures hide cached task rows; transient network, 429
and 5xx failures preserve them.
- Current failure state can differ from the historical failed run. The
snapshot uses the existing canonical rich projection and verified
missing-ID coverage. Partial context failures retain an explicit retry
path.
- Mutation retirement follows events delivered to the current server
process. This adds no cross-replica invalidation protocol or distributed
read-after-write guarantee.
- New UI assets require the matching API. Older UI/session consumers
retain their existing response and endpoint behavior. There is no
database migration, provider call or deployment change.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, code execution, browser
automation and delegated source review. The exact runtime model ID and
context-window size are not exposed by this session.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-03 09:28:57 -06:00

666 lines
28 KiB
TypeScript

// @vitest-environment jsdom
import { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Company } from "@paperclipai/shared";
import { queryKeys } from "../lib/queryKeys";
import {
CompanyProvider,
resolveBootstrapCompanySelection,
shouldClearStoredCompanySelection,
useCompany,
} from "./CompanyContext";
const mockCompaniesApi = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
detachInflightList: vi.fn(),
}));
vi.mock("../api/companies", () => ({
companiesApi: mockCompaniesApi,
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
function sessionFor(userId: string) {
return {
session: { id: `session-${userId}`, userId },
user: { id: userId, name: "Example", email: `${userId}@example.com`, image: null },
};
}
const activeCompany = { id: "company-1" };
const secondActiveCompany = { id: "company-2" };
const archivedCompany = { id: "archived-company" };
function makeCompany(id: string): Company {
return {
id,
name: "Paperclip",
description: null,
status: "active",
pauseReason: null,
pausedAt: null,
issuePrefix: "PAP",
issueCounter: 1,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
defaultResponsibleUserId: null,
requireBoardApprovalForNewAgents: false,
interactionResolverGovernance: {},
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
logoAssetId: null,
logoUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
async function flushReact() {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
let captured: ReturnType<typeof useCompany> | null = null;
function Probe({ onSelectedCompanyId }: { onSelectedCompanyId: (companyId: string | null) => void }) {
const company = useCompany();
captured = company;
const { selectedCompanyId } = company;
useEffect(() => {
onSelectedCompanyId(selectedCompanyId);
}, [onSelectedCompanyId, selectedCompanyId]);
return <div data-selected-company-id={selectedCompanyId ?? ""} />;
}
describe("resolveBootstrapCompanySelection", () => {
it("boots the addressed company before the remembered company", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany, secondActiveCompany], sidebarCompanies: [activeCompany, secondActiveCompany],
selectedCompanyId: null, storedCompanyId: activeCompany.id, routeCompanyId: secondActiveCompany.id,
})).toBe(secondActiveCompany.id);
});
it("retains a manual selection and does not bootstrap into an archived route", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany, secondActiveCompany], sidebarCompanies: [activeCompany, secondActiveCompany],
selectedCompanyId: activeCompany.id, storedCompanyId: activeCompany.id, routeCompanyId: secondActiveCompany.id,
})).toBe(activeCompany.id);
expect(resolveBootstrapCompanySelection({
companies: [activeCompany, archivedCompany], sidebarCompanies: [activeCompany],
selectedCompanyId: null, storedCompanyId: activeCompany.id, routeCompanyId: archivedCompany.id,
})).toBe(activeCompany.id);
});
it("does not expose a stale stored company id before companies load", () => {
expect(resolveBootstrapCompanySelection({
companies: [],
sidebarCompanies: [],
selectedCompanyId: null,
storedCompanyId: "stale-company",
})).toBeNull();
});
it("replaces a stale stored company id with the first loaded company", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: null,
storedCompanyId: "stale-company",
})).toBe("company-1");
});
it("keeps a valid selected company ahead of stored bootstrap state", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: "company-1",
storedCompanyId: "stale-company",
})).toBe("company-1");
});
it("keeps a valid stored company id instead of falling back to the first company", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany, secondActiveCompany],
sidebarCompanies: [activeCompany, secondActiveCompany],
selectedCompanyId: null,
storedCompanyId: "company-2",
})).toBe("company-2");
});
it("uses selectable sidebar companies before archived companies", () => {
expect(resolveBootstrapCompanySelection({
companies: [archivedCompany, activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: null,
storedCompanyId: "archived-company",
})).toBe("company-1");
});
it("keeps an explicitly selected archived company that still exists", () => {
// The Layout route-sync selects the company the URL names, archived
// included. Vetoing that selection here re-selected an active company,
// the route-sync selected the archived one again, and the two effects
// ping-ponged until React threw #185 and unmounted the app.
expect(resolveBootstrapCompanySelection({
companies: [archivedCompany, activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: "archived-company",
storedCompanyId: null,
})).toBe("archived-company");
});
it("still replaces a selected company that no longer exists at all", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: "deleted-company",
storedCompanyId: null,
})).toBe("company-1");
});
});
describe("shouldClearStoredCompanySelection", () => {
it("does not clear the stored company selection during an unauthorized company list response", () => {
expect(shouldClearStoredCompanySelection({
companies: [],
isLoading: false,
unauthorized: true,
errored: false,
})).toBe(false);
});
it("clears the stored company selection when an authorized company list is empty", () => {
expect(shouldClearStoredCompanySelection({
companies: [],
isLoading: false,
unauthorized: false,
errored: false,
})).toBe(true);
});
it("does not clear the stored company selection when the request failed", () => {
// `companiesListQueryOptions` sets `retry: false`, and a failure before any
// success leaves `data` undefined — which the provider defaults to an empty
// list. That is indistinguishable from "asked, and owns nothing", so
// without this a single failed request on a cold load would drop the
// customer's stored company.
expect(shouldClearStoredCompanySelection({
companies: [],
isLoading: false,
unauthorized: false,
errored: true,
})).toBe(false);
});
});
describe("CompanyProvider", () => {
let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
captured = null;
localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
mockAuthApi.getSession.mockResolvedValue(null);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
queryClient.clear();
container.remove();
vi.clearAllMocks();
});
it("seeds navigation from the session and preserves ordinary company invalidation", async () => {
queryClient.setDefaultOptions({ queries: { retry: false, staleTime: 30_000 } });
mockAuthApi.getSession.mockResolvedValue({ ...sessionFor("one"), boardBootstrap: {
userId: "one", companies: [makeCompany("bootstrap-company")],
boardAccess: { userId: "one", isInstanceAdmin: false, companyIds: ["bootstrap-company"] },
} });
await act(() => root.render(<QueryClientProvider client={queryClient}><CompanyProvider>
<Probe onSelectedCompanyId={() => {}} />
</CompanyProvider></QueryClientProvider>));
await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("bootstrap-company"));
expect(mockCompaniesApi.list).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.companies.list("one"))).toMatchObject({ companies: [{ id: "bootstrap-company" }] });
mockCompaniesApi.list.mockResolvedValue([makeCompany("fresh-company")]);
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }));
await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("fresh-company"));
expect(mockCompaniesApi.list).toHaveBeenCalledOnce();
});
it("never exposes the previous account's selection while a new bootstrap is ready", async () => {
queryClient.setDefaultOptions({ queries: { retry: false, staleTime: 30_000 } });
const boot = (userId: string) => ({ ...sessionFor(userId), boardBootstrap: {
userId, companies: [makeCompany(`company-${userId}`)],
boardAccess: { userId, isInstanceAdmin: false, companyIds: [`company-${userId}`] },
} });
mockAuthApi.getSession.mockResolvedValue(boot("one"));
const snapshots: Array<{ selected: string | null; company: string | undefined }> = [];
function AccountProbe() {
const current = useCompany();
snapshots.push({ selected: current.selectedCompanyId, company: current.companies[0]?.id });
return null;
}
await act(() => root.render(<QueryClientProvider client={queryClient}><CompanyProvider><AccountProbe /></CompanyProvider></QueryClientProvider>));
await vi.waitFor(() => expect(snapshots.at(-1)?.selected).toBe("company-one"));
await act(() => { queryClient.setQueryData(queryKeys.auth.session, boot("two")); });
await vi.waitFor(() => expect(snapshots.at(-1)?.selected).toBe("company-two"));
expect(snapshots.filter(value => value.company === "company-two").some(value => value.selected === "company-one")).toBe(false);
expect(mockCompaniesApi.list).not.toHaveBeenCalled();
});
it("replaces a returning account's cached companies with its revoked session snapshot once", async () => {
queryClient.setDefaultOptions({ queries: { retry: false, staleTime: Infinity } });
const boot = (userId: string, ids: string[]) => ({ ...sessionFor(userId), boardBootstrap: {
userId, companies: ids.map(makeCompany),
boardAccess: { userId, isInstanceAdmin: false, companyIds: ids },
} });
const seen: Array<{ selected: string | null; companies: string[] }> = [];
function AccountProbe() {
const current = useCompany();
seen.push({ selected: current.selectedCompanyId, companies: current.companies.map(company => company.id) });
return null;
}
mockAuthApi.getSession.mockResolvedValue(boot("one", ["revoked-company"]));
await act(() => root.render(<QueryClientProvider client={queryClient}><CompanyProvider><AccountProbe /></CompanyProvider></QueryClientProvider>));
await vi.waitFor(() => expect(seen.at(-1)?.selected).toBe("revoked-company"));
await act(() => { queryClient.setQueryData(queryKeys.auth.session, boot("two", ["company-two"])); });
await vi.waitFor(() => expect(seen.at(-1)?.selected).toBe("company-two"));
const returningAt = seen.length;
await act(() => { queryClient.setQueryData(queryKeys.auth.session, boot("one", [])); });
await vi.waitFor(() => expect(seen.at(-1)).toEqual({ selected: null, companies: [] }));
expect(seen.slice(returningAt).some(value => value.companies.includes("revoked-company"))).toBe(false);
expect(queryClient.getQueryData(queryKeys.companies.list("one"))).toEqual({ companies: [], unauthorized: false });
expect(mockCompaniesApi.list).not.toHaveBeenCalled();
// The session seed must not overwrite a later authoritative read.
mockCompaniesApi.list.mockResolvedValue([makeCompany("new-membership")]);
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }));
await vi.waitFor(() => expect(seen.at(-1)?.selected).toBe("new-membership"));
expect(mockCompaniesApi.list).toHaveBeenCalledOnce();
});
it("reseeds a collected bootstrap query and restores ordinary reads after remount", async () => {
queryClient.setDefaultOptions({ queries: { retry: false, staleTime: Infinity, gcTime: 0 } });
const bootstrap = {
userId: "one", companies: [makeCompany("bootstrap-company")],
boardAccess: { userId: "one", isInstanceAdmin: false, companyIds: ["bootstrap-company"] },
};
mockAuthApi.getSession.mockResolvedValue({ ...sessionFor("one"), boardBootstrap: bootstrap });
const render = (mounted: boolean) => root.render(<QueryClientProvider client={queryClient}>
{mounted ? <CompanyProvider><Probe onSelectedCompanyId={() => {}} /></CompanyProvider> : null}
</QueryClientProvider>);
await act(() => render(true));
await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("bootstrap-company"));
expect(mockCompaniesApi.list).not.toHaveBeenCalled();
await act(() => render(false));
await vi.waitFor(() => expect(queryClient.getQueryState(queryKeys.companies.list("one"))).toBeUndefined());
captured = null;
await act(() => render(true));
await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("bootstrap-company"));
mockCompaniesApi.list.mockResolvedValue([makeCompany("ordinary-reader-company")]);
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }));
await vi.waitFor(() => expect(mockCompaniesApi.list).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(captured?.selectedCompanyId).toBe("ordinary-reader-company"));
expect(queryClient.getQueryData(queryKeys.companies.list("one"))).toMatchObject({ companies: [{ id: "ordinary-reader-company" }] });
});
it("publishes the URL company without first exposing the remembered company", async () => {
const previousUrl = window.location.href;
window.history.replaceState(null, "", "/TWO/issues");
localStorage.setItem("paperclip.selectedCompanyId", "company-1");
queryClient.setQueryData(queryKeys.auth.session, null);
queryClient.setQueryData(queryKeys.companies.list(null), {
companies: [{ ...makeCompany("company-1"), issuePrefix: "ONE" }, { ...makeCompany("company-2"), issuePrefix: "TWO" }],
unauthorized: false,
});
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
const seen: Array<string | null> = [];
try {
await act(async () => root.render(<QueryClientProvider client={queryClient}><CompanyProvider>
<Probe onSelectedCompanyId={id => seen.push(id)} />
</CompanyProvider></QueryClientProvider>));
expect(seen.filter(Boolean)).toEqual(["company-2"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-2");
} finally {
window.history.replaceState(null, "", previousUrl);
}
});
it("keeps the stored company when the company request fails", async () => {
// The seam the predicate test above cannot reach: the provider defaults a
// failed request to an empty list, so the effect has to be told the
// request errored or it reads that as "asked, and owns nothing" and clears
// the customer's stored company.
localStorage.setItem("paperclip.selectedCompanyId", "company-a");
mockCompaniesApi.list.mockRejectedValue(new Error("companies unavailable"));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={() => {}} />
</CompanyProvider>
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-a");
});
it("does not expose a stale stored company id before companies load", async () => {
localStorage.setItem("paperclip.selectedCompanyId", "stale-company");
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
const seen: Array<string | null> = [];
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={(companyId) => seen.push(companyId)} />
</CompanyProvider>
</QueryClientProvider>,
);
});
expect(seen).toEqual([null]);
});
it("replaces a stale stored company id with the first loaded company", async () => {
localStorage.setItem("paperclip.selectedCompanyId", "stale-company");
// Signed out, and *known* to be: the list is keyed by account, so it cannot
// resolve before the session does. Seeding the answer is how this test says
// the account lookup has already happened.
queryClient.setQueryData(queryKeys.auth.session, null);
queryClient.setQueryData(queryKeys.companies.list(null), {
companies: [makeCompany("company-1")],
unauthorized: false,
});
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
const seen: Array<string | null> = [];
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={(companyId) => seen.push(companyId)} />
</CompanyProvider>
</QueryClientProvider>,
);
});
expect(seen).toEqual([null, "company-1"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
});
// The `["companies"]` cache entry is shared app-wide and carries no account
// identity, so it survives a change of account in this tab. Cached data is
// served whether or not it is stale, so freshness cannot stand in for
// "belongs to the account signed in now".
describe("when the account changes in this tab", () => {
async function bootWithFirstAccount(seen: Array<string | null>) {
mockAuthApi.getSession.mockResolvedValue(sessionFor("user-1"));
queryClient.setQueryData(queryKeys.companies.list("user-1"), {
companies: [makeCompany("company-1")],
unauthorized: false,
});
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={(companyId) => seen.push(companyId)} />
</CompanyProvider>
</QueryClientProvider>,
);
});
await flushReact();
expect(seen).toEqual([null, "company-1"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
}
it("drops the previous account's selection and re-reads the list", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
let resolveSecondList: ((companies: Company[]) => void) | null = null;
mockCompaniesApi.list.mockImplementation(
() =>
new Promise<Company[]>((resolve) => {
resolveSecondList = resolve;
}),
);
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
// Nothing from the previous account is exposed while the new list loads.
expect(seen).toEqual([null, "company-1", null]);
expect(queryClient.getQueryData(queryKeys.companies.list("user-2"))).toBeUndefined();
// The previous account's entry may still sit in the cache; keying by
// account is what makes that harmless, since nothing reads it now.
expect(queryClient.getQueryData(queryKeys.companies.list("user-1"))).toMatchObject({
companies: [{ id: "company-1" }],
});
expect(resolveSecondList).not.toBeNull();
// The replacement fetch must not be coalesced into a `/companies` request
// issued under the previous session.
expect(mockCompaniesApi.detachInflightList).toHaveBeenCalled();
await act(async () => {
resolveSecondList?.([makeCompany("company-2")]);
await Promise.resolve();
});
await flushReact();
expect(seen).toEqual([null, "company-1", null, "company-2"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-2");
});
// The replacement fetch is the only thing standing between the account
// change and a usable app: the cache entry has already been removed, so a
// failure here leaves the tab with no list and no selection. It must not
// also leave it with no way out.
it("recovers the list when the replacement fetch fails and is retried", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
mockCompaniesApi.list.mockRejectedValue(new Error("network down"));
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
// Past the bounded retries, so the failure has actually settled.
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
});
await flushReact();
// The previous account's company is gone and nothing took its place.
expect(seen).toEqual([null, "company-1", null]);
// Reported as unavailable, not as an account that owns nothing. Without
// this the switcher renders "No companies", which is a false claim.
expect(captured?.companyListUnavailable).toBe(true);
expect(captured?.companies).toEqual([]);
// The recovery path actually recovers.
mockCompaniesApi.list.mockResolvedValue([makeCompany("company-2")]);
await act(async () => {
await captured?.retryCompanies();
});
await flushReact();
expect(captured?.companyListUnavailable).toBe(false);
expect(seen).toEqual([null, "company-1", null, "company-2"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-2");
});
// A transient blip must not need the customer to notice and click anything.
// Nothing configures a retry — the second attempt is the observer's own,
// issued when it rebinds to a fresh query after the removal above. This
// pins that, so the recovery affordance stays reserved for real outages
// rather than becoming the only way back from a one-off blip.
it("rides out a single failed replacement request without help", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
mockCompaniesApi.list
.mockRejectedValueOnce(new Error("blip"))
.mockResolvedValue([makeCompany("company-2")]);
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
});
await flushReact();
expect(captured?.companyListUnavailable).toBe(false);
expect(seen).toEqual([null, "company-1", null, "company-2"]);
});
// The failure flag must not outlive the failure. A later success — a focus
// refetch, an invalidation from anywhere — settles the question, including
// when the honest answer is an empty list. Otherwise an account that owns
// nothing reads as "couldn't load", behind a retry that cannot change it.
it("stops reporting the list as unavailable once any fetch succeeds", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
mockCompaniesApi.list.mockRejectedValue(new Error("network down"));
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
});
await flushReact();
expect(captured?.companyListUnavailable).toBe(true);
// This account genuinely owns no companies, and the request says so.
mockCompaniesApi.list.mockResolvedValue([]);
await act(async () => {
await queryClient.refetchQueries({ queryKey: queryKeys.companies.list("user-2") });
});
await flushReact();
expect(captured?.companies).toEqual([]);
expect(captured?.companyListUnavailable).toBe(false);
});
// The guarantee the account-keyed entry buys, stated directly: a list sitting
// in the cache for another account is not merely distrusted, it is not
// reachable. No gate, no freshness check, no refetch stands between the two —
// they are different entries.
it("cannot read another account's cached list at all", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
// user-2's list is slow. If the entries were shared, the cached user-1
// list would answer in the meantime — which is the whole bug.
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
expect(captured?.companies).toEqual([]);
expect(captured?.selectedCompanyId).toBeNull();
expect(captured?.loading).toBe(true);
// Still cached, still keyed to whom it belongs, still unread.
expect(queryClient.getQueryData(queryKeys.companies.list("user-1"))).toMatchObject({
companies: [{ id: "company-1" }],
});
});
// A session request that failed answers nothing. Keying the list to
// `anonymous` on that basis would write an authenticated response — the
// request still carries the cookie — into the entry every signed-out reader
// trusts, which is the original bug rebuilt for anonymous.
//
// Cold cache on purpose: after a prior success React Query retains the old
// session `data` through a failed refetch, so the identity survives anyway
// and the bug does not show. It needs a session that never answered.
it("does not treat a never-answered session as a signed-out session", async () => {
mockAuthApi.getSession.mockRejectedValue(new Error("network down"));
localStorage.setItem("paperclip.selectedCompanyId", "company-1");
mockCompaniesApi.list.mockResolvedValue([makeCompany("company-1")]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={() => undefined} />
</CompanyProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
// Nothing asked for a list it could not attribute, and nothing landed in
// the anonymous entry.
expect(mockCompaniesApi.list).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.companies.list(null))).toBeUndefined();
// And the stored company survives: unavailable is not "owns nothing".
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
});
it("leaves the selection alone when the same account is observed again", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
const listCallsAfterBoot = mockCompaniesApi.list.mock.calls.length;
// A session refetch returns an equal-but-new object for the same account.
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-1"));
});
await flushReact();
expect(seen).toEqual([null, "company-1"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
expect(mockCompaniesApi.list.mock.calls.length).toBe(listCallsAfterBoot);
});
});
});