feat: mount the global modals inside UpgradeDialogProvider so the mailbox-allowance dialog can offer the plan that lifts the cap instead of throwing UpgradeDialogProvider not found, and assert the provider boundary so a sibling cannot drift back outside it

This commit is contained in:
Matthew Meszaros
2026-09-20 20:08:24 +02:00
parent e919d415b0
commit eaa7cfb129
2 changed files with 65 additions and 7 deletions
+50
View File
@@ -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 = [
"<TagsModal />",
"<FoldersModal />",
"<AddEmailModal />",
"<ComposeWindow />",
"<PasskeyEnrollPrompt />",
"<PermissionDeniedModal />",
"<ReauthModal />",
];
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("<UpgradeDialogProvider>", "</UpgradeDialogProvider>");
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("<ConfirmProvider>", "</ConfirmProvider>");
for (const modal of GLOBAL_MODALS) {
expect(inside, `${modal} is mounted outside ConfirmProvider`).toContain(modal);
}
});
});
+15 -7
View File
@@ -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 <UserProvider>
<DataSyncProvider>
<ConfirmProvider>
@@ -51,14 +59,14 @@ export default function RootAppLayout() {
</RealtimeManager>
</SocketProvider>
</LinkProvider>
<TagsModal />
<FoldersModal />
<AddEmailModal />
<ComposeWindow />
<PasskeyEnrollPrompt />
<PermissionDeniedModal />
<ReauthModal />
</UpgradeDialogProvider>
<TagsModal />
<FoldersModal />
<AddEmailModal />
<ComposeWindow />
<PasskeyEnrollPrompt />
<PermissionDeniedModal />
<ReauthModal />
</ConfirmProvider>
</DataSyncProvider>
</UserProvider>