Skip to content
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
28 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
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
109 changes: 76 additions & 33 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,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,7 +383,7 @@ import {
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from ${JSON.stringify(appPageBoundaryRenderPath)};
import {
buildAppPageRouteElement as __buildAppPageRouteElement,
buildAppPageElements as __buildAppPageElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from ${JSON.stringify(appPageRouteWiringPath)};
import {
Expand Down Expand Up @@ -903,10 +904,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 +1016,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 Down Expand Up @@ -1716,14 +1729,11 @@ 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);
Comment on lines 1730 to 1734
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.

Regression: middleware can clobber framework redirect headers.

The old code intentionally set x-action-redirect* headers after __mergeMiddlewareResponseHeaders with an explicit comment:

"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."

The new code sets these headers in the Headers constructor before the merge call. Since mergeMiddlewareResponseHeaders uses target.set(key, value) for non-cookie/non-vary headers (app-page-response.ts:183), if middleware sets x-action-redirect, x-action-redirect-type, or x-action-redirect-status, those values will overwrite the framework's authoritative values.

This is a security-relevant regression — middleware could redirect server action responses to an unintended URL.

Suggested change
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);
const redirectHeaders = new Headers({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
});
// 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));

Note: this pattern appears in the generated code for all route variants (the same regression is repeated in each snapshot branch of the entry template).

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 +1753,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 +1787,22 @@ 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" });
const actionHeaders = new Headers({
"Content-Type": "text/x-component; charset=utf-8",
"Vary": "RSC, Accept",
});
__mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers);
const actionResponse = new Response(rscStream, {
status: _mwCtx.status ?? 200,
headers: actionHeaders,
});
if (actionPendingCookies.length > 0 || actionDraftCookie) {
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 +2125,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 +2180,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,7 +2215,10 @@ 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" });
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,
Expand All @@ -2197,7 +2242,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 +2273,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,6 +2318,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return LayoutComp({ params: _asyncLayoutParams, children: null });
},
probePage() {
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 PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
},
revalidateSeconds,
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