diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index d7e3a4f82..550dbc80f 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -798,6 +798,8 @@ export function matchConfigPattern( // the simple catch-all branch cannot express, // - a named param is followed by a dot (the simple branch would treat // "slug.md" as the whole param name), + // - a named param is embedded after a literal prefix in the same path + // segment (e.g. `/blog-:slug`), // - the pattern has multiple named params and any of them is a catch-all // (e.g. `/:locale/files/:path*`). The simple catch-all branch only // handles trailing-catch-all-with-static-prefix; mixed cases need regex. @@ -808,6 +810,7 @@ export function matchConfigPattern( pattern.includes("\\") || /:[\w-]+[*+][^/]/.test(pattern) || /:[\w-]+\./.test(pattern) || + /[^/]:[\w-]+/.test(pattern) || (catchAllAnchor && namedParamCount > 1) ) { try { @@ -1089,11 +1092,12 @@ export function matchRewrite( ? collectConditionParams(rewrite.has, rewrite.missing, ctx) : _emptyParams(); if (!conditionParams) continue; - // Collapse protocol-relative URLs (e.g. //evil.com from decoded %2F in catch-all params). - return substituteAndSanitizeDestination(rewrite.destination, { + const rewriteParams = { ...params, ...conditionParams, - }); + }; + // Collapse protocol-relative URLs (e.g. //evil.com from decoded %2F in catch-all params). + return substituteAndSanitizeRewriteDestination(rewrite.destination, rewriteParams); } } return null; @@ -1141,6 +1145,94 @@ function substituteAndSanitizeDestination( return sanitizeDestination(substituteDestinationParams(destination, params)); } +/** + * Match Next.js's rewrite-specific prepareDestination behavior: source params + * that are not consumed by the destination path/host are exposed to the target + * page through query. + * + * https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/prepare-destination.ts + */ +function substituteAndSanitizeRewriteDestination( + destination: string, + params: Record, +): string { + const rewritten = substituteAndSanitizeDestination(destination, params); + if (!shouldAppendRewriteParamsToQuery(destination, params)) return rewritten; + + const existingQueryKeys = getDestinationQueryKeys(destination); + const paramsToAppend: [string, string][] = []; + for (const [key, value] of Object.entries(params)) { + if (key === "nextInternalLocale" || existingQueryKeys.has(key)) continue; + paramsToAppend.push([key, value]); + } + + if (paramsToAppend.length === 0) return rewritten; + return appendQueryParams(rewritten, paramsToAppend); +} + +function shouldAppendRewriteParamsToQuery( + destination: string, + params: Record, +): boolean { + const keys = Object.keys(params).filter((key) => key !== "nextInternalLocale"); + if (keys.length === 0) return false; + return !destinationPathOrHostUsesParam(destination, keys); +} + +function destinationPathOrHostUsesParam(destination: string, keys: string[]): boolean { + const pathAndHost = getDestinationPathAndHost(destination); + if (!pathAndHost) return false; + for (const key of keys) { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (new RegExp(`:${escapedKey}([+*])?(?![A-Za-z0-9_])`).test(pathAndHost)) return true; + } + return false; +} + +function getDestinationPathAndHost(destination: string): string { + const hashIndex = destination.indexOf("#"); + const beforeHash = hashIndex === -1 ? destination : destination.slice(0, hashIndex); + const hash = hashIndex === -1 ? "" : destination.slice(hashIndex); + const queryIndex = beforeHash.indexOf("?"); + const beforeQuery = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex); + + const schemeMatch = /^[a-z][a-z0-9+.-]*:\/\//i.exec(beforeQuery); + if (!schemeMatch) return `${beforeQuery}${hash}`; + + const withoutScheme = beforeQuery.slice(schemeMatch[0].length); + const slashIndex = withoutScheme.indexOf("/"); + if (slashIndex === -1) return `${withoutScheme}${hash}`; + return `${withoutScheme.slice(0, slashIndex)}${withoutScheme.slice(slashIndex)}${hash}`; +} + +function getDestinationQueryKeys(destination: string): Set { + const hashIndex = destination.indexOf("#"); + const beforeHash = hashIndex === -1 ? destination : destination.slice(0, hashIndex); + const queryIndex = beforeHash.indexOf("?"); + if (queryIndex === -1) return new Set(); + + const query = beforeHash.slice(queryIndex + 1); + return new Set(new URLSearchParams(query).keys()); +} + +function appendQueryParams(url: string, params: Iterable<[string, string]>): string { + const hashIndex = url.indexOf("#"); + const beforeHash = hashIndex === -1 ? url : url.slice(0, hashIndex); + const hash = hashIndex === -1 ? "" : url.slice(hashIndex); + + const queryIndex = beforeHash.indexOf("?"); + const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex); + const query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1); + + const merged = new URLSearchParams(query); + for (const [key, value] of params) { + merged.append(key, value); + } + + const search = merged.toString(); + return `${base}${search ? `?${search}` : ""}${hash}`; +} + /** * Sanitize a redirect/rewrite destination to collapse protocol-relative URLs. * diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index f5db7d38b..230161d9a 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -84,7 +84,7 @@ import { hydrateRoot } from "react-dom/client"; // Mirrors Next.js's bootstrap order: client/next.ts statically imports // from './' before calling initialize/hydrate, so window.next is set up // before any async work. -import { wrapWithRouterContext } from "next/router"; +import Router, { wrapWithRouterContext } from "next/router"; const pageLoaders = { ${loaderEntries.join(",\n")} @@ -149,7 +149,9 @@ async function hydrate() { }; } - const { pageProps } = nextData.props; + const props = nextData.props && typeof nextData.props === "object" ? nextData.props : {}; + const rawPageProps = props.pageProps; + const pageProps = rawPageProps && typeof rawPageProps === "object" ? rawPageProps : {}; const loader = pageLoaders[nextData.page]; if (!loader) { console.error("[vinext] No page loader for route:", nextData.page); @@ -171,7 +173,12 @@ async function hydrate() { const appModule = await appLoader(); const AppComponent = appModule.default; window.__VINEXT_APP__ = AppComponent; - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + element = React.createElement(AppComponent, { + ...props, + Component: PageComponent, + pageProps: rawPageProps, + router: Router, + }); } catch { element = React.createElement(PageComponent, pageProps); } @@ -181,8 +188,13 @@ async function hydrate() { ` } + let resolveHydrationCommit; + const hydrationCommitted = new Promise((resolve) => { + resolveHydrationCommit = resolve; + }); + // Wrap with RouterContext.Provider so next/router and next/compat/router work during hydration. - element = wrapWithRouterContext(element); + element = wrapWithRouterContext(element, resolveHydrationCommit); const container = document.getElementById("__next"); if (!container) { @@ -192,6 +204,20 @@ async function hydrate() { const root = hydrateRoot(container, element, hydrateRootOptions); window.__VINEXT_ROOT__ = root; + await hydrationCommitted; + const hydratedAt = performance.now(); + window.__VINEXT_HYDRATED_AT = hydratedAt; + window.__NEXT_HYDRATED = true; + window.__NEXT_HYDRATED_AT = hydratedAt; + window.__NEXT_HYDRATED_CB?.(); + + if (nextData.isFallback) { + await Router.replace( + window.location.pathname + window.location.search + window.location.hash, + undefined, + { _h: 1, scroll: false }, + ); + } } hydrate(); diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index ec89004f8..c55979ee2 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -220,7 +220,7 @@ import React from "react"; import { renderToReadableStream } from "react-dom/server.edge"; import { resetSSRHead, getSSRHeadHTML, setDocumentInitialHead } from "next/head"; import { flushPreloads } from "next/dynamic"; -import { setSSRContext, wrapWithRouterContext, getPagesNavigationIsReadyFromSerializedState } from "next/router"; +import Router, { setSSRContext, wrapWithRouterContext, getPagesNavigationIsReadyFromSerializedState } from "next/router"; import { _runWithCacheState, configureMemoryCacheHandler as __configureMemoryCacheHandler } from "next/cache"; import { registerConfiguredCacheAdapters as __registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters"; import { runWithPrivateCache } from "vinext/cache-runtime"; @@ -406,18 +406,36 @@ const _renderPage = __createPagesPageHandler({ renderIsrPassToStringAsync: _renderIsrPassToStringAsync, safeJsonStringify, sanitizeDestination: sanitizeDestinationLocal, - createPageElement(PageComponent, AppComponent, pageProps) { + createPageElement(PageComponent, AppComponent, props) { + const rawPageProps = props?.pageProps; + const pageProps = rawPageProps && typeof rawPageProps === "object" + ? props.pageProps + : {}; return AppComponent - ? React.createElement(AppComponent, { Component: PageComponent, pageProps }) + ? React.createElement(AppComponent, { + ...props, + Component: PageComponent, + pageProps: rawPageProps, + router: Router, + }) : React.createElement(PageComponent, pageProps); }, - enhancePageElement(PageComponent, AppComponent, pageProps, opts) { + enhancePageElement(PageComponent, AppComponent, props, opts) { + const rawPageProps = props?.pageProps; + const pageProps = rawPageProps && typeof rawPageProps === "object" + ? props.pageProps + : {}; let FinalApp = AppComponent; let FinalComp = PageComponent; if (opts && typeof opts.enhanceApp === "function" && FinalApp) FinalApp = opts.enhanceApp(FinalApp); if (opts && typeof opts.enhanceComponent === "function") FinalComp = opts.enhanceComponent(FinalComp); return FinalApp - ? React.createElement(FinalApp, { Component: FinalComp, pageProps }) + ? React.createElement(FinalApp, { + ...props, + Component: FinalComp, + pageProps: rawPageProps, + router: Router, + }) : React.createElement(FinalComp, pageProps); }, AppComponent, diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index a11be01bc..5ba790ed9 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -66,6 +66,8 @@ declare global { | React.ComponentType<{ Component: React.ComponentType>; pageProps: unknown; + router?: unknown; + [key: string]: unknown; }> | undefined; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index d824c22d0..5b6963241 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3322,6 +3322,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ): Promise => { try { let url: string = req.url ?? "/"; + const originalRequestUrl = url; // If no pages directory, skip this middleware entirely // (app router is handled by @vitejs/plugin-rsc's built-in middleware) @@ -3804,6 +3805,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { pipelineResult.resolvedUrl, req.__vinextMiddlewareStatus, pipelineResult.isDataReq, + originalRequestUrl, ); } } catch (e) { diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index e8933b1a0..e9d560ece 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -33,6 +33,7 @@ import { pickRootParams, setRootParams, type RootParams } from "vinext/shims/roo import { createRequestContext, runWithRequestContext } from "vinext/shims/unified-request-context"; import { flattenErrorCauses } from "../utils/error-cause.js"; import { hasBasePath } from "../utils/base-path.js"; +import { mergeRewriteQuery } from "../utils/query.js"; import { applyAppMiddleware, type AppMiddlewareContext } from "./app-middleware.js"; import { mergeMiddlewareResponseHeaders } from "./app-page-response.js"; import { handleAppPrerenderEndpoint } from "./app-prerender-endpoints.js"; @@ -353,6 +354,20 @@ async function applyRewrite( return rewritten; } +function applyInternalRewriteDestination(rewritten: string, url: URL): string { + const merged = mergeRewriteQuery(`${url.pathname}${url.search}`, rewritten); + const hashIndex = merged.indexOf("#"); + const beforeHash = hashIndex === -1 ? merged : merged.slice(0, hashIndex); + const queryIndex = beforeHash.indexOf("?"); + if (queryIndex === -1) { + url.search = ""; + return beforeHash; + } + + url.search = beforeHash.slice(queryIndex); + return beforeHash.slice(0, queryIndex); +} + function applyConfigHeadersToMiddlewareRedirect( response: Response, options: { @@ -580,7 +595,7 @@ async function handleAppRscRequest( matchPathname(cleanPathname), ); if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; - if (beforeFilesRewrite) cleanPathname = beforeFilesRewrite; + if (beforeFilesRewrite) cleanPathname = applyInternalRewriteDestination(beforeFilesRewrite, url); if (isImageOptimizationPath(cleanPathname)) { const imageRedirect = resolveDevImageRedirect( @@ -701,7 +716,7 @@ async function handleAppRscRequest( ); if (afterFilesRewrite instanceof Response) return afterFilesRewrite; if (afterFilesRewrite) { - cleanPathname = afterFilesRewrite; + cleanPathname = applyInternalRewriteDestination(afterFilesRewrite, url); match = options.matchRoute(cleanPathname); } } @@ -720,7 +735,7 @@ async function handleAppRscRequest( ); if (fallbackRewrite instanceof Response) return fallbackRewrite; if (fallbackRewrite) { - cleanPathname = fallbackRewrite; + cleanPathname = applyInternalRewriteDestination(fallbackRewrite, url); match = options.matchRoute(cleanPathname); } } diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 8a5afabcb..7c8d56df1 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -65,8 +65,13 @@ import { runDocumentRenderPage, } from "./pages-document-initial-props.js"; import { callDocumentGetInitialProps } from "./document-initial-head.js"; -import { loadPagesGetInitialProps } from "./pages-get-initial-props.js"; +import { + hasPagesGetInitialProps, + loadDevAppInitialProps, + loadPagesGetInitialProps, +} from "./pages-get-initial-props.js"; import { isBotUserAgent } from "../utils/html-limited-bots.js"; +import { isUnknownRecord } from "../utils/record.js"; /** * Render a React element to a string using renderToReadableStream. @@ -112,6 +117,7 @@ function writeGsspRedirect( res: ServerResponse, redirect: { destination: string; statusCode?: number; permanent?: boolean }, isDataReq: boolean, + props: Record, ): void { const status = redirect.statusCode ?? (redirect.permanent ? 308 : 307); // Sanitize destination to prevent open redirect via protocol-relative URLs. @@ -130,7 +136,16 @@ function writeGsspRedirect( dataHeaders[NEXTJS_DEPLOYMENT_ID_HEADER] = deploymentId; } res.writeHead(200, dataHeaders); - res.end(JSON.stringify({ pageProps: { __N_REDIRECT: dest, __N_REDIRECT_STATUS: status } })); + res.end( + JSON.stringify({ + ...props, + pageProps: { + ...(isUnknownRecord(props.pageProps) ? props.pageProps : {}), + __N_REDIRECT: dest, + __N_REDIRECT_STATUS: status, + }, + }), + ); return; } @@ -448,6 +463,7 @@ export function createSSRHandler( * client-side navigations in the Pages Router. */ isDataReq: boolean = false, + originalUrl: string = url, ): Promise => { const _reqStart = now(); let _compileEnd: number | undefined; @@ -535,6 +551,14 @@ export function createSSRHandler( } const { route, params } = match; + // Implements the Next.js `req.url` contract: data-fetching methods observe + // the original request URL. For ordinary page requests this defaults to + // `url`, so the assignment is a no-op. + req.url = originalUrl; + const parsedResolvedUrl = new URL(localeStrippedUrl, "http://vinext.local"); + const originalRequestSearch = new URL(originalUrl, "http://vinext.local").search; + const gsspResolvedUrl = parsedResolvedUrl.pathname + originalRequestSearch; + const requestAsPath = isDataReq ? gsspResolvedUrl : originalUrl; // Next.js exposes `params: null` to data-fetching contexts (gSSP, gSP) on // non-dynamic routes — see render.tsx's `...(pageIsDynamic ? { params } : undefined)`. // Internal use (query merging, _app router context) keeps the matched @@ -614,7 +638,7 @@ export function createSSRHandler( routerShim.setSSRContext({ pathname: patternToNextFormat(route.pattern), query, - asPath: url, + asPath: requestAsPath, navigationIsReady, nextData: pagesNextData, locale: locale ?? currentDefaultLocale, @@ -667,12 +691,14 @@ export function createSSRHandler( // Collect page props via data fetching methods let pageProps: Record = {}; + let renderProps: Record = { pageProps }; let isrRevalidateSeconds: number | null = null; // Set when `getStaticPaths: { fallback: true }` is configured and the // requested path is NOT in the pre-rendered list. Triggers the loading // shell render below: `getStaticProps`/`getServerSideProps` are skipped // and `useRouter().isFallback === true`, matching Next.js render.tsx. let isFallbackRender = false; + let shouldPersistFallbackData = false; // Handle getStaticPaths for dynamic routes: validate the path, // respect `fallback: false` (return 404 for unlisted paths), and @@ -746,12 +772,14 @@ export function createSSRHandler( const userAgent = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader; const isBotRequest = !!userAgent && isBotUserAgent(userAgent, htmlLimitedBots); if (fallback === true && !isValidPath && !isDataReq && !isBotRequest) { - isFallbackRender = true; - if (typeof routerShim.setSSRContext === "function") { + const fallbackCacheKey = pagesIsrCacheKey(url.split("?")[0]); + const generatedEntry = await isrGet(fallbackCacheKey); + isFallbackRender = generatedEntry?.value.value?.kind !== "PAGES"; + if (isFallbackRender && typeof routerShim.setSSRContext === "function") { routerShim.setSSRContext({ pathname: patternToNextFormat(route.pattern), query, - asPath: url, + asPath: requestAsPath, navigationIsReady: false, locale: locale ?? currentDefaultLocale, locales: i18nConfig?.locales, @@ -761,6 +789,7 @@ export function createSSRHandler( }); } } + shouldPersistFallbackData = fallback === true && !isValidPath && isDataReq; } // Headers set by getServerSideProps for explicit forwarding to @@ -769,7 +798,54 @@ export function createSSRHandler( // would silently break if streamPageToResponse is refactored. const gsspExtraHeaders: Record = {}; + const hasAppGetInitialProps = hasPagesGetInitialProps(AppComponent); + + // Thin glue over loadDevAppInitialProps: build the React AppTree closure, + // delegate the decision to the tested helper, and apply the result. + // Returns true when the App ended the response (caller must stop). + async function loadAppInitialProps(): Promise { + if (!hasAppGetInitialProps) { + return false; + } + const appResult = await loadDevAppInitialProps({ + appComponent: AppComponent, + appTree: (appTreeProps: Record) => { + const appTree = React.createElement(AppComponent, { + ...appTreeProps, + Component: PageComponent, + pageProps: appTreeProps.pageProps, + router: routerShim.default, + }); + return typeof routerShim.wrapWithRouterContext === "function" + ? routerShim.wrapWithRouterContext(appTree) + : appTree; + }, + component: PageComponent, + req, + res, + pathname: patternToNextFormat(route.pattern), + query, + asPath: requestAsPath, + locale: locale ?? currentDefaultLocale, + locales: i18nConfig?.locales, + defaultLocale: currentDefaultLocale, + }); + + if (appResult.kind === "response-sent") { + return true; + } + if (appResult.kind === "render") { + pageProps = appResult.pageProps; + renderProps = appResult.renderProps; + } + return false; + } + if (typeof pageModule.getServerSideProps === "function" && !isFallbackRender) { + if (await loadAppInitialProps()) { + return; + } + renderProps = { ...renderProps, __N_SSP: true }; // Snapshot existing headers so we can detect what gSSP adds. const headersBeforeGSSP = new Set(Object.keys(res.getHeaders())); @@ -778,7 +854,7 @@ export function createSSRHandler( req, res, query, - resolvedUrl: localeStrippedUrl, + resolvedUrl: gsspResolvedUrl, locale: locale ?? currentDefaultLocale, locales: i18nConfig?.locales, defaultLocale: currentDefaultLocale, @@ -799,10 +875,14 @@ export function createSSRHandler( // it before serialising; otherwise pageProps would be a Promise // and the rendered page would receive empty props. See // packages/next/src/server/render.tsx (deferredContent). - pageProps = await Promise.resolve(result.props); + pageProps = { + ...pageProps, + ...(await Promise.resolve(result.props)), + }; + renderProps = { ...renderProps, pageProps }; } if (result && "redirect" in result) { - writeGsspRedirect(res, result.redirect, isDataReq); + writeGsspRedirect(res, result.redirect, isDataReq, renderProps); return; } if (result && "notFound" in result && result.notFound) { @@ -925,6 +1005,7 @@ export function createSSRHandler( cached && !cached.isStale && cached.value.value?.kind === "PAGES" && + !cached.value.value.generatedFromDataRequest && !scriptNonce && !isDataReq ) { @@ -954,6 +1035,7 @@ export function createSSRHandler( cached && cached.isStale && cached.value.value?.kind === "PAGES" && + !cached.value.value.generatedFromDataRequest && !scriptNonce && !isDataReq ) { @@ -972,9 +1054,90 @@ export function createSSRHandler( // explicitly so background regeneration cannot inherit // a standalone execution-context scope from the caller. executionContext: null, + ssrContext: { + pathname: patternToNextFormat(route.pattern), + query, + asPath: requestAsPath, + navigationIsReady, + locale: locale ?? currentDefaultLocale, + locales: i18nConfig?.locales, + defaultLocale: currentDefaultLocale, + }, + i18nContext: i18nConfig + ? { + locale: locale ?? currentDefaultLocale, + locales: i18nConfig.locales, + defaultLocale: currentDefaultLocale, + domainLocales, + hostname: req.headers.host?.split(":", 1)[0], + } + : null, }); return runWithRequestContext(regenContext, async () => { ensureFetchPatch(); + let freshPageProps: Record = {}; + let freshRenderProps: Record = { pageProps: freshPageProps }; + + // oxlint-disable-next-line typescript/no-explicit-any + let RegenApp: any = null; + const appPath = path.join(pagesDir, "_app"); + if (findFileWithExtensions(appPath, matcher)) { + try { + const appMod = (await runner.import(appPath)) as Record; + RegenApp = appMod.default ?? null; + } catch { + // _app failed to load + } + } + + if (RegenApp && hasPagesGetInitialProps(RegenApp)) { + const regenReq = { url: req.url, headers: req.headers, method: req.method }; + const regenRes = { + headersSent: false, + writableEnded: false, + statusCode: 200, + getHeaders() { + return {}; + }, + }; + const initialProps = await loadPagesGetInitialProps(RegenApp, { + AppTree: (appTreeProps: Record) => { + const appTree = React.createElement(RegenApp, { + ...appTreeProps, + Component: pageModule.default, + pageProps: appTreeProps.pageProps, + router: routerShim.default, + }); + return typeof routerShim.wrapWithRouterContext === "function" + ? routerShim.wrapWithRouterContext(appTree) + : appTree; + }, + Component: pageModule.default, + router: { + pathname: patternToNextFormat(route.pattern), + query, + asPath: requestAsPath, + }, + ctx: { + req: regenReq, + res: regenRes, + pathname: patternToNextFormat(route.pattern), + query, + asPath: requestAsPath, + locale: locale ?? currentDefaultLocale, + locales: i18nConfig?.locales, + defaultLocale: currentDefaultLocale, + }, + }); + if (regenRes.headersSent || regenRes.writableEnded) return; + if (initialProps) { + freshRenderProps = initialProps; + freshPageProps = isUnknownRecord(initialProps.pageProps) + ? initialProps.pageProps + : {}; + } + } + const freshResult = await pageModule.getStaticProps({ params: userFacingParams, locale: locale ?? currentDefaultLocale, @@ -986,15 +1149,18 @@ export function createSSRHandler( }); if (freshResult && "props" in freshResult) { const revalidate = - typeof freshResult.revalidate === "number" ? freshResult.revalidate : 0; + typeof freshResult.revalidate === "number" + ? freshResult.revalidate + : (cached.value.cacheControl?.revalidate ?? 0); if (revalidate > 0) { - const freshProps = freshResult.props; + freshPageProps = { ...freshPageProps, ...freshResult.props }; + freshRenderProps = { ...freshRenderProps, pageProps: freshPageProps }; if (typeof routerShim.setSSRContext === "function") { routerShim.setSSRContext({ pathname: patternToNextFormat(route.pattern), query, - asPath: url, + asPath: requestAsPath, navigationIsReady, locale: locale ?? currentDefaultLocale, locales: i18nConfig?.locales, @@ -1016,26 +1182,14 @@ export function createSSRHandler( } } - // Re-render the page with fresh props inside fresh - // render sub-scopes so head/cache state cannot leak. - // oxlint-disable-next-line typescript/no-explicit-any - let RegenApp: any = null; - const appPath = path.join(pagesDir, "_app"); - if (findFileWithExtensions(appPath, matcher)) { - try { - const appMod = (await runner.import(appPath)) as Record; - RegenApp = appMod.default ?? null; - } catch { - // _app failed to load - } - } - let el = RegenApp ? React.createElement(RegenApp, { + ...freshRenderProps, Component: pageModule.default, - pageProps: freshProps, + pageProps: freshRenderProps.pageProps, + router: routerShim.default, }) - : React.createElement(pageModule.default, freshProps); + : React.createElement(pageModule.default, freshPageProps); if (routerShim.wrapWithRouterContext) { el = routerShim.wrapWithRouterContext(el); } @@ -1066,7 +1220,7 @@ export function createSSRHandler( }; const freshNextData = ``; const nextDataScript = createInlineScriptTag( `window.__NEXT_DATA__ = ${safeJsonStringify({ - props: { pageProps }, + props: renderProps, page: patternToNextFormat(route.pattern), query: params, buildId: process.env.__VINEXT_BUILD_ID, @@ -1477,7 +1706,7 @@ hydrate(); documentContext: { pathname: patternToNextFormat(route.pattern), query, - asPath: url, + asPath: requestAsPath, }, // Used by `_document.getInitialProps` -> `ctx.renderPage` to wrap // App/Component with user enhancers (e.g. styled-components, @@ -1501,6 +1730,7 @@ hydrate(); let enhancedElement: React.ReactElement; if (FinalApp) { enhancedElement = createElement(FinalApp, { + ...renderProps, Component: FinalComp, pageProps, }); @@ -1543,6 +1773,7 @@ hydrate(); if (!scriptNonce && isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { let isrElement = AppComponent ? createElement(AppComponent, { + ...renderProps, Component: pageModule.default, pageProps, }) diff --git a/packages/vinext/src/server/pages-data-route.ts b/packages/vinext/src/server/pages-data-route.ts index 90b717a75..257ca0d62 100644 --- a/packages/vinext/src/server/pages-data-route.ts +++ b/packages/vinext/src/server/pages-data-route.ts @@ -100,7 +100,21 @@ export function buildNextDataJsonResponse( safeJsonStringify: (value: unknown) => string, init?: ResponseInit, ): Response { - const body = safeJsonStringify({ pageProps }); + return buildNextDataPropsJsonResponse({ pageProps }, safeJsonStringify, init); +} + +/** + * Build a `_next/data` JSON response from the full Pages props object returned + * through `_app.getInitialProps`. Next.js serializes the same outer props + * object that would be passed to ``, so custom app-level props remain + * siblings of `pageProps` in the data envelope. + */ +export function buildNextDataPropsJsonResponse( + props: Record, + safeJsonStringify: (value: unknown) => string, + init?: ResponseInit, +): Response { + const body = safeJsonStringify(props); return new Response(body, { status: init?.status ?? 200, statusText: init?.statusText, diff --git a/packages/vinext/src/server/pages-get-initial-props.ts b/packages/vinext/src/server/pages-get-initial-props.ts index 32cfbf32f..3e4b30c51 100644 --- a/packages/vinext/src/server/pages-get-initial-props.ts +++ b/packages/vinext/src/server/pages-get-initial-props.ts @@ -2,13 +2,13 @@ type PagesGetInitialPropsContext = { req?: unknown; res?: unknown; err?: unknown; - pathname: string; - query: Record; - asPath: string; + pathname?: string; + query?: Record; + asPath?: string; locale?: string; locales?: string[]; defaultLocale?: string; -}; +} & Record; type PagesGetInitialProps = (context: PagesGetInitialPropsContext) => unknown; @@ -92,3 +92,90 @@ export async function loadPagesGetInitialProps( return result; } + +/** + * Decision returned by {@link loadDevAppInitialProps}. + * + * - `skip`: the custom `App` has no `getInitialProps`; the caller renders with + * its existing props unchanged. + * - `response-sent`: `_app.getInitialProps` ended the response itself (wrote + * headers / body); the caller must stop and not render. + * - `render`: the caller should render with the returned `pageProps` / + * `renderProps`. + */ +export type DevAppInitialPropsResult = + | { kind: "skip" } + | { kind: "response-sent" } + | { + kind: "render"; + pageProps: Record; + renderProps: Record & { pageProps: unknown }; + }; + +export type DevAppInitialPropsContext = { + appComponent: unknown; + /** + * Builds the `AppTree` element passed to `getInitialProps`. Injected so this + * module stays free of React; the dev SSR handler supplies the real + * `React.createElement` closure. + */ + appTree: (appTreeProps: Record) => unknown; + component: unknown; + req: unknown; + res: unknown; + pathname: string; + query: Record; + asPath: string; + locale?: string; + locales?: string[]; + defaultLocale?: string; +}; + +/** + * Run the custom `App`'s `getInitialProps` for the dev SSR render path and + * return a decision the caller applies. + * + * This is the dev-server counterpart to the production page-data resolver's + * app-initial-props loading. It is invoked lazily — only when a request is + * actually going to render (cache miss / on-demand revalidation), never on an + * ISR cache HIT/STALE that serves cached HTML verbatim — so userland `App` + * data code does not run on the cache hot path. + */ +export async function loadDevAppInitialProps( + ctx: DevAppInitialPropsContext, +): Promise { + if (!hasPagesGetInitialProps(ctx.appComponent)) { + return { kind: "skip" }; + } + + const initialProps = await loadPagesGetInitialProps(ctx.appComponent, { + AppTree: ctx.appTree, + Component: ctx.component, + router: { pathname: ctx.pathname, query: ctx.query, asPath: ctx.asPath }, + ctx: { + req: ctx.req, + res: ctx.res, + pathname: ctx.pathname, + query: ctx.query, + asPath: ctx.asPath, + locale: ctx.locale, + locales: ctx.locales, + defaultLocale: ctx.defaultLocale, + }, + }); + + if (isResponseSent(ctx.res)) { + return { kind: "response-sent" }; + } + + // Post-guard, loadPagesGetInitialProps always resolves to an object (it only + // returns null when getInitialProps is absent, excluded above). Preserve the + // raw `pageProps` value in the App envelope; derive an object-safe projection + // only for merging data-function props and direct page rendering. + const initialPageProps = isPropsObject(initialProps) ? initialProps.pageProps : undefined; + const pageProps = isPropsObject(initialPageProps) ? initialPageProps : {}; + const renderProps = isPropsObject(initialProps) + ? { ...initialProps, pageProps: initialPageProps } + : { pageProps: initialPageProps }; + return { kind: "render", pageProps, renderProps }; +} diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index 689c6349d..e43ae6b8c 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -22,10 +22,11 @@ import { isResponseSent, loadPagesGetInitialProps, } from "./pages-get-initial-props.js"; -import { buildNextDataJsonResponse } from "./pages-data-route.js"; +import { buildNextDataPropsJsonResponse } from "./pages-data-route.js"; import { NEXTJS_DEPLOYMENT_ID_HEADER } from "./headers.js"; import { isSerializableProps } from "./pages-serializable-props.js"; import { isBotUserAgent } from "../utils/html-limited-bots.js"; +import { isUnknownRecord } from "../utils/record.js"; type PagesRedirectResult = { destination: string; @@ -64,6 +65,10 @@ type PagesGsspContextResponse = { responsePromise: Promise; }; +type PagesRenderProps = Record & { + pageProps: unknown; +}; + export type PagesPageModule = { default?: unknown; getStaticPaths?: (context: { @@ -114,9 +119,10 @@ export type PagesPageModule = { type RenderPagesIsrHtmlOptions = { buildId: string | null; cachedHtml: string; - createPageElement: (pageProps: Record) => ReactNode; + createPageElement: (props: Record) => ReactNode; i18n: PagesI18nRenderContext; pageProps: Record; + props?: Record; params: Record; renderIsrPassToStringAsync: (element: ReactNode) => Promise; routePattern: string; @@ -139,7 +145,8 @@ export type ResolvePagesPageDataOptions = { isDataReq?: boolean; err?: unknown; createGsspReqRes: () => PagesGsspContextResponse; - createPageElement: (pageProps: Record) => ReactNode; + createAppTree?: (props: Record) => ReactNode; + createPageElement: (props: Record) => ReactNode; fontLinkHeader: string; i18n: PagesI18nRenderContext; isrCacheKey: (router: string, pathname: string) => string; @@ -186,9 +193,11 @@ export type ResolvePagesPageDataOptions = { deploymentId?: string; htmlLimitedBots?: string; pageModule: PagesPageModule; + AppComponent?: unknown; params: Record; query: Record; asPath?: string; + resolvedUrl?: string; route: Pick; routePattern: string; routeUrl: string; @@ -233,6 +242,7 @@ type ResolvePagesPageDataRenderResult = { gsspRes: PagesGsspResponse | null; isrRevalidateSeconds: number | null; pageProps: Record; + props: PagesRenderProps; /** * True when `getStaticPaths` returned `fallback: true` AND the requested path * is not in the pre-rendered list. The caller renders a loading shell with @@ -289,6 +299,84 @@ function resolvePagesRedirectStatus(redirect: PagesRedirectResult): number { return redirect.statusCode != null ? redirect.statusCode : redirect.permanent ? 308 : 307; } +function normalizePagesRenderProps(props: Record): PagesRenderProps { + return { + ...props, + pageProps: props.pageProps, + }; +} + +type PagesAppInitialPropsResult = + | { kind: "props"; pageProps: Record; renderProps: PagesRenderProps } + | { kind: "response"; response: Promise }; + +/** + * Load `_app.getInitialProps` and return the normalized render props and the + * extracted `pageProps`. This is shared between the foreground render path and + * the stale-while-revalidate background regeneration path so both produce the + * same full props envelope (app-level props plus the page's `pageProps`). + * + * `getSharedReqRes` lets callers share the same mock req/res with other + * data-fetching steps (e.g. `getServerSideProps`) when they run in the same + * request context. + */ +async function loadPagesAppInitialRenderProps( + options: Pick< + ResolvePagesPageDataOptions, + | "AppComponent" + | "createAppTree" + | "createPageElement" + | "err" + | "i18n" + | "pageModule" + | "query" + | "routePattern" + | "routeUrl" + | "asPath" + >, + getSharedReqRes: () => PagesGsspContextResponse, +): Promise { + let pageProps: Record = {}; + let renderProps: PagesRenderProps = { pageProps }; + + if (!hasPagesGetInitialProps(options.AppComponent)) { + return { kind: "props", pageProps, renderProps }; + } + + const { req, res, responsePromise } = getSharedReqRes(); + const initialProps = await loadPagesGetInitialProps(options.AppComponent, { + AppTree: options.createAppTree ?? options.createPageElement, + Component: options.pageModule.default, + router: { + pathname: options.routePattern, + query: options.query, + asPath: options.asPath ?? options.routeUrl, + }, + ctx: { + req, + res, + err: options.err, + pathname: options.routePattern, + query: options.query, + asPath: options.asPath ?? options.routeUrl, + locale: options.i18n.locale, + locales: options.i18n.locales, + defaultLocale: options.i18n.defaultLocale, + }, + }); + + if (isResponseSent(res)) { + return { kind: "response", response: responsePromise }; + } + + if (initialProps) { + renderProps = normalizePagesRenderProps(initialProps); + pageProps = isUnknownRecord(renderProps.pageProps) ? renderProps.pageProps : {}; + } + + return { kind: "props", pageProps, renderProps }; +} + /** * Build the response for a `getServerSideProps` / `getStaticProps` * `{ redirect }` result. @@ -316,6 +404,7 @@ function buildPagesRedirectResponse( ResolvePagesPageDataOptions, "isDataReq" | "sanitizeDestination" | "safeJsonStringify" | "deploymentId" >, + props: PagesRenderProps = { pageProps: {} }, ): Response { const destination = options.sanitizeDestination(redirect.destination); @@ -326,10 +415,14 @@ function buildPagesRedirectResponse( if (options.deploymentId) { init.headers[NEXTJS_DEPLOYMENT_ID_HEADER] = options.deploymentId; } - return buildNextDataJsonResponse( + return buildNextDataPropsJsonResponse( { - __N_REDIRECT: destination, - __N_REDIRECT_STATUS: resolvePagesRedirectStatus(redirect), + ...props, + pageProps: { + ...(isUnknownRecord(props.pageProps) ? props.pageProps : {}), + __N_REDIRECT: destination, + __N_REDIRECT_STATUS: resolvePagesRedirectStatus(redirect), + }, }, options.safeJsonStringify, init, @@ -484,13 +577,15 @@ function rewritePagesCachedHtml( } export async function renderPagesIsrHtml(options: RenderPagesIsrHtmlOptions): Promise { + const renderProps = options.props ?? { pageProps: options.pageProps }; const freshBody = await options.renderIsrPassToStringAsync( - options.createPageElement(options.pageProps), + options.createPageElement(renderProps), ); const nextDataScript = buildPagesNextDataScript({ buildId: options.buildId, i18n: options.i18n, pageProps: options.pageProps, + props: renderProps, params: options.params, routePattern: options.routePattern, safeJsonStringify: options.safeJsonStringify, @@ -524,6 +619,7 @@ export async function resolvePagesPageData( // (`/_next/data/...json`) still call `getStaticProps` so the client can // hydrate the page after the fallback shell ships. let isFallback = false; + let shouldPersistFallbackData = false; if (typeof options.pageModule.getStaticPaths === "function" && options.route.isDynamic) { const pathsResult = await options.pageModule.getStaticPaths({ @@ -551,29 +647,65 @@ export async function resolvePagesPageData( if (fallback === true && !isValidPath && !options.isDataReq && !isBotRequest) { isFallback = true; } + shouldPersistFallbackData = fallback === true && !isValidPath && options.isDataReq === true; } let pageProps: Record = {}; let gsspRes: PagesMutableGsspResponse | null = null; + let sharedReqRes: PagesGsspContextResponse | null = null; + function getSharedReqRes(): PagesGsspContextResponse { + sharedReqRes ??= options.createGsspReqRes(); + return sharedReqRes; + } + + let renderProps: PagesRenderProps = { pageProps }; + + async function loadForegroundAppInitialRenderProps(): Promise { + const result = await loadPagesAppInitialRenderProps(options, getSharedReqRes); + if (result.kind === "response") { + return { + kind: "response", + response: await result.response, + }; + } + renderProps = result.renderProps; + pageProps = result.pageProps; + return null; + } + if (isFallback) { - return { - kind: "render", - gsspRes: null, - isrRevalidateSeconds: null, - pageProps, - isFallback: true, - }; + const pathname = options.routeUrl.split("?")[0]; + const cached = await options.isrGet(options.isrCacheKey("pages", pathname)); + if (cached?.value.value?.kind !== "PAGES") { + const appShortCircuit = await loadForegroundAppInitialRenderProps(); + if (appShortCircuit) return appShortCircuit; + pageProps = {}; + renderProps = { ...renderProps, pageProps }; + return { + kind: "render", + gsspRes: null, + isrRevalidateSeconds: null, + pageProps, + props: renderProps, + isFallback: true, + }; + } } if (typeof options.pageModule.getServerSideProps === "function") { - const { req, res, responsePromise } = options.createGsspReqRes(); + const shortCircuit = await loadForegroundAppInitialRenderProps(); + if (shortCircuit) { + return shortCircuit; + } + renderProps = { ...renderProps, __N_SSP: true }; + const { req, res, responsePromise } = getSharedReqRes(); const result = await options.pageModule.getServerSideProps({ params: userFacingParams, req, res, query: options.query, - resolvedUrl: options.routeUrl, + resolvedUrl: options.resolvedUrl ?? options.routeUrl, locale: options.i18n.locale, locales: options.i18n.locales, defaultLocale: options.i18n.defaultLocale, @@ -591,13 +723,17 @@ export async function resolvePagesPageData( // before serialising; otherwise pageProps would be a Promise and the // rendered page would receive empty props. See // packages/next/src/server/render.tsx (deferredContent). - pageProps = (await Promise.resolve(result.props)) as Record; + pageProps = { + ...pageProps, + ...((await Promise.resolve(result.props)) as Record), + }; + renderProps = { ...renderProps, pageProps }; } if (result?.redirect) { return { kind: "response", - response: buildPagesRedirectResponse(result.redirect, options), + response: buildPagesRedirectResponse(result.redirect, options, renderProps), }; } @@ -635,7 +771,9 @@ export async function resolvePagesPageData( // handling in render.tsx / base-server.ts. if ( !options.isOnDemandRevalidate && + cached?.isStale === false && cachedValue?.kind === "PAGES" && + !cachedValue.generatedFromDataRequest && cached && !cached.isStale && !options.scriptNonce && @@ -665,6 +803,7 @@ export async function resolvePagesPageData( if ( !options.isOnDemandRevalidate && cachedValue?.kind === "PAGES" && + !cachedValue.generatedFromDataRequest && cached && cached.isStale && !options.scriptNonce && @@ -674,6 +813,22 @@ export async function resolvePagesPageData( cacheKey, async function () { return options.runInFreshUnifiedContext(async () => { + options.applyRequestContexts(); + // Rebuild the full App render props before re-running getStaticProps + // so the regenerated HTML / __NEXT_DATA__ still contains app-level + // props from _app.getInitialProps. Mirrors the foreground path. + const freshAppResult = await loadPagesAppInitialRenderProps(options, () => + options.createGsspReqRes(), + ); + if (freshAppResult.kind === "response") { + // _app.getInitialProps short-circuited the request during background + // regeneration. We cannot turn that into an HTTP response here, so + // skip the cache write and let the stale entry remain. + return; + } + let freshPageProps = freshAppResult.pageProps; + let freshRenderProps = freshAppResult.renderProps; + const freshResult = await options.pageModule.getStaticProps?.({ params: userFacingParams, locale: options.i18n.locale, @@ -686,18 +841,24 @@ export async function resolvePagesPageData( revalidateReason: "stale", }); - if ( - freshResult?.props && - typeof freshResult.revalidate === "number" && - freshResult.revalidate > 0 - ) { - options.applyRequestContexts(); + if (freshResult?.props) { + freshPageProps = { ...freshPageProps, ...freshResult.props }; + freshRenderProps = { ...freshRenderProps, pageProps: freshPageProps }; + } + + const freshRevalidateSeconds = + typeof freshResult?.revalidate === "number" && freshResult.revalidate > 0 + ? freshResult.revalidate + : cached.value.cacheControl?.revalidate; + + if (freshResult?.props && freshRevalidateSeconds && freshRevalidateSeconds > 0) { const freshHtml = await renderPagesIsrHtml({ buildId: options.buildId, cachedHtml: cachedValue.html, createPageElement: options.createPageElement, i18n: options.i18n, - pageProps: freshResult.props, + pageProps: freshPageProps, + props: freshRenderProps, params: options.params, renderIsrPassToStringAsync: options.renderIsrPassToStringAsync, routePattern: options.routePattern, @@ -708,8 +869,8 @@ export async function resolvePagesPageData( await options.isrSet( cacheKey, - buildPagesCacheValue(freshHtml, freshResult.props, options.statusCode), - freshResult.revalidate, + buildPagesCacheValue(freshHtml, freshRenderProps, options.statusCode), + freshRevalidateSeconds, undefined, options.expireSeconds, ); @@ -742,32 +903,46 @@ export async function resolvePagesPageData( }; } - const result = await options.pageModule.getStaticProps({ - params: userFacingParams, - locale: options.i18n.locale, - locales: options.i18n.locales, - defaultLocale: options.i18n.defaultLocale, - // Maps Next.js's resolution in `render.tsx`: - // isOnDemandRevalidate ? "on-demand" - // : isBuildTimeSSG ? "build" - // : "stale" - // We pick "stale" as the default at runtime so existing-but-missing - // (cache evicted) entries surface as a regeneration rather than a build. - revalidateReason: options.isOnDemandRevalidate - ? "on-demand" - : options.isBuildTimePrerendering - ? "build" - : "stale", - }); + const generatedPageData = + !options.isOnDemandRevalidate && + cached?.isStale === false && + cachedValue?.kind === "PAGES" && + cachedValue.generatedFromDataRequest && + isUnknownRecord(cachedValue.pageData) + ? cachedValue.pageData + : null; + if (!generatedPageData) { + const shortCircuit = await loadForegroundAppInitialRenderProps(); + if (shortCircuit) return shortCircuit; + } + const result = generatedPageData + ? null + : await options.pageModule.getStaticProps({ + params: userFacingParams, + locale: options.i18n.locale, + locales: options.i18n.locales, + defaultLocale: options.i18n.defaultLocale, + revalidateReason: options.isOnDemandRevalidate + ? "on-demand" + : options.isBuildTimePrerendering + ? "build" + : "stale", + }); + + if (generatedPageData) { + renderProps = generatedPageData as PagesRenderProps; + pageProps = isUnknownRecord(renderProps.pageProps) ? renderProps.pageProps : {}; + } if (result?.props) { - pageProps = result.props; + pageProps = { ...pageProps, ...result.props }; + renderProps = { ...renderProps, pageProps }; } if (result?.redirect) { return { kind: "response", - response: buildPagesRedirectResponse(result.redirect, options), + response: buildPagesRedirectResponse(result.redirect, options, renderProps), }; } @@ -789,15 +964,47 @@ export async function resolvePagesPageData( if (typeof result?.revalidate === "number" && result.revalidate > 0) { isrRevalidateSeconds = result.revalidate; + } else if (cachedValue?.kind === "PAGES" && cachedValue.generatedFromDataRequest) { + isrRevalidateSeconds = cached?.value.cacheControl?.revalidate ?? 31_536_000; + } + + if (shouldPersistFallbackData) { + const revalidateSeconds = isrRevalidateSeconds ?? 31_536_000; + await options.isrSet( + cacheKey, + { + kind: "PAGES", + html: "", + pageData: renderProps, + generatedFromDataRequest: true, + headers: undefined, + status: undefined, + }, + revalidateSeconds, + undefined, + options.expireSeconds, + ); + } + } + + if ( + typeof options.pageModule.getServerSideProps !== "function" && + typeof options.pageModule.getStaticProps !== "function" && + hasPagesGetInitialProps(options.AppComponent) + ) { + const shortCircuit = await loadForegroundAppInitialRenderProps(); + if (shortCircuit) { + return shortCircuit; } } if ( typeof options.pageModule.getServerSideProps !== "function" && typeof options.pageModule.getStaticProps !== "function" && + !hasPagesGetInitialProps(options.AppComponent) && hasPagesGetInitialProps(options.pageModule.default) ) { - const { req, res, responsePromise } = options.createGsspReqRes(); + const { req, res, responsePromise } = getSharedReqRes(); const initialProps = await loadPagesGetInitialProps(options.pageModule.default, { req, res, @@ -819,6 +1026,7 @@ export async function resolvePagesPageData( if (initialProps) { pageProps = { ...pageProps, ...initialProps }; + renderProps = { ...renderProps, pageProps }; } } @@ -827,6 +1035,7 @@ export async function resolvePagesPageData( gsspRes, isrRevalidateSeconds, pageProps, + props: renderProps, isFallback: false, }; } diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index 8103d76a4..ce586d15f 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -25,9 +25,10 @@ import { buildPagesReadinessNextData } from "./pages-readiness.js"; import type { PagesI18nRenderContext } from "./pages-page-response.js"; import type { RenderPageEnhancers } from "./pages-document-initial-props.js"; import { - buildNextDataJsonResponse, + buildNextDataPropsJsonResponse, buildNextDataNotFoundResponse, normalizePagesDataRequest, + parseNextDataPathname, } from "./pages-data-route.js"; import { buildDefaultPagesNotFoundResponse } from "./pages-default-404.js"; import { @@ -160,13 +161,13 @@ export type CreatePagesPageHandlerOptions = { createPageElement: ( PageComponent: ComponentType, AppComponent: ComponentType | null, - pageProps: Record, + props: Record, ) => ReactNode; /** Build the element with optional App/Component enhancers (for _document). */ enhancePageElement: ( PageComponent: ComponentType, AppComponent: ComponentType | null, - pageProps: Record, + props: Record, opts: RenderPageEnhancers, ) => ReactNode; /** The `_app` page component (or null). */ @@ -180,6 +181,7 @@ type RenderPageOptions = { isDataReq?: boolean; statusCode?: number; asPath?: string; + originalUrl?: string; renderErrorPageOnMiss?: boolean; __isInternalErrorRender?: boolean; __forcedRoute?: PageRoute; @@ -289,25 +291,51 @@ export function createPagesPageHandler( options: RenderPageOptions | null | undefined, ): Promise { let isDataReq = !!(options && options.isDataReq); + const requestUrl = new URL(request.url); + const rawOriginalUrl = + options && typeof options.originalUrl === "string" + ? options.originalUrl + : requestUrl.pathname + requestUrl.search; + const originalRequestUrl = new URL(rawOriginalUrl, requestUrl); + const originalRequestPathAndSearch = originalRequestUrl.pathname + originalRequestUrl.search; + let dataRequestPathname: string | null = null; + let dataRequestSearch = ""; + const initialDataNorm = normalizePagesDataRequest(request, buildId); // Auto-detect /_next/data/... requests by inspecting the incoming URL. // When the worker pipeline forwards an unrewritten data URL as the `url` // arg, normalize it to the page path here. if (!isDataReq) { - const dataNorm = normalizePagesDataRequest(request, buildId); - if (dataNorm.notFoundResponse) return dataNorm.notFoundResponse; - if (dataNorm.isDataReq) { + if (initialDataNorm.notFoundResponse) return initialDataNorm.notFoundResponse; + if (initialDataNorm.isDataReq) { isDataReq = true; + dataRequestPathname = initialDataNorm.normalizedPathname; + dataRequestSearch = initialDataNorm.search; if (url && url.startsWith("/_next/data/")) { const qs = url.includes("?") ? url.slice(url.indexOf("?")) : ""; - url = dataNorm.normalizedPathname + qs; + url = initialDataNorm.normalizedPathname + qs; } } + } else if (initialDataNorm.isDataReq) { + dataRequestPathname = initialDataNorm.normalizedPathname; + dataRequestSearch = initialDataNorm.search; + } + + if (isDataReq && dataRequestPathname === null && buildId) { + const originalDataMatch = parseNextDataPathname(originalRequestUrl.pathname, buildId); + if (originalDataMatch) { + dataRequestPathname = originalDataMatch.pagePathname; + dataRequestSearch = originalRequestUrl.search; + } } const statusCode = options && typeof options.statusCode === "number" ? options.statusCode : undefined; - const asPath = options && typeof options.asPath === "string" ? options.asPath : undefined; + const defaultAsPath = + isDataReq && dataRequestPathname + ? dataRequestPathname + dataRequestSearch + : originalRequestPathAndSearch; + const asPath = options && typeof options.asPath === "string" ? options.asPath : defaultAsPath; const renderErrorPageOnMiss = !(options && options.renderErrorPageOnMiss === false); // Guard against infinite recursion when the user's custom 500/error page // itself throws during render. When this flag is set, the catch block @@ -505,6 +533,9 @@ export function createPagesPageHandler( } catch { /* font preloads not available */ } + const parsedRouteUrl = new URL(routeUrl, originalRequestUrl); + const routePathname = parsedRouteUrl.pathname || "/"; + const pagesResolvedUrl = routePathname + originalRequestUrl.search; const pageDataResult = await resolvePagesPageData({ isDataReq, @@ -514,10 +545,19 @@ export function createPagesPageHandler( deploymentId: process.env.__VINEXT_DEPLOYMENT_ID || process.env.NEXT_DEPLOYMENT_ID, htmlLimitedBots: vinextConfig.htmlLimitedBots, createGsspReqRes() { - return createPagesReqRes({ body: undefined, query, request, url: routeUrl }); + return createPagesReqRes({ + body: undefined, + query, + request, + url: originalRequestPathAndSearch, + }); + }, + createAppTree(appTreeProps) { + const el = createPageElement(PageComponent, AppComponent, appTreeProps); + return typeof wrapWithRouterContext === "function" ? wrapWithRouterContext(el) : el; }, - createPageElement(currentPageProps) { - const el = createPageElement(PageComponent, AppComponent, currentPageProps); + createPageElement(currentProps) { + const el = createPageElement(PageComponent, AppComponent, currentProps); return typeof wrapWithRouterContext === "function" ? wrapWithRouterContext(el) : el; }, fontLinkHeader, @@ -541,16 +581,18 @@ export function createPagesPageHandler( request.headers.get(PRERENDER_REVALIDATE_HEADER), ), pageModule, + AppComponent, params, query, asPath: renderAsPath ?? routeUrl, + resolvedUrl: pagesResolvedUrl, renderIsrPassToStringAsync, route: { isDynamic: route.isDynamic }, routePattern, routeUrl, runInFreshUnifiedContext(callback) { const revalCtx = createRequestContext({ - executionContext: getRequestExecutionContext(), + executionContext: null, }); return runWithRequestContext(revalCtx, async () => { ensureFetchPatch(); @@ -586,8 +628,10 @@ export function createPagesPageHandler( } let pageProps = pageDataResult.pageProps; + let renderProps = pageDataResult.props; if (routePattern === "/_error" && typeof renderStatusCode === "number") { pageProps = { ...pageProps, statusCode: renderStatusCode }; + renderProps = { ...renderProps, pageProps }; } const gsspRes = pageDataResult.gsspRes; const isrRevalidateSeconds = pageDataResult.isrRevalidateSeconds; @@ -611,7 +655,8 @@ export function createPagesPageHandler( // ── _next/data JSON envelope short-circuit ───────────────────────── // For client-side navigations Next.js fetches /_next/data//.json - // and expects { pageProps } as JSON instead of the full HTML page. + // and expects the full props envelope (pageProps plus any app-level + // props like __N_SSP, __N_SSG) as JSON instead of the full HTML page. if (isDataReq) { const init: ResponseInit & { headers: Record } = { headers: {} }; if (gsspRes && typeof gsspRes.getHeaders === "function") { @@ -648,7 +693,7 @@ export function createPagesPageHandler( init.headers[NEXTJS_DEPLOYMENT_ID_HEADER] = deploymentId; } } - return buildNextDataJsonResponse(pageProps, safeJsonStringify, init); + return buildNextDataPropsJsonResponse(renderProps, safeJsonStringify, init); } // Include both the matched page module and the global _app module. @@ -673,12 +718,12 @@ export function createPagesPageHandler( clearSsrContext() { if (typeof setSSRContext === "function") setSSRContext(null); }, - createPageElement(currentPageProps) { - const el = createPageElement(PageComponent, AppComponent, currentPageProps); + createPageElement(currentProps) { + const el = createPageElement(PageComponent, AppComponent, currentProps); return typeof wrapWithRouterContext === "function" ? wrapWithRouterContext(el) : el; }, enhancePageElement(renderPageOpts) { - const el = enhancePageElement(PageComponent, AppComponent, pageProps, renderPageOpts); + const el = enhancePageElement(PageComponent, AppComponent, renderProps, renderPageOpts); return typeof wrapWithRouterContext === "function" ? wrapWithRouterContext(el) : el; }, DocumentComponent, @@ -697,6 +742,7 @@ export function createPagesPageHandler( i18n: buildI18nRenderContext(i18nConfig, locale, currentDefaultLocale, domainLocales), isFallback: isFallbackRender, pageProps, + props: renderProps, params, renderDocumentToString(element) { return renderToStringAsync(element); diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index f2fe7ce39..69ef63d7e 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -183,6 +183,7 @@ type RenderPagesPageResponseOptions = { */ isFallback?: boolean; pageProps: Record; + props?: Record; params: Record; renderDocumentToString: (element: ReactNode) => Promise; renderToReadableStream: (element: ReactNode) => Promise>; @@ -249,6 +250,7 @@ export function buildPagesNextDataScript( | "i18n" | "isFallback" | "pageProps" + | "props" | "params" | "routePattern" | "safeJsonStringify" @@ -259,7 +261,7 @@ export function buildPagesNextDataScript( }, ): string { const nextDataPayload: Record = { - props: { pageProps: options.pageProps }, + props: options.props ?? { pageProps: options.pageProps }, page: options.routePattern, query: options.params, buildId: options.buildId, @@ -471,6 +473,7 @@ function applyGsspHeaders( export async function renderPagesPageResponse( options: RenderPagesPageResponseOptions, ): Promise { + const renderProps = options.props ?? { pageProps: options.pageProps }; options.resetSSRHead?.(); await options.flushPreloads?.(); @@ -485,6 +488,7 @@ export async function renderPagesPageResponse( i18n: options.i18n, isFallback: options.isFallback, pageProps: options.pageProps, + props: renderProps, params: options.params, routePattern: options.routePattern, safeJsonStringify: options.safeJsonStringify, @@ -540,7 +544,7 @@ export async function renderPagesPageResponse( // (`rendered`), this element is never used, so there's no point // constructing the tree on that path. const pageElement = withScriptNonce( - React.createElement(React.Fragment, null, options.createPageElement(options.pageProps)), + React.createElement(React.Fragment, null, options.createPageElement(renderProps)), options.scriptNonce, ); bodyStream = await options.renderToReadableStream(pageElement); diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index 3ef94479d..526c41284 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -44,6 +44,7 @@ import { addBasePathToPathname, hasBasePath } from "../utils/base-path.js"; export type PagesRenderOptions = { isDataReq?: boolean; renderErrorPageOnMiss?: boolean; + originalUrl?: string; }; export type MiddlewareResult = { @@ -270,7 +271,8 @@ export async function runPagesRequest( } // Step 5: Middleware - let resolvedUrl = pathname + search; + const originalResolvedUrl = pathname + search; + let resolvedUrl = originalResolvedUrl; const middlewareHeaders: HeaderRecord = {}; let middlewareStatus: number | undefined; @@ -408,13 +410,14 @@ export async function runPagesRequest( // Step 9: beforeFiles rewrites let configRewriteFired = false; if (configRewrites.beforeFiles?.length) { - const rewritten = matchRewrite( - matchResolvedPathname(resolvedPathname), - configRewrites.beforeFiles, - postMwReqCtx, - basePathState, - ); - if (rewritten) { + for (const rewrite of configRewrites.beforeFiles) { + const rewritten = matchRewrite( + matchResolvedPathname(resolvedPathname), + [rewrite], + postMwReqCtx, + basePathState, + ); + if (!rewritten) continue; if (isExternalUrl(rewritten)) { // Bare proxy — no middleware-header merge (see Step 8 asymmetry note). return { type: "response", response: await proxyExternal(request, rewritten) }; @@ -493,14 +496,49 @@ export async function runPagesRequest( } } + const refreshDataRewriteHeader = () => { + if ( + (isDataReq || isDataRequest) && + resolvedUrl !== originalResolvedUrl && + !isExternalUrl(resolvedUrl) + ) { + middlewareHeaders["x-nextjs-rewrite"] = resolvedUrl; + } else { + delete middlewareHeaders["x-nextjs-rewrite"]; + } + }; + refreshDataRewriteHeader(); + // Step 13: Render + fallback rewrites if (typeof deps.renderPage === "function") { // Reuse the Step 12 match unless afterFiles changed the pathname. - const renderPageMatch = resolvedPathnameChanged + let renderPageMatch = resolvedPathnameChanged ? deps.matchPageRoute ? deps.matchPageRoute(resolvedPathname, request) : null : pageMatch; + if ((isDataReq || isDataRequest) && !renderPageMatch && configRewrites.fallback?.length) { + const fallbackRewrite = matchRewrite( + matchResolvedPathname(resolvedPathname), + configRewrites.fallback, + postMwReqCtx, + basePathState, + ); + if (fallbackRewrite) { + if (isExternalUrl(fallbackRewrite)) { + return { + type: "response", + response: await proxyExternal(request, fallbackRewrite), + }; + } + resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); + resolvedPathname = resolvedUrl.split("?")[0]; + renderPageMatch = deps.matchPageRoute + ? deps.matchPageRoute(resolvedPathname, request) + : null; + refreshDataRewriteHeader(); + } + } // A data request must not defer-render the error page or run fallback rewrites. // Node/dev signal this via `isDataReq` (set when a `/_next/data/` path is // normalized); the worker never normalizes those paths (no buildId at request @@ -598,6 +636,7 @@ export async function runPagesRequest( resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); } } + refreshDataRewriteHeader(); return { type: "render", diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 4a63cd6e7..15d244766 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -1682,6 +1682,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // here — stale clients can fall back to a hard navigation without // accidentally triggering middleware/SSR on a bogus path. let isDataReq = false; + const originalRenderUrl = url; if (isNextDataPathname(pathname)) { const dataMatch = pagesBuildId ? parseNextDataPathname(pathname, pagesBuildId) : null; if (!dataMatch) { @@ -1747,7 +1748,11 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { resolvedUrl: string, options?: PagesRenderOptions, stagedHeaders?: Headers, - ) => renderPage(request, resolvedUrl, ssrManifest, undefined, stagedHeaders, options) + ) => + renderPage(request, resolvedUrl, ssrManifest, undefined, stagedHeaders, { + ...options, + originalUrl: originalRenderUrl, + }) : null, handleApi: typeof handleApi === "function" diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 7bdc35926..9631ffbfc 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -112,6 +112,7 @@ export type CachedPagesValue = { kind: "PAGES"; html: string; pageData: object; + generatedFromDataRequest?: boolean; headers: Record | undefined; status: number | undefined; }; diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 1d6568e8e..3c8f5530e 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -367,6 +367,7 @@ type UrlObject = { }; type TransitionOptions = { + _h?: 1; shallow?: boolean; scroll?: boolean; locale?: string | false; @@ -1088,6 +1089,17 @@ function getRouteQueryFromNextData( const routeQuery: Record = {}; if (!nextData?.query || !nextData.page) return routeQuery; + if (extractRouteParamsFromPath(nextData.page, resolvedPath) === null) { + for (const [key, value] of Object.entries(nextData.query)) { + if (typeof value === "string") { + routeQuery[key] = value; + } else if (Array.isArray(value)) { + routeQuery[key] = [...value]; + } + } + return routeQuery; + } + const routeParamNames = extractRouteParamNames(nextData.page); if (routeParamNames.length === 0) return routeQuery; @@ -1291,10 +1303,10 @@ type NavigateClientOptions = { /** Wire format of `/_next/data//.json` response bodies. */ type PagesDataResponse = { - pageProps?: Record; - // Server may also emit `notFound`, `__N_SSP`, etc. — we only consume - // `pageProps`; everything else triggers a hard reload per the - // user-configured fallback policy. + pageProps?: unknown; + // Mirrors Next.js's full Pages props envelope. `_app.getInitialProps` + // can add app-level props beside `pageProps`, and clients must thread + // that outer envelope through App during hydration/navigation. [key: string]: unknown; }; @@ -1391,10 +1403,16 @@ function getMiddlewarePagesDataFetchUrl(browserUrl: string): string | null { return buildPagesDataHref(__basePath, buildId, appPathname, parsed.search); } -async function resolveMiddlewareDataRedirect( +type MiddlewareDataEffect = { + redirectLocation: string | null; + rewriteTarget: string | null; + response: Response; +}; + +async function resolveMiddlewareDataEffect( browserUrl: string, signal: AbortSignal, -): Promise { +): Promise { const dataUrl = getMiddlewarePagesDataFetchUrl(browserUrl); if (!dataUrl) return null; @@ -1413,7 +1431,11 @@ async function resolveMiddlewareDataRedirect( "x-nextjs-data": "1", }, }); - return res.headers.get("x-nextjs-redirect"); + return { + redirectLocation: res.headers.get("x-nextjs-redirect"), + rewriteTarget: res.headers.get("x-nextjs-rewrite"), + response: res, + }; } catch { return null; } @@ -1466,11 +1488,12 @@ function handleDataRedirect( */ async function navigateClientData( url: string, - target: PagesDataTarget, + initialTarget: PagesDataTarget, controller: AbortController, navId: number, assertStillCurrent: () => void, options: NavigateClientOptions = {}, + prefetchedResponse?: Response, ): Promise { const root = window.__VINEXT_ROOT__; if (!root) { @@ -1494,17 +1517,22 @@ async function navigateClientData( if (controller.signal.aborted) { throw new NavigationCancelledError(url); } - let res: Response; - try { - const headers: Record = { Accept: "application/json", "x-nextjs-data": "1" }; - const deploymentId = getDeploymentId(); - if (deploymentId) headers[NEXT_DEPLOYMENT_ID_HEADER] = deploymentId; - res = await dedupedPagesDataFetch(target.dataHref, { headers }); - } catch (err: unknown) { - if (err instanceof DOMException && err.name === "AbortError") { - throw new NavigationCancelledError(url); + let res = prefetchedResponse; + if (!res) { + try { + const headers: Record = { + Accept: "application/json", + "x-nextjs-data": "1", + }; + const deploymentId = getDeploymentId(); + if (deploymentId) headers[NEXT_DEPLOYMENT_ID_HEADER] = deploymentId; + res = await dedupedPagesDataFetch(initialTarget.dataHref, { headers }); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") { + throw new NavigationCancelledError(url); + } + throw err; } - throw err; } assertStillCurrent(); @@ -1531,6 +1559,17 @@ async function navigateClientData( scheduleHardNavigationAndThrow(url, `Data navigation failed: ${res.status} ${res.statusText}`); } + const rewriteTarget = res.headers.get("x-nextjs-rewrite"); + const target = rewriteTarget + ? resolvePagesDataNavigationTarget(rewriteTarget, __basePath) + : initialTarget; + if (!target) { + scheduleHardNavigationAndThrow( + url, + "Data navigation failed: rewrite target has no page loader", + ); + } + let body: PagesDataResponse; try { body = (await res.json()) as PagesDataResponse; @@ -1539,8 +1578,9 @@ async function navigateClientData( } assertStillCurrent(); - const pageProps: Record = - body.pageProps && typeof body.pageProps === "object" ? body.pageProps : {}; + const props: Record = isUnknownRecord(body) ? body : {}; + const rawPageProps = props.pageProps; + const pageProps: Record = isUnknownRecord(rawPageProps) ? rawPageProps : {}; // gSSP/gSP redirect marker. When getServerSideProps/getStaticProps returns // `{ redirect }`, the data endpoint replies 200 with `__N_REDIRECT` / @@ -1597,16 +1637,18 @@ async function navigateClientData( let element: ReactElement; if (AppComponent) { element = React.createElement(AppComponent, { + ...props, Component: PageComponent, - pageProps, + pageProps: rawPageProps, + router: Router, }); } else { element = React.createElement(PageComponent, pageProps); } - // Build the updated __NEXT_DATA__. The JSON envelope is intentionally - // minimal (just `pageProps`), so we synthesise the surrounding fields - // from the data we already have: the matched pattern, the params, and - // the previous nextData's buildId/locale state. This keeps + // Build the updated __NEXT_DATA__. The JSON envelope is the full Pages + // props object, so preserve it while synthesising the surrounding fields + // from the matched pattern, params, and previous nextData's buildId/locale + // state. This keeps // `useRouter()`, `getPagesNavigationContext()`, and any code reading // `window.__NEXT_DATA__` in sync after a JSON navigation — mirroring // what the HTML path produces. @@ -1638,7 +1680,7 @@ async function navigateClientData( : (prev as VinextNextData | undefined)?.locale; const nextData = { ...prev, - props: { pageProps }, + props, page: target.pattern, query: mergedQuery, buildId: target.buildId, @@ -1733,7 +1775,9 @@ async function navigateClientHtml( } const nextData = parseVinextNextDataJson(nextDataJson); - const { pageProps } = nextData.props; + const props = nextData.props && typeof nextData.props === "object" ? nextData.props : {}; + const rawPageProps = props.pageProps; + const pageProps: Record = isUnknownRecord(rawPageProps) ? rawPageProps : {}; // Defer writing window.__NEXT_DATA__ until just before root.render() — // writing it here would let a stale navigation briefly pollute the global // between this assertStillCurrent() and the next one after await import(). @@ -1807,8 +1851,10 @@ async function navigateClientHtml( let element; if (AppComponent) { element = React.createElement(AppComponent, { + ...props, Component: PageComponent, - pageProps, + pageProps: rawPageProps, + router: Router, }); } else { element = React.createElement(PageComponent, pageProps); @@ -1877,11 +1923,12 @@ async function navigateClient( } else { let browserUrl = url; let htmlFetchUrl = fetchUrl; - const dataTarget = resolvePagesDataNavigationTarget(browserUrl, __basePath); + let dataTarget = resolvePagesDataNavigationTarget(browserUrl, __basePath); + let middlewareDataResponse: Response | undefined; if (!dataTarget) { - let redirectLocation: string | null; + let middlewareEffect: MiddlewareDataEffect | null; try { - redirectLocation = await resolveMiddlewareDataRedirect(browserUrl, controller.signal); + middlewareEffect = await resolveMiddlewareDataEffect(browserUrl, controller.signal); } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { throw new NavigationCancelledError(browserUrl); @@ -1889,6 +1936,7 @@ async function navigateClient( throw err; } assertStillCurrent(); + const redirectLocation = middlewareEffect?.redirectLocation ?? null; if (redirectLocation) { const redirectedUrl = resolveLocalRedirectUrl(redirectLocation); if (!redirectedUrl) { @@ -1899,6 +1947,9 @@ async function navigateClient( window.location.pathname + window.location.search; browserUrl = redirectedUrl; htmlFetchUrl = redirectedUrl; + } else if (middlewareEffect?.rewriteTarget) { + dataTarget = resolvePagesDataNavigationTarget(middlewareEffect.rewriteTarget, __basePath); + if (dataTarget) middlewareDataResponse = middlewareEffect.response; } } @@ -1910,6 +1961,7 @@ async function navigateClient( navId, assertStillCurrent, options, + middlewareDataResponse, ); } else { await navigateClientHtml( @@ -2206,7 +2258,7 @@ async function performNavigation( const navState = { url: resolvedNoHash, as: resolvedNoHash, options: navStateOptions }; // Hash-only change — no page fetch needed - if (isHashOnlyChange(full)) { + if (options?._h !== 1 && isHashOnlyChange(full)) { // Snapshot the outgoing entry's scroll before updateHistory mints a new // key, so a later back-popstate restores the position the user had // reached here rather than {x: 0, y: 0}. Upstream snapshots inside @@ -2254,7 +2306,10 @@ async function performNavigation( } if (mode === "push") saveScrollPosition(); - routerEvents.emit("routeChangeStart", resolved, { shallow }); + const isQueryUpdating = options?._h === 1; + if (!isQueryUpdating) { + routerEvents.emit("routeChangeStart", resolved, { shallow }); + } routerEvents.emit("beforeHistoryChange", resolved, { shallow }); updateHistory(mode, full, navState); if (!shallow) { @@ -2273,7 +2328,9 @@ async function performNavigation( } } onStateUpdate?.(); - routerEvents.emit("routeChangeComplete", resolved, { shallow }); + if (!isQueryUpdating) { + routerEvents.emit("routeChangeComplete", resolved, { shallow }); + } // Hash scrolling after routeChangeComplete, matching Next.js ordering: // x/y restoration happens during the render commit, then hash scrolling // happens after the completion event. diff --git a/tests/app-router-next-config-dev.test.ts b/tests/app-router-next-config-dev.test.ts index 9c4c89fbd..65f176abc 100644 --- a/tests/app-router-next-config-dev.test.ts +++ b/tests/app-router-next-config-dev.test.ts @@ -42,6 +42,15 @@ describe("App Router next.config.js features (dev server integration)", () => { expect(html).toContain("About"); }); + it("appends unused rewrite source params to App Router searchParams", async () => { + const res = await fetch(`${baseUrl}/rewrite-search-param/hello?lang=en&_rsc=test-token`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toMatch(/Results for:\s*()?\s*from-rewrite/); + expect(html).toMatch(/Lang:\s*()?\s*en/); + expect(html).toMatch(/Term:\s*()?\s*hello/); + }); + it("applies rewrites with repeated dynamic params in the destination", async () => { const res = await fetch(`${baseUrl}/repeat-rewrite/hello`); expect(res.status).toBe(200); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 3d748c7ef..255a2dd20 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -952,12 +952,16 @@ describe("Pages Router entry template", () => { } }); - it("keeps the Pages Router client tree shape stable after hydration", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-hydrated-")); + it("hydrates _app with the full Pages props envelope", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-props-")); const pagesDir = path.join(tmpDir, "pages"); try { fs.mkdirSync(pagesDir, { recursive: true }); + fs.writeFileSync( + path.join(pagesDir, "_app.tsx"), + "export default function App({ Component, pageProps }) { return ; }", + ); fs.writeFileSync( path.join(pagesDir, "index.tsx"), "export default function Page() { return null; }", @@ -969,9 +973,23 @@ describe("Pages Router entry template", () => { createValidFileMatcher(), ); + expect(code).toContain( + 'const props = nextData.props && typeof nextData.props === "object" ? nextData.props : {};', + ); + expect(code).toContain("const rawPageProps = props.pageProps;"); + expect(code).toContain( + 'const pageProps = rawPageProps && typeof rawPageProps === "object" ? rawPageProps : {};', + ); + expect(code).toContain('import Router, { wrapWithRouterContext } from "next/router";'); + expect(code).toContain("router: Router,"); + expect(code).toContain("pageProps: rawPageProps,"); + expect(code).toContain("element = wrapWithRouterContext(element, resolveHydrationCommit);"); + expect(code).toContain("await hydrationCommitted;"); + expect(code).toContain("if (nextData.isFallback) {"); + expect(code).toContain("await Router.replace("); + expect(code).toContain("{ _h: 1, scroll: false },"); expect(code).not.toContain("function VinextHydrationMarker"); expect(code).not.toContain("React.createElement(VinextHydrationMarker"); - expect(code).toContain("element = wrapWithRouterContext(element);"); expect(code).toContain("hydrateRoot(container, element, hydrateRootOptions)"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/tests/fixtures/app-basic/app/search/page.tsx b/tests/fixtures/app-basic/app/search/page.tsx index 939c8824a..46dc444d4 100644 --- a/tests/fixtures/app-basic/app/search/page.tsx +++ b/tests/fixtures/app-basic/app/search/page.tsx @@ -3,9 +3,9 @@ import SearchForm from "./search-form"; export default async function SearchPage({ searchParams, }: { - searchParams: Promise<{ q?: string; lang?: string; source?: string }>; + searchParams: Promise<{ q?: string; lang?: string; source?: string; term?: string }>; }) { - const { q, lang, source } = await searchParams; + const { q, lang, source, term } = await searchParams; return (

Search

@@ -14,6 +14,7 @@ export default async function SearchPage({ {!q &&

Enter a search term

} {lang &&

Lang: {lang}

} {source &&

Source: {source}

} + {term &&

Term: {term}

}
); } diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index 8d0acd905..eb613c16f 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -115,6 +115,13 @@ const nextConfig: NextConfig = { has: [{ type: "cookie", key: "mw-before-user" }], destination: "/about", }, + // Used by Vitest: app-router-next-config-dev.test.ts — unused source + // params must be appended to the destination query and exposed through + // App Router searchParams, matching Next.js prepareDestination. + { + source: "/rewrite-search-param/:term", + destination: "/search?q=from-rewrite", + }, ], afterFiles: [ // Used by Vitest: app-router.test.ts diff --git a/tests/pages-get-initial-props.test.ts b/tests/pages-get-initial-props.test.ts new file mode 100644 index 000000000..77d803063 --- /dev/null +++ b/tests/pages-get-initial-props.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + type DevAppInitialPropsContext, + loadDevAppInitialProps, +} from "../packages/vinext/src/server/pages-get-initial-props.js"; + +/** + * Real inputs, no module mocks: loadDevAppInitialProps takes the App component, + * a plain req/res, and an AppTree builder as parameters — its actual boundary. + * Tests drive it with genuine getInitialProps functions and observe the + * returned decision. + */ +function createContext( + overrides: Partial = {}, +): DevAppInitialPropsContext { + return { + appComponent: function App() { + return null; + }, + appTree: (appTreeProps) => ({ tree: appTreeProps }), + component: function Page() { + return null; + }, + req: { url: "/posts/post" }, + res: { headersSent: false, writableEnded: false }, + pathname: "/posts/[slug]", + query: { slug: "post" }, + asPath: "/posts/post", + locale: "en", + locales: ["en", "fr"], + defaultLocale: "en", + ...overrides, + }; +} + +describe("loadDevAppInitialProps", () => { + it("skips when the App has no getInitialProps", async () => { + const result = await loadDevAppInitialProps(createContext()); + expect(result).toEqual({ kind: "skip" }); + }); + + it("returns App and page-level props on a render", async () => { + const appComponent = Object.assign( + function App() { + return null; + }, + { + getInitialProps() { + return { appProp: "from-app", pageProps: { pageProp: "from-app-page" } }; + }, + }, + ); + + const result = await loadDevAppInitialProps(createContext({ appComponent })); + + expect(result).toEqual({ + kind: "render", + pageProps: { pageProp: "from-app-page" }, + renderProps: { appProp: "from-app", pageProps: { pageProp: "from-app-page" } }, + }); + }); + + it("preserves missing pageProps in the App envelope", async () => { + const appComponent = Object.assign( + function App() { + return null; + }, + { + // No pageProps key at all. + getInitialProps() { + return { appProp: "from-app" }; + }, + }, + ); + + const result = await loadDevAppInitialProps(createContext({ appComponent })); + + expect(result).toEqual({ + kind: "render", + pageProps: {}, + renderProps: { appProp: "from-app", pageProps: undefined }, + }); + }); + + it("reports response-sent when getInitialProps ends the response itself", async () => { + const res = { headersSent: false, writableEnded: false }; + const appComponent = Object.assign( + function App() { + return null; + }, + { + getInitialProps() { + // Simulate App.getInitialProps writing the response directly. + res.headersSent = true; + return {}; + }, + }, + ); + + const result = await loadDevAppInitialProps(createContext({ appComponent, res })); + + expect(result).toEqual({ kind: "response-sent" }); + }); + + it("passes router and ctx fields plus an AppTree builder to getInitialProps", async () => { + let received: Record | undefined; + const appComponent = Object.assign( + function App() { + return null; + }, + { + async getInitialProps(context: Record) { + received = context; + const appTree = context.AppTree as (p: Record) => unknown; + // Exercise the injected AppTree builder. + const tree = appTree({ pageProps: { x: 1 } }); + return { tree, pageProps: {} }; + }, + }, + ); + + await loadDevAppInitialProps(createContext({ appComponent })); + + expect(received).toBeDefined(); + expect(received).toMatchObject({ + Component: expect.any(Function), + AppTree: expect.any(Function), + router: { pathname: "/posts/[slug]", query: { slug: "post" }, asPath: "/posts/post" }, + ctx: { + pathname: "/posts/[slug]", + query: { slug: "post" }, + asPath: "/posts/post", + locale: "en", + locales: ["en", "fr"], + defaultLocale: "en", + }, + }); + }); +}); diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts index 3dde4a39f..afc0a231a 100644 --- a/tests/pages-page-data.test.ts +++ b/tests/pages-page-data.test.ts @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { renderPagesIsrHtml, @@ -62,6 +63,24 @@ function createOptions( } describe("pages page data", () => { + it("preserves non-object pageProps returned by custom app getInitialProps", async () => { + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign(function App() {}, { + getInitialProps() { + return { appValue: "preserved", pageProps: null }; + }, + }), + }), + ); + + expect(result).toMatchObject({ + kind: "render", + pageProps: {}, + props: { appValue: "preserved", pageProps: null }, + }); + }); + it("renders fresh ISR HTML while preserving custom document gaps and tail scripts", async () => { const html = await renderPagesIsrHtml({ buildId: "build-123", @@ -94,6 +113,99 @@ describe("pages page data", () => { expect(html).toContain('"__vinext":{"hasMiddleware":true}'); }); + it("preserves custom app props in fallback shells", async () => { + const AppComponent = Object.assign(function App() {}, { + getInitialProps() { + return { appValue: "preserved", pageProps: { discarded: true } }; + }, + }); + const result = await resolvePagesPageData( + createOptions({ + AppComponent, + pageModule: { + default: function Page() {}, + getStaticPaths() { + return { fallback: true, paths: [] }; + }, + }, + route: { isDynamic: true }, + }), + ); + + expect(result).toMatchObject({ + kind: "render", + isFallback: true, + pageProps: {}, + props: { appValue: "preserved", pageProps: {} }, + }); + }); + + it("preserves custom app props during stale ISR regeneration", async () => { + const isrSet = vi.fn(async () => {}); + const createPageElement = vi.fn(() => "page"); + let requestContextsApplied = false; + let appGetInitialPropsCalls = 0; + let regenerationPromise: Promise | undefined; + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign(function App() {}, { + getInitialProps() { + appGetInitialPropsCalls += 1; + if (appGetInitialPropsCalls > 1) { + expect(requestContextsApplied).toBe(true); + } + return { appValue: "fresh-app", pageProps: { fromApp: true } }; + }, + }), + applyRequestContexts() { + requestContextsApplied = true; + }, + createPageElement, + isrGet: vi.fn().mockResolvedValue({ + isStale: true, + value: { + cacheControl: { revalidate: 1, expire: undefined }, + value: { + kind: "PAGES", + html: '
stale
', + pageData: {}, + }, + }, + }), + isrSet, + pageModule: { + default: function Page() {}, + getStaticProps() { + expect(requestContextsApplied).toBe(true); + return { props: { fromStatic: true }, revalidate: 10 }; + }, + }, + triggerBackgroundRegeneration: vi.fn((_key, callback) => { + regenerationPromise = callback(); + }), + }), + ); + + expect(result.kind).toBe("response"); + await regenerationPromise; + expect(createPageElement).toHaveBeenCalledWith({ + appValue: "fresh-app", + pageProps: { fromApp: true, fromStatic: true }, + }); + expect(isrSet).toHaveBeenCalledWith( + "pages:/posts/post", + expect.objectContaining({ + pageData: { + appValue: "fresh-app", + pageProps: { fromApp: true, fromStatic: true }, + }, + }), + 10, + undefined, + 300, + ); + }); + it("returns a notFound signal when getStaticPaths excludes a dynamic HTML path", async () => { const result = await resolvePagesPageData( createOptions({ @@ -346,6 +458,104 @@ describe("pages page data", () => { expect(result.pageProps).toEqual({}); }); + it("skips the fallback shell after its data request generated the path", async () => { + const getStaticProps = vi.fn(async () => ({ props: { slug: "regenerated" } })); + const result = await resolvePagesPageData( + createOptions({ + isrGet: vi.fn().mockResolvedValue({ + isStale: false, + value: { + cacheControl: { revalidate: 60 }, + lastModified: 1, + value: { + kind: "PAGES", + html: "", + pageData: { pageProps: { slug: "unknown" } }, + generatedFromDataRequest: true, + headers: undefined, + status: undefined, + }, + }, + }), + pageModule: { + async getStaticPaths() { + return { fallback: true, paths: [] }; + }, + getStaticProps, + }, + params: { slug: "unknown" }, + query: { slug: "unknown" }, + route: { isDynamic: true }, + routeUrl: "/posts/unknown", + }), + ); + + expect(result).toMatchObject({ + kind: "render", + isFallback: false, + isrRevalidateSeconds: 60, + pageProps: { slug: "unknown" }, + }); + expect(getStaticProps).not.toHaveBeenCalled(); + }); + + it("reruns getStaticProps for on-demand revalidation of fallback data", async () => { + const getStaticProps = vi.fn(async () => ({ props: { slug: "regenerated" }, revalidate: 60 })); + const result = await resolvePagesPageData( + createOptions({ + isOnDemandRevalidate: true, + isrGet: vi.fn().mockResolvedValue({ + isStale: false, + value: { + cacheControl: { revalidate: 60 }, + lastModified: 1, + value: { + kind: "PAGES", + html: "", + pageData: { pageProps: { slug: "cached" } }, + generatedFromDataRequest: true, + headers: undefined, + status: undefined, + }, + }, + }), + pageModule: { getStaticProps }, + }), + ); + + expect(getStaticProps).toHaveBeenCalledWith( + expect.objectContaining({ revalidateReason: "on-demand" }), + ); + expect(result).toMatchObject({ kind: "render", pageProps: { slug: "regenerated" } }); + }); + + it("reruns getStaticProps when generated fallback data is stale", async () => { + const getStaticProps = vi.fn(async () => ({ props: { slug: "regenerated" }, revalidate: 60 })); + const result = await resolvePagesPageData( + createOptions({ + isrGet: vi.fn().mockResolvedValue({ + isStale: true, + value: { + cacheControl: { revalidate: 60 }, + lastModified: 1, + value: { + kind: "PAGES", + html: "", + pageData: { pageProps: { slug: "cached" } }, + generatedFromDataRequest: true, + headers: undefined, + status: undefined, + }, + }, + }), + pageModule: { getStaticProps }, + }), + ); + + expect(getStaticProps).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ kind: "render", pageProps: { slug: "regenerated" } }); + }); + it("short-circuits getServerSideProps responses after res.end()", async () => { const responsePromise = Promise.resolve( new Response('{"ok":true}', { @@ -495,6 +705,7 @@ describe("pages page data", () => { value: { lastModified: 1, cacheState: "stale", + cacheControl: { revalidate: 15, expire: 300 }, value: { kind: "PAGES", html: 'cached
stale-body
', @@ -509,7 +720,6 @@ describe("pages page data", () => { async getStaticProps() { return { props: { title: "fresh" }, - revalidate: 15, }; }, }, @@ -526,7 +736,9 @@ describe("pages page data", () => { expect(result.response.status).toBe(200); expect(result.response.headers.get("x-vinext-cache")).toBe("STALE"); - expect(result.response.headers.get("cache-control")).toBe("s-maxage=0, stale-while-revalidate"); + expect(result.response.headers.get("cache-control")).toBe( + "s-maxage=15, stale-while-revalidate=285", + ); expect(result.response.headers.get("link")).toBe( "; rel=preload; as=font; type=font/woff2; crossorigin", ); @@ -547,7 +759,7 @@ describe("pages page data", () => { expect.objectContaining({ kind: "PAGES", html: expect.stringContaining("
fresh-body
"), - pageData: { title: "fresh" }, + pageData: { pageProps: { title: "fresh" } }, }), 15, undefined, @@ -558,7 +770,7 @@ describe("pages page data", () => { expect.objectContaining({ kind: "PAGES", html: expect.stringContaining('"__vinext":{"hasMiddleware":true}'), - pageData: { title: "fresh" }, + pageData: { pageProps: { title: "fresh" } }, }), 15, undefined, @@ -566,6 +778,220 @@ describe("pages page data", () => { ); }); + it("preserves _app.getInitialProps app-level props during stale ISR regeneration", async () => { + let regenPromise: Promise | null = null; + const isrSet = vi.fn(async () => {}); + const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise) => { + regenPromise = renderFn(); + }); + let capturedRenderProps: Record | undefined; + function createPageElement(props: Record): ReactNode { + capturedRenderProps = props; + return null; + } + + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign( + function App() { + return null; + }, + { + getInitialProps() { + return { + appProp: "from-app", + pageProps: {}, + }; + }, + }, + ), + createPageElement, + isrGet: vi.fn().mockResolvedValue({ + isStale: true, + value: { + lastModified: 1, + cacheState: "stale", + value: { + kind: "PAGES", + html: '
stale-body
', + pageData: { stale: true }, + headers: undefined, + status: undefined, + }, + }, + }), + isrSet, + pageModule: { + async getStaticProps() { + return { + props: { pageProp: "from-page" }, + revalidate: 60, + }; + }, + }, + triggerBackgroundRegeneration, + }), + ); + + expect(result.kind).toBe("response"); + if (result.kind !== "response") { + throw new Error("expected response result"); + } + expect(result.response.headers.get("x-vinext-cache")).toBe("STALE"); + + if (!regenPromise) { + throw new Error("expected stale ISR regeneration to start"); + } + const pendingRegen: Promise = regenPromise; + await pendingRegen; + + expect(capturedRenderProps).toEqual( + expect.objectContaining({ + appProp: "from-app", + pageProps: { pageProp: "from-page" }, + }), + ); + + expect(isrSet).toHaveBeenCalledOnce(); + const regeneratedCacheValue = isrSet.mock.calls[0]?.[1]; + expect(regeneratedCacheValue).toEqual( + expect.objectContaining({ + kind: "PAGES", + pageData: { + appProp: "from-app", + pageProps: { pageProp: "from-page" }, + }, + }), + ); + expect(regeneratedCacheValue?.html).toContain('"appProp":"from-app"'); + expect(regeneratedCacheValue?.html).toContain('"pageProp":"from-page"'); + expect(regeneratedCacheValue?.html).toContain('"page":"/posts/[slug]"'); + }); + + it("does not run _app.getInitialProps on a fresh ISR cache HIT", async () => { + const appGip = vi.fn().mockResolvedValue({ + appProp: "from-app", + pageProps: {}, + }); + + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign( + function App() { + return null; + }, + { getInitialProps: appGip }, + ), + isrGet: vi.fn().mockResolvedValue({ + isStale: false, + value: { + lastModified: 1, + cacheState: "fresh", + value: { + kind: "PAGES", + html: '
cached-body
', + pageData: { pageProp: "cached" }, + headers: undefined, + status: undefined, + }, + }, + }), + pageModule: { + async getStaticProps() { + return { props: { pageProp: "fresh" }, revalidate: 60 }; + }, + }, + triggerBackgroundRegeneration: vi.fn(), + }), + ); + + expect(result.kind).toBe("response"); + if (result.kind !== "response") { + throw new Error("expected response result"); + } + expect(result.response.headers.get("x-vinext-cache")).toBe("HIT"); + expect(appGip).not.toHaveBeenCalled(); + }); + + it("only runs _app.getInitialProps in the stale ISR regeneration path, not on the immediate stale response", async () => { + let regenPromise: Promise | null = null; + const isrSet = vi.fn(async () => {}); + const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise) => { + regenPromise = renderFn(); + }); + + let insideRegenContext = false; + let foregroundGipCalls = 0; + let regenGipCalls = 0; + const appGip = vi.fn().mockImplementation(() => { + if (insideRegenContext) { + regenGipCalls++; + } else { + foregroundGipCalls++; + } + return Promise.resolve({ appProp: "from-app", pageProps: {} }); + }); + + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign( + function App() { + return null; + }, + { getInitialProps: appGip }, + ), + isrGet: vi.fn().mockResolvedValue({ + isStale: true, + value: { + lastModified: 1, + cacheState: "stale", + value: { + kind: "PAGES", + html: '
stale-body
', + pageData: { stale: true }, + headers: undefined, + status: undefined, + }, + }, + }), + isrSet, + pageModule: { + async getStaticProps() { + return { props: { pageProp: "from-page" }, revalidate: 60 }; + }, + }, + runInFreshUnifiedContext: async (callback) => { + insideRegenContext = true; + try { + return await callback(); + } finally { + insideRegenContext = false; + } + }, + triggerBackgroundRegeneration, + }), + ); + + expect(result.kind).toBe("response"); + if (result.kind !== "response") { + throw new Error("expected response result"); + } + expect(result.response.headers.get("x-vinext-cache")).toBe("STALE"); + // App GIP must not run before serving the stale response. + expect(foregroundGipCalls).toBe(0); + + if (!regenPromise) { + throw new Error("expected stale ISR regeneration to start"); + } + const pendingRegen: Promise = regenPromise; + await pendingRegen; + + // App GIP must run exactly once, inside the background regeneration callback. + expect(regenGipCalls).toBe(1); + expect(appGip).toHaveBeenCalledOnce(); + expect(isrSet).toHaveBeenCalledOnce(); + }); + it("preserves vinext module metadata during stale ISR regeneration", async () => { let regenPromise: Promise | null = null; const isrSet = vi.fn(async () => {}); @@ -690,6 +1116,7 @@ describe("pages page data", () => { gsspRes: null, isrRevalidateSeconds: 30, pageProps: { title: "hello" }, + props: { pageProps: { title: "hello" } }, isFallback: false, }); }); @@ -954,6 +1381,11 @@ describe("pages page data", () => { it("includes x-nextjs-deployment-id on redirect data response from getServerSideProps", async () => { const result = await resolvePagesPageData( createOptions({ + AppComponent: Object.assign(function App() {}, { + getInitialProps() { + return { appValue: "preserved", pageProps: {} }; + }, + }), isDataReq: true, deploymentId: "test-deploy-abc", pageModule: { @@ -968,7 +1400,13 @@ describe("pages page data", () => { if (result.kind !== "response") throw new Error("expected response"); expect(result.response.status).toBe(200); expect(result.response.headers.get("x-nextjs-deployment-id")).toBe("test-deploy-abc"); - const body = (await result.response.json()) as { pageProps: Record }; + const body = (await result.response.json()) as { + __N_SSP?: boolean; + appValue?: string; + pageProps: Record; + }; + expect(body.__N_SSP).toBe(true); + expect(body.appValue).toBe("preserved"); expect(body.pageProps.__N_REDIRECT).toBe("/new-page"); }); diff --git a/tests/pages-request-pipeline.test.ts b/tests/pages-request-pipeline.test.ts index c8ee27f11..27f67053e 100644 --- a/tests/pages-request-pipeline.test.ts +++ b/tests/pages-request-pipeline.test.ts @@ -164,6 +164,65 @@ describe("middleware", () => { ); }); + it("exposes the middleware rewrite target on Pages data responses", async () => { + const result = await runPagesRequest( + makeRequest("/to-blog/post"), + baseDeps({ + isDataRequest: true, + runMiddleware: makeMiddleware({ + continue: true, + rewriteUrl: "/fallback-true-blog/post", + }), + renderPage: makeRenderPage(200, '{"pageProps":{"slug":"post"}}'), + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/fallback-true-blog/post"); + }); + + it("does not expose the middleware rewrite target on HTML responses", async () => { + const result = await runPagesRequest( + makeRequest("/to-blog/post"), + baseDeps({ + runMiddleware: makeMiddleware({ + continue: true, + rewriteUrl: "/fallback-true-blog/post", + }), + renderPage: makeRenderPage(200), + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.headers.get("x-nextjs-rewrite")).toBeNull(); + }); + + it("exposes the final config rewrite URL with appended source params", async () => { + const result = await runPagesRequest( + makeRequest("/config-rewrite-to-dynamic-static/post-2"), + baseDeps({ + isDataRequest: true, + configRewrites: { + beforeFiles: [ + { + source: "/config-rewrite-to-dynamic-static/:rewriteSlug", + destination: "/ssg", + }, + ], + afterFiles: [], + fallback: [], + }, + renderPage: makeRenderPage(200), + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/ssg?rewriteSlug=post-2"); + }); + // 6. Middleware response short-circuit → {type:"response"} with middleware response it("middleware response short-circuit returns the middleware response", async () => { const middlewareResponse = new Response("blocked", { status: 403 }); @@ -317,6 +376,35 @@ describe("beforeFiles rewrites", () => { expect.any(Headers), ); }); + + it("applies every matching beforeFiles rewrite in sequence", async () => { + const renderPage = makeRenderPage(200); + const result = await runPagesRequest( + makeRequest("/start"), + baseDeps({ + isDataRequest: true, + configRewrites: { + beforeFiles: [ + { source: "/start", destination: "/middle?first=1" }, + { source: "/middle", destination: "/destination?second=2" }, + ], + afterFiles: [], + fallback: [], + }, + renderPage, + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(renderPage).toHaveBeenCalledWith( + expect.any(Request), + "/destination?first=1&second=2", + undefined, + expect.any(Headers), + ); + expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/destination?first=1&second=2"); + }); }); // 10. Out-of-basePath reject when basePath: "/base" and hadBasePath: false and no configRewrite fired @@ -518,6 +606,31 @@ describe("render intent", () => { expect(result.isDataReq).toBe(true); expect(result.renderOptions).toEqual({ isDataReq: true }); }); + + it("stages the final dev fallback rewrite target for data navigation", async () => { + const result = await runPagesRequest( + makeRequest("/to-blog/post"), + baseDeps({ + isDataReq: true, + matchPageRoute: () => null, + configRewrites: { + beforeFiles: [], + afterFiles: [], + fallback: [ + { + source: "/to-blog/:slug", + destination: "/fallback-true-blog/:slug", + }, + ], + }, + }), + ); + + expect(result.type).toBe("render"); + if (result.type !== "render") return; + expect(result.resolvedUrl).toBe("/fallback-true-blog/post"); + expect(result.stagedHeaders["x-nextjs-rewrite"]).toBe("/fallback-true-blog/post"); + }); }); // 15. {type:"response"} from renderPage (happy path) @@ -618,6 +731,40 @@ describe("deferred error page re-render on 404", () => { expect(renderPage).toHaveBeenCalledTimes(1); expect(renderPage.mock.calls[0][2]).toBeUndefined(); }); + + it("resolves fallback rewrites before rendering a production data request", async () => { + const renderPage = vi.fn( + async (_request: Request, url: string) => new Response(`data ${url}`, { status: 200 }), + ); + const matchPageRoute = vi.fn((pathname: string) => + pathname === "/fallback-target" ? ({ route: { isDynamic: false } } as any) : null, + ); + + const result = await runPagesRequest( + makeRequest("/missing-page"), + baseDeps({ + isDataRequest: true, + matchPageRoute, + configRewrites: { + beforeFiles: [], + afterFiles: [], + fallback: [{ source: "/missing-page", destination: "/fallback-target?from=fallback" }], + }, + renderPage, + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(renderPage).toHaveBeenCalledOnce(); + expect(renderPage).toHaveBeenCalledWith( + expect.any(Request), + "/fallback-target?from=fallback", + undefined, + expect.any(Headers), + ); + expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/fallback-target?from=fallback"); + }); }); // 19. preserveCredentialHeaders: isExternalUrl(resolvedUrl) → passed to applyMiddlewareRequestHeaders diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 118d65fd2..3003c5e86 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -145,6 +145,149 @@ export default function middleware() { ); } +function writeGsspAppInitialPropsContextFixture(rootDir: string): void { + fs.mkdirSync(path.join(rootDir, "pages", "blog", "[post]"), { recursive: true }); + fs.mkdirSync(path.join(rootDir, "pages", "rewrite-target"), { recursive: true }); + const nmLink = path.join(rootDir, "node_modules"); + if (!fs.existsSync(nmLink)) { + fs.symlinkSync(path.join(process.cwd(), "node_modules"), nmLink); + } + fs.writeFileSync( + path.join(rootDir, "next.config.js"), + `module.exports = { + generateBuildId: () => "test-build-id", + async rewrites() { + return [ + { source: "/blog-post-1", destination: "/blog/post-1" }, + { source: "/blog-post-2", destination: "/blog/post-2?hello=world" }, + { source: "/blog-:param", destination: "/blog/post-3" }, + { source: "/rewrite-source/:path+", destination: "/rewrite-target" }, + ]; + }, +}; +`, + ); + fs.writeFileSync( + path.join(rootDir, "pages", "_app.jsx"), + `import App from "next/app"; + +class MyApp extends App { + static async getInitialProps(ctx) { + const { req, query, pathname, asPath } = ctx.ctx; + let pageProps = {}; + + if (ctx.Component.getInitialProps) { + pageProps = await ctx.Component.getInitialProps(ctx.ctx); + } + + return { + appProps: { + url: (req || {}).url, + query, + pathname, + asPath, + }, + pageProps, + }; + } + + render() { + const { Component, pageProps, appProps, router } = this.props; + return ; + } +} + +export default MyApp; +`, + ); + fs.writeFileSync( + path.join(rootDir, "pages", "blog", "[post]", "index.jsx"), + `import { useRouter } from "next/router"; + +export async function getServerSideProps({ params, resolvedUrl }) { + return { + props: { + params, + resolvedUrl, + post: params.post, + }, + }; +} + +export default function BlogPost({ post, params, appProps, appRouter, resolvedUrl }) { + const router = useRouter(); + + return ( + <> +

Post: {post}

+
{JSON.stringify(params)}
+
{JSON.stringify(router.query)}
+
{JSON.stringify(appProps.query)}
+
{appProps.url}
+
{appRouter.pathname}
+
{resolvedUrl}
+
{router.asPath}
+ + ); +} +`, + ); + fs.writeFileSync( + path.join(rootDir, "pages", "something.jsx"), + `import { useRouter } from "next/router"; + +export async function getServerSideProps({ params, query, resolvedUrl }) { + return { + props: { + resolvedUrl, + world: "world", + query: query || {}, + params: params || {}, + }, + }; +} + +export default function Something({ world, params, query, appProps, resolvedUrl }) { + const router = useRouter(); + + return ( + <> +

hello: {world}

+
{JSON.stringify(params)}
+
{JSON.stringify(query)}
+
{JSON.stringify(router.query)}
+
{JSON.stringify(appProps.query)}
+
{appProps.url}
+
{resolvedUrl}
+
{router.asPath}
+ + ); +} +`, + ); + fs.writeFileSync( + path.join(rootDir, "pages", "rewrite-target", "index.jsx"), + `import { useRouter } from "next/router"; + +export async function getServerSideProps({ req }) { + return { props: { url: req.url } }; +} + +export default function RewriteTarget({ url }) { + const router = useRouter(); + + return ( + <> +

rewrite-target

+

{router.asPath}

+

{url}

+ + ); +} +`, + ); +} + async function buildPagesFixtureToOutDir(rootDir: string, outDir: string): Promise { await build({ root: rootDir, @@ -578,7 +721,12 @@ describe("Pages Router integration", () => { expect(res.headers.get("content-type")).toContain("application/json"); expect(res.headers.get("location")).toBeNull(); - const body = (await res.json()) as { pageProps?: Record }; + const body = (await res.json()) as { + __N_SSP?: boolean; + appProps?: Record; + pageProps?: Record; + }; + expect((body as { __N_SSP?: boolean }).__N_SSP).toBe(true); expect(body.pageProps?.__N_REDIRECT).toBe("/gssp-redirect-target"); expect(body.pageProps?.__N_REDIRECT_STATUS).toBe(307); }); @@ -1361,6 +1509,111 @@ describe("Pages Router integration", () => { expect(html).toContain("Server-Side Rendered"); }); + // Ported from Next.js: + // test/e2e/getserversideprops/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/getserversideprops/test/index.test.ts + it("passes original req.url, query, asPath, and resolvedUrl through _app.getInitialProps on GSSP pages", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-gssp-app-context-dev-")); + writeGsspAppInitialPropsContextFixture(tmpDir); + + let tempServer: Awaited>["server"] | undefined; + try { + const started = await startFixtureServer(tmpDir); + tempServer = started.server; + const fixtureUrl = started.baseUrl; + + const dynamicRes = await fetch(`${fixtureUrl}/blog/post-1`); + expect(dynamicRes.status).toBe(200); + const dynamicHtml = await dynamicRes.text(); + const elementText = (html: string, id: string) => { + const match = html.match(new RegExp(`<[^>]+id="${id}"[^>]*>(.*?)]+>`)); + expect(match).not.toBeNull(); + return match?.[1]?.replaceAll(""", '"') ?? ""; + }; + const expectElementText = (html: string, id: string, expected: string) => { + expect(elementText(html, id)).toBe(expected); + }; + const expectElementJson = (html: string, id: string, expected: unknown) => { + expect(JSON.parse(elementText(html, id))).toEqual(expected); + }; + expect(dynamicHtml).toMatch(/Post:\s*()?\s*post-1/); + expectElementJson(dynamicHtml, "params", { post: "post-1" }); + expectElementJson(dynamicHtml, "query", { post: "post-1" }); + expectElementJson(dynamicHtml, "app-query", { post: "post-1" }); + expectElementText(dynamicHtml, "app-url", "/blog/post-1"); + expectElementText(dynamicHtml, "app-router-pathname", "/blog/[post]"); + expectElementText(dynamicHtml, "resolved-url", "/blog/post-1"); + expectElementText(dynamicHtml, "as-path", "/blog/post-1"); + const dynamicNextDataMatch = dynamicHtml.match( + /