diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts
index 773e694827..67c7ce01c5 100644
--- a/packages/vinext/src/client/navigation-runtime.ts
+++ b/packages/vinext/src/client/navigation-runtime.ts
@@ -5,6 +5,7 @@ import type { AppRouterScrollIntent } from "vinext/shims/app-router-scroll-state
export type NavigationRuntimeSnapshot = {
pathname: string;
searchParams: [string, string][];
+ searchParamsAccessMode?: "dynamic" | "bailout" | "force-static";
};
export type NavigationRuntimeRscChunk = string | [3, string];
@@ -131,6 +132,7 @@ function isNavigationRuntimeSnapshot(value: unknown): value is NavigationRuntime
if (!isUnknownRecord(value)) return false;
const pathname = Reflect.get(value, "pathname");
const searchParams = Reflect.get(value, "searchParams");
+ const searchParamsAccessMode = Reflect.get(value, "searchParamsAccessMode");
return (
typeof pathname === "string" &&
Array.isArray(searchParams) &&
@@ -140,7 +142,11 @@ function isNavigationRuntimeSnapshot(value: unknown): value is NavigationRuntime
entry.length === 2 &&
typeof entry[0] === "string" &&
typeof entry[1] === "string",
- )
+ ) &&
+ (searchParamsAccessMode === undefined ||
+ searchParamsAccessMode === "dynamic" ||
+ searchParamsAccessMode === "bailout" ||
+ searchParamsAccessMode === "force-static")
);
}
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 5beba2d5ef..cab0690e6e 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -736,6 +736,7 @@ export default createAppRscHandler({
draftModeSecret: __draftModeSecret,
dispatchMatchedPage({
clientReuseManifest,
+ clientSearchParams,
cleanPathname,
displayPathname,
formState,
@@ -812,6 +813,7 @@ export default createAppRscHandler({
}, layoutParamAccess, displayPathname);
},
clientReuseManifest,
+ clientSearchParams,
cleanPathname,
displayPathname,
clearRequestContext() {
diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts
index 3cfe103866..34f7370185 100644
--- a/packages/vinext/src/server/app-browser-entry.ts
+++ b/packages/vinext/src/server/app-browser-entry.ts
@@ -58,6 +58,7 @@ import {
registerNavigationRuntimeBootstrap,
registerNavigationRuntimeFunctions,
type NavigationRuntimeNavigate,
+ type NavigationRuntimeSnapshot,
type NavigationRuntimeVisibleCommitMode,
type NavigationRuntimeRscBootstrap,
} from "../client/navigation-runtime.js";
@@ -1218,14 +1219,33 @@ function restoreHydrationNavigationContext(
pathname: string,
searchParams: SearchParamInput,
params: Record,
+ searchParamsAccessMode?: NavigationRuntimeSnapshot["searchParamsAccessMode"],
): void {
setNavigationContext({
pathname,
searchParams: new URLSearchParams(searchParams),
params,
+ searchParamsAccessMode,
});
}
+function applyHydrationNavigationSnapshot(
+ nav: NavigationRuntimeSnapshot,
+ params: Record,
+): void {
+ const shouldDeferLiveSearchParams =
+ nav.searchParamsAccessMode === "bailout" || nav.searchParamsAccessMode === "force-static";
+ // Static HTML hydrates against the same neutral search params the server
+ // rendered. Once hydration commits, the browser URL becomes authoritative
+ // and updates useSearchParams() without a server/client mismatch.
+ restoreHydrationNavigationContext(
+ nav.pathname,
+ shouldDeferLiveSearchParams ? nav.searchParams : window.location.search,
+ params,
+ nav.searchParamsAccessMode,
+ );
+}
+
function restorePopstateScrollPosition(
state: unknown,
options?: {
@@ -1356,11 +1376,7 @@ async function readInitialRscStream(): Promise | null
applyClientParams(vinext.__VINEXT_RSC_PARAMS__);
}
if (vinext.__VINEXT_RSC_NAV__) {
- restoreHydrationNavigationContext(
- vinext.__VINEXT_RSC_NAV__.pathname,
- vinext.__VINEXT_RSC_NAV__.searchParams,
- params,
- );
+ applyHydrationNavigationSnapshot(vinext.__VINEXT_RSC_NAV__, params);
}
return createProgressiveRscStream();
@@ -1421,7 +1437,7 @@ function applyRuntimeRscBootstrap(rsc: NavigationRuntimeRscBootstrap): void {
applyClientParams(rsc.params);
}
if (rsc.nav) {
- restoreHydrationNavigationContext(rsc.nav.pathname, rsc.nav.searchParams, params);
+ applyHydrationNavigationSnapshot(rsc.nav, params);
}
}
diff --git a/packages/vinext/src/server/app-browser-error.ts b/packages/vinext/src/server/app-browser-error.ts
index db7c4ae463..6e05910871 100644
--- a/packages/vinext/src/server/app-browser-error.ts
+++ b/packages/vinext/src/server/app-browser-error.ts
@@ -1,5 +1,6 @@
import { isNavigationSignalError } from "../utils/navigation-signal.js";
import { isUnknownRecord } from "../utils/record.js";
+import { isBailoutToCSRError } from "vinext/shims/navigation-errors";
type VinextHydrateRootErrorInfo = {
componentStack?: string;
@@ -81,5 +82,10 @@ export function prodOnCaughtError(error: unknown, errorInfo: VinextHydrateRootEr
}
export function prodOnRecoverableError(error: unknown): void {
- reportGlobalError(error instanceof Error && error.cause !== undefined ? error.cause : error);
+ const cause = error instanceof Error && error.cause !== undefined ? error.cause : error;
+ // Static Client Component hooks intentionally leave an incomplete Suspense
+ // boundary for the browser to render. Match Next.js and do not report that
+ // expected bailout as a hydration failure.
+ if (isBailoutToCSRError(cause)) return;
+ reportGlobalError(cause);
}
diff --git a/packages/vinext/src/server/app-page-cache-finalizer.ts b/packages/vinext/src/server/app-page-cache-finalizer.ts
index ff3116e744..4bb4f84b5a 100644
--- a/packages/vinext/src/server/app-page-cache-finalizer.ts
+++ b/packages/vinext/src/server/app-page-cache-finalizer.ts
@@ -37,6 +37,7 @@ type BuildAppPageCacheRenderObservation = (input: {
type FinalizeAppPageHtmlCacheResponseOptions = {
capturedDynamicUsageBeforeContextCleanup?: () => boolean;
capturedRscDataPromise: Promise | null;
+ searchParamsAccessedPromise?: Promise;
cleanPathname: string;
consumeDynamicUsage: () => boolean;
dynamicUsageCheckComplete?: boolean;
@@ -169,8 +170,10 @@ export function finalizeAppPageHtmlCacheResponse(
const cachePromise = (async () => {
try {
const cachedHtml = await readStreamAsText(streamForCache);
+ const searchParamsAccessed = await (options.searchParamsAccessedPromise ?? false);
if (
+ searchParamsAccessed ||
options.capturedDynamicUsageBeforeContextCleanup?.() === true ||
options.consumeDynamicUsage()
) {
diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts
index dfdd61a712..4dde283f6c 100644
--- a/packages/vinext/src/server/app-page-dispatch.ts
+++ b/packages/vinext/src/server/app-page-dispatch.ts
@@ -248,6 +248,7 @@ export type DispatchAppPageOptions = {
},
) => Promise;
clientReuseManifest?: ClientReuseManifestParseResult;
+ clientSearchParams?: URLSearchParams;
cleanPathname: string;
displayPathname?: string;
clearRequestContext: () => void;
@@ -354,11 +355,7 @@ export type DispatchAppPageOptions = {
scheduleBackgroundRegeneration: AppPageBackgroundRegenerator;
scriptNonce?: string;
searchParams: URLSearchParams;
- setNavigationContext: (context: {
- params: AppPageParams;
- pathname: string;
- searchParams: URLSearchParams;
- }) => void;
+ setNavigationContext: (context: NavigationContext) => void;
renderMode?: AppRscRenderMode;
};
@@ -523,6 +520,7 @@ async function runAppPageRevalidationContext<
pathname: options.displayPathname ?? options.cleanPathname,
searchParams: new URLSearchParams(),
params: options.params,
+ searchParamsAccessMode: options.dynamicConfig === "force-static" ? "force-static" : "bailout",
});
return await runWithFetchDedupe(renderFn);
});
@@ -578,6 +576,14 @@ async function dispatchAppPageInner(
(!isPrerender || options.pprFallbackShell !== undefined) && serveStreamingMetadata;
const isPrefetchDynamicShell = options.renderMode === APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL;
const isDraftMode = isDraftModeRequest(options.request, options.draftModeSecret);
+ const shouldBailoutClientSearchParams =
+ options.isProduction &&
+ (isDynamicError ||
+ (!isForceDynamic &&
+ !isDraftMode &&
+ options.isProgressiveActionRender !== true &&
+ !options.scriptNonce &&
+ (isPrerender || (currentRevalidateSeconds !== null && currentRevalidateSeconds > 0))));
const requestHeadersContext = getHeadersContext();
const shouldUseEmptySearchParams = isForceStatic || isPrefetchDynamicShell;
const hasRequestSearchParams =
@@ -988,7 +994,13 @@ async function dispatchAppPageInner(
options.setNavigationContext({
pathname: options.displayPathname ?? options.cleanPathname,
searchParams: pageSearchParams,
+ clientSearchParams: options.clientSearchParams,
params: navigationParams,
+ searchParamsAccessMode: isForceStatic
+ ? "force-static"
+ : shouldBailoutClientSearchParams
+ ? "bailout"
+ : "dynamic",
});
const layoutClassifications = getEffectiveLayoutClassifications(
diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts
index cec3a215b2..c7c7455c25 100644
--- a/packages/vinext/src/server/app-page-render.ts
+++ b/packages/vinext/src/server/app-page-render.ts
@@ -775,7 +775,7 @@ export async function renderAppPageLifecycle(
options.isPrerender !== true &&
cdnCacheAdapter.pageCacheMode === "edge";
const shouldCompleteDynamicUsageBeforeResponse =
- usesResponseEdgeCache &&
+ (usesResponseEdgeCache || options.isDynamicError) &&
options.isProgressiveActionRender !== true &&
(revalidateSeconds === null || revalidateSeconds > 0) &&
!options.isDraftMode &&
@@ -1110,7 +1110,11 @@ export async function renderAppPageLifecycle(
});
let dynamicUsageCheckComplete = false;
- if (shouldCompleteDynamicUsageBeforeResponse && !dynamicUsedDuringRender) {
+ if (
+ shouldCompleteDynamicUsageBeforeResponse &&
+ !dynamicUsageCheckComplete &&
+ !dynamicUsedDuringRender
+ ) {
// A CDN must decide from the response headers whether to cache the body,
// unlike the origin cache which can wait for the stream to drain before
// committing it. Inspect CDN-managed candidates within strict time/size
@@ -1120,7 +1124,9 @@ export async function renderAppPageLifecycle(
const verification = await verifyCdnCacheCandidateStream(safeHtmlStream);
safeHtmlStream = verification.stream;
if (verification.complete) {
- dynamicUsedDuringRender = dynamicUsedBeforeContextCleanup || options.consumeDynamicUsage();
+ const searchParamsAccessed = await (htmlRender.searchParamsAccessed ?? false);
+ dynamicUsedDuringRender =
+ searchParamsAccessed || dynamicUsedBeforeContextCleanup || options.consumeDynamicUsage();
dynamicUsedDuringHtmlRender = dynamicUsedDuringRender;
dynamicUsageCheckComplete = true;
}
@@ -1194,6 +1200,7 @@ export async function renderAppPageLifecycle(
return dynamicUsedBeforeContextCleanup;
},
capturedRscDataPromise: capturedRscDataRef.value,
+ searchParamsAccessedPromise: htmlRender.searchParamsAccessed,
cleanPathname: options.cleanPathname,
consumeDynamicUsage: options.consumeDynamicUsage,
dynamicUsageCheckComplete,
diff --git a/packages/vinext/src/server/app-page-response.ts b/packages/vinext/src/server/app-page-response.ts
index 6554884a95..f5fcb12644 100644
--- a/packages/vinext/src/server/app-page-response.ts
+++ b/packages/vinext/src/server/app-page-response.ts
@@ -221,6 +221,13 @@ export function resolveAppPageHtmlResponsePolicy(
};
}
+ if (options.dynamicUsedDuringRender && options.isDynamicError) {
+ return {
+ cacheControl: NO_STORE_CACHE_CONTROL,
+ shouldWriteToCache: false,
+ };
+ }
+
if ((options.isForceStatic || options.isDynamicError) && options.revalidateSeconds === null) {
return {
cacheControl: STATIC_CACHE_CONTROL,
diff --git a/packages/vinext/src/server/app-page-stream.ts b/packages/vinext/src/server/app-page-stream.ts
index e77b15c228..b6b9f66ba2 100644
--- a/packages/vinext/src/server/app-page-stream.ts
+++ b/packages/vinext/src/server/app-page-stream.ts
@@ -27,6 +27,7 @@ export type AppSsrRenderResult = {
htmlStream: ReadableStream;
metadataReady: Promise;
capturedRscData: Promise | null;
+ searchParamsAccessed?: Promise;
shellErrorRecovered?: boolean;
/**
* Preload `Link` header value emitted by React during SSR (via `onHeaders`),
@@ -56,6 +57,7 @@ function normalizeAppSsrRenderResult(
htmlStream: raw,
metadataReady: resolvedMetadataReady,
capturedRscData: fallbackCapturedRscData,
+ searchParamsAccessed: Promise.resolve(false),
shellErrorRecovered: false,
};
}
@@ -206,6 +208,7 @@ type AppPageHtmlStreamRecoveryResult = {
response: Response | null;
metadataReady: Promise;
capturedRscData: Promise | null;
+ searchParamsAccessed: Promise;
shellErrorRecovered: boolean;
/** React-emitted preload `Link` header (already capped). */
linkHeader?: string;
@@ -310,14 +313,21 @@ export async function renderAppPageHtmlStreamWithRecovery(
): Promise {
try {
const rawResult = await options.renderHtmlStream();
- const { htmlStream, metadataReady, capturedRscData, linkHeader, shellErrorRecovered } =
- normalizeAppSsrRenderResult(rawResult);
+ const {
+ htmlStream,
+ metadataReady,
+ capturedRscData,
+ searchParamsAccessed,
+ linkHeader,
+ shellErrorRecovered,
+ } = normalizeAppSsrRenderResult(rawResult);
options.onShellRendered?.();
return {
htmlStream,
response: null,
metadataReady,
capturedRscData,
+ searchParamsAccessed: searchParamsAccessed ?? Promise.resolve(false),
shellErrorRecovered: shellErrorRecovered === true,
linkHeader,
};
@@ -329,6 +339,7 @@ export async function renderAppPageHtmlStreamWithRecovery(
response: await options.renderSpecialErrorResponse(specialError),
metadataReady: resolvedMetadataReady,
capturedRscData: null,
+ searchParamsAccessed: Promise.resolve(false),
shellErrorRecovered: false,
};
}
@@ -340,6 +351,7 @@ export async function renderAppPageHtmlStreamWithRecovery(
response: boundaryResponse,
metadataReady: resolvedMetadataReady,
capturedRscData: null,
+ searchParamsAccessed: Promise.resolve(false),
shellErrorRecovered: false,
};
}
diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts
index c4270b8062..94c4a6d8cd 100644
--- a/packages/vinext/src/server/app-rsc-handler.ts
+++ b/packages/vinext/src/server/app-rsc-handler.ts
@@ -153,6 +153,7 @@ function applyMiddlewareContextToResponse(
type DispatchMatchedPageOptions = {
clientReuseManifest: ClientReuseManifestParseResult;
+ clientSearchParams: URLSearchParams;
cleanPathname: string;
displayPathname: string;
formState: ReactFormState | null;
@@ -271,6 +272,7 @@ type RenderPagesFallbackOptions = {
};
type NavigationContextValue = {
+ clientSearchParams?: URLSearchParams;
params: AppPageParams;
pathname: string;
searchParams: URLSearchParams;
@@ -608,6 +610,7 @@ async function handleAppRscRequest(
// has/missing matching. This mirrors Next.js' navigation middleware fixture.
const normalizedUserlandRequest = requestWithoutRscSuffix(request);
const userlandRequest = requestWithoutRscCacheBustingSearchParam(normalizedUserlandRequest);
+ const visibleSearchParams = new URL(userlandRequest.url).searchParams;
const middlewareContext: AppRscMiddlewareContext = {
headers: null,
requestHeaders: null,
@@ -797,6 +800,7 @@ async function handleAppRscRequest(
options.setNavigationContext({
pathname: canonicalPathname,
searchParams: getResolvedSearchParams(),
+ clientSearchParams: visibleSearchParams,
params: {},
});
@@ -1110,6 +1114,7 @@ async function handleAppRscRequest(
options.setNavigationContext({
pathname: canonicalPathname,
searchParams: resolvedSearchParams,
+ clientSearchParams: visibleSearchParams,
params: renderParams,
});
const rootParams = pickRootParams(renderParams, route.rootParamNames);
@@ -1150,6 +1155,7 @@ async function handleAppRscRequest(
const pageResponse = await options.dispatchMatchedPage({
clientReuseManifest,
+ clientSearchParams: visibleSearchParams,
cleanPathname,
displayPathname: canonicalPathname,
formState,
diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts
index 714f72d851..c18ac92349 100644
--- a/packages/vinext/src/server/app-ssr-entry.ts
+++ b/packages/vinext/src/server/app-ssr-entry.ts
@@ -10,6 +10,7 @@ import type { RenderToReadableStreamOptions } from "react-dom/server";
import { renderToReadableStream, renderToStaticMarkup } from "react-dom/server.edge";
import clientReferences from "virtual:vite-rsc/client-references";
import type { NavigationContext } from "vinext/shims/navigation-server";
+import { isBailoutToCSRError } from "vinext/shims/navigation-errors";
import {
ServerInsertedHTMLContext,
clearServerInsertedHTML,
@@ -237,7 +238,8 @@ function renderInsertedHtml(insertedElements: readonly unknown[]): string {
insertedHTML += renderToStaticMarkup(
createReactElement(Fragment, null, element as ReactNode),
);
- } catch {
+ } catch (error) {
+ if (isBailoutToCSRError(error)) throw error;
// Ignore individual callback failures so the rest of the page can render.
}
}
@@ -315,7 +317,11 @@ function buildHeadInjectionHtml(
): string {
const navPayload = {
pathname: navContext.pathname,
- searchParams: [...navContext.searchParams.entries()],
+ // Search params belong to the browser URL, not a pathname-keyed HTML
+ // artifact that may be reused by another request. The browser entry
+ // exposes them from window.location after the neutral snapshot hydrates.
+ searchParams: [] as [string, string][],
+ searchParamsAccessMode: navContext.searchParamsAccessMode,
};
const rscMetadataScript = createInlineScriptTag(
createNavigationRuntimeRscMetadataScript(
@@ -393,7 +399,35 @@ export async function handleSsr(
},
): Promise {
return runWithNavigationContext(async () => {
- const ssrNavigationContext = requireNavigationContext(navContext);
+ const incomingNavigationContext = requireNavigationContext(navContext);
+ let didAccessSearchParams = false;
+ let resolveSearchParamsAccessed!: (accessed: boolean) => void;
+ const searchParamsAccessed = new Promise((resolve) => {
+ resolveSearchParamsAccessed = resolve;
+ });
+ let didResolveSearchParamsAccessed = false;
+ const resolveSearchParamsAccess = (): void => {
+ if (didResolveSearchParamsAccessed) return;
+ didResolveSearchParamsAccessed = true;
+ resolveSearchParamsAccessed(didAccessSearchParams);
+ };
+ const ssrNavigationContext: NavigationContext = {
+ ...incomingNavigationContext,
+ searchParams:
+ incomingNavigationContext.searchParamsAccessMode === "force-static"
+ ? incomingNavigationContext.searchParams
+ : (incomingNavigationContext.clientSearchParams ??
+ incomingNavigationContext.searchParams),
+ onSearchParamsAccess() {
+ if (incomingNavigationContext.searchParamsAccessMode === "force-static") return;
+ // Next.js bails client useSearchParams() to the nearest Suspense
+ // boundary during static generation. Vinext can render it during SSR,
+ // so keep the response private instead. Resolve this observation only
+ // when the stream flushes: useServerInsertedHTML callbacks are rendered
+ // by the stream transform after the main Fizz tree reaches allReady.
+ didAccessSearchParams = true;
+ },
+ };
await clientReferencePreloader.preload();
@@ -402,6 +436,7 @@ export async function handleSsr(
clearServerInsertedHTML();
const cleanup = (): void => {
+ resolveSearchParamsAccess();
setNavigationContext(null);
clearServerInsertedHTML();
};
@@ -678,7 +713,19 @@ export async function handleSsr(
};
let didInjectHeadHTML = false;
const getInsertedHTML = (): string => {
- const insertedHTML = renderInsertedHtml(renderServerInsertedHTML());
+ const previousSearchParamsAccessMode = ssrNavigationContext.searchParamsAccessMode;
+ ssrNavigationContext.searchParamsAccessMode = "bailout";
+ let insertedHTML: string;
+ try {
+ // Next.js executes these callbacks outside the component request
+ // scope. Client navigation hooks are therefore invalid here in
+ // both development and production, even on an otherwise dynamic
+ // route. Use the same bailout sentinel as static SSR so the late
+ // stream failure cannot be mistaken for cacheable output.
+ insertedHTML = renderInsertedHtml(renderServerInsertedHTML());
+ } finally {
+ ssrNavigationContext.searchParamsAccessMode = previousSearchParamsAccessMode;
+ }
const errorMetaHTML = errorMetaRenderer.flush();
const initialDevServerErrorHTML = createInitialDevServerErrorScript(
options?.initialDevServerError,
@@ -740,6 +787,7 @@ export async function handleSsr(
// this promise expecting it to be load-bearing in production.
metadataReady: Promise.resolve(),
capturedRscData: options?.capturedRscDataRef?.value ?? null,
+ searchParamsAccessed,
shellErrorRecovered,
linkHeader: reactLinkHeader,
};
diff --git a/packages/vinext/src/shims/navigation-context-state.ts b/packages/vinext/src/shims/navigation-context-state.ts
index 5077b7b824..a9480306b6 100644
--- a/packages/vinext/src/shims/navigation-context-state.ts
+++ b/packages/vinext/src/shims/navigation-context-state.ts
@@ -1,4 +1,5 @@
import * as React from "react";
+import { isBailoutToCSRError } from "./navigation-errors.js";
const LAYOUT_SEGMENT_CONTEXT_KEY = Symbol.for("vinext.layoutSegmentContext");
const SERVER_INSERTED_HTML_CONTEXT_KEY = Symbol.for("vinext.serverInsertedHTMLContext");
@@ -18,7 +19,16 @@ export type SegmentMap = Readonly> & {
export type NavigationContext = {
pathname: string;
searchParams: URLSearchParams;
+ /** Browser-visible query before middleware and config rewrites. */
+ clientSearchParams?: URLSearchParams;
params: Record;
+ searchParamsAccessMode?: "dynamic" | "bailout" | "force-static";
+ /**
+ * SSR runs in a separate Vite environment. Its request-local callback records
+ * Client Component useSearchParams() access and reports the result back to
+ * the RSC-owned cache lifecycle after the HTML stream is consumed.
+ */
+ onSearchParamsAccess?: () => void;
};
type NavigationContextsGlobal = typeof globalThis & {
@@ -193,7 +203,8 @@ function renderInsertedHTMLCallbacks(clear: boolean): unknown[] {
try {
const result = callback();
if (result != null) results.push(result);
- } catch {
+ } catch (error) {
+ if (isBailoutToCSRError(error)) throw error;
// One style registry must not suppress output from the others.
}
}
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 7f397eb4b1..2f91b65857 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -54,6 +54,7 @@ import { stripBasePath } from "../utils/base-path.js";
import { isBotUserAgent } from "../utils/html-limited-bots.js";
import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js";
import { assertSafeNavigationUrl } from "./url-safety.js";
+import { BailoutToCSRError } from "./navigation-errors.js";
import { markPprFallbackShellDynamicBoundary } from "./ppr-fallback-shell.js";
import { AppRouterContext, type AppRouterInstance } from "./internal/app-router-context.js";
import { getPagesNavigationContext as _getPagesNavigationContext } from "./internal/pages-router-accessor.js";
@@ -1336,7 +1337,14 @@ let _cachedEmptyClientSearchParams: ReadonlyURLSearchParams | null = null;
* be visible until the next commit.
*/
function getSearchParamsSnapshot(): ReadonlyURLSearchParams {
- if (getNavigationContext()) return getServerSearchParamsSnapshot();
+ const context = getNavigationContext();
+ if (
+ context &&
+ context.searchParamsAccessMode !== "bailout" &&
+ context.searchParamsAccessMode !== "force-static"
+ ) {
+ return getServerSearchParamsSnapshot();
+ }
const pagesCtx = _getPagesNavigationContext();
if (pagesCtx) {
@@ -1649,6 +1657,20 @@ export function usePathname(): string | null {
export function useSearchParams(): ReadonlyURLSearchParams {
if (isServer) {
markPprFallbackShellDynamicBoundary();
+ const navigationContext = getNavigationContext();
+ if (navigationContext?.searchParamsAccessMode === "bailout") {
+ // Next.js turns Client Component useSearchParams() into a CSR bailout
+ // during static generation. A surrounding Suspense boundary can then be
+ // cached safely and the browser reads the current URL during hydration.
+ // Ported from Next.js: packages/next/src/server/app-render/dynamic-rendering.ts
+ throw new BailoutToCSRError("useSearchParams()");
+ }
+ // Client Components execute this branch during SSR. App Router search
+ // params are request-specific, so observing them must invalidate a shared
+ // ISR render just like reading the page-level searchParams prop does.
+ // Keep the Pages Router compatibility path unchanged: it has its own cache
+ // semantics and does not install an App navigation context.
+ navigationContext?.onSearchParamsAccess?.();
// During SSR for "use client" components, the navigation context may not be set.
// getServerSearchParamsSnapshot also covers the Pages Router compat shim.
return getServerSearchParamsSnapshot();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 802d62cd4e..8419142110 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1169,6 +1169,8 @@ importers:
specifier: 'catalog:'
version: 0.2.2(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.9(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)
+ tests/fixtures/cdn-late-dynamic: {}
+
tests/fixtures/cf-app-basic:
dependencies:
'@cloudflare/vite-plugin':
diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts
index a39133c659..3729c5c36a 100644
--- a/tests/app-browser-entry.test.ts
+++ b/tests/app-browser-entry.test.ts
@@ -7,6 +7,7 @@ import {
prodOnCaughtError,
prodOnRecoverableError,
} from "../packages/vinext/src/server/app-browser-error.js";
+import { BailoutToCSRError } from "../packages/vinext/src/shims/navigation-errors.js";
import {
clearAppNavigationFailureTarget,
handleAppNavigationFailure,
@@ -8038,6 +8039,14 @@ describe("prodOnRecoverableError (hydrateRoot prod handler)", () => {
expect(reportErrorSpy).toHaveBeenCalledWith(cause);
});
});
+
+ it("does not report an expected static-generation CSR bailout", () => {
+ withFakeReportError((reportErrorSpy) => {
+ const cause = new BailoutToCSRError("useSearchParams()");
+ prodOnRecoverableError(new Error("recoverable", { cause }));
+ expect(reportErrorSpy).not.toHaveBeenCalled();
+ });
+ });
});
describe("app browser form-state hydration", () => {
diff --git a/tests/app-cdn-cache-production.test.ts b/tests/app-cdn-cache-production.test.ts
index b01251c592..72e4e816f0 100644
--- a/tests/app-cdn-cache-production.test.ts
+++ b/tests/app-cdn-cache-production.test.ts
@@ -133,6 +133,72 @@ describe("App Router production responses with a CDN-managed cache", () => {
expect(first.headers.get("cache-control")).toMatch(/no-store/);
});
+ it("edge-caches the Suspense bailout for ISR client search params", async () => {
+ const edge = createWorkersLikeEdge(handler);
+ const pathname = `/query-isr/query-${Date.now()}`;
+
+ const attacker = await edge.fetch(
+ new Request(`https://example.com${pathname}?q=EDGE_ATTACKER_PAYLOAD`),
+ );
+ const attackerHtml = await attacker.text();
+ expect(attackerHtml).toContain('id="query-loading"');
+ expect(attackerHtml).not.toContain("EDGE_ATTACKER_PAYLOAD");
+ expect(attacker.headers.get("cdn-cache-control")).toMatch(/public, max-age=60/);
+
+ const victim = await edge.fetch(new Request(`https://example.com${pathname}`));
+ const victimHtml = await victim.text();
+ expect(victimHtml).toBe(attackerHtml);
+ expect(victimHtml).not.toContain("EDGE_ATTACKER_PAYLOAD");
+ expect(edge.originRequests).toBe(1);
+ });
+
+ it("rejects query access while serializing server-inserted HTML", async () => {
+ const edge = createWorkersLikeEdge(handler);
+ const pathname = `/query-inserted-error/query-${Date.now()}`;
+
+ await expect(
+ edge.fetch(new Request(`https://example.com${pathname}?q=INSERTED_EDGE_ATTACKER`)),
+ ).rejects.toThrow("Bail out to client-side rendering: useSearchParams()");
+ await expect(edge.fetch(new Request(`https://example.com${pathname}`))).rejects.toThrow(
+ "Bail out to client-side rendering: useSearchParams()",
+ );
+ expect(edge.originRequests).toBe(2);
+ });
+
+ it("rejects unbounded dynamic-error client search params", async () => {
+ const edge = createWorkersLikeEdge(handler);
+ const pathname = `/query-dynamic-error-unbounded/query-${Date.now()}`;
+
+ const first = await edge.fetch(
+ new Request(`https://example.com${pathname}?q=EDGE_ATTACKER_PAYLOAD`),
+ );
+ expect(first.status).toBe(500);
+ expect(first.headers.get("cdn-cache-control")).toBeNull();
+ expect(first.headers.get("cache-control")).toMatch(/no-store/);
+
+ const second = await edge.fetch(new Request(`https://example.com${pathname}`));
+ expect(second.status).toBe(500);
+ expect(second.headers.get("cdn-cache-control")).toBeNull();
+ expect(edge.originRequests).toBe(2);
+ });
+
+ it("edge-caches dynamic-error client search params as a Suspense bailout", async () => {
+ const edge = createWorkersLikeEdge(handler);
+ const pathname = `/query-dynamic-error/query-${Date.now()}`;
+
+ const first = await edge.fetch(
+ new Request(`https://example.com${pathname}?q=EDGE_ATTACKER_PAYLOAD`),
+ );
+ const firstHtml = await first.text();
+ expect(firstHtml).toContain("query fallback");
+ expect(firstHtml).not.toContain("EDGE_ATTACKER_PAYLOAD");
+ expect(first.headers.get("cdn-cache-control")).toMatch(/public, max-age=60/);
+
+ const second = await edge.fetch(new Request(`https://example.com${pathname}`));
+ expect(await second.text()).toBe(firstHtml);
+ expect(edge.originRequests).toBe(1);
+ });
+
it("still lets the edge cache a deferred response that remains static", async () => {
const edge = createWorkersLikeEdge(handler);
diff --git a/tests/app-page-cache.test.ts b/tests/app-page-cache.test.ts
index 15ebccd422..0f9604ed7f 100644
--- a/tests/app-page-cache.test.ts
+++ b/tests/app-page-cache.test.ts
@@ -1102,6 +1102,48 @@ describe("app page cache helpers", () => {
]);
});
+ it("waits for SSR search params observation before writing the HTML cache", async () => {
+ const pendingCacheWrites: Promise[] = [];
+ const isrSet = vi.fn();
+ let resolveSearchParamsAccessed!: (accessed: boolean) => void;
+ const searchParamsAccessedPromise = new Promise((resolve) => {
+ resolveSearchParamsAccessed = resolve;
+ });
+
+ const response = finalizeAppPageHtmlCacheResponse(new Response("query-specific
"), {
+ capturedRscDataPromise: null,
+ cleanPathname: "/search-params-observation",
+ consumeDynamicUsage() {
+ return false;
+ },
+ getPageTags() {
+ return ["/search-params-observation"];
+ },
+ isrHtmlKey(pathname) {
+ return "html:" + pathname;
+ },
+ isrRscKey(pathname) {
+ return "rsc:" + pathname;
+ },
+ isrSet,
+ revalidateSeconds: 60,
+ searchParamsAccessedPromise,
+ waitUntil(promise) {
+ pendingCacheWrites.push(promise);
+ },
+ });
+
+ await expect(response.text()).resolves.toBe("query-specific
");
+ expect(pendingCacheWrites).toHaveLength(1);
+
+ await Promise.resolve();
+ expect(isrSet).not.toHaveBeenCalled();
+
+ resolveSearchParamsAccessed(true);
+ await pendingCacheWrites[0];
+ expect(isrSet).not.toHaveBeenCalled();
+ });
+
it("skips HTML and RSC cache writes when dynamic usage was captured before context cleanup", async () => {
const pendingCacheWrites: Promise[] = [];
const debugCalls: Array<[string, string]> = [];
diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts
index 544cb96517..eef0500e34 100644
--- a/tests/app-page-dispatch.test.ts
+++ b/tests/app-page-dispatch.test.ts
@@ -1456,9 +1456,7 @@ describe("app page dispatch", () => {
});
try {
- const response = await dispatchAppPage(options);
-
- await expect(response.text()).rejects.toThrow('Page with `dynamic = "error"`');
+ await expect(dispatchAppPage(options)).rejects.toThrow('Page with `dynamic = "error"`');
expect(receivedQueries).toEqual([]);
} finally {
setHeadersContext(null);
diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts
index d0433a7242..5d6f7290e6 100644
--- a/tests/app-page-render.test.ts
+++ b/tests/app-page-render.test.ts
@@ -873,6 +873,36 @@ describe("app page render lifecycle", () => {
}
});
+ it("keeps CDN-managed HTML private when SSR reports useSearchParams access", async () => {
+ setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
+ const common = createCommonOptions();
+
+ try {
+ const response = await renderAppPageLifecycle({
+ ...common.options,
+ isProduction: true,
+ revalidateSeconds: 30,
+ loadSsrHandler: async () => ({
+ async handleSsr() {
+ return {
+ htmlStream: createStream(["query-specific"]),
+ metadataReady: Promise.resolve(),
+ capturedRscData: null,
+ searchParamsAccessed: Promise.resolve(true),
+ };
+ },
+ }),
+ });
+
+ expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
+ expect(response.headers.get("cdn-cache-control")).toBeNull();
+ await expect(response.text()).resolves.toBe("query-specific");
+ expect(common.isrSet).not.toHaveBeenCalled();
+ } finally {
+ setCdnCacheAdapter(new DefaultCdnCacheAdapter());
+ }
+ });
+
it("does not wait for cacheLife-only RSC capture before returning production HTML responses", async () => {
const common = createCommonOptions();
const releaseRsc = createDeferred();
diff --git a/tests/app-page-response.test.ts b/tests/app-page-response.test.ts
index 6f5041979f..5f9ea7527c 100644
--- a/tests/app-page-response.test.ts
+++ b/tests/app-page-response.test.ts
@@ -150,6 +150,24 @@ describe("app page response helpers", () => {
});
});
+ it("keeps late dynamic-error HTML observations private", () => {
+ expect(
+ resolveAppPageHtmlResponsePolicy({
+ dynamicUsedDuringRender: true,
+ hasScriptNonce: false,
+ isDraftMode: false,
+ isDynamicError: true,
+ isForceDynamic: false,
+ isForceStatic: false,
+ isProduction: true,
+ revalidateSeconds: null,
+ }),
+ ).toEqual({
+ cacheControl: "no-store, must-revalidate",
+ shouldWriteToCache: false,
+ });
+ });
+
it("resolves HTML response policy precedence", () => {
expect(
resolveAppPageHtmlResponsePolicy({
diff --git a/tests/app-router-production-server.test.ts b/tests/app-router-production-server.test.ts
index 6d2c1fc396..2f360a5695 100644
--- a/tests/app-router-production-server.test.ts
+++ b/tests/app-router-production-server.test.ts
@@ -1611,6 +1611,103 @@ describe("App Router Production server (startProdServer)", () => {
expect(html2).not.toContain('"filter">alpha<');
});
+ // Next.js treats useSearchParams() during static generation as a dynamic/CSR
+ // boundary rather than caching request-specific HTML.
+ // Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
+ // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts
+ it("caches client useSearchParams behind its ISR Suspense boundary", async () => {
+ const slug = `query-poison-${Date.now()}`;
+ const pathname = `/isr-client-search-poison/${slug}`;
+
+ const attackerRes = await fetch(`${baseUrl}${pathname}?q=ATTACKER_PAYLOAD`);
+ expect(attackerRes.status).toBe(200);
+ const attackerHtml = await attackerRes.text();
+ expect(attackerHtml).toContain("loading");
+ expect(attackerHtml).not.toContain("ATTACKER_PAYLOAD");
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const victimRes = await fetch(`${baseUrl}${pathname}`);
+ const victimHtml = await victimRes.text();
+ expect(victimRes.headers.get("x-vinext-cache")).toBe("HIT");
+ expect(victimHtml).not.toContain("ATTACKER_PAYLOAD");
+ expect(victimHtml).toContain("loading");
+
+ const otherVictimRes = await fetch(`${baseUrl}${pathname}?q=INNOCENT_PAYLOAD`);
+ const otherVictimHtml = await otherVictimRes.text();
+ expect(otherVictimRes.headers.get("x-vinext-cache")).toBe("HIT");
+ expect(otherVictimHtml).not.toContain("ATTACKER_PAYLOAD");
+ expect(otherVictimHtml).toContain("loading");
+ expect(otherVictimHtml).not.toContain("INNOCENT_PAYLOAD");
+ });
+
+ it("keeps force-static client search params out of reusable ISR HTML", async () => {
+ const pathname = "/isr-client-search-force-static";
+ const firstRes = await fetch(`${baseUrl}${pathname}?q=FIRST_QUERY`);
+ expect(firstRes.status).toBe(200);
+ const firstHtml = await firstRes.text();
+ expect(firstHtml).toContain("Force-static query: none");
+ expect(firstHtml).not.toContain("FIRST_QUERY");
+
+ const secondRes = await fetch(`${baseUrl}${pathname}?q=SECOND_QUERY`);
+ expect(secondRes.status).toBe(200);
+ expect(secondRes.headers.get("x-vinext-cache")).toBe("HIT");
+ const secondHtml = await secondRes.text();
+ expect(secondHtml).toContain("Force-static query: none");
+ expect(secondHtml).not.toContain("FIRST_QUERY");
+ expect(secondHtml).not.toContain("SECOND_QUERY");
+ });
+
+ it("bails client useSearchParams to Suspense under dynamic = 'error'", async () => {
+ const slug = `dynamic-error-query-${Date.now()}`;
+ const firstRes = await fetch(
+ `${baseUrl}/isr-client-search-dynamic-error/${slug}?q=REQUEST_SPECIFIC`,
+ );
+ expect(firstRes.status).toBe(200);
+ const firstHtml = await firstRes.text();
+ expect(firstHtml).toContain("loading");
+ expect(firstHtml).not.toContain("REQUEST_SPECIFIC");
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const secondRes = await fetch(`${baseUrl}/isr-client-search-dynamic-error/${slug}`);
+ expect(secondRes.status).toBe(200);
+ expect(secondRes.headers.get("x-vinext-cache")).toBe("HIT");
+ const secondHtml = await secondRes.text();
+ expect(secondHtml).toContain("loading");
+ expect(secondHtml).not.toContain("REQUEST_SPECIFIC");
+ });
+
+ it("rejects search params read while serializing server-inserted HTML", async () => {
+ const slug = `inserted-query-${Date.now()}`;
+ const pathname = `/isr-client-search-inserted-error/${slug}`;
+
+ const attackerRes = await fetch(`${baseUrl}${pathname}?q=INSERTED_ATTACKER`);
+ expect(attackerRes.status).toBe(500);
+ expect(await attackerRes.text()).not.toContain("INSERTED_ATTACKER");
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const victimRes = await fetch(`${baseUrl}${pathname}`);
+ expect(victimRes.status).toBe(500);
+ expect(victimRes.headers.get("x-vinext-cache")).not.toBe("HIT");
+ const victimHtml = await victimRes.text();
+ expect(victimHtml).not.toContain("INSERTED_ATTACKER");
+ });
+
+ it("rejects unbounded dynamic-error client search params without caching", async () => {
+ const slug = `unbounded-query-${Date.now()}`;
+ const pathname = `/isr-client-search-dynamic-error-unbounded/${slug}`;
+
+ const attackerRes = await fetch(`${baseUrl}${pathname}?q=UNBOUNDED_ATTACKER`);
+ expect(attackerRes.status).toBe(500);
+ expect(await attackerRes.text()).not.toContain("UNBOUNDED_ATTACKER");
+
+ const victimRes = await fetch(`${baseUrl}${pathname}`);
+ expect(victimRes.status).toBe(500);
+ expect(victimRes.headers.get("x-vinext-cache")).not.toBe("HIT");
+ });
+
// 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/e2e/app-router/nextjs-compat/client-cache.spec.ts b/tests/e2e/app-router/nextjs-compat/client-cache.spec.ts
index 9cb33234e2..5c89e85be8 100644
--- a/tests/e2e/app-router/nextjs-compat/client-cache.spec.ts
+++ b/tests/e2e/app-router/nextjs-compat/client-cache.spec.ts
@@ -436,3 +436,170 @@ test.describe("Next.js compat: client cache", () => {
expect(requestsFor(requests, `${ROOT}/2`)).toEqual([]);
});
});
+
+test("ISR hydration uses the current visitor's search params", async ({ page, request }) => {
+ const hydrationErrors: string[] = [];
+ page.on("console", (message) => {
+ if (message.type() === "error") hydrationErrors.push(message.text());
+ });
+ page.on("pageerror", (error) => hydrationErrors.push(error.message));
+
+ const slug = `hydration-query-${Date.now()}`;
+ const pathname = `/isr-client-search-poison/${slug}`;
+
+ const attackerResponse = await request.get(`${pathname}?q=ATTACKER_PAYLOAD`);
+ expect(attackerResponse.status()).toBe(200);
+ const attackerHtml = await attackerResponse.text();
+ expect(attackerHtml).toContain("loading");
+ expect(attackerHtml).not.toContain("ATTACKER_PAYLOAD");
+
+ await page.waitForTimeout(100);
+
+ const victimResponse = await page.goto(pathname);
+ expect(victimResponse?.headers()["x-vinext-cache"]).toBe("HIT");
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("query-echo")).toHaveText("Search query: none");
+
+ const otherVictimResponse = await page.goto(`${pathname}?q=INNOCENT_PAYLOAD`);
+ expect(otherVictimResponse?.headers()["x-vinext-cache"]).toBe("HIT");
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("query-echo")).toHaveText("Search query: INNOCENT_PAYLOAD");
+ await expect(page.locator("body")).not.toContainText("ATTACKER_PAYLOAD");
+ expect(hydrationErrors).toEqual([]);
+});
+
+test("force-static hydration reads search params from the current URL", async ({ page }) => {
+ const hydrationErrors: string[] = [];
+ page.on("console", (message) => {
+ if (message.type() === "error") hydrationErrors.push(message.text());
+ });
+ page.on("pageerror", (error) => hydrationErrors.push(error.message));
+
+ const pathname = "/isr-client-search-force-static";
+
+ const firstResponse = await page.goto(`${pathname}?q=FIRST_QUERY`);
+ expect(firstResponse?.status()).toBe(200);
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("force-static-query-echo")).toHaveText(
+ "Force-static query: FIRST_QUERY",
+ );
+
+ const secondResponse = await page.goto(`${pathname}?q=SECOND_QUERY`);
+ expect(secondResponse?.status()).toBe(200);
+ expect(secondResponse?.headers()["x-vinext-cache"]).toBe("HIT");
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("force-static-query-echo")).toHaveText(
+ "Force-static query: SECOND_QUERY",
+ );
+ await expect(page.locator("body")).not.toContainText("FIRST_QUERY");
+ expect(hydrationErrors).toEqual([]);
+});
+
+// Next.js confirms useSearchParams wrapped in Suspense must retain a static shell:
+// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/missing-suspense-with-csr-bailout/missing-suspense-with-csr-bailout.test.ts
+test("dynamic-error client search params bail out to cached Suspense HTML", async ({ page }) => {
+ const hydrationErrors: string[] = [];
+ page.on("console", (message) => {
+ if (message.type() === "error") hydrationErrors.push(message.text());
+ });
+ page.on("pageerror", (error) => hydrationErrors.push(error.message));
+
+ const slug = `dynamic-error-hydration-${Date.now()}`;
+ const pathname = `/isr-client-search-dynamic-error/${slug}`;
+
+ const firstResponse = await page.goto(`${pathname}?q=FIRST_QUERY`);
+ expect(firstResponse?.status()).toBe(200);
+ const firstHtml = await firstResponse?.text();
+ expect(firstHtml).toContain("loading");
+ expect(firstHtml).not.toContain("FIRST_QUERY");
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("dynamic-error-query-echo")).toHaveText(
+ "Dynamic-error query: FIRST_QUERY",
+ );
+
+ await page.waitForTimeout(1_100);
+
+ const secondResponse = await page.goto(`${pathname}?q=SECOND_QUERY`);
+ expect(secondResponse?.status()).toBe(200);
+ expect(secondResponse?.headers()["x-vinext-cache"]).toBe("STALE");
+ const secondHtml = await secondResponse?.text();
+ expect(secondHtml).toContain("loading");
+ expect(secondHtml).not.toContain("SECOND_QUERY");
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("dynamic-error-query-echo")).toHaveText(
+ "Dynamic-error query: SECOND_QUERY",
+ );
+ await expect(page.locator("body")).not.toContainText("FIRST_QUERY");
+
+ await expect
+ .poll(
+ async () => {
+ const response = await page.request.get(pathname);
+ return response.headers()["x-vinext-cache"];
+ },
+ { timeout: 10_000 },
+ )
+ .toBe("HIT");
+
+ const thirdResponse = await page.goto(`${pathname}?q=THIRD_QUERY`);
+ expect(thirdResponse?.status()).toBe(200);
+ expect(thirdResponse?.headers()["x-vinext-cache"]).toBe("HIT");
+ const thirdHtml = await thirdResponse?.text();
+ expect(thirdHtml).toContain("loading");
+ expect(thirdHtml).not.toContain("THIRD_QUERY");
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("dynamic-error-query-echo")).toHaveText(
+ "Dynamic-error query: THIRD_QUERY",
+ );
+ expect(hydrationErrors).toEqual([]);
+});
+
+test("unbounded dynamic-error client search params fail without a cache artifact", async ({
+ request,
+}) => {
+ const slug = `unbounded-dynamic-error-${Date.now()}`;
+ const pathname = `/isr-client-search-dynamic-error-unbounded/${slug}`;
+
+ const first = await request.get(`${pathname}?q=UNBOUNDED_ATTACKER`);
+ expect(first.status()).toBe(500);
+ expect(await first.text()).not.toContain("UNBOUNDED_ATTACKER");
+
+ const second = await request.get(pathname);
+ expect(second.status()).toBe(500);
+ expect(second.headers()["x-vinext-cache"]).not.toBe("HIT");
+});
+
+test("rewrite destination query is hidden from SSR client hooks", async ({ page }) => {
+ const hydrationErrors: string[] = [];
+ page.on("console", (message) => {
+ if (message.type() === "error") hydrationErrors.push(message.text());
+ });
+ page.on("pageerror", (error) => hydrationErrors.push(error.message));
+
+ const response = await page.goto("/nextjs-compat/rewrite-query-visible?shown=yes");
+ expect(response?.status()).toBe(200);
+ const html = await response?.text();
+ expect(html).toContain("server:shown=yes&hidden=secret");
+ expect(html).toContain("client:shown=yes");
+ expect(html).not.toContain("client:shown=yes&hidden=secret");
+
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("rewrite-page-query")).toHaveText("server:shown=yes&hidden=secret");
+ await expect(page.getByTestId("rewrite-client-query")).toHaveText("client:shown=yes");
+ expect(hydrationErrors).toEqual([]);
+});
+
+test("server-inserted callback query access fails without cache publication", async ({
+ request,
+}) => {
+ const slug = `inserted-dynamic-error-${Date.now()}`;
+ const pathname = `/isr-client-search-inserted-error/${slug}`;
+
+ const first = await request.get(`${pathname}?q=INSERTED_ATTACKER`);
+ expect(first.status()).toBe(500);
+ expect(await first.text()).not.toContain("INSERTED_ATTACKER");
+
+ const second = await request.get(pathname);
+ expect(second.status()).toBe(500);
+ expect(second.headers()["x-vinext-cache"]).not.toBe("HIT");
+});
diff --git a/tests/e2e/app-router/search-params-static.spec.ts b/tests/e2e/app-router/search-params-static.spec.ts
new file mode 100644
index 0000000000..93e82c04a3
--- /dev/null
+++ b/tests/e2e/app-router/search-params-static.spec.ts
@@ -0,0 +1,45 @@
+import { expect, test } from "@playwright/test";
+import { waitForAppRouterHydration } from "../helpers";
+
+// Next.js 16.2.7 oracle coverage for development request semantics.
+test("dynamic-error client query hooks stay request-scoped in development", async ({ page }) => {
+ const slug = `dev-query-${Date.now()}`;
+ const response = await page.goto(`/isr-client-search-dynamic-error/${slug}?q=DEVELOPMENT_QUERY`);
+ expect(response?.status()).toBe(200);
+ expect(await response?.text()).toContain("DEVELOPMENT_QUERY");
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("dynamic-error-query-echo")).toHaveText(
+ "Dynamic-error query: DEVELOPMENT_QUERY",
+ );
+});
+
+test("rewrite destination query stays server-only in development", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("console", (message) => {
+ if (message.type() === "error") errors.push(message.text());
+ });
+ page.on("pageerror", (error) => errors.push(error.message));
+
+ const response = await page.goto("/nextjs-compat/rewrite-query-visible?shown=yes");
+ expect(response?.status()).toBe(200);
+ const html = await response?.text();
+ expect(html).toContain("server:shown=yes&hidden=secret");
+ expect(html).toContain("client:shown=yes");
+ expect(html).not.toContain("client:shown=yes&hidden=secret");
+
+ await waitForAppRouterHydration(page);
+ await expect(page.getByTestId("rewrite-page-query")).toHaveText("server:shown=yes&hidden=secret");
+ await expect(page.getByTestId("rewrite-client-query")).toHaveText("client:shown=yes");
+ expect(errors).toEqual([]);
+});
+
+test("server-inserted callbacks cannot read client query hooks in development", async ({
+ request,
+}) => {
+ const slug = `dev-inserted-${Date.now()}`;
+ const response = await request.get(
+ `/isr-client-search-inserted-error/${slug}?q=DEVELOPMENT_QUERY`,
+ );
+ expect(response.status()).toBe(500);
+ expect(await response.text()).not.toContain("DEVELOPMENT_QUERY");
+});
diff --git a/tests/e2e/cloudflare-workers/dynamic-preload.spec.ts b/tests/e2e/cloudflare-workers/dynamic-preload.spec.ts
index fd89f6f130..842ed310f0 100644
--- a/tests/e2e/cloudflare-workers/dynamic-preload.spec.ts
+++ b/tests/e2e/cloudflare-workers/dynamic-preload.spec.ts
@@ -67,4 +67,84 @@ test.describe("Cloudflare Workers dynamic preloads", () => {
void consoleErrors;
});
+
+ test("keeps dynamic-error client queries out of shared Worker HTML", async ({
+ page,
+ request,
+ consoleErrors,
+ }) => {
+ const slug = `worker-query-${Date.now()}`;
+ const pathname = `/query-dynamic-error/${slug}`;
+
+ const first = await request.get(`${BASE_URL}${pathname}?q=WORKER_FIRST`);
+ expect(first.status()).toBe(200);
+ const firstHtml = await first.text();
+ expect(firstHtml).toContain("worker query fallback");
+ expect(firstHtml).not.toContain("WORKER_FIRST");
+
+ await page.waitForTimeout(1_100);
+ const second = await page.goto(`${BASE_URL}${pathname}?q=WORKER_SECOND`);
+ expect(second?.status()).toBe(200);
+ expect(second?.headers()["x-vinext-cache"]).toBe("STALE");
+ const secondHtml = await second?.text();
+ expect(secondHtml).toContain("worker query fallback");
+ expect(secondHtml).not.toContain("WORKER_SECOND");
+ await expect(page.getByTestId("worker-query")).toHaveText("worker query:WORKER_SECOND");
+ await expect(page.locator("body")).not.toContainText("WORKER_FIRST");
+
+ await expect
+ .poll(
+ async () => {
+ const response = await request.get(`${BASE_URL}${pathname}`);
+ return response.headers()["x-vinext-cache"];
+ },
+ { timeout: 10_000 },
+ )
+ .toBe("HIT");
+
+ const third = await page.goto(`${BASE_URL}${pathname}?q=WORKER_THIRD`);
+ expect(third?.status()).toBe(200);
+ expect(third?.headers()["x-vinext-cache"]).toBe("HIT");
+ const thirdHtml = await third?.text();
+ expect(thirdHtml).toContain("worker query fallback");
+ expect(thirdHtml).not.toContain("WORKER_THIRD");
+ await expect(page.getByTestId("worker-query")).toHaveText("worker query:WORKER_THIRD");
+ void consoleErrors;
+ });
+
+ test("rejects unbounded and server-inserted query hooks in a pure App Worker", async ({
+ request,
+ }) => {
+ const slug = `worker-reject-${Date.now()}`;
+
+ const unbounded = await request.get(
+ `${BASE_URL}/query-dynamic-error-unbounded/${slug}?q=WORKER_ATTACKER`,
+ );
+ expect(unbounded.status()).toBe(500);
+ expect(await unbounded.text()).not.toContain("WORKER_ATTACKER");
+
+ const inserted = await request.get(
+ `${BASE_URL}/query-inserted-error/${slug}?q=WORKER_INSERTED_ATTACKER`,
+ );
+ expect(inserted.status()).toBe(500);
+ expect(await inserted.text()).not.toContain("WORKER_INSERTED_ATTACKER");
+ });
+
+ test("keeps rewrite destination query out of Worker client hooks", async ({
+ page,
+ consoleErrors,
+ }) => {
+ const response = await page.goto(`${BASE_URL}/rewrite-query-visible?shown=yes`);
+ expect(response?.status()).toBe(200);
+ const html = await response?.text();
+ expect(html).toContain("server:shown=yes&hidden=secret");
+ expect(html).toContain("client:shown=yes");
+ expect(html).not.toContain("client:shown=yes&hidden=secret");
+
+ await expect(page.getByTestId("worker-rewrite-server")).toHaveText(
+ "server:shown=yes&hidden=secret",
+ );
+ await expect(page.getByTestId("worker-rewrite-client")).toHaveText("client:shown=yes");
+ void consoleErrors;
+ });
});
diff --git a/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error-unbounded/[slug]/client.tsx b/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error-unbounded/[slug]/client.tsx
new file mode 100644
index 0000000000..a2c9cc2816
--- /dev/null
+++ b/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error-unbounded/[slug]/client.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function QueryEcho() {
+ const searchParams = useSearchParams();
+ return worker unbounded query:{searchParams.get("q") ?? "none"}
;
+}
diff --git a/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error-unbounded/[slug]/page.tsx b/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error-unbounded/[slug]/page.tsx
new file mode 100644
index 0000000000..df7c7064c7
--- /dev/null
+++ b/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error-unbounded/[slug]/page.tsx
@@ -0,0 +1,14 @@
+import QueryEcho from "./client";
+
+export const dynamic = "error";
+export const revalidate = 60;
+
+// Ported from Next.js: test/e2e/app-dir/missing-suspense-with-csr-bailout/
+// https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir/missing-suspense-with-csr-bailout
+export default function WorkerUnboundedDynamicErrorPage() {
+ return (
+
+
+
+ );
+}
diff --git a/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error/[slug]/client.tsx b/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error/[slug]/client.tsx
new file mode 100644
index 0000000000..ece21b84c8
--- /dev/null
+++ b/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error/[slug]/client.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function QueryEcho() {
+ const searchParams = useSearchParams();
+ return worker query:{searchParams.get("q") ?? "none"}
;
+}
diff --git a/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error/[slug]/page.tsx b/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error/[slug]/page.tsx
new file mode 100644
index 0000000000..ef596cb9ee
--- /dev/null
+++ b/tests/e2e/cloudflare-workers/fixture/app/query-dynamic-error/[slug]/page.tsx
@@ -0,0 +1,15 @@
+import { Suspense } from "react";
+import QueryEcho from "./client";
+
+export const dynamic = "error";
+export const revalidate = 1;
+
+export default function WorkerDynamicErrorPage() {
+ return (
+
+ worker query fallback
}>
+
+
+
+ );
+}
diff --git a/tests/e2e/cloudflare-workers/fixture/app/query-inserted-error/[slug]/client.tsx b/tests/e2e/cloudflare-workers/fixture/app/query-inserted-error/[slug]/client.tsx
new file mode 100644
index 0000000000..f9daa29dae
--- /dev/null
+++ b/tests/e2e/cloudflare-workers/fixture/app/query-inserted-error/[slug]/client.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { useSearchParams, useServerInsertedHTML } from "next/navigation";
+
+function InsertedQueryMeta() {
+ const searchParams = useSearchParams();
+ return ;
+}
+
+export default function InsertedQueryRegistration() {
+ useServerInsertedHTML(() => );
+ return worker inserted query registered
;
+}
diff --git a/tests/e2e/cloudflare-workers/fixture/app/query-inserted-error/[slug]/page.tsx b/tests/e2e/cloudflare-workers/fixture/app/query-inserted-error/[slug]/page.tsx
new file mode 100644
index 0000000000..5c70e5f9dd
--- /dev/null
+++ b/tests/e2e/cloudflare-workers/fixture/app/query-inserted-error/[slug]/page.tsx
@@ -0,0 +1,12 @@
+import InsertedQueryRegistration from "./client";
+
+export const dynamic = "error";
+export const revalidate = 60;
+
+export default function WorkerInsertedQueryPage() {
+ return (
+
+
+
+ );
+}
diff --git a/tests/e2e/cloudflare-workers/fixture/app/rewrite-query-destination/client.tsx b/tests/e2e/cloudflare-workers/fixture/app/rewrite-query-destination/client.tsx
new file mode 100644
index 0000000000..fa1742591e
--- /dev/null
+++ b/tests/e2e/cloudflare-workers/fixture/app/rewrite-query-destination/client.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function VisibleRewriteQuery() {
+ const searchParams = useSearchParams();
+ return client:{searchParams.toString()}
;
+}
diff --git a/tests/e2e/cloudflare-workers/fixture/app/rewrite-query-destination/page.tsx b/tests/e2e/cloudflare-workers/fixture/app/rewrite-query-destination/page.tsx
new file mode 100644
index 0000000000..593cd16d59
--- /dev/null
+++ b/tests/e2e/cloudflare-workers/fixture/app/rewrite-query-destination/page.tsx
@@ -0,0 +1,17 @@
+import VisibleRewriteQuery from "./client";
+
+export default async function WorkerRewriteQueryDestination({
+ searchParams,
+}: {
+ searchParams: Promise<{ shown?: string; hidden?: string }>;
+}) {
+ const query = await searchParams;
+ return (
+
+
+ server:shown={query.shown ?? "none"}&hidden={query.hidden ?? "none"}
+
+
+
+ );
+}
diff --git a/tests/e2e/cloudflare-workers/fixture/next.config.mjs b/tests/e2e/cloudflare-workers/fixture/next.config.mjs
new file mode 100644
index 0000000000..c680429abf
--- /dev/null
+++ b/tests/e2e/cloudflare-workers/fixture/next.config.mjs
@@ -0,0 +1,10 @@
+export default {
+ async rewrites() {
+ return [
+ {
+ source: "/rewrite-query-visible",
+ destination: "/rewrite-query-destination?hidden=secret",
+ },
+ ];
+ },
+};
diff --git a/tests/e2e/cloudflare-workers/fixture/worker/index.mjs b/tests/e2e/cloudflare-workers/fixture/worker/index.mjs
index 68c9ce4b58..1c8fce1390 100644
--- a/tests/e2e/cloudflare-workers/fixture/worker/index.mjs
+++ b/tests/e2e/cloudflare-workers/fixture/worker/index.mjs
@@ -4,15 +4,18 @@ const CSP = "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';";
export default {
async fetch(request, env, ctx) {
+ // Nonced responses intentionally skip ISR cache reads, so scope the nonce
+ // to the preload route that exercises it.
+ const usesNonce = new URL(request.url).pathname === "/dynamic-preload";
const requestHeaders = new Headers(request.headers);
- requestHeaders.set("content-security-policy", CSP);
+ if (usesNonce) requestHeaders.set("content-security-policy", CSP);
const response = await handler.fetch(
new Request(request, { headers: requestHeaders }),
env,
ctx,
);
const responseHeaders = new Headers(response.headers);
- responseHeaders.set("content-security-policy", CSP);
+ if (usesNonce) responseHeaders.set("content-security-policy", CSP);
return new Response(response.body, {
headers: responseHeaders,
status: response.status,
diff --git a/tests/fixtures/app-basic/app/isr-client-search-dynamic-error-unbounded/[slug]/client.tsx b/tests/fixtures/app-basic/app/isr-client-search-dynamic-error-unbounded/[slug]/client.tsx
new file mode 100644
index 0000000000..a5a4ac5d5b
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-dynamic-error-unbounded/[slug]/client.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function UnboundedDynamicErrorQuery() {
+ const searchParams = useSearchParams();
+ return Unbounded query: {searchParams.get("q") ?? "none"}
;
+}
diff --git a/tests/fixtures/app-basic/app/isr-client-search-dynamic-error-unbounded/[slug]/page.tsx b/tests/fixtures/app-basic/app/isr-client-search-dynamic-error-unbounded/[slug]/page.tsx
new file mode 100644
index 0000000000..23ccf84a8b
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-dynamic-error-unbounded/[slug]/page.tsx
@@ -0,0 +1,15 @@
+import UnboundedDynamicErrorQuery from "./client";
+
+export const dynamic = "error";
+export const revalidate = 60;
+
+// Ported from Next.js: test/e2e/app-dir/missing-suspense-with-csr-bailout/
+// https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir/missing-suspense-with-csr-bailout
+export default function UnboundedDynamicErrorPage() {
+ return (
+
+ Unbounded dynamic-error query
+
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/isr-client-search-dynamic-error/[slug]/client.tsx b/tests/fixtures/app-basic/app/isr-client-search-dynamic-error/[slug]/client.tsx
new file mode 100644
index 0000000000..2e87bfed47
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-dynamic-error/[slug]/client.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function DynamicErrorQueryEcho() {
+ const searchParams = useSearchParams();
+ return (
+
+ Dynamic-error query: {searchParams.get("q") ?? "none"}
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/isr-client-search-dynamic-error/[slug]/page.tsx b/tests/fixtures/app-basic/app/isr-client-search-dynamic-error/[slug]/page.tsx
new file mode 100644
index 0000000000..7b7dd510d5
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-dynamic-error/[slug]/page.tsx
@@ -0,0 +1,16 @@
+import { Suspense } from "react";
+import DynamicErrorQueryEcho from "./client";
+
+export const dynamic = "error";
+export const revalidate = 1;
+
+export default function DynamicErrorClientSearchParamsPage() {
+ return (
+
+ Dynamic-error client search params
+ loading}>
+
+
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/isr-client-search-force-static/client.tsx b/tests/fixtures/app-basic/app/isr-client-search-force-static/client.tsx
new file mode 100644
index 0000000000..42541be252
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-force-static/client.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function ForceStaticQueryEcho() {
+ const searchParams = useSearchParams();
+ return (
+
+ Force-static query: {searchParams.get("q") ?? "none"}
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/isr-client-search-force-static/page.tsx b/tests/fixtures/app-basic/app/isr-client-search-force-static/page.tsx
new file mode 100644
index 0000000000..fd576de8d2
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-force-static/page.tsx
@@ -0,0 +1,16 @@
+import { Suspense } from "react";
+import ForceStaticQueryEcho from "./client";
+
+export const dynamic = "force-static";
+export const revalidate = 60;
+
+export default function ForceStaticClientSearchParamsPage() {
+ return (
+
+ Force-static client search params
+ loading}>
+
+
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/isr-client-search-inserted-error/[slug]/client.tsx b/tests/fixtures/app-basic/app/isr-client-search-inserted-error/[slug]/client.tsx
new file mode 100644
index 0000000000..b76acccf59
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-inserted-error/[slug]/client.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { useSearchParams, useServerInsertedHTML } from "next/navigation";
+
+function InsertedQueryMeta() {
+ const searchParams = useSearchParams();
+ return ;
+}
+
+export default function InsertedQueryRegistration() {
+ useServerInsertedHTML(() => );
+ return Inserted query registered
;
+}
diff --git a/tests/fixtures/app-basic/app/isr-client-search-inserted-error/[slug]/page.tsx b/tests/fixtures/app-basic/app/isr-client-search-inserted-error/[slug]/page.tsx
new file mode 100644
index 0000000000..6abaf1639a
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-inserted-error/[slug]/page.tsx
@@ -0,0 +1,13 @@
+import InsertedQueryRegistration from "./client";
+
+export const dynamic = "error";
+export const revalidate = 60;
+
+export default function InsertedQueryPage() {
+ return (
+
+ Inserted client search params
+
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/isr-client-search-poison/[slug]/client.tsx b/tests/fixtures/app-basic/app/isr-client-search-poison/[slug]/client.tsx
new file mode 100644
index 0000000000..6f52c9a629
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-poison/[slug]/client.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function QueryEcho() {
+ const searchParams = useSearchParams();
+ return Search query: {searchParams.get("q") ?? "none"}
;
+}
diff --git a/tests/fixtures/app-basic/app/isr-client-search-poison/[slug]/page.tsx b/tests/fixtures/app-basic/app/isr-client-search-poison/[slug]/page.tsx
new file mode 100644
index 0000000000..f9fc279484
--- /dev/null
+++ b/tests/fixtures/app-basic/app/isr-client-search-poison/[slug]/page.tsx
@@ -0,0 +1,15 @@
+import { Suspense } from "react";
+import QueryEcho from "./client";
+
+export const revalidate = 1;
+
+export default function ClientSearchParamsIsrPage() {
+ return (
+
+ Client search params ISR
+ loading}>
+
+
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/rewrite-query-destination/client.tsx b/tests/fixtures/app-basic/app/nextjs-compat/rewrite-query-destination/client.tsx
new file mode 100644
index 0000000000..1284aab531
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/rewrite-query-destination/client.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function VisibleRewriteQuery() {
+ const searchParams = useSearchParams();
+ return client:{searchParams.toString()}
;
+}
diff --git a/tests/fixtures/app-basic/app/nextjs-compat/rewrite-query-destination/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/rewrite-query-destination/page.tsx
new file mode 100644
index 0000000000..1b86672552
--- /dev/null
+++ b/tests/fixtures/app-basic/app/nextjs-compat/rewrite-query-destination/page.tsx
@@ -0,0 +1,17 @@
+import VisibleRewriteQuery from "./client";
+
+export default async function RewriteQueryDestination({
+ searchParams,
+}: {
+ searchParams: Promise<{ shown?: string; hidden?: string }>;
+}) {
+ const query = await searchParams;
+ return (
+
+
+ server:shown={query.shown ?? "none"}&hidden={query.hidden ?? "none"}
+
+
+
+ );
+}
diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts
index eb613c16ff..1f71d1d9b3 100644
--- a/tests/fixtures/app-basic/next.config.ts
+++ b/tests/fixtures/app-basic/next.config.ts
@@ -122,6 +122,12 @@ const nextConfig: NextConfig = {
source: "/rewrite-search-param/:term",
destination: "/search?q=from-rewrite",
},
+ // Ported from Next.js rewrite/searchParams behavior: Page props receive
+ // the resolved destination query while client hooks see the visible URL.
+ {
+ source: "/nextjs-compat/rewrite-query-visible",
+ destination: "/nextjs-compat/rewrite-query-destination?hidden=secret",
+ },
],
afterFiles: [
// Used by Vitest: app-router.test.ts
diff --git a/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error-unbounded/[slug]/client.tsx b/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error-unbounded/[slug]/client.tsx
new file mode 100644
index 0000000000..2becaee5c6
--- /dev/null
+++ b/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error-unbounded/[slug]/client.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function QueryEcho() {
+ const searchParams = useSearchParams();
+ return query:{searchParams.get("q") ?? "none"}
;
+}
diff --git a/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error-unbounded/[slug]/page.tsx b/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error-unbounded/[slug]/page.tsx
new file mode 100644
index 0000000000..f781404432
--- /dev/null
+++ b/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error-unbounded/[slug]/page.tsx
@@ -0,0 +1,14 @@
+import QueryEcho from "./client";
+
+export const dynamic = "error";
+export const revalidate = 60;
+
+// Ported from Next.js: test/e2e/app-dir/missing-suspense-with-csr-bailout/
+// https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir/missing-suspense-with-csr-bailout
+export default function QueryDynamicErrorUnboundedPage() {
+ return (
+
+
+
+ );
+}
diff --git a/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error/[slug]/client.tsx b/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error/[slug]/client.tsx
new file mode 100644
index 0000000000..2becaee5c6
--- /dev/null
+++ b/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error/[slug]/client.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function QueryEcho() {
+ const searchParams = useSearchParams();
+ return query:{searchParams.get("q") ?? "none"}
;
+}
diff --git a/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error/[slug]/page.tsx b/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error/[slug]/page.tsx
new file mode 100644
index 0000000000..ebe04864f9
--- /dev/null
+++ b/tests/fixtures/cdn-late-dynamic/app/query-dynamic-error/[slug]/page.tsx
@@ -0,0 +1,15 @@
+import { Suspense } from "react";
+import QueryEcho from "./client";
+
+export const dynamic = "error";
+export const revalidate = 60;
+
+export default function QueryDynamicErrorPage() {
+ return (
+
+ query fallback}>
+
+
+
+ );
+}
diff --git a/tests/fixtures/cdn-late-dynamic/app/query-inserted-error/[slug]/client.tsx b/tests/fixtures/cdn-late-dynamic/app/query-inserted-error/[slug]/client.tsx
new file mode 100644
index 0000000000..b76acccf59
--- /dev/null
+++ b/tests/fixtures/cdn-late-dynamic/app/query-inserted-error/[slug]/client.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { useSearchParams, useServerInsertedHTML } from "next/navigation";
+
+function InsertedQueryMeta() {
+ const searchParams = useSearchParams();
+ return ;
+}
+
+export default function InsertedQueryRegistration() {
+ useServerInsertedHTML(() => );
+ return Inserted query registered
;
+}
diff --git a/tests/fixtures/cdn-late-dynamic/app/query-inserted-error/[slug]/page.tsx b/tests/fixtures/cdn-late-dynamic/app/query-inserted-error/[slug]/page.tsx
new file mode 100644
index 0000000000..6abaf1639a
--- /dev/null
+++ b/tests/fixtures/cdn-late-dynamic/app/query-inserted-error/[slug]/page.tsx
@@ -0,0 +1,13 @@
+import InsertedQueryRegistration from "./client";
+
+export const dynamic = "error";
+export const revalidate = 60;
+
+export default function InsertedQueryPage() {
+ return (
+
+ Inserted client search params
+
+
+ );
+}
diff --git a/tests/fixtures/cdn-late-dynamic/app/query-isr/[slug]/client.tsx b/tests/fixtures/cdn-late-dynamic/app/query-isr/[slug]/client.tsx
new file mode 100644
index 0000000000..a5a53740cd
--- /dev/null
+++ b/tests/fixtures/cdn-late-dynamic/app/query-isr/[slug]/client.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+
+export default function QueryEcho() {
+ const searchParams = useSearchParams();
+ return query:{searchParams.get("q") ?? "none"}
;
+}
diff --git a/tests/fixtures/cdn-late-dynamic/app/query-isr/[slug]/page.tsx b/tests/fixtures/cdn-late-dynamic/app/query-isr/[slug]/page.tsx
new file mode 100644
index 0000000000..b9218162ff
--- /dev/null
+++ b/tests/fixtures/cdn-late-dynamic/app/query-isr/[slug]/page.tsx
@@ -0,0 +1,15 @@
+import { Suspense } from "react";
+import QueryEcho from "./client";
+
+export const revalidate = 60;
+
+export default function QueryIsrPage() {
+ return (
+
+ Query-aware ISR page
+ loading}>
+
+
+
+ );
+}
diff --git a/tests/nextjs-compat/rsc-context-lazy-stream.test.ts b/tests/nextjs-compat/rsc-context-lazy-stream.test.ts
index 4089e8019f..33e32adbc4 100644
--- a/tests/nextjs-compat/rsc-context-lazy-stream.test.ts
+++ b/tests/nextjs-compat/rsc-context-lazy-stream.test.ts
@@ -272,9 +272,8 @@ describe("navigation runtime RSC bootstrap: nav context embedded for hydration s
const { nav } = extractRscBootstrap(html);
- // searchParams is serialised as [...urlSearchParams.entries()] — an array of
- // [key, value] pairs — to preserve duplicate keys (e.g. ?tag=a&tag=b).
- // With no query string it should be an empty array.
+ // Search params are restored from window.location during hydration so a
+ // pathname-keyed HTML artifact never carries another request's query.
expect(nav.searchParams).toEqual([]);
});
@@ -289,32 +288,17 @@ describe("navigation runtime RSC bootstrap: nav context embedded for hydration s
});
// ── 4. searchParams are correct (with query string) ───────────────────────
- //
- // This is the critical hydration parity test. Without __VINEXT_RSC_NAV__:
- // - SSR renders: hello
- // - getServerSnapshot returns: new URLSearchParams() → ""
- // → React sees "hello" ≠ "" → hydration mismatch error #418
- //
- // With __VINEXT_RSC_NAV__:
- // - SSR renders: hello
- // - browser entry calls setNavigationContext with new URLSearchParams([["q","hello"]])
- // - getServerSnapshot returns: "hello"
- // → React sees "hello" = "hello" → no mismatch
- it("navigation runtime searchParams carries query params from request URL", async () => {
+ it("navigation runtime keeps query params out of the reusable bootstrap", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}?q=hello&page=3`);
const html = await res.text();
const { nav } = extractRscBootstrap(html);
- // Serialised as array of [key, value] pairs to preserve duplicates.
- expect(nav.searchParams).toEqual([
- ["q", "hello"],
- ["page", "3"],
- ]);
+ expect(nav.searchParams).toEqual([]);
});
- it("navigation runtime searchParams agrees with SSR-rendered useSearchParams() output", async () => {
+ it("SSR useSearchParams output stays request-specific while the bootstrap is neutral", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}?q=hello&page=3`);
const html = await res.text();
@@ -322,13 +306,8 @@ describe("navigation runtime RSC bootstrap: nav context embedded for hydration s
expect(html).toContain('hello');
expect(html).toContain('3');
- // Embedded payload must match
const { nav } = extractRscBootstrap(html);
-
- // new URLSearchParams(nav.searchParams) reconstructs the same params
- const sp = new URLSearchParams(nav.searchParams);
- expect(sp.get("q")).toBe("hello");
- expect(sp.get("page")).toBe("3");
+ expect(nav.searchParams).toEqual([]);
});
it("SSR-rendered useSearchParams() reflects query params (confirms parity source)", async () => {
@@ -338,8 +317,8 @@ describe("navigation runtime RSC bootstrap: nav context embedded for hydration s
// The SSR path: RSC environment sets navigation context from request URL,
// passes it to SSR environment via handleSsr(rscStream, navContext, ...).
// The SSR environment's useSearchParams() reads navContext.searchParams.
- // The __VINEXT_RSC_NAV__ payload comes from the same navContext.
- // Both should reflect the request query string.
+ // The hydration bootstrap deliberately omits the query and the browser
+ // restores it from window.location instead.
expect(html).toContain('hello');
expect(html).toContain('3');
expect(html).toContain(`${NAV_ROUTE}`);
@@ -347,7 +326,7 @@ describe("navigation runtime RSC bootstrap: nav context embedded for hydration s
// ── 5. Special characters in searchParams are safely serialised ───────────
- it("navigation runtime searchParams handles special characters without XSS", async () => {
+ it("navigation runtime omits special-character searchParams from inline scripts", async () => {
// Ensure the JSON serialisation (safeJsonStringify) encodes characters
// that could break out of a injection.
expect(html).not.toContain(`"q":"${specialQ}"`);
- // But when we parse the embedded JSON, the value round-trips correctly.
const { nav } = extractRscBootstrap(html);
- const sp = new URLSearchParams(nav.searchParams);
- expect(sp.get("q")).toBe(specialQ);
+ expect(nav.searchParams).toEqual([]);
});
// ── 6. __VINEXT_RSC_PARAMS__ for dynamic segment routes ──────────────────
diff --git a/tests/nextjs-compat/use-client-page-pathname.test.ts b/tests/nextjs-compat/use-client-page-pathname.test.ts
index 0cbc8ba050..ca8981a60a 100644
--- a/tests/nextjs-compat/use-client-page-pathname.test.ts
+++ b/tests/nextjs-compat/use-client-page-pathname.test.ts
@@ -90,14 +90,12 @@ describe('"use client" page component: usePathname() SSR (issue #688)', () => {
expect(html).toContain('q=hello&page=2');
});
- it("navigation runtime searchParams matches query string", async () => {
+ it("navigation runtime omits request searchParams from the reusable bootstrap", async () => {
const res = await fetch(`${_baseUrl}${ROUTE}?q=test`);
const html = await res.text();
const { nav } = extractRscBootstrap(html);
- const sp = new URLSearchParams(nav.searchParams);
-
- expect(sp.get("q")).toBe("test");
+ expect(nav.searchParams).toEqual([]);
});
// Ported from Next.js: test/e2e/app-dir/app/index.test.ts