-
Notifications
You must be signed in to change notification settings - Fork 253
test: expand Next.js compat coverage and fix parity bugs #578
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 all commits
5fdfb94
5b5b084
129b6ed
9342d34
17473c7
59bd8bc
5a03d6d
3979dbe
bff4433
baf1790
799d950
8a32893
3ebe0e4
bee5680
3675a4b
b016176
070e7d9
0efb654
fbfb261
9fb39a9
9a36356
1063790
fe7374a
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 |
|---|---|---|
|
|
@@ -342,8 +342,9 @@ function renderToReadableStream(model, options) { | |
| } | ||
| import { createElement, Suspense, Fragment } from "react"; | ||
| import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; | ||
| import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; | ||
| import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase, runWithHeadersContext } from "next/headers"; | ||
| import { NextRequest, NextFetchEvent } from "next/server"; | ||
| import DefaultHttpErrorComponent from "next/error"; | ||
| import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; | ||
| import { LayoutSegmentProvider } from "vinext/layout-segment-context"; | ||
| import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; | ||
|
|
@@ -509,6 +510,36 @@ function makeThenableParams(obj) { | |
| return Object.assign(Promise.resolve(plain), plain); | ||
| } | ||
|
|
||
| // Compute the subset of params that a layout at a given tree position should receive. | ||
| // In Next.js, each layout only sees params from its own segment and ancestor segments — | ||
| // NOT from child segments deeper in the tree. For example, with | ||
| // /base/[param1]/[param2]/page.tsx, the layout at [param1]/ gets {param1} but not {param2}. | ||
|
Contributor
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 function is well-structured and the loop bounds are correct. Verified: Minor observation: the segment-type detection logic (optional catch-all, catch-all, dynamic) is duplicated between |
||
| function __scopeParamsForLayout(routeSegments, treePosition, fullParams) { | ||
| var scoped = {}; | ||
| // Scan segments from root up to (but not including) this layout's position | ||
| for (var i = 0; i < treePosition; i++) { | ||
| var seg = routeSegments[i]; | ||
| if (!seg) continue; | ||
| // Optional catch-all: [[...param]] | ||
| if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") { | ||
| var pn = seg.slice(5, -2); | ||
| // Only include if the value is a non-empty array (Next.js omits empty optional catch-all) | ||
| if (pn in fullParams && Array.isArray(fullParams[pn]) && fullParams[pn].length > 0) scoped[pn] = fullParams[pn]; | ||
| // Catch-all: [...param] | ||
| } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { | ||
| var pn2 = seg.slice(4, -1); | ||
| if (pn2 in fullParams) scoped[pn2] = fullParams[pn2]; | ||
| // Dynamic: [param] | ||
| // The dot guard is defensive — routing currently only produces [w-]+ dynamic | ||
| // segments, but it prevents accidental matches on any future segment formats. | ||
| } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { | ||
| var pn3 = seg.slice(1, -1); | ||
| if (pn3 in fullParams) scoped[pn3] = fullParams[pn3]; | ||
| } | ||
| } | ||
| return scoped; | ||
| } | ||
|
|
||
| // Resolve route tree segments to actual values using matched params. | ||
| // Dynamic segments like [id] are replaced with param values, catch-all | ||
| // segments like [...slug] are joined with "/", and route groups are kept as-is. | ||
|
|
@@ -531,6 +562,8 @@ function __resolveChildSegments(routeSegments, treePosition, params) { | |
| var v2 = params[pn2]; | ||
| result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); | ||
| // Dynamic: [param] | ||
| // The dot guard is defensive — routing currently only produces [w-]+ dynamic | ||
| // segments, but it prevents accidental matches on any future segment formats. | ||
| } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { | ||
| var pn3 = seg.slice(1, -1); | ||
| result.push(params[pn3] || seg); | ||
|
|
@@ -742,6 +775,11 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req | |
| BoundaryComponent = boundaryModule?.default ?? null; | ||
| } | ||
| const layouts = opts?.layouts ?? route?.layouts ?? rootLayouts; | ||
| if (!BoundaryComponent && statusCode === 404) { | ||
| BoundaryComponent = function DefaultNotFoundBoundary() { | ||
| return createElement(DefaultHttpErrorComponent, { statusCode: 404 }); | ||
| }; | ||
| } | ||
| if (!BoundaryComponent) return null; | ||
|
|
||
| // Resolve metadata and viewport from parent layouts so that not-found/error | ||
|
|
@@ -791,12 +829,13 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req | |
| const _treePositions = route?.layoutTreePositions; | ||
| const _routeSegs = route?.routeSegments || []; | ||
| const _fallbackParams = opts?.matchedParams ?? route?.params ?? {}; | ||
| const _asyncFallbackParams = makeThenableParams(_fallbackParams); | ||
| for (let i = layouts.length - 1; i >= 0; i--) { | ||
| const LayoutComponent = layouts[i]?.default; | ||
| if (LayoutComponent) { | ||
| element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParams }); | ||
| const _tp = _treePositions ? _treePositions[i] : 0; | ||
| const _scopedParams = __scopeParamsForLayout(_routeSegs, _tp, _fallbackParams); | ||
| const _asyncScopedParams = makeThenableParams(_scopedParams); | ||
| element = createElement(LayoutComponent, { children: element, params: _asyncScopedParams }); | ||
| const _cs = __resolveChildSegments(_routeSegs, _tp, _fallbackParams); | ||
| element = createElement(LayoutSegmentProvider, { childSegments: _cs }, element); | ||
| } | ||
|
|
@@ -835,11 +874,15 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req | |
| // For HTML (full page load) responses, wrap with layouts only (no client-side | ||
| // wrappers needed since SSR generates the complete HTML document). | ||
| const _fallbackParamsHtml = opts?.matchedParams ?? route?.params ?? {}; | ||
| const _asyncFallbackParamsHtml = makeThenableParams(_fallbackParamsHtml); | ||
| const _treePositionsHtml = route?.layoutTreePositions; | ||
| const _routeSegsHtml = route?.routeSegments || []; | ||
| for (let i = layouts.length - 1; i >= 0; i--) { | ||
| const LayoutComponent = layouts[i]?.default; | ||
| if (LayoutComponent) { | ||
| element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParamsHtml }); | ||
| const _tpHtml = _treePositionsHtml ? _treePositionsHtml[i] : 0; | ||
| const _scopedParamsHtml = __scopeParamsForLayout(_routeSegsHtml, _tpHtml, _fallbackParamsHtml); | ||
| const _asyncScopedParamsHtml = makeThenableParams(_scopedParamsHtml); | ||
| element = createElement(LayoutComponent, { children: element, params: _asyncScopedParamsHtml }); | ||
| } | ||
| } | ||
| const _pathname = new URL(request.url).pathname; | ||
|
|
@@ -930,12 +973,13 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc | |
| const _errTreePositions = route?.layoutTreePositions; | ||
| const _errRouteSegs = route?.routeSegments || []; | ||
| const _errParams = matchedParams ?? route?.params ?? {}; | ||
| const _asyncErrParams = makeThenableParams(_errParams); | ||
| for (let i = layouts.length - 1; i >= 0; i--) { | ||
| const LayoutComponent = layouts[i]?.default; | ||
| if (LayoutComponent) { | ||
| element = createElement(LayoutComponent, { children: element, params: _asyncErrParams }); | ||
| const _etp = _errTreePositions ? _errTreePositions[i] : 0; | ||
| const _errScopedParams = __scopeParamsForLayout(_errRouteSegs, _etp, _errParams); | ||
| const _asyncErrScopedParams = makeThenableParams(_errScopedParams); | ||
| element = createElement(LayoutComponent, { children: element, params: _asyncErrScopedParams }); | ||
| const _ecs = __resolveChildSegments(_errRouteSegs, _etp, _errParams); | ||
| element = createElement(LayoutSegmentProvider, { childSegments: _ecs }, element); | ||
| } | ||
|
|
@@ -956,11 +1000,15 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc | |
| } else { | ||
| // For HTML (full page load) responses, wrap with layouts only. | ||
| const _errParamsHtml = matchedParams ?? route?.params ?? {}; | ||
| const _asyncErrParamsHtml = makeThenableParams(_errParamsHtml); | ||
| const _errTreePositionsHtml = route?.layoutTreePositions; | ||
| const _errRouteSegsHtml = route?.routeSegments || []; | ||
| for (let i = layouts.length - 1; i >= 0; i--) { | ||
| const LayoutComponent = layouts[i]?.default; | ||
| if (LayoutComponent) { | ||
| element = createElement(LayoutComponent, { children: element, params: _asyncErrParamsHtml }); | ||
| const _etpHtml = _errTreePositionsHtml ? _errTreePositionsHtml[i] : 0; | ||
| const _errScopedParamsHtml = __scopeParamsForLayout(_errRouteSegsHtml, _etpHtml, _errParamsHtml); | ||
| const _asyncErrScopedParamsHtml = makeThenableParams(_errScopedParamsHtml); | ||
| element = createElement(LayoutComponent, { children: element, params: _asyncErrScopedParamsHtml }); | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -1154,10 +1202,17 @@ async function buildPageElement(route, params, opts, searchParams) { | |
| }); | ||
| } | ||
|
|
||
| // force-static pages receive empty searchParams rather than real request data. | ||
| // This mirrors Next.js, which strips dynamic request state from force-static | ||
| // render paths instead of exposing live values. | ||
| const isForceStatic = route.page?.dynamic === "force-static"; | ||
|
Contributor
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. Good fix. The |
||
| const effectiveSpObj = isForceStatic ? {} : spObj; | ||
| const effectiveHasSearchParams = isForceStatic ? false : hasSearchParams; | ||
|
|
||
| const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([ | ||
| Promise.all(layoutMetaPromises), | ||
| Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))), | ||
| route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null), | ||
| route.page ? resolveModuleMetadata(route.page, params, effectiveSpObj, pageParentPromise) : Promise.resolve(null), | ||
| route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null), | ||
| ]); | ||
|
|
||
|
|
@@ -1176,7 +1231,7 @@ async function buildPageElement(route, params, opts, searchParams) { | |
| // Always provide searchParams prop when the URL object is available, even | ||
| // when the query string is empty -- pages that do "await searchParams" need | ||
| // it to be a thenable rather than undefined. | ||
| pageProps.searchParams = makeThenableParams(spObj); | ||
| pageProps.searchParams = makeThenableParams(effectiveSpObj); | ||
| // If the URL has query parameters, mark the page as dynamic. | ||
| // In Next.js, only accessing the searchParams prop signals dynamic usage, | ||
| // but a Proxy-based approach doesn't work here because React's RSC debug | ||
|
|
@@ -1185,7 +1240,7 @@ async function buildPageElement(route, params, opts, searchParams) { | |
| // read searchParams. Checking for non-empty query params is a safe | ||
| // approximation: pages with query params in the URL are almost always | ||
| // dynamic, and this avoids false positives from React internals. | ||
| if (hasSearchParams) markDynamicUsage(); | ||
| if (effectiveHasSearchParams) markDynamicUsage(); | ||
| } | ||
| let element = createElement(PageComponent, pageProps); | ||
|
|
||
|
|
@@ -1288,7 +1343,11 @@ async function buildPageElement(route, params, opts, searchParams) { | |
| } | ||
| } | ||
|
|
||
| const layoutProps = { children: element, params: makeThenableParams(params) }; | ||
| // Scope params for this layout — each layout only sees params from its | ||
| // own segment and ancestor segments, not child dynamic segments. | ||
| const _bpeTp = route.layoutTreePositions ? route.layoutTreePositions[i] : 0; | ||
| const _bpeScopedParams = __scopeParamsForLayout(route.routeSegments || [], _bpeTp, params); | ||
| const layoutProps = { children: element, params: makeThenableParams(_bpeScopedParams) }; | ||
|
|
||
| // Add parallel slot elements to the layout that defines them. | ||
| // Each slot has a layoutIndex indicating which layout it belongs to. | ||
|
|
@@ -1778,7 +1837,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { | |
| const mwRequest = new Request(mwUrl, request); | ||
| const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); | ||
| const mwFetchEvent = new NextFetchEvent({ page: cleanPathname }); | ||
| const mwResponse = await middlewareFn(nextRequest, mwFetchEvent); | ||
| // SYNC: middleware-headers-context — this pattern is duplicated in | ||
| // server/middleware.ts and entries/pages-server-entry.ts. | ||
| // Changes here must be mirrored in both sibling files. | ||
| let _mwDraftCookie = null; | ||
| let mwResponse = await runWithHeadersContext(headersContextFromRequest(nextRequest), async () => { | ||
| const _prevHeadersPhase = setHeadersAccessPhase("middleware"); | ||
| try { | ||
| const _middlewareResponse = await middlewareFn(nextRequest, mwFetchEvent); | ||
| _mwDraftCookie = getDraftModeCookieHeader(); | ||
|
Contributor
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. Nit: |
||
| return _middlewareResponse; | ||
| } finally { | ||
| setHeadersAccessPhase(_prevHeadersPhase); | ||
| } | ||
| }); | ||
| if (_mwDraftCookie && mwResponse) { | ||
|
Contributor
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. Capturing |
||
| const _mwHeaders = new Headers(mwResponse.headers); | ||
| _mwHeaders.append("set-cookie", _mwDraftCookie); | ||
| mwResponse = new Response(mwResponse.body, { | ||
| status: mwResponse.status, | ||
| statusText: mwResponse.statusText, | ||
| headers: _mwHeaders, | ||
| }); | ||
| } | ||
| const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); | ||
| const _mwExecCtx = _getRequestExecutionContext(); | ||
| if (_mwExecCtx && typeof _mwExecCtx.waitUntil === "function") { _mwExecCtx.waitUntil(_mwWaitUntil); } | ||
|
|
@@ -2336,7 +2417,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { | |
| if (typeof handlerFn === "function") { | ||
| const previousHeadersPhase = setHeadersAccessPhase("route-handler"); | ||
| try { | ||
| const response = await handlerFn(request, { params }); | ||
| // Wrap in NextRequest so route handlers get .nextUrl, .cookies, .geo, .ip, etc. | ||
| // Next.js passes NextRequest to route handlers, not plain Request. | ||
| const routeRequest = request instanceof NextRequest ? request : new NextRequest(request); | ||
|
Contributor
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. Correct fix. Note that unlike page components which receive |
||
| const response = await handlerFn(routeRequest, { params }); | ||
| const dynamicUsedInHandler = consumeDynamicUsage(); | ||
| const handlerSetCacheControl = response.headers.has("cache-control"); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,12 @@ import { | |
| } from "../config/config-matchers.js"; | ||
| import type { HasCondition, NextI18nConfig } from "../config/next-config.js"; | ||
| import { NextRequest, NextFetchEvent } from "../shims/server.js"; | ||
| import { | ||
| getDraftModeCookieHeader, | ||
| headersContextFromRequest, | ||
| runWithHeadersContext, | ||
| setHeadersAccessPhase, | ||
| } from "../shims/headers.js"; | ||
| import { normalizePath } from "./normalize-path.js"; | ||
| import { shouldKeepMiddlewareHeader } from "./middleware-request-headers.js"; | ||
| import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; | ||
|
|
@@ -438,10 +444,25 @@ export async function runMiddleware( | |
| const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); | ||
| const fetchEvent = new NextFetchEvent({ page: normalizedPathname }); | ||
|
|
||
| // Execute the middleware | ||
| // SYNC: middleware-headers-context — this pattern is duplicated in | ||
|
Contributor
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 SYNC tag |
||
| // entries/pages-server-entry.ts and entries/app-rsc-entry.ts (code-generated). | ||
| // Changes here must be mirrored in both entry templates. | ||
| // | ||
| // Execute the middleware with a next/headers context so middleware can use | ||
| // headers() / draftMode() like Next.js allows. | ||
| let response: Response | undefined; | ||
| let draftCookie: string | null = null; | ||
| try { | ||
| response = await middlewareFn(nextRequest, fetchEvent); | ||
| response = await runWithHeadersContext(headersContextFromRequest(nextRequest), async () => { | ||
| const previousPhase = setHeadersAccessPhase("middleware"); | ||
| try { | ||
| const middlewareResponse = await middlewareFn(nextRequest, fetchEvent); | ||
| draftCookie = getDraftModeCookieHeader(); | ||
| return middlewareResponse; | ||
| } finally { | ||
| setHeadersAccessPhase(previousPhase); | ||
| } | ||
| }); | ||
| } catch (e: any) { | ||
| console.error("[vinext] Middleware error:", e); | ||
| const message = | ||
|
|
@@ -456,6 +477,16 @@ export async function runMiddleware( | |
| }; | ||
| } | ||
|
|
||
| if (draftCookie && response) { | ||
| const responseHeaders = new Headers(response.headers); | ||
| responseHeaders.append("set-cookie", draftCookie); | ||
| response = new Response(response.body, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers: responseHeaders, | ||
| }); | ||
| } | ||
|
|
||
| // Drain waitUntil promises (fire-and-forget: we don't block the response | ||
| // on these — matches platform semantics where waitUntil runs after response). | ||
| void fetchEvent.drainWaitUntil(); | ||
|
|
||
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.
Nit:
^1.2.0is a wide range for a test utility. Consider pinning to1.2.0to avoid surprise breakage from semver-minor bumps, though this is very low risk for a test-only dep.