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
18 changes: 9 additions & 9 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +1040 to +1045
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would it be possible to filter out those internals in a proxy instead? If so, we could use the proxy approach and align behaviour with Next.js.

This change as-is would mark every route we give search params to as dynamic, regardles of whether they use search params or not.

markDynamicUsage();
}
const __mountedSlotsHeader = __normalizeMountedSlotsHeader(
request?.headers?.get("x-vinext-mounted-slots"),
Expand Down
108 changes: 54 additions & 54 deletions tests/__snapshots__/entry-templates.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
20 changes: 20 additions & 0 deletions tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1140,7 +1140,7 @@
expect(res.status).toBe(200);
const cacheControl = res.headers.get("cache-control");
// Normal pages should not have no-store
expect(cacheControl).toBeNull();

Check failure on line 1143 in tests/app-router.test.ts

View workflow job for this annotation

GitHub Actions / Vitest (integration report)

[integration] tests/app-router.test.ts > App Router integration > non-force-dynamic pages do not set no-store

AssertionError: expected 'no-store, must-revalidate' to be null - Expected: null + Received: "no-store, must-revalidate" ❯ tests/app-router.test.ts:1143:26 ❯ node_modules/.pnpm/@voidzero-dev+vite-plus-test@0.1.12_@opentelemetry+api@1.9.1_@types+node@25.2.3_@voidze_a3ea19f17851c3656c7749bae4bcea4a/node_modules/@voidzero-dev/vite-plus-test/dist/@vitest/runner/index.js:1258:22
});

it("export const dynamic = 'force-static' sets long-lived Cache-Control", async () => {
Expand Down Expand Up @@ -1247,7 +1247,7 @@

// revalidate=60 should set s-maxage=60 on first request (cache MISS)
const cacheControl = res.headers.get("cache-control");
expect(cacheControl).toContain("s-maxage=60");

Check failure on line 1250 in tests/app-router.test.ts

View workflow job for this annotation

GitHub Actions / Vitest (integration report)

[integration] tests/app-router.test.ts > App Router integration > export const revalidate sets ISR Cache-Control header

AssertionError: expected 'no-store, must-revalidate' to contain 's-maxage=60' Expected: "s-maxage=60" Received: "no-store, must-revalidate" ❯ tests/app-router.test.ts:1250:26 ❯ node_modules/.pnpm/@voidzero-dev+vite-plus-test@0.1.12_@opentelemetry+api@1.9.1_@types+node@25.2.3_@voidze_a3ea19f17851c3656c7749bae4bcea4a/node_modules/@voidzero-dev/vite-plus-test/dist/@vitest/runner/index.js:1258:22
expect(cacheControl).toContain("stale-while-revalidate");
});

Expand Down Expand Up @@ -1968,7 +1968,7 @@
expect(res2.status).toBe(200);
const html2 = await res2.text();
const reqId2 = extractRequestId(html2);
expect(reqId2).toBe(reqId1);

Check failure on line 1971 in tests/app-router.test.ts

View workflow job for this annotation

GitHub Actions / Vitest (integration report)

[integration] tests/app-router.test.ts > App Router Production server (startProdServer) > revalidateTag invalidates App Router ISR page entries by fetch tag

AssertionError: expected 'whzonbxz0zl' to be '0x3iqwx5cnt' // Object.is equality Expected: "0x3iqwx5cnt" Received: "whzonbxz0zl" ❯ tests/app-router.test.ts:1971:20 ❯ node_modules/.pnpm/@voidzero-dev+vite-plus-test@0.1.12_@opentelemetry+api@1.9.1_@types+node@25.2.3_@voidze_a3ea19f17851c3656c7749bae4bcea4a/node_modules/@voidzero-dev/vite-plus-test/dist/@vitest/runner/index.js:1258:22
expect(res2.headers.get("x-vinext-cache")).toBe("HIT");

const tagRes = await fetch(`${baseUrl}/api/revalidate-tag`);
Expand All @@ -1995,7 +1995,7 @@
expect(res2.status).toBe(200);
const html2 = await res2.text();
const reqId2 = extractRequestId(html2);
expect(reqId2).toBe(reqId1);

Check failure on line 1998 in tests/app-router.test.ts

View workflow job for this annotation

GitHub Actions / Vitest (integration report)

[integration] tests/app-router.test.ts > App Router Production server (startProdServer) > revalidatePath invalidates App Router ISR page entries by path tag

AssertionError: expected 'vn6r617rpe' to be '5gcit5tbl1c' // Object.is equality Expected: "5gcit5tbl1c" Received: "vn6r617rpe" ❯ tests/app-router.test.ts:1998:20 ❯ node_modules/.pnpm/@voidzero-dev+vite-plus-test@0.1.12_@opentelemetry+api@1.9.1_@types+node@25.2.3_@voidze_a3ea19f17851c3656c7749bae4bcea4a/node_modules/@voidzero-dev/vite-plus-test/dist/@vitest/runner/index.js:1258:22
expect(res2.headers.get("x-vinext-cache")).toBe("HIT");

const pathRes = await fetch(`${baseUrl}/api/revalidate-path`);
Expand Down Expand Up @@ -2046,6 +2046,26 @@
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
Expand Down Expand Up @@ -2364,7 +2384,7 @@
loadingPath: null,
errorPath: null,
layoutErrorPaths: [],
notFoundPath: null,

Check failure on line 2387 in tests/app-router.test.ts

View workflow job for this annotation

GitHub Actions / Vitest (integration report)

[integration] tests/app-router.test.ts > App Router Static export > exports static App Router pages to HTML files

AssertionError: expected [ 'connection-test.html', …(32) ] to include 'index.html' ❯ Proxy.<anonymous> node_modules/.pnpm/@voidzero-dev+vite-plus-test@0.1.12_@opentelemetry+api@1.9.1_@types+node@25.2.3_@voidze_a3ea19f17851c3656c7749bae4bcea4a/node_modules/@voidzero-dev/vite-plus-test/dist/@vitest/expect/index.js:1314:17 ❯ Proxy.<anonymous> node_modules/.pnpm/@voidzero-dev+vite-plus-test@0.1.12_@opentelemetry+api@1.9.1_@types+node@25.2.3_@voidze_a3ea19f17851c3656c7749bae4bcea4a/node_modules/@voidzero-dev/vite-plus-test/dist/@vitest/expect/index.js:1151:19 ❯ Proxy.methodWrapper node_modules/.pnpm/@voidzero-dev+vite-plus-test@0.1.12_@opentelemetry+api@1.9.1_@types+node@25.2.3_@voidze_a3ea19f17851c3656c7749bae4bcea4a/node_modules/@voidzero-dev/vite-plus-test/dist/vendor/chai.mjs:1234:23 ❯ tests/app-router.test.ts:2387:26 ❯ node_modules/.pnpm/@voidzero-dev+vite-plus-test@0.1.12_@opentelemetry+api@1.9.1_@types+node@25.2.3_@voidze_a3ea19f17851c3656c7749bae4bcea4a/node_modules/@voidzero-dev/vite-plus-test/dist/@vitest/runner/index.js:1258:22
notFoundPaths: [],
forbiddenPath: null,
unauthorizedPath: null,
Expand Down
16 changes: 16 additions & 0 deletions tests/fixtures/app-basic/app/search-params-page/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const revalidate = 60;

export default async function SearchParamsPage(props: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await props.searchParams;
const filter = sp.filter ?? "none";

return (
<div>
<h1>Search Params Page</h1>
<p id="filter">filter={String(filter)}</p>
<p id="keys">keys={Object.keys(sp).sort().join(",")}</p>
</div>
);
}
Loading