) => 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 stub composer
;
+ },
+ };
+});
+vi.mock('@/components/search/mv-search-form', () => ({
+ MvSearchForm: ({ onSearch }: { onSearch: () => void }) => ,
+}));
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: ({ onReply, onReplyAll, onForward }: { onReply: () => void; onReplyAll: () => void; onForward: () => void }) => (
+
+ message viewer
+
+
+
+
+ ),
+}));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => device.current,
+ useMediaQuery: () => false,
+ useIsDesktop: () => true,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+vi.mock('@/components/email/email-list', () => ({
+ EmailList: () => null,
+}));
+
+const { MailApp } = await import('../mail-app');
+
+const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
+const MESSAGE = {
+ id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }],
+ subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10,
+ hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {},
+} as never;
+
+beforeEach(() => {
+ device.current = { isMobile: false, isTablet: false, isDesktop: true };
+ stub.close = 'none';
+ stub.closes = [];
+ stub.mounts = [];
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ const client = {
+ getAccountId: () => 'acc', getUsername: () => 'a@example.org',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ getEmail: vi.fn().mockResolvedValue(null),
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
+ getClientForAccount: () => client as never,
+ getAllConnectedClients: () => new Map([['login-a', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
+ useEmailStore.setState({
+ emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
+ selectedEmailKeys: new Set(), unreadSection: null,
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ } as never);
+ useToastStore.getState().clearToasts();
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ useToastStore.getState().clearToasts();
+});
+
+const composerMode = () => screen.getByTestId('stub-composer').getAttribute('data-mode');
+
+describe('Compose while a reply is docked (compose-p2-1)', () => {
+ for (const action of ['stub reply', 'stub forward']) {
+ it(`opens a new message in the pane after ${action}`, async () => {
+ render();
+ fireEvent.click(await screen.findByText(action));
+ const reply = await screen.findByTestId('stub-composer');
+ expect(reply.closest('[data-reply-dock]')).not.toBeNull();
+
+ fireEvent.click(screen.getByText('stub compose'));
+ await waitFor(() => expect(composerMode()).toBe('compose'));
+ // The new message takes the pane, not the dock, and stays put.
+ expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).toBeNull();
+ const mounted = stub.mounts.length;
+ await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
+ expect(stub.mounts.length).toBe(mounted);
+ expect(composerMode()).toBe('compose');
+ });
+ }
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index bdc888cfc..1ea598b03 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -304,7 +304,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
composerSessionRef.current = composerSessionId;
const composerHostRef = useRef('pane');
// The compose session docked under its message (dockedReply).
- const dockedSessionRef = useRef(null);
+ const [dockedSession, setDockedSession] = useState(null);
const showComposerRef = useRef(showComposer);
showComposerRef.current = showComposer;
// Fields a plugin filled in through onBeforeCompose for the composer about
@@ -4923,8 +4923,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// anew when the message goes away (another folder opened meanwhile).
const dockCandidate = !mvShell && !isMobile && showComposer && !isEmbedded && !floatingComposer
&& (pendingDraft?.mode ?? composerMode) !== 'compose';
- if (dockCandidate && selectedEmail && !selectedEmail.keywords?.$draft) dockedSessionRef.current = composerSessionId;
- const dockedReply = dockCandidate && dockedSessionRef.current === composerSessionId;
+ const dockOnOpen = dockCandidate && Boolean(selectedEmail) && !selectedEmail?.keywords?.$draft;
+ if (dockOnOpen && dockedSession !== composerSessionId) setDockedSession(composerSessionId);
+ const dockedReply = dockCandidate && (dockOnOpen || dockedSession === composerSessionId);
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane';
composerHostRef.current = composerHost;
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
@@ -5275,6 +5276,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
sessionKey={`inline-${composerSessionId}`}
sourceEmailId={answeredEmailId}
onSaveState={(data) => {
+ // A composer whose session another one has replaced: its state
+ // belongs to no composer on screen. Taken as the new session's,
+ // it turned a new message into the old reply and moved it between
+ // the dock and the pane for ever (React error 185, compose-p2-1).
+ // Whatever opens a composer closes the old one first, which saves it.
+ if (composerSessionRef.current !== composerSessionId) return;
setComposerPrefill(null);
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
suppressComposerStateSaveSessionRef.current = null;