mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
fix: close the event or task editor on browser back instead of leaving the calendar, asking first when it has changes
This commit is contained in:
@@ -254,6 +254,52 @@ describe('CalendarApp unsaved editor changes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CalendarApp browser Back with an editor open (R5)', () => {
|
||||
const backEntry = () => (window.history.state as Record<string, unknown> | null)?.__closeOnBack;
|
||||
// Earlier tests leave history behind: wait for this editor's own entry.
|
||||
const openEditorEntry = async () => {
|
||||
const before = backEntry();
|
||||
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
|
||||
await waitFor(() => expect(backEntry()).toBeTruthy());
|
||||
await waitFor(() => expect(backEntry()).not.toBe(before));
|
||||
};
|
||||
|
||||
it('closes an untouched editor instead of leaving the calendar', async () => {
|
||||
renderApp();
|
||||
await openEditorEntry();
|
||||
window.history.back();
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull());
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(backEntry()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('asks before Back drops changes, and asks again after Keep editing', async () => {
|
||||
renderApp();
|
||||
await openEditorEntry();
|
||||
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Lunch' } });
|
||||
window.history.back();
|
||||
const ask = await screen.findByRole('alertdialog', { name: 'discard.title' });
|
||||
fireEvent.click(within(ask).getByRole('button', { name: 'discard.keep' }));
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
|
||||
expect(screen.getByPlaceholderText('form.title')).toHaveValue('Lunch');
|
||||
// The entry is back, so the next Back asks again
|
||||
expect(backEntry()).toBeTruthy();
|
||||
|
||||
window.history.back();
|
||||
const again = await screen.findByRole('alertdialog', { name: 'discard.title' });
|
||||
fireEvent.click(within(again).getByRole('button', { name: 'discard.confirm' }));
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull());
|
||||
await waitFor(() => expect(backEntry()).toBeUndefined());
|
||||
});
|
||||
|
||||
it('takes its history entry back off when the editor closes another way', async () => {
|
||||
renderApp();
|
||||
await openEditorEntry();
|
||||
fireEvent.click(screen.getByText('form.cancel'));
|
||||
await waitFor(() => expect(backEntry()).toBeUndefined());
|
||||
});
|
||||
});
|
||||
|
||||
function toastTitles(): string[] {
|
||||
return useToastStore.getState().toasts.map((t) => t.title);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links";
|
||||
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import {
|
||||
@@ -942,6 +943,24 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
: proInterfaceActive ? null : calendarLinkPath,
|
||||
);
|
||||
|
||||
// Browser Back closes the event or task editor (asking first when it has
|
||||
// unsaved changes) instead of leaving the calendar (R5).
|
||||
const closeEditors = useCallback(() => {
|
||||
setShowEventModal(false);
|
||||
setEditEvent(null);
|
||||
setPendingPreview(null);
|
||||
setDefaultCalendarIdForCreate(undefined);
|
||||
setDefaultModalAllDay(false);
|
||||
setShowTaskModal(false);
|
||||
setEditTask(null);
|
||||
}, []);
|
||||
useCloseOnBack({
|
||||
open: showEventModal || showTaskModal,
|
||||
enabled: isEmbedded ? isFocusedProTab : !proInterfaceActive,
|
||||
shouldHold: () => eventEditorDirtyRef.current || taskEditorDirtyRef.current,
|
||||
onBack: () => whenEditorsDiscarded(closeEditors),
|
||||
});
|
||||
|
||||
const handleEditFromDetail = useCallback(() => {
|
||||
if (detailEvent) {
|
||||
const ev = detailEvent;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useCloseOnBack } from '../use-close-on-back';
|
||||
|
||||
const entry = () => (window.history.state as Record<string, unknown> | null)?.__closeOnBack;
|
||||
|
||||
async function openOverlay(options: Partial<Parameters<typeof useCloseOnBack>[0]> = {}) {
|
||||
const before = entry();
|
||||
const onBack = vi.fn();
|
||||
const view = renderHook((props: { open: boolean }) => useCloseOnBack({ onBack, ...options, open: props.open }), {
|
||||
initialProps: { open: true },
|
||||
});
|
||||
await waitFor(() => expect(entry()).toBeTruthy());
|
||||
await waitFor(() => expect(entry()).not.toBe(before));
|
||||
return { ...view, onBack };
|
||||
}
|
||||
|
||||
describe('useCloseOnBack (R5)', () => {
|
||||
it('calls onBack when Back leaves the overlay entry', async () => {
|
||||
const { onBack, unmount } = await openOverlay();
|
||||
window.history.back();
|
||||
await waitFor(() => expect(onBack).toHaveBeenCalledTimes(1));
|
||||
expect(entry()).toBeUndefined();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('puts the entry back while the overlay holds', async () => {
|
||||
const { onBack, unmount } = await openOverlay({ shouldHold: () => true });
|
||||
window.history.back();
|
||||
await waitFor(() => expect(onBack).toHaveBeenCalledTimes(1));
|
||||
expect(entry()).toBeTruthy();
|
||||
unmount();
|
||||
await waitFor(() => expect(entry()).toBeUndefined());
|
||||
});
|
||||
|
||||
it('removes its entry when the overlay closes another way, keeping the latest URL', async () => {
|
||||
const { rerender } = await openOverlay();
|
||||
// The page rewrites the URL while the overlay is open
|
||||
window.history.replaceState(window.history.state, '', '/calendar/week/2026-09-14');
|
||||
rerender({ open: false });
|
||||
await waitFor(() => expect(entry()).toBeUndefined());
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/calendar/week/2026-09-14'));
|
||||
});
|
||||
|
||||
it('does not undo a navigation that pushed its own entry', async () => {
|
||||
const { unmount } = await openOverlay();
|
||||
// The router pushes in the same commit that unmounts the overlay
|
||||
unmount();
|
||||
window.history.pushState({ other: true }, '', '/mail');
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(window.history.state).toEqual({ other: true });
|
||||
expect(window.location.pathname).toBe('/mail');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const STATE_KEY = "__closeOnBack";
|
||||
|
||||
// Closing one overlay leaves a Back traversal in flight; the next one must
|
||||
// not push its entry until that has landed, or the Back would pop it.
|
||||
let pendingBack: Promise<void> | null = null;
|
||||
|
||||
interface UseCloseOnBackOptions {
|
||||
/** Whether the overlay is open: it holds a history entry while it is. */
|
||||
open: boolean;
|
||||
/** Called when browser Back (or the phone's back gesture) leaves the entry. */
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Whether Back should stay on the overlay for now, e.g. an editor with
|
||||
* unsaved changes that asks first: the entry is put back, so a later Back
|
||||
* asks again, and `onBack` decides whether to close.
|
||||
*/
|
||||
shouldHold?: () => boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser Back closes an overlay instead of leaving the page (R5). The
|
||||
* overlay gets a history entry of its own while it is open; closing it any
|
||||
* other way takes that entry back off, so no stray steps are left for Back
|
||||
* to walk through. The URL is left alone: the entry copies the current one,
|
||||
* Next's router state included, and after the clean-up Back the address bar
|
||||
* is set to whatever the page showed last.
|
||||
*/
|
||||
export function useCloseOnBack({ open, onBack, shouldHold, enabled = true }: UseCloseOnBackOptions) {
|
||||
const onBackRef = useRef(onBack);
|
||||
onBackRef.current = onBack;
|
||||
const shouldHoldRef = useRef(shouldHold);
|
||||
shouldHoldRef.current = shouldHold;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !enabled || typeof window === "undefined") return;
|
||||
const entryId = generateUUID();
|
||||
let cancelled = false;
|
||||
let pushed = false;
|
||||
const push = () => {
|
||||
window.history.pushState({ ...window.history.state, [STATE_KEY]: entryId }, "");
|
||||
pushed = true;
|
||||
};
|
||||
const onPop = () => {
|
||||
if ((window.history.state as Record<string, unknown> | null)?.[STATE_KEY] === entryId) return;
|
||||
pushed = false;
|
||||
if (shouldHoldRef.current?.()) push();
|
||||
onBackRef.current();
|
||||
};
|
||||
// Deferred so Strict Mode's setup/cleanup/setup doesn't push and pop.
|
||||
const timer = window.setTimeout(async () => {
|
||||
if (pendingBack) await pendingBack;
|
||||
if (cancelled) return;
|
||||
push();
|
||||
window.addEventListener("popstate", onPop);
|
||||
}, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("popstate", onPop);
|
||||
if (!pushed || (window.history.state as Record<string, unknown> | null)?.[STATE_KEY] !== entryId) return;
|
||||
// After this commit's effects, so the URL the page writes for the
|
||||
// closed state is the one restored once Back has landed.
|
||||
queueMicrotask(() => {
|
||||
// A navigation in the same commit (leaving the calendar through the
|
||||
// rail) has pushed its own entry by now: that one must not be undone.
|
||||
if ((window.history.state as Record<string, unknown> | null)?.[STATE_KEY] !== entryId) return;
|
||||
const url = window.location.href;
|
||||
pendingBack = new Promise<void>((resolve) => {
|
||||
window.addEventListener("popstate", () => {
|
||||
if (window.location.href !== url) window.history.replaceState(window.history.state, "", url);
|
||||
pendingBack = null;
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
window.history.back();
|
||||
});
|
||||
};
|
||||
}, [open, enabled]);
|
||||
}
|
||||
Reference in New Issue
Block a user