diff --git a/web/src/app/app/layout.test.ts b/web/src/app/app/layout.test.ts new file mode 100644 index 000000000..4fc3263a9 --- /dev/null +++ b/web/src/app/app/layout.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// The global modals are siblings of AppLayout, not children of it, and they sit +// lower in the file than the provider that has to reach them. That makes it +// easy to close a provider above them and not notice: nothing fails to compile, +// nothing fails to render, and the throw only arrives when someone opens the +// one modal that uses the context. +// +// MailboxAllowanceDialog, reached through AddEmailModal, calls useUpgradeDialog +// to offer the plan that lifts the mailbox cap. With it outside the provider +// that threw "UpgradeDialogProvider not found" at the exact moment a customer +// hit their allowance. +const source = readFileSync(join(__dirname, "layout.tsx"), "utf8"); + +const GLOBAL_MODALS = [ + "", + "", + "", + "", + "", + "", + "", +]; + +function between(open: string, close: string): string { + const start = source.indexOf(open); + const end = source.indexOf(close); + expect(start, `${open} missing from layout.tsx`).toBeGreaterThan(-1); + expect(end, `${close} missing from layout.tsx`).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe("the /app provider tree", () => { + it("keeps every global modal inside UpgradeDialogProvider", () => { + const inside = between("", ""); + for (const modal of GLOBAL_MODALS) { + expect(inside, `${modal} is mounted outside UpgradeDialogProvider`).toContain(modal); + } + }); + + it("keeps every global modal inside ConfirmProvider", () => { + // Their action handlers call useConfirm(); this is the same trap. + const inside = between("", ""); + for (const modal of GLOBAL_MODALS) { + expect(inside, `${modal} is mounted outside ConfirmProvider`).toContain(modal); + } + }); +}); diff --git a/web/src/app/app/layout.tsx b/web/src/app/app/layout.tsx index 3a8c38871..5ccc9c8f3 100644 --- a/web/src/app/app/layout.tsx +++ b/web/src/app/app/layout.tsx @@ -29,6 +29,14 @@ export default function RootAppLayout() { // inside their action handlers (delete confirmations etc.). They // need UserProvider too (state lives there: tagsEdit, foldersEdit, // addEmail), so they sit between the two providers. + // + // They are inside UpgradeDialogProvider for the same class of reason, and + // it is easy to get wrong: they render below AppLayout in the file but are + // siblings of it, so a provider that wraps only AppLayout does not reach + // them. AddEmailModal renders MailboxAllowanceDialog, which calls + // useUpgradeDialog to offer the plan that lifts the cap, so leaving it + // outside threw the moment someone hit their mailbox allowance -- exactly + // when the upgrade path is what they needed. return @@ -51,14 +59,14 @@ export default function RootAppLayout() { + + + + + + + - - - - - - -