diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index 4b88afde7e..d7051f8612 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -46,9 +46,13 @@ export default function LogsPage() { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - // initial id from URL (synchronously on client) so child can open on mount - const initialId = - typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null; + // initial id from URL (synchronously on client) so child can open on mount. + // Read once via lazy state: window.location lags router.replace() by one + // render, so re-reading it on every render flips this prop mid-session and + // re-triggers the child's deep-link effect right when the modal closes. + const [initialId] = useState(() => + typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null + ); async function handleExport(hours: number) { setExporting(true); diff --git a/tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx b/tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx new file mode 100644 index 0000000000..27d418ee46 --- /dev/null +++ b/tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +/** + * TDD regression: dashboard/logs — closing the request-detail modal after the + * FIRST row click immediately reopens it; only the second close works. + * + * Root cause: LogsPage computes `initialId` from `window.location` on EVERY + * render, but Next.js App Router syncs `window.location` only after the + * navigation commits — i.e. after the re-render triggered by + * `router.replace()`. So: + * + * 1. Row click → openDetail → router.replace("?id=X"): page re-renders while + * location is still the old URL → initialId stays null, the child's + * one-shot `initialOpenedRef` guard is never consumed. + * 2. First close → closeDetail → router.replace(no id): page re-renders while + * location STILL carries "?id=X" → initialId flips null → "X" → the child's + * deep-link effect fires (guard still unarmed) → openDetail reopens the + * modal. + * 3. Second close works because the guard is now armed. + * + * The router mock below reproduces that ordering: replace() re-renders the + * page synchronously (like the App Router segment re-render) and the URL is + * committed to window.location separately, after the render — via + * commitPendingUrl(). + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const routerControl = vi.hoisted(() => ({ + pendingUrl: null as string | null, + bumpPageRender: () => {}, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: (url: string) => { + // Mirror App Router ordering: the segment re-renders first, the URL is + // synced to window.location only after the commit (commitPendingUrl()). + routerControl.pendingUrl = url; + routerControl.bumpPageRender(); + }, + push: vi.fn(), + prefetch: vi.fn(), + refresh: vi.fn(), + }), + usePathname: () => "/dashboard/logs", + useSearchParams: () => new URLSearchParams(globalThis.location.search), +})); + +vi.mock("@/store/emailPrivacyStore", () => ({ + default: () => ({ emailsVisible: true }), +})); + +// LogsPage imports { ConfirmModal, RequestLoggerV2 } from the barrel; keep the +// real logger (the component under test) and stub the unrelated ConfirmModal +// so the test doesn't drag the whole barrel into jsdom. +vi.mock("@/shared/components", async () => { + const { default: RequestLoggerV2 } = await import( + "../../../src/shared/components/RequestLoggerV2.tsx" + ); + const ConfirmModal = ({ isOpen }: { isOpen: boolean }) => + isOpen ?
: null; + return { RequestLoggerV2, ConfirmModal }; +}); + +const { default: LogsPage } = await import( + "../../../src/app/(dashboard)/dashboard/logs/page.tsx" +); + +// Stands in for the App Router segment root: router.replace() re-renders the +// whole page tree, which is exactly what re-evaluates LogsPage's initialId. +function Harness() { + const [, setVersion] = React.useState(0); + React.useEffect(() => { + routerControl.bumpPageRender = () => setVersion((v) => v + 1); + return () => { + routerControl.bumpPageRender = () => {}; + }; + }, []); + return