From cd685d41b3c83eaa0cd26f329b89960108db434f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:36:14 +0100 Subject: [PATCH] fix: keep unsaved admin edits when a window or tablet crosses the desktop breakpoint --- .../__tests__/admin-shell-resize.test.tsx | 58 +++++++++++++++++++ components/admin/admin-shell.tsx | 20 ++++++- 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 components/admin/__tests__/admin-shell-resize.test.tsx diff --git a/components/admin/__tests__/admin-shell-resize.test.tsx b/components/admin/__tests__/admin-shell-resize.test.tsx new file mode 100644 index 000000000..6a3863e84 --- /dev/null +++ b/components/admin/__tests__/admin-shell-resize.test.tsx @@ -0,0 +1,58 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('next/navigation', () => ({ usePathname: () => '/admin/settings' })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/settings/about-data-settings', () => ({ AboutFooter: () => null })); +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(() => Promise.resolve({ ok: false })), + getPathPrefix: () => '', + withBasePath: (href: string) => href, +})); + +import { AdminShell } from '../admin-shell'; + +// Turning a tablet round crosses the breakpoint between the shell's two +// layouts. The page used to be remounted there, so what the admin had typed +// and not yet saved was gone without a word. +describe('AdminShell across the desktop breakpoint', () => { + function mockWidth(initial: boolean) { + let desktop = initial; + const listeners = new Set<() => void>(); + window.matchMedia = vi.fn((query: string) => ({ + get matches() { return desktop && query === '(width >= 64rem)'; }, + media: query, + addEventListener: (_: string, cb: () => void) => listeners.add(cb), + removeEventListener: (_: string, cb: () => void) => listeners.delete(cb), + })) as unknown as typeof window.matchMedia; + return (next: boolean) => act(() => { + desktop = next; + listeners.forEach((cb) => cb()); + }); + } + + function Page({ onMount }: { onMount: () => void }) { + const [value, setValue] = useState('saved'); + useEffect(onMount, [onMount]); + return setValue(e.target.value)} />; + } + + it('keeps the page mounted, with its edits, in both directions', () => { + Element.prototype.scrollTo = () => {}; + const setDesktop = mockWidth(true); + const onMount = vi.fn(); + render( + {}}> + + , + ); + fireEvent.change(screen.getByRole('textbox', { name: 'field' }), { target: { value: 'edited' } }); + + setDesktop(false); + expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue('edited'); + setDesktop(true); + expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue('edited'); + expect(onMount).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index 5343afe10..55a277d1a 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { usePathname } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { ArrowLeft, ChevronRight, KeyRound, LogOut, Search, Shield, X } from '@/components/icons'; @@ -62,6 +63,15 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, const [pendingApprovals, setPendingApprovals] = useState(0); const [pendingSection, setPendingSection] = useState<{ page: AdminPageId; id: string } | null>(null); const scrollRef = useRef(null); + // The two layouts are different trees, so a page drawn inside them would be + // remounted when the window crosses the breakpoint (a tablet turned round) + // and lose what the admin had typed. It lives in one element instead, which + // moves into whichever layout is showing. The portal comes after the layout + // so the element is in the document before the page's effects run. + const [pageHost] = useState(() => (typeof document === 'undefined' ? null : document.createElement('div'))); + const pageSlot = useCallback((slot: HTMLDivElement | null) => { + if (slot && pageHost) slot.appendChild(pageHost); + }, [pageHost]); const onChangePassword = pathname === '/admin/change-password'; const activePage: AdminPageId | null = location.page ?? (isDesktop && !onChangePassword ? 'dashboard' : null); @@ -257,8 +267,10 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, ); } + const showIndex = location.page === null && !onChangePassword; + const page = pageHost && (isDesktop || !showIndex) ? createPortal(children, pageHost) : null; + if (!isDesktop) { - const showIndex = location.page === null && !onChangePassword; const title = onChangePassword ? 'Change password' : activeDef?.label ?? 'Admin'; const backHref = location.detail ? adminPath(location.page) : '/admin'; return ( @@ -346,7 +358,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, ) : ( {readOnlyBanner} - {children} + )} @@ -355,6 +367,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, + {page} ); } @@ -445,12 +458,13 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, {readOnlyBanner} - {children} + + {page} ); }