-
Notifications
You must be signed in to change notification settings - Fork 296
feat: flat keyed payload for App Router layout persistence #750
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 23 commits
7f4f8eb
5d8525b
be33773
ca40d05
bddda39
38d33ca
8c22db3
d488978
ec008fa
5395efc
955f577
ce76239
c7a03d5
7fead69
311b10a
2b5f68c
7554b20
1014aed
5e516bd
ee2fbdd
f8e2276
c346485
d2a4d13
9b9a6d5
f4949b4
c606e62
a220f3d
9959097
7c62628
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
|
@@ -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[]; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -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(); | ||
|
|
@@ -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(", ")}], | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
|
|
@@ -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. | ||
|
|
@@ -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: | ||
|
|
@@ -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)} | ||
|
|
@@ -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); | ||
| ` | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The request flow now jumps from metadata handling directly into route rendering, but the prior Useful? React with 👍 / 👎. |
||
|
|
@@ -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); | ||
| } | ||
|
|
@@ -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( | ||
|
|
@@ -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) { | ||
|
||
| 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); | ||
|
|
@@ -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); | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
|
|
@@ -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() { | ||
|
|
@@ -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) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
basePathis 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.svgor app routes resolving at/...whenbasePathis/app) instead of returning 404, which breaks Next.js basePath semantics and the existing app-router basePath expectations.Useful? React with 👍 / 👎.