diff --git a/components/mail/__tests__/mail-app-composer-replace.test.tsx b/components/mail/__tests__/mail-app-composer-replace.test.tsx
new file mode 100644
index 000000000..f56066c13
--- /dev/null
+++ b/components/mail/__tests__/mail-app-composer-replace.test.tsx
@@ -0,0 +1,174 @@
+import React from 'react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { useToastStore } from '@/stores/toast-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+
+// One composer at a time (compose-p2-1, compose-p2-2). Another one opening
+// while a composer is on screen closes that one the way its X does (save,
+// "Draft saved"), then opens. It never crashes and never drops the text.
+
+const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
+// What the stub composer's close does: 'saves' closes and lets the next one
+// open, 'fails' keeps the composer (a save the server refused).
+const stub = vi.hoisted(() => ({
+ close: 'none' as 'none' | 'saves' | 'fails',
+ closes: [] as string[],
+ mounts: [] as string[],
+}));
+
+vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
+vi.mock('@/components/layout/sidebar', () => ({
+ Sidebar: ({ onCompose }: { onCompose: () => void }) => ,
+}));
+// As the real composer: it hands its state (its own mode, untouched) to the
+// host as it unmounts without an explicit close, and offers its close.
+vi.mock('@/components/email/email-composer', async () => {
+ const { useEffect, useRef } = await import('react');
+ return {
+ EmailComposer: ({ mode, onSaveState, onClose, requestCloseRef }: {
+ mode: string;
+ onSaveState: (data: Record) => void;
+ onClose: () => void;
+ requestCloseRef?: { current: unknown };
+ }) => {
+ const closed = useRef(false);
+ useEffect(() => {
+ stub.mounts.push(mode);
+ // The old host moved the composer between dock and pane for ever
+ // (React error 185 in the browser); stop it here instead of hanging.
+ if (stub.mounts.length > 40) throw new Error('composer remount loop');
+ if (requestCloseRef && stub.close !== 'none') {
+ requestCloseRef.current = (afterClose?: () => void) => {
+ stub.closes.push(mode);
+ if (stub.close === 'fails') return;
+ closed.current = true;
+ onClose();
+ afterClose?.();
+ };
+ }
+ return () => {
+ if (requestCloseRef) requestCloseRef.current = null;
+ if (closed.current) return;
+ onSaveState({ to: '', cc: '', bcc: '', subject: '', body: '', mode, draftId: null, unedited: true });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return