Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
61eefa0
fix(router): honor hybrid pages route priority
NathanDrake2406 Jun 13, 2026
f7f91f9
fix(router): share hybrid owner decision with client navigation
NathanDrake2406 Jun 13, 2026
50983b7
fix(router): mirror server hybrid owner decision on the client
NathanDrake2406 Jun 13, 2026
85e9b8d
docs(router): fix stale score in static-prefix catch-all test comment
NathanDrake2406 Jun 13, 2026
6e45654
test(use-params): fix direct-load single dynamic param to use /a inst…
NathanDrake2406 Jun 13, 2026
9dbd7fa
fix(router): compare hybrid routes structurally
james-elicx Jun 13, 2026
3eab594
fix(router): reject hybrid route conflicts
james-elicx Jun 13, 2026
db321bb
fix(router): refresh hybrid route ownership
james-elicx Jun 13, 2026
9cae882
fix(router): preserve hybrid routing lifecycle
james-elicx Jun 13, 2026
46d833b
fix(router): recheck pages routes after rewrites
james-elicx Jun 13, 2026
63c64f4
fix(router): preserve rewritten pages queries
james-elicx Jun 13, 2026
5571ddb
fix(router): preserve rewritten route ownership
james-elicx Jun 13, 2026
15257ea
fix(router): resolve client rewrites sequentially
james-elicx Jun 13, 2026
a937f05
fix(router): apply rewrite phases sequentially
james-elicx Jun 13, 2026
a77be8a
fix(router): preserve rewrite params and endpoints
james-elicx Jun 13, 2026
df15092
fix(router): hand off endpoint navigations
james-elicx Jun 13, 2026
f43ef02
fix(router): preserve rewrite fragment params
james-elicx Jun 13, 2026
ab7eefa
Merge origin/main into nathan/use-params-parity
james-elicx Jun 13, 2026
56a6d5a
Merge origin/main into nathan/use-params-parity
james-elicx Jun 14, 2026
2395fde
test(e2e): avoid hybrid fixture route conflicts
james-elicx Jun 14, 2026
0215e47
test(pages): await async config validation
james-elicx Jun 14, 2026
e691ff2
test(hybrid): avoid duplicate page fixtures
james-elicx Jun 14, 2026
9c5dc46
chore(router): clarify hybrid priority semantics
james-elicx Jun 14, 2026
8538c43
refactor(router): remove duplicate app route matcher
james-elicx Jun 14, 2026
b251bce
docs(router): clarify client hybrid comparator
james-elicx 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
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// Ensure app router works when a pages directory is present
export default function Page() {
return <div>pages index</div>;
}
18 changes: 18 additions & 0 deletions packages/vinext/src/client/vinext-next-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ import { isUnknownRecord } from "../utils/record.js";

export type VinextLinkPrefetchRoute = {
canPrefetchLoadingShell: boolean;
documentOnly?: boolean;
isDynamic: boolean;
patternParts: string[];
};

/**
* Pages Router route pattern exposed to the client so the App Router's
* navigation runtime can decide whether a soft-navigated URL should be
* handled by Pages (hard nav) or App (RSC). Mirrors the public shape of
* `VinextLinkPrefetchRoute` so a single trie matcher handles both.
*
* `canPrefetchLoadingShell` is always `false` for Pages routes — Pages
* does not have a separate loading boundary and its prefetch surface is
* `_next/data/<buildId>/<page>.json`.
*/
export type VinextPagesLinkPrefetchRoute = {
canPrefetchLoadingShell: false;
documentOnly?: boolean;
isDynamic: boolean;
patternParts: string[];
};
Expand Down
36 changes: 32 additions & 4 deletions packages/vinext/src/config/config-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1089,16 +1089,44 @@ export function matchRewrite(
? collectConditionParams(rewrite.has, rewrite.missing, ctx)
: _emptyParams();
if (!conditionParams) continue;
const destinationParams = { ...params, ...conditionParams };
// Collapse protocol-relative URLs (e.g. //evil.com from decoded %2F in catch-all params).
return substituteAndSanitizeDestination(rewrite.destination, {
...params,
...conditionParams,
});
const destination = substituteAndSanitizeDestination(rewrite.destination, destinationParams);
return appendRewriteParamsToQuery(destination, rewrite.destination, destinationParams);
}
}
return null;
}

