Skip to content
Merged
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
34 changes: 18 additions & 16 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,24 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// route it to the nearest error.tsx boundary (or global-error.tsx).
const layoutMods = route.layouts.filter(Boolean);

// Convert URLSearchParams → plain object for page generateMetadata() and
// pageProps.searchParams. Built before the layout loop so the page metadata
// call (below) and pageProps can reference the same object.
// NOTE: Layouts do NOT receive searchParams in generateMetadata() — only
// pages do. This matches Next.js behavior (resolve-metadata.ts:777).
const spObj = Object.create(null);
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

// Build the parent promise chain and kick off metadata resolution in one pass.
// Each layout module is called exactly once. layoutMetaPromises[i] is the
// promise for layout[i]'s own metadata result.
Expand All @@ -977,22 +995,6 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// Page's parent is the fully-accumulated layout metadata.
const pageParentPromise = accumulatedMetaPromise;

// Convert URLSearchParams → plain object so we can pass it to
// resolveModuleMetadata (which expects Record<string, string | string[]>).
// This same object is reused for pageProps.searchParams below.
const spObj = {};
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([
Promise.all(layoutMetaPromises),
Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
Expand Down
204 changes: 108 additions & 96 deletions tests/__snapshots__/entry-templates.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,24 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// route it to the nearest error.tsx boundary (or global-error.tsx).
const layoutMods = route.layouts.filter(Boolean);

// Convert URLSearchParams → plain object for page generateMetadata() and
// pageProps.searchParams. Built before the layout loop so the page metadata
// call (below) and pageProps can reference the same object.
// NOTE: Layouts do NOT receive searchParams in generateMetadata() — only
// pages do. This matches Next.js behavior (resolve-metadata.ts:777).
const spObj = Object.create(null);
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

// Build the parent promise chain and kick off metadata resolution in one pass.
// Each layout module is called exactly once. layoutMetaPromises[i] is the
// promise for layout[i]'s own metadata result.
Expand All @@ -736,22 +754,6 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// Page's parent is the fully-accumulated layout metadata.
const pageParentPromise = accumulatedMetaPromise;

// Convert URLSearchParams → plain object so we can pass it to
// resolveModuleMetadata (which expects Record<string, string | string[]>).
// This same object is reused for pageProps.searchParams below.
const spObj = {};
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([
Promise.all(layoutMetaPromises),
Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
Expand Down Expand Up @@ -2848,6 +2850,24 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// route it to the nearest error.tsx boundary (or global-error.tsx).
const layoutMods = route.layouts.filter(Boolean);

// Convert URLSearchParams → plain object for page generateMetadata() and
// pageProps.searchParams. Built before the layout loop so the page metadata
// call (below) and pageProps can reference the same object.
// NOTE: Layouts do NOT receive searchParams in generateMetadata() — only
// pages do. This matches Next.js behavior (resolve-metadata.ts:777).
const spObj = Object.create(null);
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

// Build the parent promise chain and kick off metadata resolution in one pass.
// Each layout module is called exactly once. layoutMetaPromises[i] is the
// promise for layout[i]'s own metadata result.
Expand All @@ -2870,22 +2890,6 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// Page's parent is the fully-accumulated layout metadata.
const pageParentPromise = accumulatedMetaPromise;

// Convert URLSearchParams → plain object so we can pass it to
// resolveModuleMetadata (which expects Record<string, string | string[]>).
// This same object is reused for pageProps.searchParams below.
const spObj = {};
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([
Promise.all(layoutMetaPromises),
Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
Expand Down Expand Up @@ -4989,6 +4993,24 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// route it to the nearest error.tsx boundary (or global-error.tsx).
const layoutMods = route.layouts.filter(Boolean);

// Convert URLSearchParams → plain object for page generateMetadata() and
// pageProps.searchParams. Built before the layout loop so the page metadata
// call (below) and pageProps can reference the same object.
// NOTE: Layouts do NOT receive searchParams in generateMetadata() — only
// pages do. This matches Next.js behavior (resolve-metadata.ts:777).
const spObj = Object.create(null);
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

// Build the parent promise chain and kick off metadata resolution in one pass.
// Each layout module is called exactly once. layoutMetaPromises[i] is the
// promise for layout[i]'s own metadata result.
Expand All @@ -5011,22 +5033,6 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// Page's parent is the fully-accumulated layout metadata.
const pageParentPromise = accumulatedMetaPromise;

// Convert URLSearchParams → plain object so we can pass it to
// resolveModuleMetadata (which expects Record<string, string | string[]>).
// This same object is reused for pageProps.searchParams below.
const spObj = {};
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([
Promise.all(layoutMetaPromises),
Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
Expand Down Expand Up @@ -7153,6 +7159,24 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// route it to the nearest error.tsx boundary (or global-error.tsx).
const layoutMods = route.layouts.filter(Boolean);

// Convert URLSearchParams → plain object for page generateMetadata() and
// pageProps.searchParams. Built before the layout loop so the page metadata
// call (below) and pageProps can reference the same object.
// NOTE: Layouts do NOT receive searchParams in generateMetadata() — only
// pages do. This matches Next.js behavior (resolve-metadata.ts:777).
const spObj = Object.create(null);
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

// Build the parent promise chain and kick off metadata resolution in one pass.
// Each layout module is called exactly once. layoutMetaPromises[i] is the
// promise for layout[i]'s own metadata result.
Expand All @@ -7175,22 +7199,6 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// Page's parent is the fully-accumulated layout metadata.
const pageParentPromise = accumulatedMetaPromise;

// Convert URLSearchParams → plain object so we can pass it to
// resolveModuleMetadata (which expects Record<string, string | string[]>).
// This same object is reused for pageProps.searchParams below.
const spObj = {};
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([
Promise.all(layoutMetaPromises),
Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
Expand Down Expand Up @@ -9297,6 +9305,24 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// route it to the nearest error.tsx boundary (or global-error.tsx).
const layoutMods = route.layouts.filter(Boolean);

// Convert URLSearchParams → plain object for page generateMetadata() and
// pageProps.searchParams. Built before the layout loop so the page metadata
// call (below) and pageProps can reference the same object.
// NOTE: Layouts do NOT receive searchParams in generateMetadata() — only
// pages do. This matches Next.js behavior (resolve-metadata.ts:777).
const spObj = Object.create(null);
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

// Build the parent promise chain and kick off metadata resolution in one pass.
// Each layout module is called exactly once. layoutMetaPromises[i] is the
// promise for layout[i]'s own metadata result.
Expand All @@ -9319,22 +9345,6 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// Page's parent is the fully-accumulated layout metadata.
const pageParentPromise = accumulatedMetaPromise;

// Convert URLSearchParams → plain object so we can pass it to
// resolveModuleMetadata (which expects Record<string, string | string[]>).
// This same object is reused for pageProps.searchParams below.
const spObj = {};
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([
Promise.all(layoutMetaPromises),
Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
Expand Down Expand Up @@ -11431,6 +11441,24 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// route it to the nearest error.tsx boundary (or global-error.tsx).
const layoutMods = route.layouts.filter(Boolean);

// Convert URLSearchParams → plain object for page generateMetadata() and
// pageProps.searchParams. Built before the layout loop so the page metadata
// call (below) and pageProps can reference the same object.
// NOTE: Layouts do NOT receive searchParams in generateMetadata() — only
// pages do. This matches Next.js behavior (resolve-metadata.ts:777).
const spObj = Object.create(null);
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

// Build the parent promise chain and kick off metadata resolution in one pass.
// Each layout module is called exactly once. layoutMetaPromises[i] is the
// promise for layout[i]'s own metadata result.
Expand All @@ -11453,22 +11481,6 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
// Page's parent is the fully-accumulated layout metadata.
const pageParentPromise = accumulatedMetaPromise;

// Convert URLSearchParams → plain object so we can pass it to
// resolveModuleMetadata (which expects Record<string, string | string[]>).
// This same object is reused for pageProps.searchParams below.
const spObj = {};
let hasSearchParams = false;
if (searchParams && searchParams.forEach) {
searchParams.forEach(function(v, k) {
hasSearchParams = true;
if (k in spObj) {
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
} else {
spObj[k] = v;
}
});
}

const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([
Promise.all(layoutMetaPromises),
Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
Expand Down
14 changes: 14 additions & 0 deletions tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,20 @@ describe("App Router integration", () => {
expect(html).toMatch(/name="description".*content="Read about my-post"/);
});

it("layout generateMetadata() does not receive searchParams (Next.js parity)", async () => {
// Parity test: In Next.js, layout generateMetadata() does NOT receive
// searchParams — only page generateMetadata() does. The layout should
// always see undefined and fall back to "home", even when the URL has
// a query string.
// See: next.js resolve-metadata.ts — `isPage ? { params, searchParams } : { params }`
const res = await fetch(`${baseUrl}/layout-metadata-search?tab=settings`);
expect(res.status).toBe(200);

const html = await res.text();
// Layout falls back to "home" because it never receives searchParams.
expect(html).toContain("<title>Layout Section: home</title>");
});

it("renders catch-all routes with multiple segments", async () => {
const res = await fetch(`${baseUrl}/docs/getting-started/install`);
expect(res.status).toBe(200);
Expand Down
26 changes: 26 additions & 0 deletions tests/fixtures/app-basic/app/layout-metadata-search/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Parity fixture: layout generateMetadata() must NOT receive searchParams.
*
* In Next.js, only page generateMetadata() receives searchParams. Layouts
* always get undefined. This fixture verifies vinext matches that behavior
* by asserting the fallback title is produced even when a query string is
* present in the URL.
*
* See: next.js resolve-metadata.ts — `isPage ? { params, searchParams } : { params }`
*/

export async function generateMetadata({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Next.js does not pass searchParams to layout generateMetadata(), this fixture tests behavior that diverges from Next.js.

Consider either:

  1. Converting this into a page-level generateMetadata test (testing that the page receives searchParams correctly), or
  2. Keeping this as a layout fixture but asserting the opposite — that the layout's metadata falls back to "home" because searchParams is not provided to layouts (matching Next.js behavior).

Option 2 would be a useful parity test to prevent future accidental passing of searchParams to layouts.

searchParams,
}: {
searchParams?: Promise<{ tab?: string }>;
}) {
const sp = searchParams ? await searchParams : undefined;
const tab = sp?.tab ?? "home";
return {
title: `Layout Section: ${tab}`,
};
}

export default function LayoutMetadataSearchLayout({ children }: { children: React.ReactNode }) {
return <div data-testid="layout-metadata-search-layout">{children}</div>;
}
8 changes: 8 additions & 0 deletions tests/fixtures/app-basic/app/layout-metadata-search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function LayoutMetadataSearchPage() {
return (
<main data-testid="layout-metadata-search-page">
<h1>Layout Metadata Search Test</h1>
<p>This page tests that layout generateMetadata receives searchParams.</p>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This copy is stale after the parity fix — the test now verifies that layout generateMetadata does not receive searchParams.

Suggested change
<p>This page tests that layout generateMetadata receives searchParams.</p>
<p>This page tests that layout generateMetadata does NOT receive searchParams.</p>

</main>
);
}
Loading