diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 7abff772a..6b231359e 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -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 | 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); } diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 35c21002d..f584a8340 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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; diff --git a/hooks/__tests__/use-close-on-back.test.tsx b/hooks/__tests__/use-close-on-back.test.tsx new file mode 100644 index 000000000..54b037869 --- /dev/null +++ b/hooks/__tests__/use-close-on-back.test.tsx @@ -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 | null)?.__closeOnBack; + +async function openOverlay(options: Partial[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'); + }); +}); diff --git a/hooks/use-close-on-back.ts b/hooks/use-close-on-back.ts new file mode 100644 index 000000000..9ac8bf1e7 --- /dev/null +++ b/hooks/use-close-on-back.ts @@ -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 | 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 | 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 | 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 | null)?.[STATE_KEY] !== entryId) return; + const url = window.location.href; + pendingBack = new Promise((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]); +}