diff --git a/src/app/(app)/search/page.test.tsx b/src/app/(app)/search/page.test.tsx
index 627a688..ceb1541 100644
--- a/src/app/(app)/search/page.test.tsx
+++ b/src/app/(app)/search/page.test.tsx
@@ -138,6 +138,12 @@ describe("UniversalSearchPage — the never-hide-a-cap invariant", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
+ // The session-expiry test swaps the pocketbase module's default
+ // export for a token-less one. Without restoring it that stub
+ // leaks into every later test, which then render no results at all
+ // and fail for a reason that has nothing to do with what they
+ // assert.
+ vi.restoreAllMocks();
});
async function type(value: string) {
@@ -218,4 +224,57 @@ describe("UniversalSearchPage — the never-hide-a-cap invariant", () => {
expect(screen.getByText(/session has expired/i)).toBeInTheDocument(),
);
});
+});
+
+describe("UniversalSearchPage — results open in a new tab", () => {
+ beforeEach(() => {
+ fetchMock.mockReset();
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ async function type(value: string) {
+ const input = screen.getByPlaceholderText(/Order no, quote no/i);
+ fireEvent.change(input, { target: { value } });
+ await vi.advanceTimersByTimeAsync(400);
+ }
+
+ it("uses a real anchor with target=_blank, not a client-side route change", async () => {
+ // next/link unmounts this page on navigation, which destroys the
+ // query and the result set. A plain anchor in a new tab leaves the
+ // search tab mounted, so clicking away and back returns to exactly
+ // where you were — the behaviour the operator asked for.
+ fetchMock.mockResolvedValue(jsonResponse(RESULT));
+
+ render();
+ await type("truco");
+
+ const link = await screen.findByText("ORD-1");
+ const anchor = link.closest("a");
+
+ expect(anchor).not.toBeNull();
+ expect(anchor).toHaveAttribute("href", "/orders/o1");
+ expect(anchor).toHaveAttribute("target", "_blank");
+ // Required: without noopener the opened page can reach back through
+ // window.opener.
+ expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
+ });
+
+ it("keeps the query in the input after a result is opened", async () => {
+ // The whole point of the new tab: returning to this tab must not
+ // have reset the search.
+ fetchMock.mockResolvedValue(jsonResponse(RESULT));
+
+ render();
+ await type("truco");
+
+ await screen.findByText("ORD-1");
+ expect(
+ (screen.getByPlaceholderText(/Order no, quote no/i) as HTMLInputElement).value,
+ ).toBe("truco");
+ });
});
\ No newline at end of file
diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx
index 84fecbf..9755c23 100644
--- a/src/app/(app)/search/page.tsx
+++ b/src/app/(app)/search/page.tsx
@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import Link from "next/link";
import { Search as SearchIcon, X, Loader2, AlertTriangle, ArrowUpRight } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -282,8 +281,30 @@ export default function UniversalSearchPage() {
{group.hits.map((hit) => (
- , not
+ next/link.
+
+ next/link does a CLIENT-SIDE route change, which
+ unmounts this page — so the query, the expanded
+ result set and the scroll position are gone when
+ you come back, and "going back" means a router
+ back that re-runs the search anyway.
+
+ A real anchor with target="_blank" opens the record
+ in a new tab and leaves this tab mounted and
+ untouched. Clicking away and back returns to
+ exactly where you were, results and all.
+
+ rel="noopener noreferrer" is required: without it
+ the opened page gets a handle on this window via
+ window.opener.
+ */}
+