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() {
+
+
+
+
+
+
+
-
-
-
-
-
-
-