diff --git a/package.json b/package.json index 155c7bd21..cd4e889c1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@types/react-dom": "catalog:", "@typescript/native-preview": "catalog:", "@vitejs/plugin-rsc": "catalog:", + "cheerio": "^1.2.0", "image-size": "catalog:", "next": "catalog:", "playwright": "catalog:", diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 071514f13..f1499bfa1 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -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}. +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"; + 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(); + return _middlewareResponse; + } finally { + setHeadersAccessPhase(_prevHeadersPhase); + } + }); + if (_mwDraftCookie && mwResponse) { + 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); + const response = await handlerFn(routeRequest, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 44be81a98..61e3aa358 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -145,7 +145,8 @@ if (typeof _instrumentation.onRequestError === "function") { // Generate middleware code if middleware.ts exists const middlewareImportCode = middlewarePath ? `import * as middlewareModule from ${JSON.stringify(middlewarePath.replace(/\\/g, "/"))}; -import { NextRequest, NextFetchEvent } from "next/server";` +import { NextRequest, NextFetchEvent } from "next/server"; +import { getDraftModeCookieHeader, headersContextFromRequest, runWithHeadersContext, setHeadersAccessPhase } from "next/headers";` : ""; // The matcher config is read from the middleware module at import time. @@ -198,12 +199,35 @@ async function _runMiddleware(request) { } var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); var fetchEvent = new NextFetchEvent({ page: normalizedPathname }); + // SYNC: middleware-headers-context — this pattern is duplicated in + // server/middleware.ts and entries/app-rsc-entry.ts. + // Changes here must be mirrored in both sibling files. var response; - try { response = await middlewareFn(nextRequest, fetchEvent); } + var draftCookie = null; + try { + response = await runWithHeadersContext(headersContextFromRequest(nextRequest), async function() { + var previousPhase = setHeadersAccessPhase("middleware"); + try { + var middlewareResponse = await middlewareFn(nextRequest, fetchEvent); + draftCookie = getDraftModeCookieHeader(); + return middlewareResponse; + } + finally { setHeadersAccessPhase(previousPhase); } + }); + } catch (e) { console.error("[vinext] Middleware error:", e); return { continue: false, response: new Response("Internal Server Error", { status: 500 }) }; } + if (draftCookie && response) { + var responseHeadersWithDraft = new Headers(response.headers); + responseHeadersWithDraft.append("set-cookie", draftCookie); + response = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeadersWithDraft, + }); + } var _mwCtx = _getRequestExecutionContext(); if (_mwCtx && typeof _mwCtx.waitUntil === "function") { _mwCtx.waitUntil(fetchEvent.drainWaitUntil()); } else { fetchEvent.drainWaitUntil(); } diff --git a/packages/vinext/src/server/middleware.ts b/packages/vinext/src/server/middleware.ts index 5bb3dec56..2317e2b22 100644 --- a/packages/vinext/src/server/middleware.ts +++ b/packages/vinext/src/server/middleware.ts @@ -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 + // 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(); diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 170fcaf6c..11f61e42d 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -30,7 +30,7 @@ export interface HeadersContext { readonlyHeaders?: Headers; } -export type HeadersAccessPhase = "render" | "action" | "route-handler"; +export type HeadersAccessPhase = "render" | "action" | "route-handler" | "middleware"; export type VinextHeadersShimState = { headersContext: HeadersContext | null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5aafb3fe..1ba06b2fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: '@vitejs/plugin-rsc': specifier: 'catalog:' version: 0.5.20(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + cheerio: + specifier: ^1.2.0 + version: 1.2.0 image-size: specifier: 'catalog:' version: 2.0.2 @@ -807,6 +810,181 @@ importers: specifier: 'catalog:' version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + tests/fixtures/app-middleware-compat: + dependencies: + '@vitejs/plugin-rsc': + specifier: 'catalog:' + version: 0.5.20(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: 'catalog:' + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)' + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + + tests/fixtures/app-not-found-default: + dependencies: + '@vitejs/plugin-rsc': + specifier: 'catalog:' + version: 0.5.20(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: 'catalog:' + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)' + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + + tests/fixtures/app-optional-catchall-root: + dependencies: + '@vitejs/plugin-rsc': + specifier: 'catalog:' + version: 0.5.20(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: 'catalog:' + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)' + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + + tests/fixtures/app-rewrites-redirects: + dependencies: + '@vitejs/plugin-rsc': + specifier: 'catalog:' + version: 0.5.20(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: 'catalog:' + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)' + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + + tests/fixtures/app-routes-trailing-slash-compat: + dependencies: + '@vitejs/plugin-rsc': + specifier: 'catalog:' + version: 0.5.20(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: 'catalog:' + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)' + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + + tests/fixtures/app-underscored-root: + dependencies: + '@vitejs/plugin-rsc': + specifier: 'catalog:' + version: 0.5.20(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: 'catalog:' + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)' + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + + tests/fixtures/app-use-cache-route-handler-only: + dependencies: + '@vitejs/plugin-rsc': + specifier: 'catalog:' + version: 0.5.20(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: 'catalog:' + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)' + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + tests/fixtures/cf-app-basic: dependencies: '@cloudflare/vite-plugin': @@ -1256,31 +1434,31 @@ packages: wrangler: ^4.66.0 '@cloudflare/workerd-darwin-64@1.20260217.0': - resolution: {integrity: sha512-t1KRT0j4gwLntixMoNujv/UaS89Q7+MPRhkklaSup5tNhl3zBZOIlasBUSir69eXetqLZu8sypx3i7zE395XXA==} + resolution: {integrity: sha512-t1KRT0j4gwLntixMoNujv/UaS89Q7+MPRhkklaSup5tNhl3zBZOIlasBUSir69eXetqLZu8sypx3i7zE395XXA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260217.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260217.0': - resolution: {integrity: sha512-9pEZ15BmELt0Opy79LTxUvbo55QAI4GnsnsvmgBxaQlc4P0dC8iycBGxbOpegkXnRx/LFj51l2zunfTo0EdATg==} + resolution: {integrity: sha512-9pEZ15BmELt0Opy79LTxUvbo55QAI4GnsnsvmgBxaQlc4P0dC8iycBGxbOpegkXnRx/LFj51l2zunfTo0EdATg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260217.0.tgz} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-linux-64@1.20260217.0': - resolution: {integrity: sha512-IrZfxQ4b/4/RDQCJsyoxKrCR+cEqKl81yZOirMOKoRrDOmTjn4evYXaHoLBh2PjUKY1Imly7ZiC6G1p0xNIOwg==} + resolution: {integrity: sha512-IrZfxQ4b/4/RDQCJsyoxKrCR+cEqKl81yZOirMOKoRrDOmTjn4evYXaHoLBh2PjUKY1Imly7ZiC6G1p0xNIOwg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260217.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260217.0': - resolution: {integrity: sha512-RGU1wq69ym4sFBVWhQeddZrRrG0hJM/SlZ5DwVDga/zBJ3WXxcDsFAgg1dToDfildTde5ySXN7jAasSmWko9rg==} + resolution: {integrity: sha512-RGU1wq69ym4sFBVWhQeddZrRrG0hJM/SlZ5DwVDga/zBJ3WXxcDsFAgg1dToDfildTde5ySXN7jAasSmWko9rg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260217.0.tgz} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-windows-64@1.20260217.0': - resolution: {integrity: sha512-4T65u1321z1Zet9n7liQsSW7g3EXM5SWIT7kJ/uqkEtkPnIzZBIowMQgkvL5W9SpGZks9t3mTQj7hiUia8Gq9Q==} + resolution: {integrity: sha512-4T65u1321z1Zet9n7liQsSW7g3EXM5SWIT7kJ/uqkEtkPnIzZBIowMQgkvL5W9SpGZks9t3mTQj7hiUia8Gq9Q==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260217.0.tgz} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -3244,6 +3422,9 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -3281,6 +3462,13 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -3343,9 +3531,16 @@ packages: resolution: {integrity: sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==} engines: {node: '>=16'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-to-react-native@3.2.0: resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3427,6 +3622,19 @@ packages: resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} engines: {node: '>=0.3.1'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} @@ -3434,6 +3642,9 @@ packages: resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} engines: {node: '>=10.0.0'} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -3441,6 +3652,18 @@ packages: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-runner@0.1.6: resolution: {integrity: sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA==} hasBin: true @@ -3603,9 +3826,16 @@ packages: hookable@6.0.1: resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + httpxy@0.3.1: resolution: {integrity: sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw==} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + icu-minify@4.8.3: resolution: {integrity: sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==} @@ -4117,6 +4347,9 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.8.8: resolution: {integrity: sha512-LF5sw9nWpHyPWzMMu9oho3r9C5DvkpmBIg4LQN78sexIzGaeRx8DWr0uy3YiFx5i2QGZN1Qqcb+OAtEVRa2bnA==} peerDependencies: @@ -4181,6 +4414,15 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4378,6 +4620,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + satori@0.16.0: resolution: {integrity: sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ==} engines: {node: '>=16'} @@ -4571,6 +4816,10 @@ packages: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} + undici@7.24.4: + resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -4762,6 +5011,15 @@ packages: resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -6476,6 +6734,8 @@ snapshots: blake3-wasm@2.1.5: {} + boolbase@1.0.0: {} + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -6509,6 +6769,29 @@ snapshots: character-reference-invalid@2.0.1: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.24.4 + whatwg-mimetype: 4.0.0 + chownr@1.1.4: {} class-variance-authority@0.7.1: @@ -6559,12 +6842,22 @@ snapshots: css-gradient-parser@0.0.16: {} + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + css-to-react-native@3.2.0: dependencies: camelize: 1.0.1 css-color-keywords: 1.0.0 postcss-value-parser: 4.2.0 + css-what@6.2.2: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -6607,10 +6900,33 @@ snapshots: diff@5.2.2: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + electron-to-chromium@1.5.307: {} emoji-regex-xs@2.0.1: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -6620,6 +6936,12 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + env-runner@0.1.6(miniflare@4.20260217.0): dependencies: crossws: 0.4.4(srvx@0.11.9) @@ -6828,8 +7150,19 @@ snapshots: hookable@6.0.1: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + httpxy@0.3.1: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + icu-minify@4.8.3: dependencies: '@formatjs/icu-messageformat-parser': 3.5.1 @@ -7473,6 +7806,10 @@ snapshots: node-releases@2.0.36: {} + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nuqs@2.8.8(next@16.1.6(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@standard-schema/spec': 1.0.0 @@ -7567,6 +7904,19 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-key@3.1.1: {} path-to-regexp@6.3.0: {} @@ -7812,6 +8162,8 @@ snapshots: safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} + satori@0.16.0: dependencies: '@shuding/opentype.js': 1.4.0-beta.0 @@ -8001,6 +8353,8 @@ snapshots: undici@7.18.2: {} + undici@7.24.4: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -8185,6 +8539,12 @@ snapshots: webpack-sources@3.3.4: {} + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 813388650..2253baf03 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -374,8 +374,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"; @@ -558,6 +559,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}. +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. @@ -580,6 +611,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); @@ -854,6 +887,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 @@ -903,12 +941,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); } @@ -935,11 +974,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; @@ -1021,12 +1064,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); } @@ -1035,11 +1079,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 }); } } } @@ -1233,10 +1281,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"; + 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), ]); @@ -1255,7 +1310,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 @@ -1264,7 +1319,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); @@ -1367,7 +1422,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. @@ -2354,7 +2413,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); + const response = await handlerFn(routeRequest, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); @@ -3360,8 +3422,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"; @@ -3544,6 +3607,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}. +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. @@ -3566,6 +3659,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); @@ -3840,6 +3935,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 @@ -3889,12 +3989,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); } @@ -3921,11 +4022,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; @@ -4007,12 +4112,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); } @@ -4021,11 +4127,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 }); } } } @@ -4219,10 +4329,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"; + 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), ]); @@ -4241,7 +4358,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 @@ -4250,7 +4367,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); @@ -4353,7 +4470,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. @@ -5343,7 +5464,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); + const response = await handlerFn(routeRequest, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); @@ -6349,8 +6473,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"; @@ -6533,6 +6658,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}. +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. @@ -6555,6 +6710,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); @@ -6830,6 +6987,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 @@ -6879,12 +7041,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); } @@ -6919,11 +7082,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; @@ -7010,12 +7177,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); } @@ -7032,11 +7200,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 }); } } } @@ -7230,10 +7402,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"; + 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), ]); @@ -7252,7 +7431,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 @@ -7261,7 +7440,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); @@ -7364,7 +7543,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. @@ -8359,7 +8542,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); + const response = await handlerFn(routeRequest, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); @@ -9373,8 +9559,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"; @@ -9557,6 +9744,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}. +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. @@ -9579,6 +9796,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); @@ -9883,6 +10102,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 @@ -9932,12 +10156,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); } @@ -9964,11 +10189,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; @@ -10050,12 +10279,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); } @@ -10064,11 +10294,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 }); } } } @@ -10262,10 +10496,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"; + 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), ]); @@ -10284,7 +10525,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 @@ -10293,7 +10534,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); @@ -10396,7 +10637,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. @@ -11386,7 +11631,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); + const response = await handlerFn(routeRequest, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); @@ -12392,8 +12640,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"; @@ -12576,6 +12825,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}. +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. @@ -12598,6 +12877,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); @@ -12879,6 +13160,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 @@ -12928,12 +13214,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); } @@ -12960,11 +13247,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; @@ -13046,12 +13337,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); } @@ -13060,11 +13352,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 }); } } } @@ -13258,10 +13554,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"; + 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), ]); @@ -13280,7 +13583,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 @@ -13289,7 +13592,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); @@ -13392,7 +13695,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. @@ -14379,7 +14686,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); + const response = await handlerFn(routeRequest, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); @@ -15385,8 +15695,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"; @@ -15569,6 +15880,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}. +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. @@ -15591,6 +15932,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); @@ -15865,6 +16208,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 @@ -15914,12 +16262,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); } @@ -15946,11 +16295,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; @@ -16032,12 +16385,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); } @@ -16046,11 +16400,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 }); } } } @@ -16244,10 +16602,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"; + 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), ]); @@ -16266,7 +16631,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 @@ -16275,7 +16640,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); @@ -16378,7 +16743,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. @@ -17193,7 +17562,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(); + return _middlewareResponse; + } finally { + setHeadersAccessPhase(_prevHeadersPhase); + } + }); + if (_mwDraftCookie && mwResponse) { + 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); } @@ -17724,7 +18115,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); + const response = await handlerFn(routeRequest, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); @@ -19276,6 +19670,7 @@ import { resolvePagesI18nRequest } from "/packages/vinext/src/server/pages import * as _instrumentation from "/tests/fixtures/pages-basic/instrumentation.ts"; import * as middlewareModule from "/tests/fixtures/pages-basic/middleware.ts"; import { NextRequest, NextFetchEvent } from "next/server"; +import { getDraftModeCookieHeader, headersContextFromRequest, runWithHeadersContext, setHeadersAccessPhase } from "next/headers"; // Run instrumentation register() once at module evaluation time — before any // requests are handled. Matches Next.js semantics: register() is called once @@ -20676,12 +21071,35 @@ async function _runMiddleware(request) { } var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); var fetchEvent = new NextFetchEvent({ page: normalizedPathname }); + // SYNC: middleware-headers-context — this pattern is duplicated in + // server/middleware.ts and entries/app-rsc-entry.ts. + // Changes here must be mirrored in both sibling files. var response; - try { response = await middlewareFn(nextRequest, fetchEvent); } + var draftCookie = null; + try { + response = await runWithHeadersContext(headersContextFromRequest(nextRequest), async function() { + var previousPhase = setHeadersAccessPhase("middleware"); + try { + var middlewareResponse = await middlewareFn(nextRequest, fetchEvent); + draftCookie = getDraftModeCookieHeader(); + return middlewareResponse; + } + finally { setHeadersAccessPhase(previousPhase); } + }); + } catch (e) { console.error("[vinext] Middleware error:", e); return { continue: false, response: new Response("Internal Server Error", { status: 500 }) }; } + if (draftCookie && response) { + var responseHeadersWithDraft = new Headers(response.headers); + responseHeadersWithDraft.append("set-cookie", draftCookie); + response = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeadersWithDraft, + }); + } var _mwCtx = _getRequestExecutionContext(); if (_mwCtx && typeof _mwCtx.waitUntil === "function") { _mwCtx.waitUntil(fetchEvent.drainWaitUntil()); } else { fetchEvent.drainWaitUntil(); } diff --git a/tests/fixtures/app-basic/app/nextjs-compat/api/edge.json/route.ts b/tests/fixtures/app-basic/app/nextjs-compat/api/edge.json/route.ts new file mode 100644 index 000000000..affa08533 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/api/edge.json/route.ts @@ -0,0 +1,13 @@ +/** + * Fixture for app-simple-routes test. + * Ported from: test/e2e/app-dir/app-simple-routes/app/api/edge.json/route.ts + */ +import { NextRequest, NextResponse } from "next/server"; + +export const GET = (req: NextRequest) => { + return NextResponse.json({ + pathname: req.nextUrl.pathname, + }); +}; + +export const runtime = "edge"; diff --git a/tests/fixtures/app-basic/app/nextjs-compat/api/node.json/route.ts b/tests/fixtures/app-basic/app/nextjs-compat/api/node.json/route.ts new file mode 100644 index 000000000..d9eeab8f8 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/api/node.json/route.ts @@ -0,0 +1,11 @@ +/** + * Fixture for app-simple-routes test. + * Ported from: test/e2e/app-dir/app-simple-routes/app/api/node.json/route.ts + */ +import { NextRequest, NextResponse } from "next/server"; + +export const GET = (req: NextRequest) => { + return NextResponse.json({ + pathname: req.nextUrl.pathname, + }); +}; diff --git a/tests/fixtures/app-basic/app/nextjs-compat/catch-all-optional/[lang]/[flags]/[[...rest]]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/catch-all-optional/[lang]/[flags]/[[...rest]]/page.tsx new file mode 100644 index 000000000..833999cf9 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/catch-all-optional/[lang]/[flags]/[[...rest]]/page.tsx @@ -0,0 +1,21 @@ +/** + * Fixture for app-catch-all-optional test. + * Ported from: test/e2e/app-dir/app-catch-all-optional/app/[lang]/[flags]/[[...rest]]/page.tsx + * + * Tests optional catch-all routing: /catch-all-optional/[lang]/[flags]/[[...rest]] + */ +export default async function Page({ + params, +}: { + params: Promise<{ lang: string; flags: string; rest?: string[] }>; +}) { + const { lang, flags, rest } = await params; + + return ( +
+
{lang}
+
{flags}
+
{rest?.join("/") ?? ""}
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/conflicting-params/api/[id]/route.ts b/tests/fixtures/app-basic/app/nextjs-compat/conflicting-params/api/[id]/route.ts new file mode 100644 index 000000000..79fce654d --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/conflicting-params/api/[id]/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from "next/server"; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: routeId } = await params; + const searchId = request.nextUrl.searchParams.get("id"); + + return Response.json({ + routeParam: routeId, + searchParam: searchId, + }); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/conflicting-params/render/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/conflicting-params/render/[id]/page.tsx new file mode 100644 index 000000000..403c56814 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/conflicting-params/render/[id]/page.tsx @@ -0,0 +1,34 @@ +import { Suspense } from "react"; + +async function SearchAndRouteParams({ + searchParams, + params, +}: { + searchParams: Promise<{ id?: string }>; + params: Promise<{ id: string }>; +}) { + const { id: searchId } = await searchParams; + const { id: routeId } = await params; + + return ( +
+

Search and Route Parameters

+

Route param id: {routeId}

+

Search param id: {searchId || "not provided"}

+
+ ); +} + +export default function Page({ + searchParams, + params, +}: { + searchParams: Promise<{ id?: string }>; + params: Promise<{ id: string }>; +}) { + return ( + Loading...}> + + + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/client-page/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/client-page/page.tsx new file mode 100644 index 000000000..829015518 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/client-page/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { PageSentinel } from "../getSentinelValue"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + return ( +
+ +
+

This is a client Page so headers() is not available

+
+
+

This is a client Page so cookies() is not available

+
+
+ {Object.entries(await searchParams).map(([key, value]) => ( +
+            {value}
+          
+ ))} +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/force-dynamic/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/force-dynamic/page.tsx new file mode 100644 index 000000000..eb126d55f --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/force-dynamic/page.tsx @@ -0,0 +1,42 @@ +import { headers, cookies } from "next/headers"; +import { connection } from "next/server"; +import { PageSentinel } from "../getSentinelValue"; + +export const dynamic = "force-dynamic"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + await connection(); + return ( +
+ +
+ {Array.from((await headers()).entries()).map(([key, value]) => { + if (key === "cookie") return null; + return ( +
+              {value}
+            
+ ); + })} +
+
+ {(await cookies()).getAll().map((cookie) => ( +
+            {cookie.value}
+          
+ ))} +
+
+ {Object.entries(await searchParams).map(([key, value]) => ( +
+            {value}
+          
+ ))} +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/force-static/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/force-static/page.tsx new file mode 100644 index 000000000..39cfcd954 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/force-static/page.tsx @@ -0,0 +1,42 @@ +import { headers, cookies } from "next/headers"; +import { connection } from "next/server"; +import { PageSentinel } from "../getSentinelValue"; + +export const dynamic = "force-static"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + await connection(); + return ( +
+ +
+ {Array.from((await headers()).entries()).map(([key, value]) => { + if (key === "cookie") return null; + return ( +
+              {value}
+            
+ ); + })} +
+
+ {(await cookies()).getAll().map((cookie) => ( +
+            {cookie.value}
+          
+ ))} +
+
+ {Object.entries(await searchParams).map(([key, value]) => ( +
+            {value}
+          
+ ))} +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/getSentinelValue.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/getSentinelValue.tsx new file mode 100644 index 000000000..345c9c6f5 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/getSentinelValue.tsx @@ -0,0 +1,11 @@ +export function getSentinelValue() { + return "at runtime"; +} + +export function LayoutSentinel() { + return
{getSentinelValue()}
; +} + +export function PageSentinel() { + return
{getSentinelValue()}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/layout.tsx new file mode 100644 index 000000000..ae07ebe8d --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/layout.tsx @@ -0,0 +1,17 @@ +import { Suspense } from "react"; +import { LayoutSentinel } from "./getSentinelValue"; + +export default function DynamicDataLayout({ children }: { children: React.ReactNode }) { + return ( +
+

+ This fixture asserts that dynamic request APIs behave correctly in top-level, force-dynamic, + force-static, and client-page configurations. +

+
+ + loading...
}>{children} + + + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/top-level/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/top-level/page.tsx new file mode 100644 index 000000000..d9793fc13 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/dynamic-data/top-level/page.tsx @@ -0,0 +1,40 @@ +import { headers, cookies } from "next/headers"; +import { connection } from "next/server"; +import { PageSentinel } from "../getSentinelValue"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + await connection(); + return ( +
+ +
+ {Array.from((await headers()).entries()).map(([key, value]) => { + if (key === "cookie") return null; + return ( +
+              {value}
+            
+ ); + })} +
+
+ {(await cookies()).getAll().map((cookie) => ( +
+            {cookie.value}
+          
+ ))} +
+
+ {Object.entries(await searchParams).map(([key, value]) => ( +
+            {value}
+          
+ ))} +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/fetch-deduping-errors/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/fetch-deduping-errors/[id]/page.tsx new file mode 100644 index 000000000..ac3d3046d --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/fetch-deduping-errors/[id]/page.tsx @@ -0,0 +1,39 @@ +/** + * Fixture for app-fetch-deduping-errors test. + * Ported from: test/e2e/app-dir/app-fetch-deduping-errors/app/[id]/page.tsx + * + * Tests that when a fetch request errors (e.g. connection refused), + * the page still renders successfully because the error is caught. + */ + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + try { + // This fetch will fail — no server on port 8111 + await fetch("http://localhost:8111/nonexistent", { + cache: "force-cache", + }); + } catch { + // Error expected — should not prevent metadata generation + } + + return { + title: `Page ${id}`, + }; +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + try { + // This fetch will fail — no server on port 8111 + await fetch("http://localhost:8111/nonexistent", { + cache: "force-cache", + }); + } catch { + // Error expected — should not prevent page rendering + } + + return
Hello World {id}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic-no-boundary/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic-no-boundary/[id]/page.tsx new file mode 100644 index 000000000..168c100e7 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic-no-boundary/[id]/page.tsx @@ -0,0 +1,18 @@ +/** + * Dynamic page without a local forbidden boundary. + * When id=403, forbidden() should escalate to the root forbidden boundary. + * Ported from: test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/[id]/page.js + */ +import { forbidden } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + if (id === "403") { + forbidden(); + } + + return

{`dynamic-no-boundary [id]`}

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic-no-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic-no-boundary/layout.tsx new file mode 100644 index 000000000..4a12328a2 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic-no-boundary/layout.tsx @@ -0,0 +1,8 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+

Dynamic with Layout

+ {children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic-no-boundary/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic-no-boundary/page.tsx new file mode 100644 index 000000000..7c4d0c4b7 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic-no-boundary/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
dynamic-with-layout
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic/[id]/forbidden.tsx b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic/[id]/forbidden.tsx new file mode 100644 index 000000000..7034bf229 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic/[id]/forbidden.tsx @@ -0,0 +1,7 @@ +/** + * Scoped forbidden boundary for dynamic/[id] segment. + * Ported from: test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/forbidden.js + */ +export default function Forbidden() { + return
{`dynamic/[id] forbidden`}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic/[id]/page.tsx new file mode 100644 index 000000000..d069d80e8 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic/[id]/page.tsx @@ -0,0 +1,15 @@ +/** + * Dynamic page that calls forbidden() when id=403. + * Ported from: test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js + */ +import { forbidden } from "next/navigation"; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + if (id === "403") { + forbidden(); + } + + return

{`dynamic [id]`}

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic/page.tsx new file mode 100644 index 000000000..7c9ee6640 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/dynamic/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
dynamic
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/forbidden.tsx b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/forbidden.tsx new file mode 100644 index 000000000..3f647964c --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/forbidden.tsx @@ -0,0 +1,7 @@ +/** + * Root forbidden boundary for forbidden-basic tests. + * Ported from: test/e2e/app-dir/forbidden/basic/app/forbidden.js + */ +export default function Forbidden() { + return

Root Forbidden

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/page.tsx new file mode 100644 index 000000000..aa9f3b6d4 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/forbidden-basic/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Forbidden test index

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/[param2]/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/[param2]/layout.tsx new file mode 100644 index 000000000..6bb1ebe7f --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/[param2]/layout.tsx @@ -0,0 +1,14 @@ +import ShowParams from "../../../show-params"; + +export default async function Lvl3Layout(props: { + children: React.ReactNode; + params: Promise>; +}) { + const params = await props.params; + return ( +
+ + {props.children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/[param2]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/[param2]/page.tsx new file mode 100644 index 000000000..b15f8953e --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/[param2]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Leaf page

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/layout.tsx new file mode 100644 index 000000000..3dfffd73f --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/layout.tsx @@ -0,0 +1,14 @@ +import ShowParams from "../../show-params"; + +export default async function Lvl2Layout(props: { + children: React.ReactNode; + params: Promise>; +}) { + const params = await props.params; + return ( +
+ + {props.children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/page.tsx new file mode 100644 index 000000000..67e085913 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/[param1]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/page.tsx new file mode 100644 index 000000000..67e085913 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/base/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/catchall/[...params]/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/catchall/[...params]/layout.tsx new file mode 100644 index 000000000..f638dca7a --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/catchall/[...params]/layout.tsx @@ -0,0 +1,14 @@ +import ShowParams from "../../show-params"; + +export default async function CatchallLayout(props: { + children: React.ReactNode; + params: Promise>; +}) { + const params = await props.params; + return ( +
+ + {props.children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/catchall/[...params]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/catchall/[...params]/page.tsx new file mode 100644 index 000000000..fe27f114d --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/catchall/[...params]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Catchall page

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/layout.tsx new file mode 100644 index 000000000..e6a6b877d --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/layout.tsx @@ -0,0 +1,21 @@ +/** + * Top-level layout for layout-params tests. + * In the Next.js test, "root-layout" and "lvl1-layout" both have no params. + * Since we nest under /nextjs-compat/layout-params/, this layout corresponds + * to both root and lvl1 — it should receive empty params. + */ +import ShowParams from "./show-params"; + +export default async function LayoutParamsLayout(props: { + children: React.ReactNode; + params: Promise>; +}) { + const params = await props.params; + return ( +
+ + + {props.children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/optional-catchall/[[...params]]/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/optional-catchall/[[...params]]/layout.tsx new file mode 100644 index 000000000..b7df402e1 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/optional-catchall/[[...params]]/layout.tsx @@ -0,0 +1,14 @@ +import ShowParams from "../../show-params"; + +export default async function OptionalCatchallLayout(props: { + children: React.ReactNode; + params: Promise>; +}) { + const params = await props.params; + return ( +
+ + {props.children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/optional-catchall/[[...params]]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/optional-catchall/[[...params]]/page.tsx new file mode 100644 index 000000000..d23747d69 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/optional-catchall/[[...params]]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Optional catchall page

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/page.tsx new file mode 100644 index 000000000..67e085913 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-params/show-params.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/show-params.tsx new file mode 100644 index 000000000..671df876e --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-params/show-params.tsx @@ -0,0 +1,21 @@ +/** + * Shared component that renders each param as a div with a predictable ID. + * Used by layout-params tests to verify which params each layout receives. + */ +export default function ShowParams({ + prefix, + params, +}: { + prefix: string; + params: Record; +}) { + return ( +
+ {Object.entries(params).map(([key, val]) => ( +
+ {JSON.stringify(val)} +
+ ))} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/rsc-redirect-test/dest/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/rsc-redirect-test/dest/page.tsx new file mode 100644 index 000000000..fc3f466a2 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/rsc-redirect-test/dest/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

Destination

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/rsc-redirect-test/origin/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/rsc-redirect-test/origin/page.tsx new file mode 100644 index 000000000..51d7585ca --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/rsc-redirect-test/origin/page.tsx @@ -0,0 +1,9 @@ +/** + * Fixture for rsc-redirect test. + * Ported from: test/e2e/app-dir/rsc-redirect/app/origin/page.tsx + */ +import { redirect } from "next/navigation"; + +export default function Page(): never { + redirect("/nextjs-compat/rsc-redirect-test/dest"); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client-no-use/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client-no-use/page.tsx new file mode 100644 index 000000000..e70343b34 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client-no-use/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +export default function Page() { + return ( + <> +

No searchParams used

+ + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client-passthrough/client-component.tsx b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client-passthrough/client-component.tsx new file mode 100644 index 000000000..2a7e2afff --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client-passthrough/client-component.tsx @@ -0,0 +1,10 @@ +"use client"; +import { use } from "react"; + +export default function ClientComponent({ + searchParams, +}: { + searchParams: Promise>; +}) { + return

Parameter: {use(searchParams).search}

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client-passthrough/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client-passthrough/page.tsx new file mode 100644 index 000000000..837482ff4 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client-passthrough/page.tsx @@ -0,0 +1,5 @@ +import ClientComponent from "./client-component"; + +export default function Page({ searchParams }: { searchParams: Promise> }) { + return ; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client/page.tsx new file mode 100644 index 000000000..20905c550 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-client/page.tsx @@ -0,0 +1,12 @@ +"use client"; +import { use } from "react"; + +type AnySearchParams = { [key: string]: string | Array | undefined }; + +export default function Page({ searchParams }: { searchParams: Promise }) { + return ( + <> +

Parameter: {use(searchParams).search}

+ + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/searchparams-server-no-use/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-server-no-use/page.tsx new file mode 100644 index 000000000..e7b47492f --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-server-no-use/page.tsx @@ -0,0 +1,8 @@ +export default function Page() { + return ( + <> +

No searchParams used

+

{crypto.randomUUID()}

+ + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/searchparams-server/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-server/page.tsx new file mode 100644 index 000000000..2db29719a --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/searchparams-server/page.tsx @@ -0,0 +1,13 @@ +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + return ( + <> +

Parameter: {params.search}

+

{crypto.randomUUID()}

+ + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/status-not-found/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/status-not-found/page.tsx new file mode 100644 index 000000000..a7edcc8ef --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/status-not-found/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from "next/navigation"; + +export default function Page() { + notFound(); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/status-redirect-client/client-component.tsx b/tests/fixtures/app-basic/app/nextjs-compat/status-redirect-client/client-component.tsx new file mode 100644 index 000000000..9778d4347 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/status-redirect-client/client-component.tsx @@ -0,0 +1,7 @@ +"use client"; +import { redirect } from "next/navigation"; + +export default function ClientComp() { + redirect("/"); + return <>; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/status-redirect-client/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/status-redirect-client/page.tsx new file mode 100644 index 000000000..e65203455 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/status-redirect-client/page.tsx @@ -0,0 +1,5 @@ +import ClientComp from "./client-component"; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/status-redirect-permanent/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/status-redirect-permanent/page.tsx new file mode 100644 index 000000000..bee97dc56 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/status-redirect-permanent/page.tsx @@ -0,0 +1,5 @@ +import { permanentRedirect } from "next/navigation"; + +export default function Page() { + permanentRedirect("/"); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/status-redirect/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/status-redirect/page.tsx new file mode 100644 index 000000000..657b8edf4 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/status-redirect/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Page() { + redirect("/"); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic-no-boundary/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic-no-boundary/[id]/page.tsx new file mode 100644 index 000000000..5d0b657f4 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic-no-boundary/[id]/page.tsx @@ -0,0 +1,13 @@ +import { unauthorized } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + if (id === "401") { + unauthorized(); + } + + return

{`dynamic-no-boundary [id]`}

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic-no-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic-no-boundary/layout.tsx new file mode 100644 index 000000000..4a12328a2 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic-no-boundary/layout.tsx @@ -0,0 +1,8 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+

Dynamic with Layout

+ {children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic-no-boundary/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic-no-boundary/page.tsx new file mode 100644 index 000000000..7c4d0c4b7 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic-no-boundary/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
dynamic-with-layout
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic/[id]/page.tsx new file mode 100644 index 000000000..ed2541c51 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic/[id]/page.tsx @@ -0,0 +1,11 @@ +import { unauthorized } from "next/navigation"; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + if (id === "401") { + unauthorized(); + } + + return

{`dynamic [id]`}

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic/[id]/unauthorized.tsx b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic/[id]/unauthorized.tsx new file mode 100644 index 000000000..147deb317 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic/[id]/unauthorized.tsx @@ -0,0 +1,3 @@ +export default function Unauthorized() { + return
{`dynamic/[id] unauthorized`}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic/page.tsx new file mode 100644 index 000000000..7c9ee6640 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/dynamic/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
dynamic
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/page.tsx new file mode 100644 index 000000000..7453868f9 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Unauthorized test index

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/unauthorized.tsx b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/unauthorized.tsx new file mode 100644 index 000000000..733874d11 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/unauthorized-basic/unauthorized.tsx @@ -0,0 +1,3 @@ +export default function Unauthorized() { + return

Root Unauthorized

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/use-params/[id]/[id2]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/use-params/[id]/[id2]/page.tsx new file mode 100644 index 000000000..a6eae5887 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/use-params/[id]/[id2]/page.tsx @@ -0,0 +1,13 @@ +"use client"; +import { useParams } from "next/navigation"; + +export default function Page() { + const params = useParams(); + if (params === null) return null; + return ( +
+
{params.id}
+
{params.id2}
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/use-params/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/use-params/[id]/page.tsx new file mode 100644 index 000000000..344dc9314 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/use-params/[id]/page.tsx @@ -0,0 +1,12 @@ +"use client"; +import { useParams } from "next/navigation"; + +export default function Page() { + const params = useParams(); + if (params === null) return null; + return ( +
+
{params.id}
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/use-params/catchall/[...path]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/use-params/catchall/[...path]/page.tsx new file mode 100644 index 000000000..f67366013 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/use-params/catchall/[...path]/page.tsx @@ -0,0 +1,12 @@ +"use client"; +import { useParams } from "next/navigation"; + +export default function Page() { + const params = useParams(); + if (params === null) return null; + return ( +
+
{JSON.stringify(params.path)}
+
+ ); +} diff --git a/tests/fixtures/app-middleware-compat/app/draft-mode-next/page.tsx b/tests/fixtures/app-middleware-compat/app/draft-mode-next/page.tsx new file mode 100644 index 000000000..73b39b9f6 --- /dev/null +++ b/tests/fixtures/app-middleware-compat/app/draft-mode-next/page.tsx @@ -0,0 +1,11 @@ +import { draftMode } from "next/headers"; + +export default async function DraftModeNextPage() { + const { isEnabled } = await draftMode(); + return ( + <> +

draft-mode-next

+

{String(isEnabled)}

+ + ); +} diff --git a/tests/fixtures/app-middleware-compat/app/draft-mode-void/page.tsx b/tests/fixtures/app-middleware-compat/app/draft-mode-void/page.tsx new file mode 100644 index 000000000..f98ca7c3d --- /dev/null +++ b/tests/fixtures/app-middleware-compat/app/draft-mode-void/page.tsx @@ -0,0 +1,11 @@ +import { draftMode } from "next/headers"; + +export default async function DraftModeVoidPage() { + const { isEnabled } = await draftMode(); + return ( + <> +

draft-mode-void

+

{String(isEnabled)}

+ + ); +} diff --git a/tests/fixtures/app-middleware-compat/app/headers/page.tsx b/tests/fixtures/app-middleware-compat/app/headers/page.tsx new file mode 100644 index 000000000..a9e9b91ea --- /dev/null +++ b/tests/fixtures/app-middleware-compat/app/headers/page.tsx @@ -0,0 +1,11 @@ +import { headers } from "next/headers"; + +export default async function HeadersPage() { + const headersObj = Object.fromEntries(await headers()); + return ( + <> +

app-dir

+

{JSON.stringify(headersObj)}

+ + ); +} diff --git a/tests/fixtures/app-middleware-compat/app/layout.tsx b/tests/fixtures/app-middleware-compat/app/layout.tsx new file mode 100644 index 000000000..418716c35 --- /dev/null +++ b/tests/fixtures/app-middleware-compat/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-middleware-compat/app/preloads/page.tsx b/tests/fixtures/app-middleware-compat/app/preloads/page.tsx new file mode 100644 index 000000000..3abb9e6fc --- /dev/null +++ b/tests/fixtures/app-middleware-compat/app/preloads/page.tsx @@ -0,0 +1,3 @@ +export default function PreloadsPage() { + return

Preloads page

; +} diff --git a/tests/fixtures/app-middleware-compat/middleware.ts b/tests/fixtures/app-middleware-compat/middleware.ts new file mode 100644 index 000000000..722a1e2dc --- /dev/null +++ b/tests/fixtures/app-middleware-compat/middleware.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; +import { unstable_cache } from "next/cache"; +import { headers as nextHeaders, draftMode } from "next/headers"; + +const getCachedValue = unstable_cache( + async () => Math.random().toString(), + ["middleware-cache-probe"], +); + +export async function middleware(request: import("next/server").NextRequest) { + const headersFromRequest = new Headers(request.headers); + const headersFromNext = await nextHeaders(); + headersFromRequest.set("x-from-middleware", "hello-from-middleware"); + + if ( + headersFromRequest.get("x-from-client") && + headersFromNext.get("x-from-client") !== headersFromRequest.get("x-from-client") + ) { + throw new Error("Expected headers from client to match"); + } + + if (request.nextUrl.searchParams.get("draft")) { + (await draftMode()).enable(); + } + + const removeHeaders = request.nextUrl.searchParams.get("remove-headers"); + if (removeHeaders) { + for (const key of removeHeaders.split(",")) { + headersFromRequest.delete(key); + } + } + + const updateHeaders = request.nextUrl.searchParams.get("update-headers"); + if (updateHeaders) { + for (const kv of updateHeaders.split(",")) { + const [key, value] = kv.split("="); + headersFromRequest.set(key, value); + } + } + + // Draft mode edge case: enable draft mode and return void (no response). + // Tests whether the draft cookie is propagated when middleware doesn't + // explicitly return a NextResponse. + if ( + request.nextUrl.pathname === "/draft-mode-void" && + request.nextUrl.searchParams.get("draft") + ) { + (await draftMode()).enable(); + // Intentionally return void — no NextResponse.next() or any response. + return; + } + + // Draft mode with explicit NextResponse.next(): enable draft mode and + // return NextResponse.next() so the cookie is carried on the response. + if ( + request.nextUrl.pathname === "/draft-mode-next" && + request.nextUrl.searchParams.get("draft") + ) { + (await draftMode()).enable(); + return NextResponse.next(); + } + + if (request.nextUrl.pathname === "/preloads") { + return NextResponse.next({ + headers: { + link: '; rel="alternate"; hreflang="en"', + }, + }); + } + + if (request.nextUrl.pathname === "/unstable-cache") { + const value = await getCachedValue(); + return NextResponse.json({ value }); + } + + if (request.nextUrl.pathname === "/test-location-header") { + return NextResponse.json( + { foo: "bar" }, + { + headers: { + location: "https://next-data-api-endpoint.vercel.app/api/random", + }, + }, + ); + } + + return NextResponse.next({ + request: { + headers: headersFromRequest, + }, + }); +} + +export const config = { + matcher: [ + "/headers", + "/api/dump-headers-serverless", + "/preloads", + "/unstable-cache", + "/test-location-header", + "/draft-mode-void", + "/draft-mode-next", + ], +}; diff --git a/tests/fixtures/app-middleware-compat/package.json b/tests/fixtures/app-middleware-compat/package.json new file mode 100644 index 000000000..5a2832e54 --- /dev/null +++ b/tests/fixtures/app-middleware-compat/package.json @@ -0,0 +1,16 @@ +{ + "name": "app-middleware-compat-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/app-middleware-compat/pages/api/dump-headers-serverless.ts b/tests/fixtures/app-middleware-compat/pages/api/dump-headers-serverless.ts new file mode 100644 index 000000000..d040c865f --- /dev/null +++ b/tests/fixtures/app-middleware-compat/pages/api/dump-headers-serverless.ts @@ -0,0 +1,11 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +type Req = IncomingMessage & { headers: Record }; +type Res = ServerResponse & { + status: (code: number) => Res; + json: (value: unknown) => void; +}; + +export default function handler(req: Req, res: Res) { + return res.status(200).setHeader("headers-from-serverless", "1").json(req.headers); +} diff --git a/tests/fixtures/app-middleware-compat/tsconfig.json b/tests/fixtures/app-middleware-compat/tsconfig.json new file mode 100644 index 000000000..97d26ad73 --- /dev/null +++ b/tests/fixtures/app-middleware-compat/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["app", "pages", "*.ts"] +} diff --git a/tests/fixtures/app-not-found-default/app/(group)/group-dynamic/[id]/page.tsx b/tests/fixtures/app-not-found-default/app/(group)/group-dynamic/[id]/page.tsx new file mode 100644 index 000000000..8a4ad30f4 --- /dev/null +++ b/tests/fixtures/app-not-found-default/app/(group)/group-dynamic/[id]/page.tsx @@ -0,0 +1,10 @@ +import { notFound } from "next/navigation"; + +export default async function Page(props: { params: Promise<{ id: string }> }) { + const params = await props.params; + if (params.id === "404") { + notFound(); + } + + return

group-dynamic [id]

; +} diff --git a/tests/fixtures/app-not-found-default/app/(group)/layout.tsx b/tests/fixtures/app-not-found-default/app/(group)/layout.tsx new file mode 100644 index 000000000..6126df960 --- /dev/null +++ b/tests/fixtures/app-not-found-default/app/(group)/layout.tsx @@ -0,0 +1,3 @@ +export default function GroupLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/tests/fixtures/app-not-found-default/app/layout.tsx b/tests/fixtures/app-not-found-default/app/layout.tsx new file mode 100644 index 000000000..16d1e9f7d --- /dev/null +++ b/tests/fixtures/app-not-found-default/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-not-found-default/app/page.tsx b/tests/fixtures/app-not-found-default/app/page.tsx new file mode 100644 index 000000000..dab69e234 --- /dev/null +++ b/tests/fixtures/app-not-found-default/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

; +} diff --git a/tests/fixtures/app-not-found-default/package.json b/tests/fixtures/app-not-found-default/package.json new file mode 100644 index 000000000..834437051 --- /dev/null +++ b/tests/fixtures/app-not-found-default/package.json @@ -0,0 +1,16 @@ +{ + "name": "app-not-found-default-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/app-not-found-default/tsconfig.json b/tests/fixtures/app-not-found-default/tsconfig.json new file mode 100644 index 000000000..862dc4454 --- /dev/null +++ b/tests/fixtures/app-not-found-default/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["app", "*.ts"] +} diff --git a/tests/fixtures/app-optional-catchall-root/app/[[...slug]]/page.tsx b/tests/fixtures/app-optional-catchall-root/app/[[...slug]]/page.tsx new file mode 100644 index 000000000..7e0068515 --- /dev/null +++ b/tests/fixtures/app-optional-catchall-root/app/[[...slug]]/page.tsx @@ -0,0 +1,13 @@ +export default async function CatchAllPage({ params }: { params: Promise<{ slug?: string[] }> }) { + const { slug } = await params; + const isEmpty = slug === undefined || (Array.isArray(slug) && slug.length === 0); + return ( + <> +

{isEmpty ? "__EMPTY__" : slug!.join("/")}

+

+ {slug === undefined ? "undefined" : Array.isArray(slug) ? "array" : typeof slug} +

+

{slug ? slug.length : 0}

+ + ); +} diff --git a/tests/fixtures/app-optional-catchall-root/app/layout.tsx b/tests/fixtures/app-optional-catchall-root/app/layout.tsx new file mode 100644 index 000000000..418716c35 --- /dev/null +++ b/tests/fixtures/app-optional-catchall-root/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-optional-catchall-root/package.json b/tests/fixtures/app-optional-catchall-root/package.json new file mode 100644 index 000000000..4f662ac4f --- /dev/null +++ b/tests/fixtures/app-optional-catchall-root/package.json @@ -0,0 +1,16 @@ +{ + "name": "app-optional-catchall-root-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/app-optional-catchall-root/tsconfig.json b/tests/fixtures/app-optional-catchall-root/tsconfig.json new file mode 100644 index 000000000..862dc4454 --- /dev/null +++ b/tests/fixtures/app-optional-catchall-root/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["app", "*.ts"] +} diff --git a/tests/fixtures/app-rewrites-redirects/app/layout.tsx b/tests/fixtures/app-rewrites-redirects/app/layout.tsx new file mode 100644 index 000000000..418716c35 --- /dev/null +++ b/tests/fixtures/app-rewrites-redirects/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-rewrites-redirects/app/page.tsx b/tests/fixtures/app-rewrites-redirects/app/page.tsx new file mode 100644 index 000000000..eeb973db1 --- /dev/null +++ b/tests/fixtures/app-rewrites-redirects/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

rewrites-redirects fixture

; +} diff --git a/tests/fixtures/app-rewrites-redirects/next.config.ts b/tests/fixtures/app-rewrites-redirects/next.config.ts new file mode 100644 index 000000000..85eea5cd8 --- /dev/null +++ b/tests/fixtures/app-rewrites-redirects/next.config.ts @@ -0,0 +1,20 @@ +import type { NextConfig } from "vinext"; + +const nextConfig: NextConfig = { + async redirects() { + return [ + { + source: "/config-redirect-itms-apps-slashes", + destination: "itms-apps://apps.apple.com/de/app/xcode/id497799835?l=en-GB&mt=12", + permanent: true, + }, + { + source: "/config-redirect-itms-apps-no-slashes", + destination: "itms-apps:apps.apple.com/de/app/xcode/id497799835?l=en-GB&mt=12", + permanent: true, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/tests/fixtures/app-rewrites-redirects/package.json b/tests/fixtures/app-rewrites-redirects/package.json new file mode 100644 index 000000000..59f648553 --- /dev/null +++ b/tests/fixtures/app-rewrites-redirects/package.json @@ -0,0 +1,16 @@ +{ + "name": "app-rewrites-redirects-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/app-rewrites-redirects/tsconfig.json b/tests/fixtures/app-rewrites-redirects/tsconfig.json new file mode 100644 index 000000000..862dc4454 --- /dev/null +++ b/tests/fixtures/app-rewrites-redirects/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["app", "*.ts"] +} diff --git a/tests/fixtures/app-routes-trailing-slash-compat/app/runtime/edge/route.ts b/tests/fixtures/app-routes-trailing-slash-compat/app/runtime/edge/route.ts new file mode 100644 index 000000000..a94b071b2 --- /dev/null +++ b/tests/fixtures/app-routes-trailing-slash-compat/app/runtime/edge/route.ts @@ -0,0 +1,2 @@ +export const runtime = "edge"; +export { GET } from "../../../handler"; diff --git a/tests/fixtures/app-routes-trailing-slash-compat/app/runtime/node/route.ts b/tests/fixtures/app-routes-trailing-slash-compat/app/runtime/node/route.ts new file mode 100644 index 000000000..198babaca --- /dev/null +++ b/tests/fixtures/app-routes-trailing-slash-compat/app/runtime/node/route.ts @@ -0,0 +1 @@ +export { GET } from "../../../handler"; diff --git a/tests/fixtures/app-routes-trailing-slash-compat/handler.ts b/tests/fixtures/app-routes-trailing-slash-compat/handler.ts new file mode 100644 index 000000000..cab684b01 --- /dev/null +++ b/tests/fixtures/app-routes-trailing-slash-compat/handler.ts @@ -0,0 +1,9 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const GET = (req: NextRequest) => { + const url = new URL(req.url); + return NextResponse.json({ + url: url.pathname, + nextUrl: req.nextUrl.pathname, + }); +}; diff --git a/tests/fixtures/app-routes-trailing-slash-compat/next.config.ts b/tests/fixtures/app-routes-trailing-slash-compat/next.config.ts new file mode 100644 index 000000000..bb2bc8a85 --- /dev/null +++ b/tests/fixtures/app-routes-trailing-slash-compat/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "vinext"; + +const nextConfig: NextConfig = { + trailingSlash: true, +}; + +export default nextConfig; diff --git a/tests/fixtures/app-routes-trailing-slash-compat/package.json b/tests/fixtures/app-routes-trailing-slash-compat/package.json new file mode 100644 index 000000000..f9a1ded95 --- /dev/null +++ b/tests/fixtures/app-routes-trailing-slash-compat/package.json @@ -0,0 +1,16 @@ +{ + "name": "app-routes-trailing-slash-compat-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/app-routes-trailing-slash-compat/tsconfig.json b/tests/fixtures/app-routes-trailing-slash-compat/tsconfig.json new file mode 100644 index 000000000..862dc4454 --- /dev/null +++ b/tests/fixtures/app-routes-trailing-slash-compat/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["app", "*.ts"] +} diff --git a/tests/fixtures/app-underscored-root/app/%5Froutable-folder/route.ts b/tests/fixtures/app-underscored-root/app/%5Froutable-folder/route.ts new file mode 100644 index 000000000..75712f7d9 --- /dev/null +++ b/tests/fixtures/app-underscored-root/app/%5Froutable-folder/route.ts @@ -0,0 +1 @@ +export { GET } from "../_handlers/route"; diff --git a/tests/fixtures/app-underscored-root/app/_handlers/route.ts b/tests/fixtures/app-underscored-root/app/_handlers/route.ts new file mode 100644 index 000000000..52f7390a8 --- /dev/null +++ b/tests/fixtures/app-underscored-root/app/_handlers/route.ts @@ -0,0 +1,7 @@ +export async function GET() { + return new Response("Hello, world!", { + headers: { + "content-type": "text/plain", + }, + }); +} diff --git a/tests/fixtures/app-underscored-root/app/layout.tsx b/tests/fixtures/app-underscored-root/app/layout.tsx new file mode 100644 index 000000000..418716c35 --- /dev/null +++ b/tests/fixtures/app-underscored-root/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-underscored-root/app/route.ts b/tests/fixtures/app-underscored-root/app/route.ts new file mode 100644 index 000000000..4084056f2 --- /dev/null +++ b/tests/fixtures/app-underscored-root/app/route.ts @@ -0,0 +1 @@ +export { GET } from "./_handlers/route"; diff --git a/tests/fixtures/app-underscored-root/package.json b/tests/fixtures/app-underscored-root/package.json new file mode 100644 index 000000000..870b9180b --- /dev/null +++ b/tests/fixtures/app-underscored-root/package.json @@ -0,0 +1,16 @@ +{ + "name": "app-underscored-root-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/app-underscored-root/tsconfig.json b/tests/fixtures/app-underscored-root/tsconfig.json new file mode 100644 index 000000000..862dc4454 --- /dev/null +++ b/tests/fixtures/app-underscored-root/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["app", "*.ts"] +} diff --git a/tests/fixtures/app-use-cache-route-handler-only/app/node/route.ts b/tests/fixtures/app-use-cache-route-handler-only/app/node/route.ts new file mode 100644 index 000000000..69e157776 --- /dev/null +++ b/tests/fixtures/app-use-cache-route-handler-only/app/node/route.ts @@ -0,0 +1,15 @@ +async function getCachedDate() { + "use cache"; + + // Ensure the value changes across revalidation events. + return new Date().toISOString(); +} + +export async function GET() { + const date1 = await getCachedDate(); + const date2 = await getCachedDate(); + + return new Response(JSON.stringify({ date1, date2 }), { + headers: { "content-type": "application/json" }, + }); +} diff --git a/tests/fixtures/app-use-cache-route-handler-only/app/revalidate/route.ts b/tests/fixtures/app-use-cache-route-handler-only/app/revalidate/route.ts new file mode 100644 index 000000000..ca5af41ca --- /dev/null +++ b/tests/fixtures/app-use-cache-route-handler-only/app/revalidate/route.ts @@ -0,0 +1,6 @@ +import { revalidatePath } from "next/cache"; + +export async function POST() { + revalidatePath("/node"); + return new Response(null, { status: 204 }); +} diff --git a/tests/fixtures/app-use-cache-route-handler-only/package.json b/tests/fixtures/app-use-cache-route-handler-only/package.json new file mode 100644 index 000000000..6cdf73f15 --- /dev/null +++ b/tests/fixtures/app-use-cache-route-handler-only/package.json @@ -0,0 +1,16 @@ +{ + "name": "app-use-cache-route-handler-only-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/app-use-cache-route-handler-only/tsconfig.json b/tests/fixtures/app-use-cache-route-handler-only/tsconfig.json new file mode 100644 index 000000000..862dc4454 --- /dev/null +++ b/tests/fixtures/app-use-cache-route-handler-only/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["app", "*.ts"] +} diff --git a/tests/helpers.ts b/tests/helpers.ts index d95c892fc..75756a6d9 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -14,10 +14,19 @@ import { pathToFileURL } from "node:url"; import { createServer, build, type ViteDevServer } from "vite"; import vinext from "../packages/vinext/src/index.js"; import path from "node:path"; +import * as cheerio from "cheerio"; // ── Fixture paths ───────────────────────────────────────────── export const PAGES_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/pages-basic"); export const APP_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/app-basic"); +export const APP_UNDERSCORED_ROOT_FIXTURE_DIR = path.resolve( + import.meta.dirname, + "./fixtures/app-underscored-root", +); +export const APP_OPTIONAL_CATCHALL_ROOT_FIXTURE_DIR = path.resolve( + import.meta.dirname, + "./fixtures/app-optional-catchall-root", +); export const PAGES_I18N_DOMAINS_FIXTURE_DIR = path.resolve( import.meta.dirname, "./fixtures/pages-i18n-domains", @@ -128,6 +137,25 @@ export async function fetchJson( return { res, data }; } +/** + * Fetch a page and return a Cheerio instance for DOM querying, + * along with the raw Response and HTML string. + * + * Usage: + * const { $, html, res } = await fetchDom(baseUrl, "/some-page"); + * expect($("#my-id").text()).toBe("hello"); + */ +export async function fetchDom( + baseUrl: string, + urlPath: string, + init?: RequestInit, +): Promise<{ res: Response; html: string; $: cheerio.CheerioAPI }> { + const res = await fetch(`${baseUrl}${urlPath}`, init); + const html = await res.text(); + const $ = cheerio.load(html); + return { res, html, $ }; +} + export interface NodeHttpResponse { status: number; headers: IncomingHttpHeaders; diff --git a/tests/nextjs-compat/allow-underscored-root-directory.test.ts b/tests/nextjs-compat/allow-underscored-root-directory.test.ts new file mode 100644 index 000000000..783752717 --- /dev/null +++ b/tests/nextjs-compat/allow-underscored-root-directory.test.ts @@ -0,0 +1,48 @@ +/** + * Next.js Compatibility Tests: _allow-underscored-root-directory + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/_allow-underscored-root-directory/_allow-underscored-root-directory.test.ts + * + * Tests underscore-prefixed private folders at the app root: + * - Root-level private folders (e.g. app/_handlers) are not routable + * - A route can re-export from a private folder + * - URL-encoded folder names (%5Ffoo) decode to underscore-prefixed URL paths and ARE routable + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_UNDERSCORED_ROOT_FIXTURE_DIR, startFixtureServer } from "../helpers.js"; + +describe("Next.js compat: _allow-underscored-root-directory", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_UNDERSCORED_ROOT_FIXTURE_DIR, { + appRouter: true, + })); + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/_allow-underscored-root-directory/_allow-underscored-root-directory.test.ts + it("should not serve app path with underscore", async () => { + const res = await fetch(`${baseUrl}/_handlers`); + expect(res.status).toBe(404); + }); + + it("should serve root route that re-exports from a private underscore folder", async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("Hello, world!"); + }); + + it("should serve app path with %5F", async () => { + const res = await fetch(`${baseUrl}/_routable-folder`); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("Hello, world!"); + }); +}); diff --git a/tests/nextjs-compat/app-middleware.test.ts b/tests/nextjs-compat/app-middleware.test.ts new file mode 100644 index 000000000..c125532b6 --- /dev/null +++ b/tests/nextjs-compat/app-middleware.test.ts @@ -0,0 +1,181 @@ +/** + * Next.js Compatibility Tests: app-middleware + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-middleware/app-middleware.test.ts + * + * HTTP-testable subset only: + * - middleware can mutate request headers seen by app pages and pages API routes + * - internal x-middleware-* control headers are stripped from responses + * - middleware can enable draft mode + * - middleware response Link headers are preserved + * - middleware can use unstable_cache and return direct JSON responses + * - a plain Location response header is not treated as a rewrite/redirect + */ + +import path from "node:path"; +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { fetchDom, fetchJson, startFixtureServer } from "../helpers.js"; + +const FIXTURE_DIR = path.resolve(import.meta.dirname, "../fixtures/app-middleware-compat"); + +async function readHeadersFromPage(baseUrl: string, urlPath: string, init?: RequestInit) { + const { res, $ } = await fetchDom(baseUrl, urlPath, init); + return { + res, + data: JSON.parse($("#headers").text()) as Record, + }; +} + +describe("Next.js compat: app-middleware", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(FIXTURE_DIR, { + appRouter: true, + })); + await fetch(`${baseUrl}/headers`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + describe.each([ + { + title: "pages API route", + path: "/api/dump-headers-serverless", + toJson: (baseUrl: string, urlPath: string, init?: RequestInit) => + fetchJson(baseUrl, urlPath, init).then(({ res, data }) => ({ + res, + data: data as Record, + })), + }, + { + title: "next/headers page", + path: "/headers", + toJson: (baseUrl: string, urlPath: string, init?: RequestInit) => + readHeadersFromPage(baseUrl, urlPath, init), + }, + ])("middleware request header mutations for $title", ({ path, toJson }) => { + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-middleware/app-middleware.test.ts + it("adds new headers", async () => { + const { data } = await toJson(baseUrl, path, { + headers: { + "x-from-client": "hello-from-client", + }, + }); + + expect(data).toMatchObject({ + "x-from-client": "hello-from-client", + "x-from-middleware": "hello-from-middleware", + }); + }); + + it("deletes headers", async () => { + const { res, data } = await toJson( + baseUrl, + `${path}?remove-headers=x-from-client1,x-from-client2`, + { + headers: { + "x-from-client1": "hello-from-client", + "X-From-Client2": "hello-from-client", + }, + }, + ); + + expect(data).not.toHaveProperty("x-from-client1"); + expect(data).not.toHaveProperty("X-From-Client2"); + expect(data).toMatchObject({ + "x-from-middleware": "hello-from-middleware", + }); + + expect(res.headers.get("x-middleware-override-headers")).toBeNull(); + expect(res.headers.get("x-middleware-request-x-from-middleware")).toBeNull(); + expect(res.headers.get("x-middleware-request-x-from-client1")).toBeNull(); + expect(res.headers.get("x-middleware-request-x-from-client2")).toBeNull(); + }); + + it("updates headers", async () => { + const { res, data } = await toJson( + baseUrl, + `${path}?update-headers=x-from-client1=new-value1,x-from-client2=new-value2`, + { + headers: { + "x-from-client1": "old-value1", + "X-From-Client2": "old-value2", + "x-from-client3": "old-value3", + }, + }, + ); + + expect(data).toMatchObject({ + "x-from-client1": "new-value1", + "x-from-client2": "new-value2", + "x-from-client3": "old-value3", + "x-from-middleware": "hello-from-middleware", + }); + + expect(res.headers.get("x-middleware-override-headers")).toBeNull(); + expect(res.headers.get("x-middleware-request-x-from-middleware")).toBeNull(); + expect(res.headers.get("x-middleware-request-x-from-client1")).toBeNull(); + expect(res.headers.get("x-middleware-request-x-from-client2")).toBeNull(); + expect(res.headers.get("x-middleware-request-x-from-client3")).toBeNull(); + }); + + it("supports draft mode", async () => { + const res = await fetch(`${baseUrl}${path}?draft=true`); + const setCookies = res.headers.getSetCookie(); + expect(setCookies.some((cookie) => cookie.includes("__prerender_bypass"))).toBe(true); + }); + }); + + describe("draftMode edge cases", () => { + // Tests that draftMode().enable() + explicit NextResponse.next() propagates the bypass cookie. + it("propagates draft mode cookie when middleware returns NextResponse.next()", async () => { + const res = await fetch(`${baseUrl}/draft-mode-next?draft=true`); + const setCookies = res.headers.getSetCookie(); + expect(setCookies.some((cookie) => cookie.includes("__prerender_bypass"))).toBe(true); + }); + + // Tests what happens when draftMode().enable() is called but middleware returns void. + // In Next.js, middleware returning void is treated as "continue" — the draft cookie + // may be lost because there is no response object to attach it to. This documents + // the expected behavior (parity with Next.js). + it("draft mode cookie behavior when middleware returns void", async () => { + const res = await fetch(`${baseUrl}/draft-mode-void?draft=true`); + // Middleware returned void, so there is no middleware response to attach + // the draft cookie to. The cookie is NOT propagated — this matches Next.js + // behavior where cookie propagation requires a response object. + const setCookies = res.headers.getSetCookie(); + const hasBypassCookie = setCookies.some((cookie) => cookie.includes("__prerender_bypass")); + // Document: void return does NOT propagate draft cookies + expect(hasBypassCookie).toBe(false); + }); + }); + + it("retains a link response header from middleware", async () => { + const res = await fetch(`${baseUrl}/preloads`); + expect(res.headers.get("link")).toContain( + '; rel="alternate"; hreflang="en"', + ); + }); + + it("supports unstable_cache in middleware", async () => { + const { res, data } = await fetchJson(baseUrl, "/unstable-cache"); + expect(res.status).toBe(200); + expect(data).toEqual({ + value: expect.any(String), + }); + }); + + it("does not incorrectly treat a Location header as a rewrite", async () => { + const { res, data } = await fetchJson(baseUrl, "/test-location-header"); + expect(res.status).toBe(200); + expect(data).toEqual({ foo: "bar" }); + expect(res.headers.get("location")).toBe( + "https://next-data-api-endpoint.vercel.app/api/random", + ); + }); +}); diff --git a/tests/nextjs-compat/app-routes-trailing-slash.test.ts b/tests/nextjs-compat/app-routes-trailing-slash.test.ts new file mode 100644 index 000000000..0307bc1a2 --- /dev/null +++ b/tests/nextjs-compat/app-routes-trailing-slash.test.ts @@ -0,0 +1,53 @@ +/** + * Next.js Compatibility Tests: app-routes-trailing-slash + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes-trailing-slash/app-routes-trailing-slash.test.ts + * + * Tests that route handlers respect trailingSlash=true: + * - requesting /runtime/ redirects to /runtime// + * - requesting the canonical slash form returns 200 and both url.pathname + * and req.nextUrl.pathname include the trailing slash + */ + +import path from "node:path"; +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { startFixtureServer, fetchJson } from "../helpers.js"; + +const FIXTURE_DIR = path.resolve( + import.meta.dirname, + "../fixtures/app-routes-trailing-slash-compat", +); + +describe("Next.js compat: app-routes-trailing-slash", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(FIXTURE_DIR, { + appRouter: true, + })); + await fetch(`${baseUrl}/runtime/node`, { redirect: "manual" }).catch(() => {}); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes-trailing-slash/app-routes-trailing-slash.test.ts + it.each(["edge", "node"])("should handle trailing slash for %s runtime", async (runtime) => { + let res = await fetch(`${baseUrl}/runtime/${runtime}`, { + redirect: "manual", + }); + + expect(res.status).toBe(308); + expect(res.headers.get("location")).toContain(`/runtime/${runtime}/`); + + const json = await fetchJson(baseUrl, `/runtime/${runtime}/`); + expect(json.res.status).toBe(200); + expect(json.data).toEqual({ + url: `/runtime/${runtime}/`, + nextUrl: `/runtime/${runtime}/`, + }); + }); +}); diff --git a/tests/nextjs-compat/catch-all-optional.test.ts b/tests/nextjs-compat/catch-all-optional.test.ts new file mode 100644 index 000000000..63b370ad7 --- /dev/null +++ b/tests/nextjs-compat/catch-all-optional.test.ts @@ -0,0 +1,64 @@ +/** + * Next.js Compatibility Tests: app-catch-all-optional + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-catch-all-optional/app-catch-all-optional.test.ts + * + * Tests optional catch-all route matching: [lang]/[flags]/[[...rest]] + * - With rest params: /en/flags/the/rest → rest = ["the", "rest"] + * - Without rest params: /en/flags → rest = undefined/[] + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchHtml } from "../helpers.js"; + +describe("Next.js compat: app-catch-all-optional", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + await fetchHtml(baseUrl, "/nextjs-compat/catch-all-optional/en/flags"); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-catch-all-optional/app-catch-all-optional.test.ts + // "should handle optional catchall" + it("should handle optional catchall with rest params", async () => { + const { html, res } = await fetchHtml( + baseUrl, + "/nextjs-compat/catch-all-optional/en/flags/the/rest", + ); + expect(res.status).toBe(200); + expect(html).toContain('data-lang="en"'); + expect(html).toContain('data-flags="flags"'); + expect(html).toContain('data-rest="the/rest"'); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-catch-all-optional/app-catch-all-optional.test.ts + // "should handle optional catchall with no params" + it("should handle optional catchall with no rest params", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/catch-all-optional/en/flags"); + expect(res.status).toBe(200); + expect(html).toContain('data-lang="en"'); + expect(html).toContain('data-flags="flags"'); + expect(html).toContain('data-rest=""'); + }); + + // Additional edge case: single rest param + it("should handle optional catchall with single rest param", async () => { + const { html, res } = await fetchHtml( + baseUrl, + "/nextjs-compat/catch-all-optional/fr/banner/home", + ); + expect(res.status).toBe(200); + expect(html).toContain('data-lang="fr"'); + expect(html).toContain('data-flags="banner"'); + expect(html).toContain('data-rest="home"'); + }); +}); diff --git a/tests/nextjs-compat/conflicting-search-and-route-params.test.ts b/tests/nextjs-compat/conflicting-search-and-route-params.test.ts new file mode 100644 index 000000000..0460c8d38 --- /dev/null +++ b/tests/nextjs-compat/conflicting-search-and-route-params.test.ts @@ -0,0 +1,48 @@ +/** + * Next.js Compatibility Tests: conflicting-search-and-route-params + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/conflicting-search-and-route-params/conflicting-search-and-route-params.test.ts + * + * Tests that when a search param and a route param have the same name (e.g. "id"), + * they are correctly distinguished — route param wins in params, search param is + * accessible via searchParams. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchDom } from "../helpers.js"; + +describe("Next.js compat: conflicting-search-and-route-params", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + // Warm up + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/conflicting-search-and-route-params/conflicting-search-and-route-params.test.ts + + it("should handle conflicting search and route params on page", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/conflicting-params/render/123?id=456"); + expect($("#route-param").text()).toContain("Route param id: 123"); + expect($("#search-param").text()).toContain("Search param id: 456"); + }); + + it("should handle conflicting search and route params on API route", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/conflicting-params/api/789?id=abc`); + const data = await res.json(); + + expect(data).toEqual({ + routeParam: "789", + searchParam: "abc", + }); + }); +}); diff --git a/tests/nextjs-compat/dynamic-data.test.ts b/tests/nextjs-compat/dynamic-data.test.ts new file mode 100644 index 000000000..989a3dd0b --- /dev/null +++ b/tests/nextjs-compat/dynamic-data.test.ts @@ -0,0 +1,93 @@ +/** + * Next.js Compatibility Tests: dynamic-data + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts + * + * Covers HTTP-testable dev-mode behavior for dynamic request APIs: + * - top-level headers()/cookies()/searchParams access + * - force-dynamic pages using request APIs + * - force-static pages receiving empty request APIs + * - client pages receiving searchParams + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchDom } from "../helpers.js"; + +const REQUEST_INIT = { + headers: { + fooheader: "foo header value", + cookie: "foocookie=foo cookie value", + }, +}; + +describe("Next.js compat: dynamic-data", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts + it("should render the dynamic apis dynamically when used in a top-level scope", async () => { + const { $ } = await fetchDom( + baseUrl, + "/nextjs-compat/dynamic-data/top-level?foo=foosearch", + REQUEST_INIT, + ); + + expect($("#layout").text()).toBe("at runtime"); + expect($("#page").text()).toBe("at runtime"); + expect($("#headers .fooheader").text()).toBe("foo header value"); + expect($("#cookies .foocookie").text()).toBe("foo cookie value"); + expect($("#searchparams .foo").text()).toBe("foosearch"); + }); + + it("should render the dynamic apis dynamically when used in a top-level scope with force dynamic", async () => { + const { $ } = await fetchDom( + baseUrl, + "/nextjs-compat/dynamic-data/force-dynamic?foo=foosearch", + REQUEST_INIT, + ); + + expect($("#layout").text()).toBe("at runtime"); + expect($("#page").text()).toBe("at runtime"); + expect($("#headers .fooheader").text()).toBe("foo header value"); + expect($("#cookies .foocookie").text()).toBe("foo cookie value"); + expect($("#searchparams .foo").text()).toBe("foosearch"); + }); + + it("should render empty objects for dynamic APIs when rendering with force-static", async () => { + const { $ } = await fetchDom( + baseUrl, + "/nextjs-compat/dynamic-data/force-static?foo=foosearch", + REQUEST_INIT, + ); + + expect($("#layout").text()).toBe("at runtime"); + expect($("#page").text()).toBe("at runtime"); + expect($("#headers .fooheader").html()).toBeNull(); + expect($("#cookies .foocookie").html()).toBeNull(); + expect($("#searchparams .foo").html()).toBeNull(); + }); + + it("should track searchParams access as dynamic when the Page is a client component", async () => { + const { $ } = await fetchDom( + baseUrl, + "/nextjs-compat/dynamic-data/client-page?foo=foosearch", + REQUEST_INIT, + ); + + expect($("#layout").text()).toBe("at runtime"); + expect($("#page").text()).toBe("at runtime"); + expect($("#searchparams .foo").text()).toBe("foosearch"); + }); +}); diff --git a/tests/nextjs-compat/fetch-deduping-errors.test.ts b/tests/nextjs-compat/fetch-deduping-errors.test.ts new file mode 100644 index 000000000..2b30414eb --- /dev/null +++ b/tests/nextjs-compat/fetch-deduping-errors.test.ts @@ -0,0 +1,56 @@ +/** + * Next.js Compatibility Tests: app-fetch-deduping-errors + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-fetch-deduping-errors/app-fetch-deduping-errors.test.ts + * + * Tests that the page still renders successfully when a fetch request + * with cache options errors (e.g. connection refused). The fetch error + * is caught in a try/catch — the page should not crash. + * + * Original test uses Playwright (browser.elementByCss), but the core + * behavior is testable at the HTTP/SSR level. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchHtml } from "../helpers.js"; + +describe("Next.js compat: app-fetch-deduping-errors", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + await fetchHtml(baseUrl, "/nextjs-compat/fetch-deduping-errors/1"); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-fetch-deduping-errors/app-fetch-deduping-errors.test.ts + // "should still successfully render when a fetch request that acquires a cache lock errors" + it("should render page despite fetch error with cache options", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/fetch-deduping-errors/1"); + expect(res.status).toBe(200); + // React SSR may insert comment nodes between text and dynamic values + expect(html).toContain("Hello World"); + expect(html).toContain("1"); + }); + + it("should render with different dynamic params despite fetch error", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/fetch-deduping-errors/42"); + expect(res.status).toBe(200); + expect(html).toContain("Hello World"); + expect(html).toContain("42"); + }); + + it("should generate metadata despite fetch error in generateMetadata", async () => { + const { html } = await fetchHtml(baseUrl, "/nextjs-compat/fetch-deduping-errors/1"); + // Metadata should still be generated even though fetch failed + expect(html).toContain(""); + expect(html).toContain("Page 1"); + }); +}); diff --git a/tests/nextjs-compat/forbidden.test.ts b/tests/nextjs-compat/forbidden.test.ts new file mode 100644 index 000000000..88159116d --- /dev/null +++ b/tests/nextjs-compat/forbidden.test.ts @@ -0,0 +1,78 @@ +/** + * Next.js Compatibility Tests: forbidden + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts + * + * Tests forbidden() boundary behavior at the HTTP/SSR level: + * - forbidden() in a dynamic route triggers the scoped forbidden.tsx boundary + * - Normal dynamic route params render correctly + * - When no local forbidden boundary exists, escalates to root forbidden + * - Response status is 403 for forbidden pages + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchHtml } from "../helpers.js"; + +describe("Next.js compat: forbidden", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + await fetchHtml(baseUrl, "/nextjs-compat/forbidden-basic"); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // ── Dynamic route with scoped forbidden boundary ────────── + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts + // "should match dynamic route forbidden boundary correctly" — /dynamic renders normally + it("dynamic route index renders correctly", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/forbidden-basic/dynamic"); + expect(res.status).toBe(200); + expect(html).toContain("dynamic"); + }); + + // "should match dynamic route forbidden boundary correctly" — /dynamic/123 renders page + it("dynamic route with valid id renders page", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/forbidden-basic/dynamic/123"); + expect(res.status).toBe(200); + expect(html).toContain("dynamic [id]"); + }); + + // "should match dynamic route forbidden boundary correctly" — /dynamic/403 triggers scoped boundary + it("forbidden() triggers scoped forbidden boundary", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/forbidden-basic/dynamic/403"); + expect(res.status).toBe(403); + expect(html).toContain("dynamic/[id] forbidden"); + }); + + // ── Escalation to root forbidden boundary ────────────────── + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts + // "should escalate forbidden to parent layout if no forbidden boundary present in current layer" + it("escalates to root forbidden when no local boundary exists", async () => { + const { html, res } = await fetchHtml( + baseUrl, + "/nextjs-compat/forbidden-basic/dynamic-no-boundary/403", + ); + expect(res.status).toBe(403); + expect(html).toContain("Root Forbidden"); + }); + + // Normal page in dynamic-no-boundary layout renders correctly + it("dynamic route without forbidden boundary renders normally for valid id", async () => { + const { html, res } = await fetchHtml( + baseUrl, + "/nextjs-compat/forbidden-basic/dynamic-no-boundary/200", + ); + expect(res.status).toBe(200); + expect(html).toContain("dynamic-no-boundary [id]"); + }); +}); diff --git a/tests/nextjs-compat/layout-params.test.ts b/tests/nextjs-compat/layout-params.test.ts new file mode 100644 index 000000000..9991db18b --- /dev/null +++ b/tests/nextjs-compat/layout-params.test.ts @@ -0,0 +1,97 @@ +/** + * Next.js Compatibility Tests: layout-params + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/layout-params/layout-params.test.ts + * + * Tests that layouts at each nesting level receive the correct params: + * - Root/static layouts get empty params + * - Dynamic segment layouts get params up to their level + * - Deepest layout gets all params + * - Catchall layouts get the full catchall array + * - Optional catchall with no segments gets no params + * + * The original Next.js test uses a custom root layout with ShowParams. + * We nest under /nextjs-compat/layout-params/ instead, with an equivalent + * layout hierarchy that produces the same #id-based DOM structure. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchDom } from "../helpers.js"; + +describe("Next.js compat: layout-params", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + // Warm up + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/layout-params/layout-params.test.ts + + describe("basic params", () => { + it("check layout without params gets no params", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/layout-params/base/something/another"); + // Root and lvl1 layouts should have no param divs (they're above dynamic segments) + expect($("#root-layout > div").length).toBe(0); + expect($("#lvl1-layout > div").length).toBe(0); + }); + + it("check layout renders just its params", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/layout-params/base/something/another"); + // lvl2 layout is at [param1] — it should see param1 only + expect($("#lvl2-layout > div").length).toBe(1); + expect($("#lvl2-param1").text()).toBe('"something"'); + }); + + it("check topmost layout renders all params", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/layout-params/base/something/another"); + // lvl3 layout is at [param1]/[param2] — it should see both + expect($("#lvl3-layout > div").length).toBe(2); + expect($("#lvl3-param1").text()).toBe('"something"'); + expect($("#lvl3-param2").text()).toBe('"another"'); + }); + }); + + describe("catchall params", () => { + it("should give catchall params just to last layout", async () => { + const { $ } = await fetchDom( + baseUrl, + "/nextjs-compat/layout-params/catchall/something/another", + ); + // Root layout should have no params + expect($("#root-layout > div").length).toBe(0); + // Catchall layout should see params array + expect($("#lvl2-layout > div").length).toBe(1); + expect($("#lvl2-params").text()).toBe('["something","another"]'); + }); + + it("should give optional catchall params just to last layout", async () => { + const { $ } = await fetchDom( + baseUrl, + "/nextjs-compat/layout-params/optional-catchall/something/another", + ); + // Root layout should have no params + expect($("#root-layout > div").length).toBe(0); + // Optional catchall layout should see params array + expect($("#lvl2-layout > div").length).toBe(1); + expect($("#lvl2-params").text()).toBe('["something","another"]'); + }); + + it("empty optional catchall params won't give params to any layout", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/layout-params/optional-catchall"); + // Root layout should have no params + expect($("#root-layout > div").length).toBe(0); + // Optional catchall layout with no segments should have no params + expect($("#lvl2-layout > div").length).toBe(0); + }); + }); +}); diff --git a/tests/nextjs-compat/metadata-dynamic-routes.test.ts b/tests/nextjs-compat/metadata-dynamic-routes.test.ts new file mode 100644 index 000000000..a81c4f1ee --- /dev/null +++ b/tests/nextjs-compat/metadata-dynamic-routes.test.ts @@ -0,0 +1,141 @@ +/** + * Next.js Compatibility Tests: metadata-dynamic-routes + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts + * + * Tests that metadata file conventions (robots.ts, sitemap.ts, manifest.ts, + * icon.tsx, opengraph-image, etc.) generate correct HTTP responses with + * proper content types and content. + * + * We test against the existing app-basic fixture which already has + * robots.ts, sitemap.ts, manifest.ts, icon.tsx, and apple-icon.png. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchDom } from "../helpers.js"; + +describe("Next.js compat: metadata-dynamic-routes", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + // Warm up + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts + + describe("robots.txt", () => { + it("should handle robots.ts dynamic routes", async () => { + const res = await fetch(`${baseUrl}/robots.txt`); + const text = await res.text(); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/plain"); + expect(text).toContain("User-Agent: *"); + expect(text).toContain("Allow: /"); + expect(text).toContain("Disallow: /private/"); + expect(text).toContain("Sitemap: https://example.com/sitemap.xml"); + }); + }); + + describe("sitemap", () => { + it("should handle sitemap.ts dynamic routes", async () => { + const res = await fetch(`${baseUrl}/sitemap.xml`); + const text = await res.text(); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/xml"); + expect(text).toContain('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'); + expect(text).toContain("<loc>https://example.com</loc>"); + expect(text).toContain("<priority>1</priority>"); + }); + + it("should contain multiple URLs in sitemap", async () => { + const res = await fetch(`${baseUrl}/sitemap.xml`); + const text = await res.text(); + + expect(text).toContain("<loc>https://example.com/about</loc>"); + expect(text).toContain("<loc>https://example.com/blog</loc>"); + }); + + it("should contain changefreq and lastmod in sitemap", async () => { + const res = await fetch(`${baseUrl}/sitemap.xml`); + const text = await res.text(); + + expect(text).toContain("<changefreq>yearly</changefreq>"); + expect(text).toContain("<changefreq>weekly</changefreq>"); + expect(text).toContain("<lastmod>"); + }); + + it("should support alternates in sitemap", async () => { + const res = await fetch(`${baseUrl}/sitemap.xml`); + const text = await res.text(); + + // Check for xhtml:link alternates + expect(text).toContain("xhtml:link"); + expect(text).toContain('hreflang="fr"'); + expect(text).toContain('href="https://example.com/fr"'); + }); + + it("should support images in sitemap", async () => { + const res = await fetch(`${baseUrl}/sitemap.xml`); + const text = await res.text(); + + expect(text).toContain("image:image"); + expect(text).toContain("<image:loc>https://example.com/image.jpg</image:loc>"); + }); + + it("should support videos in sitemap", async () => { + const res = await fetch(`${baseUrl}/sitemap.xml`); + const text = await res.text(); + + expect(text).toContain("video:video"); + expect(text).toContain("<video:title>Homepage Video</video:title>"); + expect(text).toContain( + "<video:content_loc>https://example.com/video.mp4</video:content_loc>", + ); + }); + }); + + describe("manifest", () => { + it("should handle manifest.ts dynamic routes", async () => { + const res = await fetch(`${baseUrl}/manifest.webmanifest`); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/manifest+json"); + expect(json.name).toBe("App Basic"); + expect(json.short_name).toBe("App"); + expect(json.start_url).toBe("/"); + expect(json.display).toBe("standalone"); + }); + }); + + describe("icon", () => { + it("should handle icon.tsx dynamic routes", async () => { + const res = await fetch(`${baseUrl}/icon`); + expect(res.status).toBe(200); + // icon.tsx generates an image + expect(res.headers.get("content-type")).toContain("image/"); + }); + }); + + describe("metadata link tags", () => { + it("should include robots.txt link in HTML head", async () => { + // This verifies the metadata system inserts proper link tags + // when metadata file routes exist + const { $ } = await fetchDom(baseUrl, "/"); + // Check that the page renders without error + expect($.html()).toContain("html"); + }); + }); +}); diff --git a/tests/nextjs-compat/not-found-default.test.ts b/tests/nextjs-compat/not-found-default.test.ts new file mode 100644 index 000000000..ad6776e6b --- /dev/null +++ b/tests/nextjs-compat/not-found-default.test.ts @@ -0,0 +1,60 @@ +/** + * Next.js Compatibility Tests: not-found-default + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/not-found-default/index.test.ts + * + * HTTP-testable subset only: + * - non-existent routes render the default 404 inside the root layout + * - /_not-found returns HTTP 404 + * - grouped routes without their own not-found.tsx fall back to the default 404 + */ + +import path from "node:path"; +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { fetchDom, fetchHtml, startFixtureServer } from "../helpers.js"; + +const FIXTURE_DIR = path.resolve(import.meta.dirname, "../fixtures/app-not-found-default"); + +describe("Next.js compat: not-found-default", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(FIXTURE_DIR, { + appRouter: true, + })); + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/not-found-default/index.test.ts + it("should render default 404 with root layout for non-existent page", async () => { + const { res, $ } = await fetchDom(baseUrl, "/non-existent"); + + expect(res.status).toBe(404); + expect($("html").attr("class")).toBe("root-layout-html"); + expect($("h1").text()).toContain("404"); + expect($.text()).toContain("This page could not be found."); + }); + + it("should return 404 status code for default not-found page", async () => { + const { res } = await fetchHtml(baseUrl, "/_not-found"); + expect(res.status).toBe(404); + }); + + it("should render default not found for group routes if not found is not defined", async () => { + const ok = await fetchDom(baseUrl, "/group-dynamic/123"); + expect(ok.res.status).toBe(200); + expect(ok.$("#page").text()).toBe("group-dynamic [id]"); + + const missing = await fetchDom(baseUrl, "/group-dynamic/404"); + expect(missing.res.status).toBe(404); + expect(missing.$(".group-root-layout").length).toBe(1); + expect(missing.$("h1").text()).toContain("404"); + expect(missing.$.text()).toContain("This page could not be found."); + }); +}); diff --git a/tests/nextjs-compat/optional-catchall-root.test.ts b/tests/nextjs-compat/optional-catchall-root.test.ts new file mode 100644 index 000000000..e8da9bce5 --- /dev/null +++ b/tests/nextjs-compat/optional-catchall-root.test.ts @@ -0,0 +1,59 @@ +/** + * Next.js Compatibility Tests: root-level optional catch-all + * + * Tests that `app/[[...slug]]/page.tsx` at the root correctly handles: + * - Root path `/` with no slug segments (empty params) + * - Deep paths like `/a/b` with multiple slug segments + * - Single segment paths like `/hello` + * + * This is a common Next.js pattern for apps that want a single page component + * to handle all routes. The key edge case is the root `/` where the optional + * catch-all receives undefined/empty slug. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { + APP_OPTIONAL_CATCHALL_ROOT_FIXTURE_DIR, + startFixtureServer, + fetchDom, +} from "../helpers.js"; + +describe("Next.js compat: optional catch-all at root", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_OPTIONAL_CATCHALL_ROOT_FIXTURE_DIR, { + appRouter: true, + })); + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + it("matches root path / with empty slug", async () => { + const { $, res } = await fetchDom(baseUrl, "/"); + expect(res.status).toBe(200); + // At root, slug should be undefined or empty — page renders "__EMPTY__" + expect($("#slug-value").text()).toBe("__EMPTY__"); + }); + + it("matches single segment path", async () => { + const { $, res } = await fetchDom(baseUrl, "/hello"); + expect(res.status).toBe(200); + expect($("#slug-value").text()).toBe("hello"); + expect($("#slug-type").text()).toBe("array"); + expect($("#slug-length").text()).toBe("1"); + }); + + it("matches deep path with multiple segments", async () => { + const { $, res } = await fetchDom(baseUrl, "/a/b/c"); + expect(res.status).toBe(200); + expect($("#slug-value").text()).toBe("a/b/c"); + expect($("#slug-type").text()).toBe("array"); + expect($("#slug-length").text()).toBe("3"); + }); +}); diff --git a/tests/nextjs-compat/rewrites-redirects.test.ts b/tests/nextjs-compat/rewrites-redirects.test.ts new file mode 100644 index 000000000..25568c6ef --- /dev/null +++ b/tests/nextjs-compat/rewrites-redirects.test.ts @@ -0,0 +1,55 @@ +/** + * Next.js Compatibility Tests: rewrites-redirects + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + * + * Covers the two pure-HTTP redirect tests for exotic URL schemes. The full + * Next.js suite is mostly browser navigation, but these redirects are easy to + * validate via fetch and exercise next.config redirect URL normalization. + */ + +import path from "node:path"; +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { startFixtureServer } from "../helpers.js"; + +const FIXTURE_DIR = path.resolve(import.meta.dirname, "../fixtures/app-rewrites-redirects"); + +describe("Next.js compat: rewrites-redirects", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(FIXTURE_DIR, { + appRouter: true, + })); + await fetch(`${baseUrl}/`).catch(() => {}); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + it("redirects to exotic url schemes preserving slashes", async () => { + const response = await fetch(`${baseUrl}/config-redirect-itms-apps-slashes`, { + redirect: "manual", + }); + + expect(response.headers.get("location")).toBe( + "itms-apps://apps.apple.com/de/app/xcode/id497799835?l=en-GB&mt=12", + ); + expect(response.status).toBe(308); + }); + + it("redirects to exotic url schemes without adding unwanted slashes", async () => { + const response = await fetch(`${baseUrl}/config-redirect-itms-apps-no-slashes`, { + redirect: "manual", + }); + + expect(response.headers.get("location")).toBe( + "itms-apps:apps.apple.com/de/app/xcode/id497799835?l=en-GB&mt=12", + ); + expect(response.status).toBe(308); + }); +}); diff --git a/tests/nextjs-compat/rsc-redirect.test.ts b/tests/nextjs-compat/rsc-redirect.test.ts new file mode 100644 index 000000000..38b3f284e --- /dev/null +++ b/tests/nextjs-compat/rsc-redirect.test.ts @@ -0,0 +1,66 @@ +/** + * Next.js Compatibility Tests: rsc-redirect + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts + * + * Tests redirect() behavior from a server component: + * - Document request (HTML) gets 307 redirect + * - RSC request gets 200 with redirect encoded in stream + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchHtml } from "../helpers.js"; + +describe("Next.js compat: rsc-redirect", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + await fetchHtml(baseUrl, "/nextjs-compat/rsc-redirect-test/dest"); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts + // "should get 307 status code for document request" + it("should get 307 status code for document request", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/rsc-redirect-test/origin`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + const location = res.headers.get("location"); + expect(location).toContain("/nextjs-compat/rsc-redirect-test/dest"); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts + // "should get 200 status code for rsc request" + // NOTE: Next.js returns 200 with redirect encoded in RSC stream for client-side routing. + // Vinext currently returns 307 for RSC requests too. This is a known behavioral difference. + // The client-side router in @vitejs/plugin-rsc handles the HTTP redirect. + it("RSC request also gets redirect (vinext uses HTTP redirect for both)", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/rsc-redirect-test/origin`, { + redirect: "manual", + headers: { + RSC: "1", + Accept: "text/x-component", + }, + }); + // Vinext uses HTTP 307 redirect for both document and RSC requests + expect(res.status).toBe(307); + const location = res.headers.get("location"); + expect(location).toContain("/nextjs-compat/rsc-redirect-test/dest"); + }); + + // Additional: following the redirect lands at the dest page + it("redirect leads to destination page", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/rsc-redirect-test/dest"); + expect(res.status).toBe(200); + expect(html).toContain("Destination"); + }); +}); diff --git a/tests/nextjs-compat/searchparams-static-bailout.test.ts b/tests/nextjs-compat/searchparams-static-bailout.test.ts new file mode 100644 index 000000000..f36d76f29 --- /dev/null +++ b/tests/nextjs-compat/searchparams-static-bailout.test.ts @@ -0,0 +1,72 @@ +/** + * Next.js Compatibility Tests: searchparams-static-bailout + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts + * + * Tests that searchParams are correctly passed to page components: + * - Server components can await searchParams + * - Client components can use() searchParams + * - SearchParams passed from server component to client component work + * - Pages that don't use searchParams still render correctly + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchDom } from "../helpers.js"; + +describe("Next.js compat: searchparams-static-bailout", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + // Warm up + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts + + describe("server component", () => { + it("should render searchParams in server component page", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/searchparams-server?search=hello"); + expect($("h1").text()).toBe("Parameter: hello"); + }); + + it("should render page that doesn't use searchParams", async () => { + const { $ } = await fetchDom( + baseUrl, + "/nextjs-compat/searchparams-server-no-use?search=hello", + ); + expect($("h1").text()).toBe("No searchParams used"); + }); + }); + + describe("client component", () => { + it("should render searchParams in client component page", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/searchparams-client?search=hello"); + expect($("h1").text()).toBe("Parameter: hello"); + }); + + it("should render searchParams passed from server to client component", async () => { + const { $ } = await fetchDom( + baseUrl, + "/nextjs-compat/searchparams-client-passthrough?search=hello", + ); + expect($("h1").text()).toBe("Parameter: hello"); + }); + + it("should render page that doesn't use searchParams", async () => { + const { $ } = await fetchDom( + baseUrl, + "/nextjs-compat/searchparams-client-no-use?search=hello", + ); + expect($("h1").text()).toBe("No searchParams used"); + }); + }); +}); diff --git a/tests/nextjs-compat/simple-routes.test.ts b/tests/nextjs-compat/simple-routes.test.ts new file mode 100644 index 000000000..ab8462d90 --- /dev/null +++ b/tests/nextjs-compat/simple-routes.test.ts @@ -0,0 +1,52 @@ +/** + * Next.js Compatibility Tests: app-simple-routes + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-simple-routes/app-simple-routes.test.ts + * + * Tests route handlers with dot-separated path segments: + * - /api/node.json → route handler returns { pathname } + * - /api/edge.json → route handler with runtime='edge' + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer } from "../helpers.js"; + +describe("Next.js compat: app-simple-routes", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + // Warm up with a regular page + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-simple-routes/app-simple-routes.test.ts + // "renders a node route" + it("renders a node route with dot in path", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/api/node.json`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + pathname: "/nextjs-compat/api/node.json", + }); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-simple-routes/app-simple-routes.test.ts + // "renders a edge route" + it("renders an edge route with dot in path", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/api/edge.json`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + pathname: "/nextjs-compat/api/edge.json", + }); + }); +}); diff --git a/tests/nextjs-compat/static-generation-status.test.ts b/tests/nextjs-compat/static-generation-status.test.ts new file mode 100644 index 000000000..6716ba384 --- /dev/null +++ b/tests/nextjs-compat/static-generation-status.test.ts @@ -0,0 +1,63 @@ +/** + * Next.js Compatibility Tests: static-generation-status + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/static-generation-status/index.test.ts + * + * Tests HTTP status codes from notFound(), redirect(), and permanentRedirect(): + * - notFound() → 404 + * - redirect() → 307 + * - redirect() from client component (SSR) → 307 + * - permanentRedirect() → 308 + * - Non-existent route → 404 + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer } from "../helpers.js"; + +describe("Next.js compat: static-generation-status", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + // Warm up + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/static-generation-status/index.test.ts + it("should render the page using notFound with status 404", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/status-not-found`); + expect(res.status).toBe(404); + }); + + it("should render the page using redirect with status 307", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/status-redirect`, { redirect: "manual" }); + expect(res.status).toBe(307); + }); + + it("should render the client page using redirect with status 307", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/status-redirect-client`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + }); + + it("should respond with 308 status code if permanent flag is set", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/status-redirect-permanent`, { + redirect: "manual", + }); + expect(res.status).toBe(308); + }); + + it("should render the non existed route redirect with status 404", async () => { + const res = await fetch(`${baseUrl}/does-not-exist-at-all`); + expect(res.status).toBe(404); + }); +}); diff --git a/tests/nextjs-compat/unauthorized.test.ts b/tests/nextjs-compat/unauthorized.test.ts new file mode 100644 index 000000000..0bd96a635 --- /dev/null +++ b/tests/nextjs-compat/unauthorized.test.ts @@ -0,0 +1,72 @@ +/** + * Next.js Compatibility Tests: unauthorized + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts + * + * Tests unauthorized() boundary behavior at the HTTP/SSR level: + * - unauthorized() in a dynamic route triggers the scoped unauthorized.tsx boundary + * - Normal dynamic route params render correctly + * - When no local unauthorized boundary exists, escalates to root unauthorized + * - Response status is 401 for unauthorized pages + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchHtml } from "../helpers.js"; + +describe("Next.js compat: unauthorized", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + await fetchHtml(baseUrl, "/nextjs-compat/unauthorized-basic"); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // ── Dynamic route with scoped unauthorized boundary ────────── + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts + it("dynamic route index renders correctly", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/unauthorized-basic/dynamic"); + expect(res.status).toBe(200); + expect(html).toContain("dynamic"); + }); + + it("dynamic route with valid id renders page", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/unauthorized-basic/dynamic/123"); + expect(res.status).toBe(200); + expect(html).toContain("dynamic [id]"); + }); + + it("unauthorized() triggers scoped unauthorized boundary", async () => { + const { html, res } = await fetchHtml(baseUrl, "/nextjs-compat/unauthorized-basic/dynamic/401"); + expect(res.status).toBe(401); + expect(html).toContain("dynamic/[id] unauthorized"); + }); + + // ── Escalation to root unauthorized boundary ────────────────── + + it("escalates to root unauthorized when no local boundary exists", async () => { + const { html, res } = await fetchHtml( + baseUrl, + "/nextjs-compat/unauthorized-basic/dynamic-no-boundary/401", + ); + expect(res.status).toBe(401); + expect(html).toContain("Root Unauthorized"); + }); + + it("dynamic route without unauthorized boundary renders normally for valid id", async () => { + const { html, res } = await fetchHtml( + baseUrl, + "/nextjs-compat/unauthorized-basic/dynamic-no-boundary/200", + ); + expect(res.status).toBe(200); + expect(html).toContain("dynamic-no-boundary [id]"); + }); +}); diff --git a/tests/nextjs-compat/use-cache-route-handler-only.test.ts b/tests/nextjs-compat/use-cache-route-handler-only.test.ts new file mode 100644 index 000000000..010b8c60e --- /dev/null +++ b/tests/nextjs-compat/use-cache-route-handler-only.test.ts @@ -0,0 +1,55 @@ +/** + * Next.js Compatibility Tests: use-cache-route-handler-only + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-route-handler-only/use-cache-route-handler-only.test.ts + * + * Tests that App Router route handlers can use function-level "use cache" + * without any pages in the app, and that revalidatePath() invalidates the + * cached route-handler response. + */ + +import path from "node:path"; +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { startFixtureServer, fetchJson } from "../helpers.js"; + +const FIXTURE_DIR = path.resolve( + import.meta.dirname, + "../fixtures/app-use-cache-route-handler-only", +); + +describe("Next.js compat: use-cache-route-handler-only", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(FIXTURE_DIR, { + appRouter: true, + })); + await fetch(`${baseUrl}/node`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-route-handler-only/use-cache-route-handler-only.test.ts + it("should cache results in node route handlers", async () => { + const { data, res } = await fetchJson(baseUrl, "/node"); + expect(res.status).toBe(200); + expect(data.date1).toBe(data.date2); + }); + + it("should be able to revalidate prerendered route handlers", async () => { + const { data: initial, res: res1 } = await fetchJson(baseUrl, "/node"); + expect(res1.status).toBe(200); + + const revalidateRes = await fetch(`${baseUrl}/revalidate`, { method: "POST" }); + expect(revalidateRes.status).toBe(204); + + const { data: next, res: res2 } = await fetchJson(baseUrl, "/node"); + expect(res2.status).toBe(200); + expect(initial.date1).not.toBe(next.date1); + expect(next.date1).toBe(next.date2); + }); +}); diff --git a/tests/nextjs-compat/use-params.test.ts b/tests/nextjs-compat/use-params.test.ts new file mode 100644 index 000000000..d3f36973d --- /dev/null +++ b/tests/nextjs-compat/use-params.test.ts @@ -0,0 +1,49 @@ +/** + * Next.js Compatibility Tests: use-params + * + * Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/use-params.test.ts + * + * Tests the useParams() hook in client components during SSR: + * - Single dynamic param: /[id] + * - Nested dynamic params: /[id]/[id2] + * - Catch-all params: /[...path] + */ + +import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; +import type { ViteDevServer } from "vite-plus"; +import { APP_FIXTURE_DIR, startFixtureServer, fetchDom } from "../helpers.js"; + +describe("Next.js compat: use-params", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { + appRouter: true, + })); + // Warm up + await fetch(`${baseUrl}/`); + }, 60_000); + + afterAll(async () => { + await server?.close(); + }); + + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/use-params.test.ts + + it("should work for single dynamic param", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/use-params/a"); + expect($("#param-id").text()).toBe("a"); + }); + + it("should work for nested dynamic params", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/use-params/a/b"); + expect($("#param-id").text()).toBe("a"); + expect($("#param-id2").text()).toBe("b"); + }); + + it("should work for catch all params", async () => { + const { $ } = await fetchDom(baseUrl, "/nextjs-compat/use-params/catchall/a/b/c/d/e/f/g"); + expect($("#params").text()).toBe('["a","b","c","d","e","f","g"]'); + }); +});