Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7f4f8eb
Extract app page route wiring helpers
NathanDrake2406 Apr 2, 2026
5d8525b
Add slot client primitives
NathanDrake2406 Apr 2, 2026
be33773
Fix app page error boundary serialization
NathanDrake2406 Apr 2, 2026
ca40d05
Fix client error boundary pathname reset
NathanDrake2406 Apr 2, 2026
bddda39
Document Next.js error boundary verification
NathanDrake2406 Apr 2, 2026
38d33ca
Merge local PR 2a into PR 2c base
NathanDrake2406 Apr 2, 2026
8c22db3
Merge local PR 2b into PR 2c base
NathanDrake2406 Apr 2, 2026
d488978
Implement flat App Router payload for layout persistence
NathanDrake2406 Apr 2, 2026
ec008fa
fix: address review findings in flat payload implementation
NathanDrake2406 Apr 2, 2026
5395efc
fix: normalize flat payload after use(), not before
NathanDrake2406 Apr 2, 2026
955f577
fix: produce flat RSC payload on all rendering paths
NathanDrake2406 Apr 2, 2026
ce76239
test: update unit tests for flat RSC payload on all paths
NathanDrake2406 Apr 2, 2026
c7a03d5
fix: wrap Flight thenable in Promise.resolve() before chaining .then()
NathanDrake2406 Apr 2, 2026
7fead69
fix: eliminate Promise from ElementsContext to fix React 19 hydration
NathanDrake2406 Apr 2, 2026
311b10a
test: update slot and browser state tests for resolved ElementsContext
NathanDrake2406 Apr 2, 2026
2b5f68c
ci: retrigger
NathanDrake2406 Apr 2, 2026
7554b20
fix: address code review findings (P1-P3)
NathanDrake2406 Apr 2, 2026
1014aed
fix: avoid serializing app render dependency wrappers
NathanDrake2406 Apr 2, 2026
5e516bd
Fix flat payload dependency barriers
NathanDrake2406 Apr 2, 2026
ee2fbdd
Fix template-only route wrappers
NathanDrake2406 Apr 2, 2026
f8e2276
chore: trigger CI review
NathanDrake2406 Apr 2, 2026
c346485
fix: skip Slot wrapping for layout entries without a default export
NathanDrake2406 Apr 2, 2026
d2a4d13
Merge upstream/main into feat/layout-persistence-pr-2c
NathanDrake2406 Apr 4, 2026
9b9a6d5
fix: restore merged app router entry behavior
NathanDrake2406 Apr 4, 2026
f4949b4
fix: address app router review regressions
NathanDrake2406 Apr 4, 2026
c606e62
Merge upstream/main into feat/layout-persistence-pr-2c
NathanDrake2406 Apr 5, 2026
a220f3d
Fix app-page-request intercept tests
NathanDrake2406 Apr 5, 2026
9959097
Address PR 2c review follow-ups
NathanDrake2406 Apr 6, 2026
7c62628
Refactor same-url payload commits
NathanDrake2406 Apr 6, 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
134 changes: 57 additions & 77 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ const appPageRouteWiringPath = resolveEntryPath(
import.meta.url,
);
const appPageRenderPath = resolveEntryPath("../server/app-page-render.js", import.meta.url);
const appPageResponsePath = resolveEntryPath("../server/app-page-response.js", import.meta.url);
const appPageRequestPath = resolveEntryPath("../server/app-page-request.js", import.meta.url);
const appRouteHandlerResponsePath = resolveEntryPath(
"../server/app-route-handler-response.js",
Expand Down Expand Up @@ -98,8 +97,6 @@ export type AppRouterConfig = {
* `virtual:vinext-server-entry` when this flag is set.
*/
hasPagesDir?: boolean;
/** Exact public/ file routes, using normalized leading-slash pathnames. */
publicFiles?: string[];
};

/**
Expand Down Expand Up @@ -129,7 +126,6 @@ export function generateRscEntry(
const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024;
const i18nConfig = config?.i18n ?? null;
const hasPagesDir = config?.hasPagesDir ?? false;
const publicFiles = config?.publicFiles ?? [];
// Build import map for all page and layout files
const imports: string[] = [];
const importMap: Map<string, string> = new Map();
Expand Down Expand Up @@ -214,6 +210,7 @@ ${interceptEntries.join(",\n")}
routeHandler: ${route.routePath ? getImportVar(route.routePath) : "null"},
layouts: [${layoutVars.join(", ")}],
routeSegments: ${JSON.stringify(route.routeSegments)},
templateTreePositions: ${JSON.stringify(route.templateTreePositions)},
layoutTreePositions: ${JSON.stringify(route.layoutTreePositions)},
templates: [${templateVars.join(", ")}],
errors: [${layoutErrorVars.join(", ")}],
Expand Down Expand Up @@ -382,15 +379,12 @@ import {
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from ${JSON.stringify(appPageBoundaryRenderPath)};
import {
buildAppPageRouteElement as __buildAppPageRouteElement,
buildAppPageElements as __buildAppPageElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from ${JSON.stringify(appPageRouteWiringPath)};
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from ${JSON.stringify(appPageRenderPath)};
import {
mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders,
} from ${JSON.stringify(appPageResponsePath)};
import {
buildAppPageElement as __buildAppPageElement,
resolveAppPageIntercept as __resolveAppPageIntercept,
Expand Down Expand Up @@ -822,21 +816,6 @@ function matchRoute(url) {
return _trieMatch(_routeTrie, urlParts);
}

function __createStaticFileSignal(pathname, _mwCtx) {
const headers = new Headers({
"x-vinext-static-file": encodeURIComponent(pathname),
});
if (_mwCtx.headers) {
for (const [key, value] of _mwCtx.headers) {
headers.append(key, value);
}
}
return new Response(null, {
status: _mwCtx.status ?? 200,
headers,
});
}

// matchPattern is kept for findIntercept (linear scan over small interceptLookup array).
function matchPattern(urlParts, patternParts) {
const params = Object.create(null);
Expand Down Expand Up @@ -903,10 +882,21 @@ function findIntercept(pathname) {
return null;
}

async function buildPageElement(route, params, opts, searchParams) {
async function buildPageElements(route, params, routePath, opts, searchParams) {
const PageComponent = route.page?.default;
if (!PageComponent) {
return createElement("div", null, "Page has no default export");
const _noExportRouteId = "route:" + routePath;
let _noExportRootLayout = null;
if (route.layouts?.length > 0) {
const _tp = route.layoutTreePositions?.[0] ?? 0;
const _segs = route.routeSegments?.slice(0, _tp) ?? [];
_noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/");
}
return {
__route: _noExportRouteId,
__rootLayout: _noExportRootLayout,
[_noExportRouteId]: createElement("div", null, "Page has no default export"),
};
}

// Resolve metadata and viewport from layouts and page.
Expand Down Expand Up @@ -1004,13 +994,14 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
return __buildAppPageRouteElement({
return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"},
makeThenableParams,
matchedParams: params,
resolvedMetadata,
resolvedViewport,
routePath,
rootNotFoundModule: ${rootNotFoundVar ? rootNotFoundVar : "null"},
route,
slotOverrides:
Expand All @@ -1033,7 +1024,6 @@ const __i18nConfig = ${JSON.stringify(i18nConfig)};
const __configRedirects = ${JSON.stringify(redirects)};
const __configRewrites = ${JSON.stringify(rewrites)};
const __configHeaders = ${JSON.stringify(headers)};
const __publicFiles = new Set(${JSON.stringify(publicFiles)});
const __allowedOrigins = ${JSON.stringify(allowedOrigins)};

${generateDevOriginCheckCode(config?.allowedDevOrigins)}
Expand Down Expand Up @@ -1244,9 +1234,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
${
bp
? `
if (!hasBasePath(pathname, __basePath) && !pathname.startsWith("/__vinext/")) {
return new Response("Not Found", { status: 404 });
}
// Strip basePath prefix
pathname = stripBasePath(pathname, __basePath);
Comment on lines 1264 to 1265
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reinstate basePath rejection for unprefixed requests

When basePath is configured, this handler now always strips it opportunistically and continues routing even when the incoming pathname never had that prefix. That makes routes reachable without the configured base path (for example, /logo/logo.svg or app routes resolving at /... when basePath is /app) instead of returning 404, which breaks Next.js basePath semantics and the existing app-router basePath expectations.

Useful? React with 👍 / 👎.

`
Expand Down Expand Up @@ -1604,18 +1591,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
}

// Serve public/ files as filesystem routes after middleware and before
// afterFiles/fallback rewrites, matching Next.js routing semantics.
if (
(request.method === "GET" || request.method === "HEAD") &&
!pathname.endsWith(".rsc") &&
__publicFiles.has(cleanPathname)
) {
setHeadersContext(null);
setNavigationContext(null);
return __createStaticFileSignal(cleanPathname, _mwCtx);
}

// Set navigation context for Server Components.
// Note: Headers context is already set by runWithRequestContext in the handler wrapper.
setNavigationContext({
Comment on lines 1633 to 1635
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore public-file static signal in App Router handler

The request flow now jumps from metadata handling directly into route rendering, but the prior public/ file branch that emitted x-vinext-static-file was removed. In production, static public assets are served via that signal (prod-server and worker static resolvers consume it), so App Router requests for non-metadata public files will regress from static file responses to normal route handling/404.

Useful? React with 👍 / 👎.

Expand Down Expand Up @@ -1716,14 +1691,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const redirectHeaders = new Headers({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
"x-action-redirect": actionRedirect.url,
"x-action-redirect-type": actionRedirect.type,
"x-action-redirect-status": String(actionRedirect.status),
});
// Merge middleware headers first so the framework's own redirect control
// headers below are always authoritative and cannot be clobbered by
// middleware that happens to set x-action-redirect* keys.
__mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers);
redirectHeaders.set("x-action-redirect", actionRedirect.url);
redirectHeaders.set("x-action-redirect-type", actionRedirect.type);
redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status));
for (const cookie of actionPendingCookies) {
redirectHeaders.append("Set-Cookie", cookie);
}
Expand All @@ -1743,9 +1714,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
searchParams: url.searchParams,
params: actionParams,
});
element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams);
element = buildPageElements(
actionRoute,
actionParams,
cleanPathname,
undefined,
url.searchParams,
);
} else {
element = createElement("div", null, "Page not found");
const _actionRouteId = "route:" + cleanPathname;
element = {
__route: _actionRouteId,
__rootLayout: null,
[_actionRouteId]: createElement("div", null, "Page not found"),
};
}

const onRenderError = createRscOnErrorHandler(
Expand All @@ -1766,15 +1748,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();

const actionHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" });
__mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers);
const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
const actionResponse = new Response(rscStream, { headers: actionHeaders });
if (actionPendingCookies.length > 0 || actionDraftCookie) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Merge middleware headers into server-action RSC responses

This response path now builds a fresh header object and returns new Response(rscStream, ...) without merging middleware response headers/status from _mwCtx. As a result, middleware-added Set-Cookie/CORS/security headers and status overrides are dropped on server-action rerender responses (and the same pattern is used in intercept responses), causing middleware behavior to diverge from normal app-page RSC/HTML responses.

Useful? React with 👍 / 👎.

for (const cookie of actionPendingCookies) {
actionHeaders.append("Set-Cookie", cookie);
actionResponse.headers.append("Set-Cookie", cookie);
}
if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie);
if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie);
}
return new Response(rscStream, { status: _mwCtx.status ?? 200, headers: actionHeaders });
return actionResponse;
} catch (err) {
getAndClearPendingCookies(); // Clear pending cookies on error
console.error("[vinext] Server action error:", err);
Expand Down Expand Up @@ -2097,7 +2079,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams());
const __revalElement = await buildPageElements(
route,
params,
cleanPathname,
undefined,
new URLSearchParams(),
);
const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true);
Expand Down Expand Up @@ -2146,7 +2134,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If the target URL matches an intercepting route in a parallel slot,
// render the source route with the intercepting page in the slot.
const __interceptResult = await __resolveAppPageIntercept({
buildPageElement,
buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) {
return buildPageElements(
interceptRoute,
interceptParams,
cleanPathname,
interceptOpts,
interceptSearchParams,
);
},
cleanPathname,
currentRoute: route,
findIntercept,
Expand All @@ -2173,11 +2169,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// by the client, and async server components that run during consumption need the
// context to still be live. The AsyncLocalStorage scope from runWithRequestContext
// handles cleanup naturally when all async continuations complete.
const interceptHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" });
__mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers);
return new Response(interceptStream, {
status: _mwCtx.status ?? 200,
headers: interceptHeaders,
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
});
},
searchParams: url.searchParams,
Expand All @@ -2197,7 +2190,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {

const __pageBuildResult = await __buildAppPageElement({
buildPageElement() {
return buildPageElement(route, params, interceptOpts, url.searchParams);
return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams);
},
renderErrorBoundaryPage(buildErr) {
return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
Expand Down Expand Up @@ -2228,19 +2221,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// rscCssTransform — no manual loadCss() call needed.
const _hasLoadingBoundary = !!(route.loading && route.loading.default);
const _asyncLayoutParams = makeThenableParams(params);
// Convert URLSearchParams to a plain object then wrap in makeThenableParams()
// so probePage() passes the same shape that buildPageElement() gives to the
// real render. Without this, pages that destructure await-ed searchParams
// throw TypeError during probe.
const _probeSearchObj = {};
url.searchParams.forEach(function(v, k) {
if (k in _probeSearchObj) {
_probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v];
} else {
_probeSearchObj[k] = v;
}
});
const _asyncSearchParams = makeThenableParams(_probeSearchObj);
return __renderAppPageLifecycle({
cleanPathname,
clearRequestContext() {
Expand Down Expand Up @@ -2286,7 +2266,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return LayoutComp({ params: _asyncLayoutParams, children: null });
},
probePage() {
return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
return PageComponent({ params });
},
revalidateSeconds,
renderErrorBoundaryResponse(renderErr) {
Expand Down
5 changes: 5 additions & 0 deletions packages/vinext/src/routing/app-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export type AppRoute = {
* Used at render time to compute the child segments for useSelectedLayoutSegments().
*/
routeSegments: string[];
/** Tree position (directory depth from app/ root) for each template. */
templateTreePositions?: number[];
/**
* Tree position (directory depth from app/ root) for each layout.
* Used to slice routeSegments and determine which segments are below each layout.
Expand Down Expand Up @@ -327,6 +329,7 @@ function discoverSlotSubRoutes(
forbiddenPath: parentRoute.forbiddenPath,
unauthorizedPath: parentRoute.unauthorizedPath,
routeSegments: [...parentRoute.routeSegments, ...rawSegments],
templateTreePositions: parentRoute.templateTreePositions,
layoutTreePositions: parentRoute.layoutTreePositions,
isDynamic: parentRoute.isDynamic || subIsDynamic,
params: [...parentRoute.params, ...subParams],
Expand Down Expand Up @@ -405,6 +408,7 @@ function fileToAppRoute(
// Discover layouts and templates from root to leaf
const layouts = discoverLayouts(segments, appDir, matcher);
const templates = discoverTemplates(segments, appDir, matcher);
const templateTreePositions = computeLayoutTreePositions(appDir, templates);

// Compute the tree position (directory depth) for each layout.
const layoutTreePositions = computeLayoutTreePositions(appDir, layouts);
Expand Down Expand Up @@ -449,6 +453,7 @@ function fileToAppRoute(
forbiddenPath,
unauthorizedPath,
routeSegments: segments,
templateTreePositions,
layoutTreePositions,
isDynamic,
params,
Expand Down
Loading
Loading