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