diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 2ed1beb83..ccc643ebb 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1035,15 +1035,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // when the query string is empty -- pages that do "await searchParams" need // it to be a thenable rather than undefined. pageProps.searchParams = makeThenableParams(spObj); - // If the URL has query parameters, mark the page as dynamic. - // In Next.js, only accessing the searchParams prop signals dynamic usage, - // but a Proxy-based approach doesn't work here because React's RSC debug - // serializer accesses properties on all props (e.g. $$typeof check in - // isClientReference), triggering the Proxy even when user code doesn't - // read searchParams. Checking for non-empty query params is a safe - // approximation: pages with query params in the URL are almost always - // dynamic, and this avoids false positives from React internals. - if (hasSearchParams) markDynamicUsage(); + // Mark the render as dynamic whenever searchParams is provided. In Next.js, + // accessing the searchParams prop via a Proxy triggers dynamic rendering. + // We cannot use a Proxy here because React's RSC debug serializer accesses + // properties on all props ($$typeof, etc.), causing false positives. + // Instead, we unconditionally mark dynamic: any page that receives + // searchParams is request-dependent, even when the first request has an + // empty query string. Without this, the empty-query render would be cached + // and replayed to later requests with different query parameters. + markDynamicUsage(); } const __mountedSlotsHeader = __normalizeMountedSlotsHeader( request?.headers?.get("x-vinext-mounted-slots"), diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index a18679b6a..08bd4a831 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -790,15 +790,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // when the query string is empty -- pages that do "await searchParams" need // it to be a thenable rather than undefined. pageProps.searchParams = makeThenableParams(spObj); - // If the URL has query parameters, mark the page as dynamic. - // In Next.js, only accessing the searchParams prop signals dynamic usage, - // but a Proxy-based approach doesn't work here because React's RSC debug - // serializer accesses properties on all props (e.g. $$typeof check in - // isClientReference), triggering the Proxy even when user code doesn't - // read searchParams. Checking for non-empty query params is a safe - // approximation: pages with query params in the URL are almost always - // dynamic, and this avoids false positives from React internals. - if (hasSearchParams) markDynamicUsage(); + // Mark the render as dynamic whenever searchParams is provided. In Next.js, + // accessing the searchParams prop via a Proxy triggers dynamic rendering. + // We cannot use a Proxy here because React's RSC debug serializer accesses + // properties on all props ($$typeof, etc.), causing false positives. + // Instead, we unconditionally mark dynamic: any page that receives + // searchParams is request-dependent, even when the first request has an + // empty query string. Without this, the empty-query render would be cached + // and replayed to later requests with different query parameters. + markDynamicUsage(); } const __mountedSlotsHeader = __normalizeMountedSlotsHeader( request?.headers?.get("x-vinext-mounted-slots"), @@ -2964,15 +2964,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // when the query string is empty -- pages that do "await searchParams" need // it to be a thenable rather than undefined. pageProps.searchParams = makeThenableParams(spObj); - // If the URL has query parameters, mark the page as dynamic. - // In Next.js, only accessing the searchParams prop signals dynamic usage, - // but a Proxy-based approach doesn't work here because React's RSC debug - // serializer accesses properties on all props (e.g. $$typeof check in - // isClientReference), triggering the Proxy even when user code doesn't - // read searchParams. Checking for non-empty query params is a safe - // approximation: pages with query params in the URL are almost always - // dynamic, and this avoids false positives from React internals. - if (hasSearchParams) markDynamicUsage(); + // Mark the render as dynamic whenever searchParams is provided. In Next.js, + // accessing the searchParams prop via a Proxy triggers dynamic rendering. + // We cannot use a Proxy here because React's RSC debug serializer accesses + // properties on all props ($$typeof, etc.), causing false positives. + // Instead, we unconditionally mark dynamic: any page that receives + // searchParams is request-dependent, even when the first request has an + // empty query string. Without this, the empty-query render would be cached + // and replayed to later requests with different query parameters. + markDynamicUsage(); } const __mountedSlotsHeader = __normalizeMountedSlotsHeader( request?.headers?.get("x-vinext-mounted-slots"), @@ -5145,15 +5145,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // when the query string is empty -- pages that do "await searchParams" need // it to be a thenable rather than undefined. pageProps.searchParams = makeThenableParams(spObj); - // If the URL has query parameters, mark the page as dynamic. - // In Next.js, only accessing the searchParams prop signals dynamic usage, - // but a Proxy-based approach doesn't work here because React's RSC debug - // serializer accesses properties on all props (e.g. $$typeof check in - // isClientReference), triggering the Proxy even when user code doesn't - // read searchParams. Checking for non-empty query params is a safe - // approximation: pages with query params in the URL are almost always - // dynamic, and this avoids false positives from React internals. - if (hasSearchParams) markDynamicUsage(); + // Mark the render as dynamic whenever searchParams is provided. In Next.js, + // accessing the searchParams prop via a Proxy triggers dynamic rendering. + // We cannot use a Proxy here because React's RSC debug serializer accesses + // properties on all props ($$typeof, etc.), causing false positives. + // Instead, we unconditionally mark dynamic: any page that receives + // searchParams is request-dependent, even when the first request has an + // empty query string. Without this, the empty-query render would be cached + // and replayed to later requests with different query parameters. + markDynamicUsage(); } const __mountedSlotsHeader = __normalizeMountedSlotsHeader( request?.headers?.get("x-vinext-mounted-slots"), @@ -7349,15 +7349,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // when the query string is empty -- pages that do "await searchParams" need // it to be a thenable rather than undefined. pageProps.searchParams = makeThenableParams(spObj); - // If the URL has query parameters, mark the page as dynamic. - // In Next.js, only accessing the searchParams prop signals dynamic usage, - // but a Proxy-based approach doesn't work here because React's RSC debug - // serializer accesses properties on all props (e.g. $$typeof check in - // isClientReference), triggering the Proxy even when user code doesn't - // read searchParams. Checking for non-empty query params is a safe - // approximation: pages with query params in the URL are almost always - // dynamic, and this avoids false positives from React internals. - if (hasSearchParams) markDynamicUsage(); + // Mark the render as dynamic whenever searchParams is provided. In Next.js, + // accessing the searchParams prop via a Proxy triggers dynamic rendering. + // We cannot use a Proxy here because React's RSC debug serializer accesses + // properties on all props ($$typeof, etc.), causing false positives. + // Instead, we unconditionally mark dynamic: any page that receives + // searchParams is request-dependent, even when the first request has an + // empty query string. Without this, the empty-query render would be cached + // and replayed to later requests with different query parameters. + markDynamicUsage(); } const __mountedSlotsHeader = __normalizeMountedSlotsHeader( request?.headers?.get("x-vinext-mounted-slots"), @@ -9533,15 +9533,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // when the query string is empty -- pages that do "await searchParams" need // it to be a thenable rather than undefined. pageProps.searchParams = makeThenableParams(spObj); - // If the URL has query parameters, mark the page as dynamic. - // In Next.js, only accessing the searchParams prop signals dynamic usage, - // but a Proxy-based approach doesn't work here because React's RSC debug - // serializer accesses properties on all props (e.g. $$typeof check in - // isClientReference), triggering the Proxy even when user code doesn't - // read searchParams. Checking for non-empty query params is a safe - // approximation: pages with query params in the URL are almost always - // dynamic, and this avoids false positives from React internals. - if (hasSearchParams) markDynamicUsage(); + // Mark the render as dynamic whenever searchParams is provided. In Next.js, + // accessing the searchParams prop via a Proxy triggers dynamic rendering. + // We cannot use a Proxy here because React's RSC debug serializer accesses + // properties on all props ($$typeof, etc.), causing false positives. + // Instead, we unconditionally mark dynamic: any page that receives + // searchParams is request-dependent, even when the first request has an + // empty query string. Without this, the empty-query render would be cached + // and replayed to later requests with different query parameters. + markDynamicUsage(); } const __mountedSlotsHeader = __normalizeMountedSlotsHeader( request?.headers?.get("x-vinext-mounted-slots"), @@ -11707,15 +11707,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // when the query string is empty -- pages that do "await searchParams" need // it to be a thenable rather than undefined. pageProps.searchParams = makeThenableParams(spObj); - // If the URL has query parameters, mark the page as dynamic. - // In Next.js, only accessing the searchParams prop signals dynamic usage, - // but a Proxy-based approach doesn't work here because React's RSC debug - // serializer accesses properties on all props (e.g. $$typeof check in - // isClientReference), triggering the Proxy even when user code doesn't - // read searchParams. Checking for non-empty query params is a safe - // approximation: pages with query params in the URL are almost always - // dynamic, and this avoids false positives from React internals. - if (hasSearchParams) markDynamicUsage(); + // Mark the render as dynamic whenever searchParams is provided. In Next.js, + // accessing the searchParams prop via a Proxy triggers dynamic rendering. + // We cannot use a Proxy here because React's RSC debug serializer accesses + // properties on all props ($$typeof, etc.), causing false positives. + // Instead, we unconditionally mark dynamic: any page that receives + // searchParams is request-dependent, even when the first request has an + // empty query string. Without this, the empty-query render would be cached + // and replayed to later requests with different query parameters. + markDynamicUsage(); } const __mountedSlotsHeader = __normalizeMountedSlotsHeader( request?.headers?.get("x-vinext-mounted-slots"), diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index a62b86149..6c1d596a9 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2046,6 +2046,26 @@ describe("App Router Production server (startProdServer)", () => { expect(html2).not.toContain('"filter">alpha<'); }); + it("page ISR + searchParams: empty-query first request does not cache and poison later query requests", async () => { + // First request with NO query params. The page reads searchParams, so it + // must NOT be cached even though the query string is empty. + const res1 = await fetch(`${baseUrl}/search-params-page`); + expect(res1.status).toBe(200); + expect(res1.headers.get("x-vinext-cache")).toBeNull(); + const html1 = await res1.text(); + // React inserts between text nodes, so match the id + content pattern + expect(html1).toContain('id="filter"'); + expect(html1).toContain("none"); + + // Second request WITH query params must see its own searchParams, not + // a cached empty-query response. + const res2 = await fetch(`${baseUrl}/search-params-page?filter=violet`); + expect(res2.status).toBe(200); + expect(res2.headers.get("x-vinext-cache")).toBeNull(); + const html2 = await res2.text(); + expect(html2).toContain("violet"); + }); + // Route handler ISR caching tests // These tests are ORDER-DEPENDENT: they share a single production server and // /api/static-data cache state persists across tests. HIT depends on MISS diff --git a/tests/fixtures/app-basic/app/search-params-page/page.tsx b/tests/fixtures/app-basic/app/search-params-page/page.tsx new file mode 100644 index 000000000..fefbd24b1 --- /dev/null +++ b/tests/fixtures/app-basic/app/search-params-page/page.tsx @@ -0,0 +1,16 @@ +export const revalidate = 60; + +export default async function SearchParamsPage(props: { + searchParams: Promise>; +}) { + const sp = await props.searchParams; + const filter = sp.filter ?? "none"; + + return ( +
+

Search Params Page

+

filter={String(filter)}

+

keys={Object.keys(sp).sort().join(",")}

+
+ ); +}