diff --git a/tests/e2e/archived-company-url.spec.ts b/tests/e2e/archived-company-url.spec.ts new file mode 100644 index 0000000000..87d362a737 --- /dev/null +++ b/tests/e2e/archived-company-url.spec.ts @@ -0,0 +1,67 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Regression: landing on an archived company's URL crashed the app with React + * error #185. The Layout route-sync matched the URL against the full company + * list and selected the archived company; the CompanyProvider bootstrap + * resolver only accepted non-archived companies and immediately re-selected + * an active one; the two effects ping-ponged the selection until React blew + * the nested-update limit and unmounted the tree. + * + * Field shape: a workspace whose seeded primary company was archived — every + * revisit of its remembered `/PREFIX/...` URL (first load and back/forward + * navigations alike) produced a blank page. + */ + +async function createCompany(page: Page, name: string): Promise<{ id: string; prefix: string }> { + const res = await page.request.post("/api/companies", { data: { name } }); + expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true); + const company = await res.json(); + return { id: company.id, prefix: company.issuePrefix ?? company.prefix ?? "E2E" }; +} + +function collectFatalErrors(page: Page): string[] { + const fatal: string[] = []; + page.on("pageerror", (err) => { + fatal.push(`PAGEERROR: ${err.message}`); + }); + page.on("console", async (msg) => { + if (msg.type() !== "error") return; + const text = msg.text(); + if (!/App shell crashed|Page render failed|Minified React error #185|Maximum update depth/.test(text)) { + return; + } + fatal.push(`CONSOLE: ${text.slice(0, 300)}`); + }); + return fatal; +} + +test("landing on an archived company's URL does not crash the app", async ({ page }) => { + const fatal = collectFatalErrors(page); + + const active = await createCompany(page, "Archived Loop Active"); + const archived = await createCompany(page, "Archived Loop Archived"); + const archiveRes = await page.request.patch(`/api/companies/${archived.id}`, { + data: { status: "archived" }, + }); + expect(archiveRes.ok(), `archive failed ${archiveRes.status()}: ${await archiveRes.text()}`).toBe(true); + + // Field repro #1: a fresh load straight onto the archived company's + // remembered URL (the first-open blank screen). + await page.goto(`/${archived.prefix}/dashboard`); + await page.waitForTimeout(2_500); + expect(fatal, `crash on direct load of archived company URL:\n${fatal.join("\n")}`).toEqual([]); + + // Field repro #2: visit the active company, then return to the archived + // company's URL with client-side history navigation (the "switched back" + // crash). + await page.goto(`/${active.prefix}/dashboard`); + await page.waitForLoadState("networkidle"); + await page.goBack(); + await page.waitForTimeout(2_500); + expect(fatal, `crash on back-navigation to archived company URL:\n${fatal.join("\n")}`).toEqual([]); + + // The app must have landed somewhere real, not a blank unmounted root. + const rootContent = await page.locator("#root").innerText().catch(() => ""); + expect(rootContent.length).toBeGreaterThan(0); +}); diff --git a/ui/src/context/CompanyContext.test.tsx b/ui/src/context/CompanyContext.test.tsx index cb4ffd4a20..7faad85a4f 100644 --- a/ui/src/context/CompanyContext.test.tsx +++ b/ui/src/context/CompanyContext.test.tsx @@ -107,6 +107,28 @@ describe("resolveBootstrapCompanySelection", () => { 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", () => { diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index 19587adb5f..c653fd11af 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -46,7 +46,15 @@ export function resolveBootstrapCompanySelection(input: { const selectableCompanies = input.sidebarCompanies.length > 0 ? input.sidebarCompanies : input.companies; - if (input.selectedCompanyId && selectableCompanies.some((company) => company.id === input.selectedCompanyId)) { + // An already-selected company only needs to EXIST — not to be featured in + // the sidebar. The Layout route-sync selects whatever company the URL names + // (archived included, since archived pages are still routable); if this + // resolver vetoed that selection against the sidebar-filtered list, the two + // effects would re-select against each other forever and blow React's + // nested-update limit (the archived-company blank-screen crash). The + // sidebar filter keeps shaping fresh boots below, where no explicit + // selection exists yet. + if (input.selectedCompanyId && input.companies.some((company) => company.id === input.selectedCompanyId)) { return input.selectedCompanyId; } if (input.storedCompanyId && selectableCompanies.some((company) => company.id === input.storedCompanyId)) {