Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts
Original file line number Diff line number Diff line change
@@ -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");

Check failure on line 35 in tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (app-router)

[app-router] › tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:17:7 › Next.js compat: layout state across search param changes › router.push() keeps parent client layout mounted on query-only navigation

1) [app-router] › tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:17:7 › Next.js compat: layout state across search param changes › router.push() keeps parent client layout mounted on query-only navigation Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveText(expected) failed Locator: locator('#layout-count') Expected: "2" Received: "0" Timeout: 5000ms Call log: - Expect "toHaveText" with timeout 5000ms - waiting for locator('#layout-count') 9 × locator resolved to <div id="layout-count">0</div> - unexpected value "0" 33 | 34 | await expect(page.locator("#search-params")).toContainText('"foo":"bar"'); > 35 | await expect(page.locator("#layout-count")).toHaveText("2"); | ^ 36 | await expect(page.locator("#layout-mount-count")).toHaveText("1"); 37 | }); 38 | at /home/runner/work/vinext/vinext/tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:35:49

Check failure on line 35 in tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (app-router)

[app-router] › tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:17:7 › Next.js compat: layout state across search param changes › router.push() keeps parent client layout mounted on query-only navigation

1) [app-router] › tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:17:7 › Next.js compat: layout state across search param changes › router.push() keeps parent client layout mounted on query-only navigation Error: expect(locator).toHaveText(expected) failed Locator: locator('#layout-count') Expected: "2" Received: "0" Timeout: 5000ms Call log: - Expect "toHaveText" with timeout 5000ms - waiting for locator('#layout-count') 9 × locator resolved to <div id="layout-count">0</div> - unexpected value "0" 33 | 34 | await expect(page.locator("#search-params")).toContainText('"foo":"bar"'); > 35 | await expect(page.locator("#layout-count")).toHaveText("2"); | ^ 36 | await expect(page.locator("#layout-mount-count")).toHaveText("1"); 37 | }); 38 | at /home/runner/work/vinext/vinext/tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:35:49
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");

Check failure on line 57 in tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (app-router)

[app-router] › tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:39:7 › Next.js compat: layout state across search param changes › router.replace() keeps parent client layout mounted on query-only navigation

2) [app-router] › tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:39:7 › Next.js compat: layout state across search param changes › router.replace() keeps parent client layout mounted on query-only navigation Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveText(expected) failed Locator: locator('#layout-count') Expected: "2" Received: "0" Timeout: 5000ms Call log: - Expect "toHaveText" with timeout 5000ms - waiting for locator('#layout-count') 9 × locator resolved to <div id="layout-count">0</div> - unexpected value "0" 55 | 56 | await expect(page.locator("#search-params")).toContainText('"foo":"baz"'); > 57 | await expect(page.locator("#layout-count")).toHaveText("2"); | ^ 58 | await expect(page.locator("#layout-mount-count")).toHaveText("1"); 59 | }); 60 | }); at /home/runner/work/vinext/vinext/tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:57:49

Check failure on line 57 in tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts

View workflow job for this annotation

GitHub Actions / E2E (app-router)

[app-router] › tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:39:7 › Next.js compat: layout state across search param changes › router.replace() keeps parent client layout mounted on query-only navigation

2) [app-router] › tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:39:7 › Next.js compat: layout state across search param changes › router.replace() keeps parent client layout mounted on query-only navigation Error: expect(locator).toHaveText(expected) failed Locator: locator('#layout-count') Expected: "2" Received: "0" Timeout: 5000ms Call log: - Expect "toHaveText" with timeout 5000ms - waiting for locator('#layout-count') 9 × locator resolved to <div id="layout-count">0</div> - unexpected value "0" 55 | 56 | await expect(page.locator("#search-params")).toContainText('"foo":"baz"'); > 57 | await expect(page.locator("#layout-count")).toHaveText("2"); | ^ 58 | await expect(page.locator("#layout-mount-count")).toHaveText("1"); 59 | }); 60 | }); at /home/runner/work/vinext/vinext/tests/e2e/app-router/nextjs-compat/layout-search-params.spec.ts:57:49
await expect(page.locator("#layout-mount-count")).toHaveText("1");
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>Layout Search Params</h1>
<div id="layout-count">{count}</div>
<div id="layout-mount-count">{mountCount}</div>
<button id="layout-increment" onClick={() => setCount((value) => value + 1)}>
Increment layout state
</button>
<button id="layout-push" onClick={() => router.push("?foo=bar")}>
Push foo=bar
</button>
<button id="layout-replace" onClick={() => router.replace("?foo=baz")}>
Replace foo=baz
</button>
{children}
</div>
);
}

export const LayoutShell = React.memo(LayoutShellInner);
Original file line number Diff line number Diff line change
@@ -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 (
<LayoutShell>
{children}
<div id="layout-param">{id}</div>
</LayoutShell>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export default async function LayoutSearchParamsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const params = await searchParams;

return (
<div>
<div id="search-params">{JSON.stringify(params)}</div>
<p id="page-content">Query-only navigation should preserve parent layout state.</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"use client";

export default function LayoutSearchParamsOuterLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
Loading