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:
Linus Rath
2026-10-28 06:39:15 +01:00
parent 2da1aa48db
commit 1d5091f68b
4 changed files with 204 additions and 0 deletions
@@ -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);
}
+19
View File
@@ -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');
});
});
+85
View File
@@ -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]);
}