function destinationReferencesParam(destination: string, key: string): boolean {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`:${escapedKey}(?:[+*])?(?![A-Za-z0-9_])`).test(destination);
}

function appendRewriteParamsToQuery(
destination: string,
destinationTemplate: string,
params: Record<string, string>,
): string {
const templateWithoutQuery = destinationTemplate.replace(/\?[^#]*/, "");
if (Object.keys(params).some((key) => destinationReferencesParam(templateWithoutQuery, key))) {
return destination;
}

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 destinationQuery = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
const destinationKeys = new Set(new URLSearchParams(destinationQuery).keys());
const additions = Object.entries(params)
.filter(([key]) => !destinationKeys.has(key))
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
if (additions.length === 0) return destination;
const separator = queryIndex === -1 ? "?" : destinationQuery ? "&" : "";
return `${beforeHash}${separator}${additions.join("&")}${hash}`;
}

/**
* Substitute all matched route params into a redirect/rewrite destination.
*
Expand Down
33 changes: 29 additions & 4 deletions packages/vinext/src/entries/app-browser-entry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { resolveClientRuntimeModule, resolveRuntimeEntryModule } from "./runtime-entry-module.js";
import type { VinextLinkPrefetchRoute } from "../client/vinext-next-data.js";
import type {
VinextLinkPrefetchRoute,
VinextPagesLinkPrefetchRoute,
} from "../client/vinext-next-data.js";
import type { AppRoute } from "../routing/app-router.js";
import type { RouteManifest } from "../routing/app-route-graph.js";
import type { NextRewrite } from "../config/next-config.js";

/**
* Generate the virtual browser entry module.
Expand All @@ -13,16 +17,28 @@ import type { RouteManifest } from "../routing/app-route-graph.js";
export function generateBrowserEntry(
routes: readonly AppRoute[] = [],
routeManifest: RouteManifest | null = null,
pagesPrefetchRoutes: readonly VinextPagesLinkPrefetchRoute[] = [],
rewrites: { afterFiles: NextRewrite[]; beforeFiles: NextRewrite[]; fallback: NextRewrite[] } = {
afterFiles: [],
beforeFiles: [],
fallback: [],
},
): string {
const entryPath = resolveRuntimeEntryModule("app-browser-entry");
const navigationRuntimePath = resolveClientRuntimeModule("navigation-runtime");
const prefetchRoutes: VinextLinkPrefetchRoute[] = routes
.filter(isLinkPrefetchRoute)
.map(toLinkPrefetchRoute);
const prefetchRoutes: VinextLinkPrefetchRoute[] = routes.map((route) =>
isLinkPrefetchRoute(route) ? toLinkPrefetchRoute(route) : toDocumentOnlyAppRoute(route),
);

return `import { registerNavigationRuntimeBootstrap } from ${JSON.stringify(navigationRuntimePath)};

window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(prefetchRoutes)};
// Pages route manifest for hybrid ownership decisions. In a hybrid
// app+pages build the user can land on an App page, so the App browser
// entry must also expose the Pages manifest (the Pages client entry does
// the same — whichever entry runs first emits both globals).
window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)};
window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(rewrites)};
registerNavigationRuntimeBootstrap({
routeManifest: ${buildRouteManifestExpression(routeManifest)}
});
Expand All @@ -40,6 +56,15 @@ export function isLinkPrefetchRoute(route: AppRoute): boolean {
return route.routePath === null && route.layouts.length > 0;
}

export function toDocumentOnlyAppRoute(route: AppRoute): VinextLinkPrefetchRoute {
return {
canPrefetchLoadingShell: false,
documentOnly: true,
patternParts: [...route.patternParts],
isDynamic: route.isDynamic,
};
}

/** Project an `AppRoute` down to the public `VinextLinkPrefetchRoute` shape. */
export function toLinkPrefetchRoute(route: AppRoute): VinextLinkPrefetchRoute {
return {
Expand Down
4 changes: 2 additions & 2 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1041,9 +1041,9 @@ export default __createAppRscHandler({
},
${
hasPagesDir
? `async renderPagesFallback({ isRscRequest, middlewareContext, request, url }) {
? `async renderPagesFallback({ allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }) {
return __renderPagesFallback(
{ isRscRequest, middlewareContext, request, url },
{ allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url },
{
loadPagesEntry() {
return import.meta.viteRsc.loadModule("ssr", "index");
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/entries/app-ssr-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export { default } from ${JSON.stringify(entryPath)};
${
hasPagesDir
? `
export { handleApiRoute, pageRoutes, renderPage } from "virtual:vinext-server-entry";
export { handleApiRoute, matchApiRoute, matchPageRoute, pageRoutes, renderPage } from "virtual:vinext-server-entry";
`
: ""
}`;
Expand Down
33 changes: 32 additions & 1 deletion packages/vinext/src/entries/pages-client-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,36 @@
* Extracted from index.ts.
*/
import {
apiRouter,
pagesRouter,
patternToNextFormat as pagesPatternToNextFormat,
type Route,
} from "../routing/pages-router.js";
import { createValidFileMatcher } from "../routing/file-matcher.js";
import { type ResolvedNextConfig } from "../config/next-config.js";
import type { VinextLinkPrefetchRoute } from "../client/vinext-next-data.js";
import type {
VinextLinkPrefetchRoute,
VinextPagesLinkPrefetchRoute,
} from "../client/vinext-next-data.js";
import { findFileWithExts } from "./pages-entry-helpers.js";
import { normalizePathSeparators } from "../utils/path.js";

/**
* Project a Pages `Route` down to the public `VinextPagesLinkPrefetchRoute`
* shape used for client-side hybrid ownership decisions. Mirrors
* `toLinkPrefetchRoute` in `app-browser-entry.ts`.
*
* Lives here (not in `routing/pages-router.ts`) so the routing module
* stays free of `vitext/client` type imports.
*/
function toPagesLinkPrefetchRoute(route: Route): VinextPagesLinkPrefetchRoute {
return {
canPrefetchLoadingShell: false,
isDynamic: route.isDynamic,
patternParts: [...route.patternParts],
};
}

export async function generateClientEntry(
pagesDir: string,
nextConfig: ResolvedNextConfig,
Expand All @@ -30,10 +50,15 @@ export async function generateClientEntry(
} = {},
): Promise<string> {
const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);
const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);

const appFilePath = findFileWithExts(pagesDir, "_app", fileMatcher);
const hasApp = appFilePath !== null;
const appPrefetchRoutes = options.appPrefetchRoutes ?? [];
const pagesPrefetchRoutes: VinextPagesLinkPrefetchRoute[] = [
...pageRoutes.map(toPagesLinkPrefetchRoute),
...apiRoutes.map((route) => ({ ...toPagesLinkPrefetchRoute(route), documentOnly: true })),
];
const instrumentationClientPath = options.instrumentationClientPath ?? null;

// Build a map of route pattern -> dynamic import.
Expand Down Expand Up @@ -129,6 +154,12 @@ window.__VINEXT_APP_LOADER__ = appLoader;
// when the user lands on an App Router page (see app-browser-entry.ts) — the
// two writes do not race because only one entry executes per page load.
window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(appPrefetchRoutes)};
// Pages route manifest, exposed so the App Router runtime can decide when
// a soft-navigated URL is actually owned by Pages (and must hard-navigate
// instead of issuing an RSC request). Set here AND in app-browser-entry.ts
// so whichever entry runs first emits the Pages manifest.
window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)};
window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(nextConfig.rewrites)};

async function hydrate() {
const nextData = window.__NEXT_DATA__;
Expand Down
14 changes: 14 additions & 0 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,20 @@ export function matchPageRoute(url, request) {
return matchRoute(routeUrl, pageRoutes);
}
export function matchApiRoute(url, request) {
const routeUrl = i18nConfig && request
? resolvePagesI18nRequest(
url,
i18nConfig,
request.headers,
new URL(request.url).hostname,
vinextConfig.basePath,
vinextConfig.trailingSlash,
).url
: url;
return matchRoute(routeUrl, apiRoutes);
}
// ── Pages render orchestrator — delegates to server/pages-page-handler.ts ──
//
// All next/*-derived values are passed as closures so the handler module
Expand Down
Loading
Loading