diff --git a/tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts b/tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts new file mode 100644 index 000000000..c9de35996 --- /dev/null +++ b/tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts @@ -0,0 +1,60 @@ +/** + * Next.js compat: layout state across search param changes. + * + * Based on Next.js: test/e2e/app-dir/search-params-react-key/layout-params.test.ts + * https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/search-params-react-key/layout-params.test.ts + * + * Extends the same expectation to a parent client layout rendered by a server layout: + * query-only push/replace should not remount that layout. + */ + +import { expect, test } from "@playwright/test"; +import { waitForAppRouterHydration } from "../../helpers"; + +const BASE = "http://localhost:4174"; + +test.describe("Next.js compat: layout state across search param changes", () => { + test("router.push() keeps parent client layout mounted on query-only navigation", async ({ + page, + }) => { + await page.goto(`${BASE}/nextjs-compat/layout-search-params/demo`); + await waitForAppRouterHydration(page); + + await page.click("#layout-increment"); + await page.click("#layout-increment"); + await expect(page.locator("#layout-count")).toHaveText("2"); + await expect(page.locator("#layout-mount-count")).toHaveText("1"); + + await page.click("#layout-push"); + + await expect(async () => { + expect(page.url()).toContain("foo=bar"); + }).toPass({ timeout: 10_000 }); + + await expect(page.locator("#search-params")).toContainText('"foo":"bar"'); + await expect(page.locator("#layout-count")).toHaveText("2"); + await expect(page.locator("#layout-mount-count")).toHaveText("1"); + }); + + test("router.replace() keeps parent client layout mounted on query-only navigation", async ({ + page, + }) => { + await page.goto(`${BASE}/nextjs-compat/layout-search-params/demo`); + await waitForAppRouterHydration(page); + + await page.click("#layout-increment"); + await page.click("#layout-increment"); + await expect(page.locator("#layout-count")).toHaveText("2"); + await expect(page.locator("#layout-mount-count")).toHaveText("1"); + + await page.click("#layout-replace"); + + await expect(async () => { + expect(page.url()).toContain("foo=baz"); + }).toPass({ timeout: 10_000 }); + + await expect(page.locator("#search-params")).toContainText('"foo":"baz"'); + await expect(page.locator("#layout-count")).toHaveText("2"); + await expect(page.locator("#layout-mount-count")).toHaveText("1"); + }); +}); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/[id]/layout-shell.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/[id]/layout-shell.tsx new file mode 100644 index 000000000..97388c4a6 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/[id]/layout-shell.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +declare global { + interface Window { + __vinextLayoutSearchParamsMountCount__?: number; + } +} + +function LayoutShellInner({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const [count, setCount] = useState(0); + const [mountCount, setMountCount] = useState(0); + + useEffect(() => { + window.__vinextLayoutSearchParamsMountCount__ = + (window.__vinextLayoutSearchParamsMountCount__ ?? 0) + 1; + setMountCount(window.__vinextLayoutSearchParamsMountCount__); + }, []); + + return ( +
+

Layout Search Params

+
{count}
+
{mountCount}
+ + + + {children} +
+ ); +} + +export const LayoutShell = React.memo(LayoutShellInner); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/[id]/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/[id]/layout.tsx new file mode 100644 index 000000000..1f0864871 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/[id]/layout.tsx @@ -0,0 +1,17 @@ +import { LayoutShell } from "./layout-shell"; + +export default async function LayoutSearchParamsLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ( + + {children} +
{id}
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/[id]/page.tsx new file mode 100644 index 000000000..be885279b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/[id]/page.tsx @@ -0,0 +1,14 @@ +export default async function LayoutSearchParamsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + + return ( +
+
{JSON.stringify(params)}
+

Query-only navigation should preserve parent layout state.

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/layout.tsx new file mode 100644 index 000000000..f48091142 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-search-params/layout.tsx @@ -0,0 +1,5 @@ +"use client"; + +export default function LayoutSearchParamsOuterLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +}