From 57efd1fe56930e88dda909fd15236cbecb76285a Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Fri, 10 Apr 2026 22:37:40 -0500 Subject: [PATCH] fix: always mark pages that receive searchParams as dynamic Pages that read searchParams are request-dependent even when the first request has an empty query string. Previously, markDynamicUsage() was only called when the URL contained query parameters (hasSearchParams), so an empty-query first request would be cached and replayed to later requests with different query parameters. Remove the hasSearchParams guard so markDynamicUsage() is called unconditionally whenever searchParams is provided to the page. This matches Next.js behavior where accessing the searchParams prop signals dynamic rendering regardless of whether the URL has query params. Adds a production integration test with a dedicated fixture page that reads searchParams, verifying the empty-query response is not cached and later query-specific requests see their own parameters. --- packages/vinext/src/entries/app-rsc-entry.ts | 18 +-- .../entry-templates.test.ts.snap | 108 +++++++++--------- tests/app-router.test.ts | 20 ++++ .../app-basic/app/search-params-page/page.tsx | 16 +++ 4 files changed, 99 insertions(+), 63 deletions(-) create mode 100644 tests/fixtures/app-basic/app/search-params-page/page.tsx 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(",")}

+
+ ); +}