Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
147 changes: 79 additions & 68 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,7 @@ export function generateRscEntry(
if (route.pagePath) getImportVar(route.pagePath);
if (route.routePath) getImportVar(route.routePath);
for (const layout of route.layouts) getImportVar(layout);
for (const tmpl of route.templates) {
if (tmpl) getImportVar(tmpl);
}
for (const tmpl of route.templates) getImportVar(tmpl);
if (route.loadingPath) getImportVar(route.loadingPath);
if (route.errorPath) getImportVar(route.errorPath);
if (route.layoutErrorPaths)
Expand Down Expand Up @@ -181,7 +179,7 @@ export function generateRscEntry(
// Build route table as serialized JS
const routeEntries = routes.map((route) => {
const layoutVars = route.layouts.map((l) => getImportVar(l));
const templateVars = route.templates.map((t) => (t ? getImportVar(t) : "null"));
const templateVars = route.templates.map((t) => getImportVar(t));
const notFoundVars = (route.notFoundPaths || []).map((nf) => (nf ? getImportVar(nf) : "null"));
const slotEntries = route.parallelSlots.map((slot) => {
const interceptEntries = slot.interceptingRoutes.map(
Expand Down Expand Up @@ -217,6 +215,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 @@ -385,7 +384,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 @@ -906,10 +905,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 @@ -1007,13 +1017,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 @@ -1674,10 +1685,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
returnValue = { ok: true, data };
} catch (e) {
// Detect redirect() / permanentRedirect() called inside the action.
// These throw errors with digest "NEXT_REDIRECT;<type>;<url>[;<status>]".
// The type field is empty when redirect() was called without an explicit
// type argument. In Server Action context, Next.js defaults to "push" so
// the Back button works after form submissions.
// These throw errors with digest "NEXT_REDIRECT;replace;url[;status]".
// The URL is encodeURIComponent-encoded to prevent semicolons in the URL
// from corrupting the delimiter-based digest format.
if (e && typeof e === "object" && "digest" in e) {
Expand All @@ -1686,7 +1694,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const parts = digest.split(";");
actionRedirect = {
url: decodeURIComponent(parts[2]),
type: parts[1] || "push", // Server Action → default "push"
type: parts[1] || "push", // "push" or "replace"
status: parts[3] ? parseInt(parts[3], 10) : 307,
};
returnValue = { ok: true, data: undefined };
Expand Down Expand Up @@ -1723,9 +1731,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
"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);
Expand All @@ -1749,9 +1754,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
searchParams: url.searchParams,
params: actionParams,
});
element = await 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 @@ -1772,15 +1788,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 @@ -2103,7 +2126,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 @@ -2152,44 +2181,25 @@ 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,
getRoutePattern(sourceRoute) {
return sourceRoute.pattern;
getRouteParamNames(sourceRoute) {
return sourceRoute.params;
},
getSourceRoute(sourceRouteIndex) {
return routes[sourceRouteIndex];
},
isRscRequest,
matchSourceRouteParams(pattern) {
// Extract actual URL param values by prefix-matching the request pathname
// against the source route's pattern. This handles all interception conventions:
// (.) same-level, (..) one-level-up, and (...) root — the source pattern's
// dynamic segments that align with the URL get their real values extracted.
// We must NOT use matchRoute(pattern) here: the trie would match the literal
// ":param" strings as dynamic segment values, returning e.g. {id: ":id"}.
const patternParts = pattern.split("/").filter(Boolean);
const urlParts = cleanPathname.split("/").filter(Boolean);
const params = Object.create(null);
for (let i = 0; i < patternParts.length; i++) {
const pp = patternParts[i];
if (pp.endsWith("+") || pp.endsWith("*")) {
// urlParts.slice(i) safely returns [] when i >= urlParts.length,
// which is the correct value for optional catch-all with zero segments.
params[pp.slice(1, -1)] = urlParts.slice(i);
break;
}
if (i >= urlParts.length) break;
if (pp.startsWith(":")) {
params[pp.slice(1)] = urlParts[i];
} else if (pp !== urlParts[i]) {
break;
}
}
return params;
},
renderInterceptResponse(sourceRoute, interceptElement) {
const interceptOnError = createRscOnErrorHandler(
request,
Expand All @@ -2203,7 +2213,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 @@ -2227,7 +2240,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 @@ -2258,19 +2271,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 @@ -2316,6 +2316,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
Loading
Loading