Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5382aff
fix(pages): preserve app props for gssp requests
NathanDrake2406 Jun 13, 2026
2e9304f
docs(pages): update _next/data comment to reflect full props envelope
NathanDrake2406 Jun 13, 2026
17336f7
fix(pages-router): preserve _app.getInitialProps during stale ISR reg…
NathanDrake2406 Jun 13, 2026
f238575
fix(pages-router): skip _app.getInitialProps on ISR cache hits
NathanDrake2406 Jun 13, 2026
58ed1ea
refactor(pages-router): extract dev App getInitialProps loader into a…
NathanDrake2406 Jun 13, 2026
fb8d47f
fix(pages): preserve app props across data requests
james-elicx Jun 13, 2026
deb0773
fix(pages): preserve fallback cache expiry
james-elicx Jun 13, 2026
71fe549
Merge origin/main into nathan/gssp-upstream-block
james-elicx Jun 13, 2026
4a650fe
fix(pages): preserve dev app context during ISR
james-elicx Jun 13, 2026
06d260f
Merge remote-tracking branch 'origin/main' into codex/pr-1996-review
james-elicx Jun 13, 2026
8368adc
fix(pages): hydrate middleware rewrite fallbacks
james-elicx Jun 14, 2026
197817c
fix(pages): retarget middleware data rewrites
james-elicx Jun 14, 2026
40b66d0
fix(pages): preserve rewrite query on navigation
james-elicx Jun 14, 2026
8ef56bb
fix(pages): finalize rewrite navigation state
james-elicx Jun 14, 2026
c1107b0
fix(pages): resolve rewrite data targets consistently
james-elicx Jun 14, 2026
cbdcdf6
fix(pages): await static props during regeneration
james-elicx Jun 14, 2026
4b62a76
fix(pages): preserve original query in resolved url
james-elicx Jun 14, 2026
3720ad5
Revert "fix(pages): await static props during regeneration"
james-elicx Jun 14, 2026
25556de
test(app): cover rewrite params in app search
NathanDrake2406 Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 95 additions & 3 deletions packages/vinext/src/config/config-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -808,6 +810,7 @@ export function matchConfigPattern(
pattern.includes("\\") ||
/:[\w-]+[*+][^/]/.test(pattern) ||
/:[\w-]+\./.test(pattern) ||
/[^/]:[\w-]+/.test(pattern) ||
(catchAllAnchor && namedParamCount > 1)
) {
try {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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, string>,
): 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<string, string>,
): 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<string> {
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.
*
Expand Down
34 changes: 30 additions & 4 deletions packages/vinext/src/entries/pages-client-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand All @@ -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) {
Expand All @@ -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();
Expand Down
28 changes: 23 additions & 5 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ declare global {
| React.ComponentType<{
Component: React.ComponentType<Record<string, unknown>>;
pageProps: unknown;
router?: unknown;
[key: string]: unknown;
}>
| undefined;

Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3322,6 +3322,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
): Promise<void> => {
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)
Expand Down Expand Up @@ -3804,6 +3805,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
pipelineResult.resolvedUrl,
req.__vinextMiddlewareStatus,
pipelineResult.isDataReq,
originalRequestUrl,
);
}
} catch (e) {
Expand Down
21 changes: 18 additions & 3 deletions packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -580,7 +595,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
matchPathname(cleanPathname),
);
if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite;
if (beforeFilesRewrite) cleanPathname = beforeFilesRewrite;
if (beforeFilesRewrite) cleanPathname = applyInternalRewriteDestination(beforeFilesRewrite, url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

New App Router behavior with no App Router test. This PR routes the App Router's beforeFiles/afterFiles/fallback rewrites through the new applyInternalRewriteDestination, which (via config-matchers.ts) appends unused source params to the destination query and merges them into url.search. That's a real cross-cutting change to App Router request handling — e.g. a config rewrite { source: "/blog-:param", destination: "/blog/post-3" } now exposes ?param=... to the App Router page's searchParams, and url.search mutation feeds downstream searchParams.

The new tests cover this for the Pages Router (pages-request-pipeline.test.ts, pages-router.test.ts) and the matcher itself (shims.test.ts), but there is no App Router test asserting that a rewrite's unused source params reach searchParams (or that the original query/_rsc survives). app-router-next-config-dev.test.ts already has a rewrite fixture harness — please add a case there so this App Router code path is covered and protected against regression.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

New App Router behavior with no App Router test: routing beforeFiles/afterFiles/fallback rewrites through applyInternalRewriteDestination now appends unused rewrite source params to the destination query and mutates url.search, which feeds App Router searchParams. The behavior is sound (mergeRewriteQuery preserves the original query/_rsc), but only Pages Router + the matcher are tested. Please add a case to app-router-next-config-dev.test.ts asserting a config rewrite like { source: "/blog-:param", destination: "/blog/post-3" } exposes ?param=... to the App Router page's searchParams and that _rsc survives.


if (isImageOptimizationPath(cleanPathname)) {
const imageRedirect = resolveDevImageRedirect(
Expand Down Expand Up @@ -701,7 +716,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
);
if (afterFilesRewrite instanceof Response) return afterFilesRewrite;
if (afterFilesRewrite) {
cleanPathname = afterFilesRewrite;
cleanPathname = applyInternalRewriteDestination(afterFilesRewrite, url);
match = options.matchRoute(cleanPathname);
}
}
Expand All @@ -720,7 +735,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
);
if (fallbackRewrite instanceof Response) return fallbackRewrite;
if (fallbackRewrite) {
cleanPathname = fallbackRewrite;
cleanPathname = applyInternalRewriteDestination(fallbackRewrite, url);
match = options.matchRoute(cleanPathname);
}
}
Expand Down
Loading
Loading