From 1d6c140430a824e4e3c8d014523adbbc8058bf77 Mon Sep 17 00:00:00 2001 From: MD YUNUS Date: Sun, 8 Mar 2026 17:28:53 +0530 Subject: [PATCH 1/8] test: add snapshot tests for entry template generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 7 snapshot tests covering all 5 virtual entry module generators: App Router (imported directly — already exported standalone): - generateRscEntry: minimal routes, with middleware, with config - generateSsrEntry - generateBrowserEntry Pages Router (tested via pluginContainer.load on virtual modules): - virtual:vinext-server-entry - virtual:vinext-client-entry These snapshots lock down the generated code so subsequent extraction refactors (moving generators into entries/ modules) can be verified against a known baseline. Ref #253 --- .../entry-templates.test.ts.snap | 9072 +++++++++++++++++ tests/entry-templates.test.ts | 193 + 2 files changed, 9265 insertions(+) create mode 100644 tests/__snapshots__/entry-templates.test.ts.snap create mode 100644 tests/entry-templates.test.ts diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap new file mode 100644 index 00000000..51a3c1c2 --- /dev/null +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -0,0 +1,9072 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`App Router entry templates > generateBrowserEntry snapshot 1`] = ` +" +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + encodeReply, + createTemporaryReferenceSet, +} from "@vitejs/plugin-rsc/browser"; +import { hydrateRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { setClientParams, toRscUrl, getPrefetchCache, getPrefetchedUrls, PREFETCH_CACHE_TTL } from "next/navigation"; + +let reactRoot; + +/** + * Convert the embedded RSC chunks back to a ReadableStream. + * Each chunk is a text string that needs to be encoded back to Uint8Array. + */ +function chunksToReadableStream(chunks) { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } + }); +} + +/** + * Create a ReadableStream from progressively-embedded RSC chunks. + * The server injects RSC data as + * + * Chunks are embedded as text strings (not byte arrays) since the RSC flight + * protocol is text-based. The browser entry encodes them back to Uint8Array. + * This is ~3x more compact than the previous byte-array format. + */ +function createRscEmbedTransform(embedStream) { + const reader = embedStream.getReader(); + const _decoder = new TextDecoder(); + let done = false; + let pendingChunks = []; + let reading = false; + + // Fix invalid preload "as" values in RSC Flight hint lines before + // they reach the client. React Flight emits HL hints with + // as="stylesheet" for CSS, but the HTML spec requires as="style" + // for . The fixPreloadAs() below only fixes the + // server-rendered HTML stream; this fixes the raw Flight data that + // gets embedded as __VINEXT_RSC_CHUNKS__ and processed client-side. + function fixFlightHints(text) { + // Flight hint format: :HL["url","stylesheet"] or with options + return text.replace(/(\\d+:HL\\[.*?),"stylesheet"(\\]|,)/g, '$1,"style"$2'); + } + + // Start reading RSC chunks in the background, accumulating them as text strings. + // The RSC flight protocol is text-based, so decoding to strings and embedding + // as JSON strings is ~3x more compact than the byte-array format. + async function pumpReader() { + if (reading) return; + reading = true; + try { + while (true) { + const result = await reader.read(); + if (result.done) { + done = true; + break; + } + const text = _decoder.decode(result.value, { stream: true }); + pendingChunks.push(fixFlightHints(text)); + } + } catch (err) { + if (process.env.NODE_ENV !== "production") { + console.warn("[vinext] RSC embed stream read error:", err); + } + done = true; + } + reading = false; + } + + // Fire off the background reader immediately + const pumpPromise = pumpReader(); + + return { + /** + * Flush any accumulated RSC chunks as "; + } + return scripts; + }, + + /** + * Wait for the RSC stream to fully complete and return any final + * script tags plus the closing signal. + */ + async finalize() { + await pumpPromise; + let scripts = this.flush(); + // Signal that all RSC chunks have been sent. + // Params are already embedded in — no need to include here. + scripts += ""; + return scripts; + }, + }; +} + +/** + * Render the RSC stream to HTML. + * + * @param rscStream - The RSC payload stream from the RSC environment + * @param navContext - Navigation context for client component SSR hooks. + * "use client" components like those using usePathname() need the current + * request URL during SSR, and they run in this SSR environment (separate + * from the RSC environment where the context was originally set). + * @param fontData - Font links and styles collected from the RSC environment. + * Fonts are loaded during RSC rendering (when layout calls Geist() etc.), + * and the data needs to be passed to SSR since they're separate module instances. + */ +export async function handleSsr(rscStream, navContext, fontData) { + // Wrap in a navigation ALS scope for per-request isolation in the SSR + // environment. The SSR environment has separate module instances from RSC, + // so it needs its own ALS scope. + return _runWithNavCtx(async () => { + // Set navigation context so hooks like usePathname() work during SSR + // of "use client" components + if (navContext) { + setNavigationContext(navContext); + } + + // Clear any stale callbacks from previous requests + const { clearServerInsertedHTML, flushServerInsertedHTML, useServerInsertedHTML: _addInsertedHTML } = await import("next/navigation"); + clearServerInsertedHTML(); + + try { + // Tee the RSC stream - one for SSR rendering, one for embedding in HTML. + // This ensures the browser uses the SAME RSC payload for hydration that + // was used to generate the HTML, avoiding hydration mismatches (React #418). + const [ssrStream, embedStream] = rscStream.tee(); + + // Create the progressive RSC embed helper — it reads the embed stream + // in the background and provides script tags to inject into the HTML stream. + const rscEmbed = createRscEmbedTransform(embedStream); + + // Deserialize RSC stream back to React VDOM. + // IMPORTANT: Do NOT await this — createFromReadableStream returns a thenable + // that React's renderToReadableStream can consume progressively. By passing + // the unresolved thenable, React will render Suspense fallbacks (loading.tsx) + // immediately in the HTML shell, then stream in resolved content as RSC + // chunks arrive. Awaiting here would block until all async server components + // complete, collapsing the streaming behavior. + // Lazily create the Flight root inside render so React's hook dispatcher is set + // (avoids React 19 dev-mode resolveErrorDev() crash). VinextFlightRoot returns + // a thenable (not a ReactNode), which React 19 consumes via its internal + // thenable-as-child suspend/resume behavior. This matches Next.js's approach. + let flightRoot; + function VinextFlightRoot() { + if (!flightRoot) { + flightRoot = createFromReadableStream(ssrStream); + } + return flightRoot; + } + const root = _ssrCE(VinextFlightRoot); + + // Wrap with ServerInsertedHTMLContext.Provider so libraries that use + // useContext(ServerInsertedHTMLContext) (Apollo Client, styled-components, + // etc.) get a working callback registration function during SSR. + // The provider value is useServerInsertedHTML — same function that direct + // callers use — so both paths push to the same ALS-backed callback array. + const ssrRoot = ServerInsertedHTMLContext + ? _ssrCE(ServerInsertedHTMLContext.Provider, { value: _addInsertedHTML }, root) + : root; + + // Get the bootstrap script content for the browser entry + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent("index"); + + // djb2 hash for digest generation in the SSR environment. + // Matches the RSC environment's __errorDigest function. + function ssrErrorDigest(str) { + let hash = 5381; + for (let i = str.length - 1; i >= 0; i--) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return (hash >>> 0).toString(); + } + + // Render HTML (streaming SSR) + // useServerInsertedHTML callbacks are registered during this render. + // The onError callback preserves the digest for Next.js navigation errors + // (redirect, notFound, forbidden, unauthorized) thrown inside Suspense + // boundaries during RSC streaming. Without this, React's default onError + // returns undefined and the digest is lost in the $RX() call, preventing + // client-side error boundaries from identifying the error type. + // In production, non-navigation errors also get a digest hash so they + // can be correlated with server logs without leaking details to clients. + const htmlStream = await renderToReadableStream(ssrRoot, { + bootstrapScriptContent, + onError(error) { + if (error && typeof error === "object" && "digest" in error) { + return String(error.digest); + } + // In production, generate a digest hash for non-navigation errors + if (process.env.NODE_ENV === "production" && error) { + const msg = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? (error.stack || "") : ""; + return ssrErrorDigest(msg + stack); + } + return undefined; + }, + }); + + + // Flush useServerInsertedHTML callbacks (CSS-in-JS style injection) + const insertedElements = flushServerInsertedHTML(); + + // Render the inserted elements to HTML strings + const { Fragment } = await import("react"); + let insertedHTML = ""; + for (const el of insertedElements) { + try { + insertedHTML += renderToStaticMarkup(_ssrCE(Fragment, null, el)); + } catch { + // Skip elements that can't be rendered + } + } + + // Escape HTML attribute values (defense-in-depth for font URLs/types). + function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); } + + // Build font HTML from data passed from RSC environment + // (Fonts are loaded during RSC rendering, and RSC/SSR are separate module instances) + let fontHTML = ""; + if (fontData) { + if (fontData.links && fontData.links.length > 0) { + for (const url of fontData.links) { + fontHTML += '\\n'; + } + } + // Emit for local font files + if (fontData.preloads && fontData.preloads.length > 0) { + for (const preload of fontData.preloads) { + fontHTML += '\\n'; + } + } + if (fontData.styles && fontData.styles.length > 0) { + fontHTML += '\\n'; + } + } + + // Extract client entry module URL from bootstrapScriptContent to emit + // a hint. The RSC plugin formats bootstrap + // content as: import("URL") — we extract the URL so the browser can + // speculatively fetch and parse the JS module while still processing + // the HTML body, instead of waiting until it reaches the inline script. + let modulePreloadHTML = ""; + if (bootstrapScriptContent) { + const m = bootstrapScriptContent.match(/import\\("([^"]+)"\\)/); + if (m && m[1]) { + modulePreloadHTML = '\\n'; + } + } + + // Head-injected HTML: server-inserted HTML, font HTML, route params, + // and modulepreload hints. + // RSC payload is now embedded progressively via script tags in the body stream. + // Params are embedded eagerly in so they're available before client + // hydration starts, avoiding the need for polling on the client. + const paramsScript = ''; + const injectHTML = paramsScript + modulePreloadHTML + insertedHTML + fontHTML; + + // Inject the collected HTML before and progressively embed RSC + // chunks as script tags throughout the HTML body stream. + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let injected = false; + + // Fix invalid preload "as" values in server-rendered HTML. + // React Fizz emits for CSS, + // but the HTML spec requires as="style" for . + // Note: fixFlightHints() in createRscEmbedTransform handles the + // complementary case — fixing the raw Flight stream data before + // it's embedded as __VINEXT_RSC_CHUNKS__ for client-side processing. + // See: https://html.spec.whatwg.org/multipage/links.html#link-type-preload + function fixPreloadAs(html) { + // Match in any attribute order + return html.replace(/]*\\srel="preload")[^>]*>/g, function(tag) { + return tag.replace(' as="stylesheet"', ' as="style"'); + }); + } + + // Tick-buffered RSC script injection. + // + // React's renderToReadableStream (Fizz) flushes chunks synchronously + // within one microtask — all chunks from a single flushCompletedQueues + // call arrive in the same macrotask. We buffer HTML chunks as they + // arrive, then use setTimeout(0) to defer emitting them plus any + // accumulated RSC scripts to the next macrotask. This guarantees we + // never inject '); + } + if (m) { + // Always inject shared chunks (framework, vinext runtime, entry) and + // page-specific chunks. The manifest maps module file paths to their + // associated JS/CSS assets. + // + // For page-specific injection, the module IDs may be absolute paths + // while the manifest uses relative paths. Try both the original ID + // and a suffix match to find the correct manifest entry. + var allFiles = []; + + if (moduleIds && moduleIds.length > 0) { + // Collect assets for the requested page modules + for (var mi = 0; mi < moduleIds.length; mi++) { + var id = moduleIds[mi]; + var files = m[id]; + if (!files) { + // Absolute path didn't match — try matching by suffix. + // Manifest keys are relative (e.g. "pages/about.tsx") while + // moduleIds may be absolute (e.g. "/home/.../pages/about.tsx"). + for (var mk in m) { + if (id.endsWith("/" + mk) || id === mk) { + files = m[mk]; + break; + } + } + } + if (files) { + for (var fi = 0; fi < files.length; fi++) allFiles.push(files[fi]); + } + } + + // Also inject shared chunks that every page needs: framework, + // vinext runtime, and the entry bootstrap. These are identified + // by scanning all manifest values for chunk filenames containing + // known prefixes. + for (var key in m) { + var vals = m[key]; + if (!vals) continue; + for (var vi = 0; vi < vals.length; vi++) { + var file = vals[vi]; + var basename = file.split("/").pop() || ""; + if ( + basename.startsWith("framework-") || + basename.startsWith("vinext-") || + basename.includes("vinext-client-entry") || + basename.includes("vinext-app-browser-entry") + ) { + allFiles.push(file); + } + } + } + } else { + // No specific modules — include all assets from manifest + for (var akey in m) { + var avals = m[akey]; + if (avals) { + for (var ai = 0; ai < avals.length; ai++) allFiles.push(avals[ai]); + } + } + } + + for (var ti = 0; ti < allFiles.length; ti++) { + var tf = allFiles[ti]; + // Normalize: Vite's SSR manifest values include a leading '/' + // (from base path), but we prepend '/' ourselves when building + // href/src attributes. Strip any existing leading slash to avoid + // producing protocol-relative URLs like "//assets/chunk.js". + // This also ensures consistent keys for the seen-set dedup and + // lazySet.has() checks (which use values without leading slash). + if (tf.charAt(0) === '/') tf = tf.slice(1); + if (seen.has(tf)) continue; + seen.add(tf); + if (tf.endsWith(".css")) { + tags.push(''); + } else if (tf.endsWith(".js")) { + // Skip lazy chunks — they are behind dynamic import() boundaries + // (React.lazy, next/dynamic) and should only be fetched on demand. + if (lazySet && lazySet.has(tf)) continue; + tags.push(''); + tags.push(''); + } + } + } + return tags.join("\\n "); +} + +// i18n helpers +function extractLocale(url) { + if (!i18nConfig) return { locale: undefined, url, hadPrefix: false }; + const pathname = url.split("?")[0]; + const parts = pathname.split("/").filter(Boolean); + const query = url.includes("?") ? url.slice(url.indexOf("?")) : ""; + if (parts.length > 0 && i18nConfig.locales.includes(parts[0])) { + const locale = parts[0]; + const rest = "/" + parts.slice(1).join("/"); + return { locale, url: (rest || "/") + query, hadPrefix: true }; + } + return { locale: i18nConfig.defaultLocale, url, hadPrefix: false }; +} + +function detectLocaleFromHeaders(headers) { + if (!i18nConfig) return null; + const acceptLang = headers.get("accept-language"); + if (!acceptLang) return null; + const langs = acceptLang.split(",").map(function(part) { + const pieces = part.trim().split(";"); + const q = pieces[1] ? parseFloat(pieces[1].replace("q=", "")) : 1; + return { lang: pieces[0].trim().toLowerCase(), q: q }; + }).sort(function(a, b) { return b.q - a.q; }); + for (let k = 0; k < langs.length; k++) { + const lang = langs[k].lang; + for (let j = 0; j < i18nConfig.locales.length; j++) { + if (i18nConfig.locales[j].toLowerCase() === lang) return i18nConfig.locales[j]; + } + const prefix = lang.split("-")[0]; + for (let j = 0; j < i18nConfig.locales.length; j++) { + const loc = i18nConfig.locales[j].toLowerCase(); + if (loc === prefix || loc.startsWith(prefix + "-")) return i18nConfig.locales[j]; + } + } + return null; +} + +function parseCookieLocaleFromHeader(cookieHeader) { + if (!i18nConfig || !cookieHeader) return null; + const match = cookieHeader.match(/(?:^|;\\s*)NEXT_LOCALE=([^;]*)/); + if (!match) return null; + var value; + try { value = decodeURIComponent(match[1].trim()); } catch (e) { return null; } + if (i18nConfig.locales.indexOf(value) !== -1) return value; + return null; +} + +function parseCookies(cookieHeader) { + const cookies = {}; + if (!cookieHeader) return cookies; + for (const part of cookieHeader.split(";")) { + const [key, ...rest] = part.split("="); + if (key) cookies[key.trim()] = rest.join("=").trim(); + } + return cookies; +} + +// Lightweight req/res facade for getServerSideProps and API routes. +// Next.js pages expect ctx.req/ctx.res with Node-like shapes. +function createReqRes(request, url, query, body) { + const headersObj = {}; + for (const [k, v] of request.headers) headersObj[k.toLowerCase()] = v; + + const req = { + method: request.method, + url: url, + headers: headersObj, + query: query, + body: body, + cookies: parseCookies(request.headers.get("cookie")), + }; + + let resStatusCode = 200; + const resHeaders = {}; + // set-cookie needs array support (multiple Set-Cookie headers are common) + const setCookieHeaders = []; + let resBody = null; + let ended = false; + let resolveResponse; + const responsePromise = new Promise(function(r) { resolveResponse = r; }); + + const res = { + get statusCode() { return resStatusCode; }, + set statusCode(code) { resStatusCode = code; }, + writeHead: function(code, headers) { + resStatusCode = code; + if (headers) { + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === "set-cookie") { + if (Array.isArray(v)) { for (const c of v) setCookieHeaders.push(c); } + else { setCookieHeaders.push(v); } + } else { + resHeaders[k] = v; + } + } + } + return res; + }, + setHeader: function(name, value) { + if (name.toLowerCase() === "set-cookie") { + if (Array.isArray(value)) { for (const c of value) setCookieHeaders.push(c); } + else { setCookieHeaders.push(value); } + } else { + resHeaders[name.toLowerCase()] = value; + } + return res; + }, + getHeader: function(name) { + if (name.toLowerCase() === "set-cookie") return setCookieHeaders.length > 0 ? setCookieHeaders : undefined; + return resHeaders[name.toLowerCase()]; + }, + end: function(data) { + if (ended) return; + ended = true; + if (data !== undefined && data !== null) resBody = data; + const h = new Headers(resHeaders); + for (const c of setCookieHeaders) h.append("set-cookie", c); + resolveResponse(new Response(resBody, { status: resStatusCode, headers: h })); + }, + status: function(code) { resStatusCode = code; return res; }, + json: function(data) { + resHeaders["content-type"] = "application/json"; + res.end(JSON.stringify(data)); + }, + send: function(data) { + if (typeof data === "object" && data !== null) { res.json(data); } + else { if (!resHeaders["content-type"]) resHeaders["content-type"] = "text/plain"; res.end(String(data)); } + }, + redirect: function(statusOrUrl, url2) { + if (typeof statusOrUrl === "string") { res.writeHead(307, { Location: statusOrUrl }); } + else { res.writeHead(statusOrUrl, { Location: url2 }); } + res.end(); + }, + getHeaders: function() { + var h = Object.assign({}, resHeaders); + if (setCookieHeaders.length > 0) h["set-cookie"] = setCookieHeaders; + return h; + }, + get headersSent() { return ended; }, + }; + + return { req, res, responsePromise }; +} + +/** + * Read request body as text with a size limit. + * Throws if the body exceeds maxBytes. This prevents DoS via chunked + * transfer encoding where Content-Length is absent or spoofed. + */ +async function readBodyWithLimit(request, maxBytes) { + if (!request.body) return ""; + var reader = request.body.getReader(); + var decoder = new TextDecoder(); + var chunks = []; + var totalSize = 0; + for (;;) { + var result = await reader.read(); + if (result.done) break; + totalSize += result.value.byteLength; + if (totalSize > maxBytes) { + reader.cancel(); + throw new Error("Request body too large"); + } + chunks.push(decoder.decode(result.value, { stream: true })); + } + chunks.push(decoder.decode()); + return chunks.join(""); +} + +export async function renderPage(request, url, manifest) { + const localeInfo = extractLocale(url); + const locale = localeInfo.locale; + const routeUrl = localeInfo.url; + const cookieHeader = request.headers.get("cookie") || ""; + + // i18n redirect: check NEXT_LOCALE cookie first, then Accept-Language + if (i18nConfig && !localeInfo.hadPrefix) { + const cookieLocale = parseCookieLocaleFromHeader(cookieHeader); + if (cookieLocale && cookieLocale !== i18nConfig.defaultLocale) { + return new Response(null, { status: 307, headers: { Location: "/" + cookieLocale + routeUrl } }); + } + if (!cookieLocale && i18nConfig.localeDetection !== false) { + const detected = detectLocaleFromHeaders(request.headers); + if (detected && detected !== i18nConfig.defaultLocale) { + return new Response(null, { status: 307, headers: { Location: "/" + detected + routeUrl } }); + } + } + } + + const match = matchRoute(routeUrl, pageRoutes); + if (!match) { + return new Response("

404 - Page not found

", + { status: 404, headers: { "Content-Type": "text/html" } }); + } + + const { route, params } = match; + return runWithRouterState(() => + runWithHeadState(() => + _runWithCacheState(() => + runWithPrivateCache(() => + runWithFetchCache(async () => { + try { + if (typeof setSSRContext === "function") { + setSSRContext({ + pathname: routeUrl.split("?")[0], + query: { ...params, ...parseQuery(routeUrl) }, + asPath: routeUrl, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, + }); + } + + if (i18nConfig) { + globalThis.__VINEXT_LOCALE__ = locale; + globalThis.__VINEXT_LOCALES__ = i18nConfig.locales; + globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale; + } + + const pageModule = route.module; + const PageComponent = pageModule.default; + if (!PageComponent) { + return new Response("Page has no default export", { status: 500 }); + } + + // Handle getStaticPaths for dynamic routes + if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) { + const pathsResult = await pageModule.getStaticPaths({ + locales: i18nConfig ? i18nConfig.locales : [], + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : "", + }); + const fallback = pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false; + + if (fallback === false) { + const paths = pathsResult && pathsResult.paths ? pathsResult.paths : []; + const isValidPath = paths.some(function(p) { + return Object.entries(p.params).every(function(entry) { + var key = entry[0], val = entry[1]; + var actual = params[key]; + if (Array.isArray(val)) { + return Array.isArray(actual) && val.join("/") === actual.join("/"); + } + return String(val) === String(actual); + }); + }); + if (!isValidPath) { + return new Response("

404 - Page not found

", + { status: 404, headers: { "Content-Type": "text/html" } }); + } + } + } + + let pageProps = {}; + var gsspRes = null; + if (typeof pageModule.getServerSideProps === "function") { + const { req, res, responsePromise } = createReqRes(request, routeUrl, parseQuery(routeUrl), undefined); + const ctx = { + params, req, res, + query: parseQuery(routeUrl), + resolvedUrl: routeUrl, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, + }; + const result = await pageModule.getServerSideProps(ctx); + // If gSSP called res.end() directly (short-circuit), return that response. + if (res.headersSent) { + return await responsePromise; + } + if (result && result.props) pageProps = result.props; + if (result && result.redirect) { + var gsspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307); + return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); + } + if (result && result.notFound) { + return new Response("404", { status: 404 }); + } + // Preserve the res object so headers/status/cookies set by gSSP + // can be merged into the final HTML response. + gsspRes = res; + } + // Build font Link header early so it's available for ISR cached responses too. + // Font preloads are module-level state populated at import time and persist across requests. + var _fontLinkHeader = ""; + var _allFp = []; + try { + var _fpGoogle = typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : []; + var _fpLocal = typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : []; + _allFp = _fpGoogle.concat(_fpLocal); + if (_allFp.length > 0) { + _fontLinkHeader = _allFp.map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }).join(", "); + } + } catch (e) { /* font preloads not available */ } + + let isrRevalidateSeconds = null; + if (typeof pageModule.getStaticProps === "function") { + const pathname = routeUrl.split("?")[0]; + const cacheKey = "pages:" + (pathname === "/" ? "/" : pathname.replace(/\\/$/, "")); + const cached = await isrGet(cacheKey); + + if (cached && !cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { + var _hitHeaders = { + "Content-Type": "text/html", "X-Vinext-Cache": "HIT", + "Cache-Control": "s-maxage=" + (cached.value.value.revalidate || 60) + ", stale-while-revalidate", + }; + if (_fontLinkHeader) _hitHeaders["Link"] = _fontLinkHeader; + return new Response(cached.value.value.html, { status: 200, headers: _hitHeaders }); + } + + if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { + triggerBackgroundRegeneration(cacheKey, async function() { + const freshResult = await pageModule.getStaticProps({ params }); + if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { + await isrSet(cacheKey, { kind: "PAGES", html: cached.value.value.html, pageData: freshResult.props, headers: undefined, status: undefined }, freshResult.revalidate); + } + }); + var _staleHeaders = { + "Content-Type": "text/html", "X-Vinext-Cache": "STALE", + "Cache-Control": "s-maxage=0, stale-while-revalidate", + }; + if (_fontLinkHeader) _staleHeaders["Link"] = _fontLinkHeader; + return new Response(cached.value.value.html, { status: 200, headers: _staleHeaders }); + } + + const ctx = { + params, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, + }; + const result = await pageModule.getStaticProps(ctx); + if (result && result.props) pageProps = result.props; + if (result && result.redirect) { + var gspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307); + return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); + } + if (result && result.notFound) { + return new Response("404", { status: 404 }); + } + if (typeof result.revalidate === "number" && result.revalidate > 0) { + isrRevalidateSeconds = result.revalidate; + } + } + + let element; + if (AppComponent) { + element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + } else { + element = React.createElement(PageComponent, pageProps); + } + element = wrapWithRouterContext(element); + + if (typeof resetSSRHead === "function") resetSSRHead(); + if (typeof flushPreloads === "function") await flushPreloads(); + + const ssrHeadHTML = typeof getSSRHeadHTML === "function" ? getSSRHeadHTML() : ""; + + // Collect SSR font data (Google Font links, font preloads, font-face styles) + var fontHeadHTML = ""; + function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); } + try { + var fontLinks = typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : []; + for (var fl of fontLinks) { fontHeadHTML += '\\n '; } + } catch (e) { /* next/font/google not used */ } + // Emit for all font files (reuse _allFp collected earlier for Link header) + for (var fp of _allFp) { fontHeadHTML += '\\n '; } + try { + var allFontStyles = []; + if (typeof _getSSRFontStylesGoogle === "function") allFontStyles.push(..._getSSRFontStylesGoogle()); + if (typeof _getSSRFontStylesLocal === "function") allFontStyles.push(..._getSSRFontStylesLocal()); + if (allFontStyles.length > 0) { fontHeadHTML += '\\n '; } + } catch (e) { /* font styles not available */ } + + const pageModuleIds = route.filePath ? [route.filePath] : []; + const assetTags = collectAssetTags(manifest, pageModuleIds); + const nextDataPayload = { + props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, isFallback: false, + }; + if (i18nConfig) { + nextDataPayload.locale = locale; + nextDataPayload.locales = i18nConfig.locales; + nextDataPayload.defaultLocale = i18nConfig.defaultLocale; + } + const localeGlobals = i18nConfig + ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + + ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + + ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale) + : ""; + const nextDataScript = ""; + + // Build the document shell with a placeholder for the streamed body + var BODY_MARKER = ""; + var shellHtml; + if (DocumentComponent) { + const docElement = React.createElement(DocumentComponent); + shellHtml = await renderToStringAsync(docElement); + shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); + if (ssrHeadHTML || assetTags || fontHeadHTML) { + shellHtml = shellHtml.replace("", " " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n"); + } + shellHtml = shellHtml.replace("", nextDataScript); + if (!shellHtml.includes("__NEXT_DATA__")) { + shellHtml = shellHtml.replace("", " " + nextDataScript + "\\n"); + } + } else { + shellHtml = "\\n\\n\\n \\n \\n " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n\\n\\n
" + BODY_MARKER + "
\\n " + nextDataScript + "\\n\\n"; + } + + if (typeof setSSRContext === "function") setSSRContext(null); + + // Split the shell at the body marker + var markerIdx = shellHtml.indexOf(BODY_MARKER); + var shellPrefix = shellHtml.slice(0, markerIdx); + var shellSuffix = shellHtml.slice(markerIdx + BODY_MARKER.length); + + // Start the React body stream — progressive SSR (no allReady wait) + var bodyStream = await renderToReadableStream(element); + var encoder = new TextEncoder(); + + // Create a composite stream: prefix + body + suffix + var compositeStream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(shellPrefix)); + var reader = bodyStream.getReader(); + try { + for (;;) { + var chunk = await reader.read(); + if (chunk.done) break; + controller.enqueue(chunk.value); + } + } finally { + reader.releaseLock(); + } + controller.enqueue(encoder.encode(shellSuffix)); + controller.close(); + } + }); + + // Cache the rendered HTML for ISR (needs the full string — re-render synchronously) + if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { + // Tee the stream so we can cache and respond simultaneously would be ideal, + // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. + var isrElement; + if (AppComponent) { + isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + } else { + isrElement = React.createElement(PageComponent, pageProps); + } + isrElement = wrapWithRouterContext(isrElement); + var isrHtml = await renderToStringAsync(isrElement); + var fullHtml = shellPrefix + isrHtml + shellSuffix; + var isrPathname = url.split("?")[0]; + var isrCacheKey = "pages:" + (isrPathname === "/" ? "/" : isrPathname.replace(/\\/$/, "")); + await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds); + } + + // Merge headers/status/cookies set by getServerSideProps on the res object. + // gSSP commonly uses res.setHeader("Set-Cookie", ...) or res.status(304). + var finalStatus = 200; + const responseHeaders = new Headers({ "Content-Type": "text/html" }); + if (gsspRes) { + finalStatus = gsspRes.statusCode; + var gsspHeaders = gsspRes.getHeaders(); + for (var hk of Object.keys(gsspHeaders)) { + var hv = gsspHeaders[hk]; + if (hk === "set-cookie" && Array.isArray(hv)) { + for (var sc of hv) responseHeaders.append("set-cookie", sc); + } else if (hv != null) { + responseHeaders.set(hk, String(hv)); + } + } + // Ensure Content-Type stays text/html (gSSP shouldn't override it for page renders) + responseHeaders.set("Content-Type", "text/html"); + } + if (isrRevalidateSeconds) { + responseHeaders.set("Cache-Control", "s-maxage=" + isrRevalidateSeconds + ", stale-while-revalidate"); + responseHeaders.set("X-Vinext-Cache", "MISS"); + } + // Set HTTP Link header for font preloading + if (_fontLinkHeader) { + responseHeaders.set("Link", _fontLinkHeader); + } + return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); + } catch (e) { + console.error("[vinext] SSR error:", e); + return new Response("Internal Server Error", { status: 500 }); + } + }) // end runWithFetchCache + ) // end runWithPrivateCache + ) // end _runWithCacheState + ) // end runWithHeadState + ); // end runWithRouterState +} + +export async function handleApiRoute(request, url) { + const match = matchRoute(url, apiRoutes); + if (!match) { + return new Response("404 - API route not found", { status: 404 }); + } + + const { route, params } = match; + const handler = route.module.default; + if (typeof handler !== "function") { + return new Response("API route does not export a default function", { status: 500 }); + } + + const query = { ...params }; + const qs = url.split("?")[1]; + if (qs) { + for (const [k, v] of new URLSearchParams(qs)) { + if (k in query) { + // Multi-value: promote to array (Next.js returns string[] for duplicate keys) + query[k] = Array.isArray(query[k]) ? query[k].concat(v) : [query[k], v]; + } else { + query[k] = v; + } + } + } + + // Parse request body (enforce 1MB limit to prevent memory exhaustion, + // matching Next.js default bodyParser sizeLimit). + // Check Content-Length first as a fast path, then enforce on the actual + // stream to prevent bypasses via chunked transfer encoding. + const contentLength = parseInt(request.headers.get("content-length") || "0", 10); + if (contentLength > 1 * 1024 * 1024) { + return new Response("Request body too large", { status: 413 }); + } + let body; + const ct = request.headers.get("content-type") || ""; + let rawBody; + try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); } + catch { return new Response("Request body too large", { status: 413 }); } + if (!rawBody) { + body = undefined; + } else if (ct.includes("application/json")) { + try { body = JSON.parse(rawBody); } catch { body = rawBody; } + } else { + body = rawBody; + } + + const { req, res, responsePromise } = createReqRes(request, url, query, body); + + try { + await handler(req, res); + // If handler didn't call res.end(), end it now. + // The end() method is idempotent — safe to call twice. + res.end(); + return await responsePromise; + } catch (e) { + console.error("[vinext] API error:", e); + return new Response("Internal Server Error", { status: 500 }); + } +} + + +// --- Middleware support (generated from middleware-codegen.ts) --- + +function __normalizePath(pathname) { + if ( + pathname === "/" || + (pathname.length > 1 && + pathname[0] === "/" && + !pathname.includes("//") && + !pathname.includes("/./") && + !pathname.includes("/../") && + !pathname.endsWith("/.") && + !pathname.endsWith("/..")) + ) { + return pathname; + } + var segments = pathname.split("/"); + var resolved = []; + for (var i = 0; i < segments.length; i++) { + var seg = segments[i]; + if (seg === "" || seg === ".") continue; + if (seg === "..") { resolved.pop(); } + else { resolved.push(seg); } + } + return "/" + resolved.join("/"); +} + +function __isSafeRegex(pattern) { + var quantifierAtDepth = []; + var depth = 0; + var i = 0; + while (i < pattern.length) { + var ch = pattern[i]; + if (ch === "\\\\") { i += 2; continue; } + if (ch === "[") { + i++; + while (i < pattern.length && pattern[i] !== "]") { + if (pattern[i] === "\\\\") i++; + i++; + } + i++; + continue; + } + if (ch === "(") { + depth++; + if (quantifierAtDepth.length <= depth) quantifierAtDepth.push(false); + else quantifierAtDepth[depth] = false; + i++; + continue; + } + if (ch === ")") { + var hadQ = depth > 0 && quantifierAtDepth[depth]; + if (depth > 0) depth--; + var next = pattern[i + 1]; + if (next === "+" || next === "*" || next === "{") { + if (hadQ) return false; + if (depth >= 0 && depth < quantifierAtDepth.length) quantifierAtDepth[depth] = true; + } + i++; + continue; + } + if (ch === "+" || ch === "*") { + if (depth > 0) quantifierAtDepth[depth] = true; + i++; + continue; + } + if (ch === "?") { + var prev = i > 0 ? pattern[i - 1] : ""; + if (prev !== "+" && prev !== "*" && prev !== "?" && prev !== "}") { + if (depth > 0) quantifierAtDepth[depth] = true; + } + i++; + continue; + } + if (ch === "{") { + var j = i + 1; + while (j < pattern.length && /[\\d,]/.test(pattern[j])) j++; + if (j < pattern.length && pattern[j] === "}" && j > i + 1) { + if (depth > 0) quantifierAtDepth[depth] = true; + i = j + 1; + continue; + } + } + i++; + } + return true; +} +function __safeRegExp(pattern, flags) { + if (!__isSafeRegex(pattern)) { + console.warn("[vinext] Ignoring potentially unsafe regex pattern (ReDoS risk): " + pattern); + return null; + } + try { return new RegExp(pattern, flags); } catch { return null; } +} + +function matchMiddlewarePattern(pathname, pattern) { + // Regex patterns: if the pattern contains "(" or "\\" it's a regex — + // pass it through to RegExp directly WITHOUT dot-escaping. + // This guard prevents regex pattern corruption from dot-escaping. + if (pattern.includes("(") || pattern.includes("\\\\")) { + var re = __safeRegExp("^" + pattern + "$"); + if (re) return re.test(pathname); + } + // Single-pass tokenizer (avoids chained .replace() flagged by CodeQL as + // incomplete sanitization — later passes could re-process earlier outputs). + var regexStr = ""; + var tokenRe = /\\/:([\\w-]+)\\*|\\/:([\\w-]+)\\+|:([\\w-]+)|[.]|[^/:.]+|./g; + var tok; + while ((tok = tokenRe.exec(pattern)) !== null) { + if (tok[1] !== undefined) { regexStr += "(?:/.*)?"; } + else if (tok[2] !== undefined) { regexStr += "(?:/.+)"; } + else if (tok[3] !== undefined) { regexStr += "([^/]+)"; } + else if (tok[0] === ".") { regexStr += "\\\\."; } + else { regexStr += tok[0]; } + } + var re2 = __safeRegExp("^" + regexStr + "$"); + return re2 ? re2.test(pathname) : pathname === pattern; +} + +function matchesMiddleware(pathname, matcher) { + if (!matcher) { + return true; + } + var patterns = []; + if (typeof matcher === "string") { patterns.push(matcher); } + else if (Array.isArray(matcher)) { + for (var m of matcher) { + if (typeof m === "string") patterns.push(m); + else if (m && typeof m === "object" && "source" in m) patterns.push(m.source); + } + } + return patterns.some(function(p) { return matchMiddlewarePattern(pathname, p); }); +} + +export async function runMiddleware(request, ctx) { + var isProxy = false; + var middlewareFn = isProxy + ? (middlewareModule.proxy ?? middlewareModule.default) + : (middlewareModule.middleware ?? middlewareModule.default); + if (typeof middlewareFn !== "function") { + var fileType = isProxy ? "Proxy" : "Middleware"; + var expectedExport = isProxy ? "proxy" : "middleware"; + throw new Error("The " + fileType + " file must export a function named \`" + expectedExport + "\` or a \`default\` function."); + } + + var config = middlewareModule.config; + var matcher = config && config.matcher; + var url = new URL(request.url); + + // Normalize pathname before matching to prevent path-confusion bypasses + // (percent-encoding like /%61dmin, double slashes like /dashboard//settings). + var decodedPathname; + try { decodedPathname = decodeURIComponent(url.pathname); } catch (e) { + return { continue: false, response: new Response("Bad Request", { status: 400 }) }; + } + var normalizedPathname = __normalizePath(decodedPathname); + + if (!matchesMiddleware(normalizedPathname, matcher)) return { continue: true }; + + // Construct a new Request with the decoded + normalized pathname so middleware + // always sees the same canonical path that the router uses. + var mwRequest = request; + if (normalizedPathname !== url.pathname) { + var mwUrl = new URL(url); + mwUrl.pathname = normalizedPathname; + mwRequest = new Request(mwUrl, request); + } + var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + var fetchEvent = new NextFetchEvent({ page: normalizedPathname }); + var response; + try { response = await middlewareFn(nextRequest, fetchEvent); } + catch (e) { + console.error("[vinext] Middleware error:", e); + return { continue: false, response: new Response("Internal Server Error", { status: 500 }) }; + } + if (ctx && typeof ctx.waitUntil === "function") { ctx.waitUntil(fetchEvent.drainWaitUntil()); } else { fetchEvent.drainWaitUntil(); } + + if (!response) return { continue: true }; + + if (response.headers.get("x-middleware-next") === "1") { + var rHeaders = new Headers(); + for (var [key, value] of response.headers) { + // Keep x-middleware-request-* headers so the production server can + // apply middleware-request header overrides before stripping internals + // from the final client response. + if ( + !key.startsWith("x-middleware-") || + key.startsWith("x-middleware-request-") + ) rHeaders.append(key, value); + } + return { continue: true, responseHeaders: rHeaders }; + } + + if (response.status >= 300 && response.status < 400) { + var location = response.headers.get("Location") || response.headers.get("location"); + if (location) return { continue: false, redirectUrl: location, redirectStatus: response.status }; + } + + var rewriteUrl = response.headers.get("x-middleware-rewrite"); + if (rewriteUrl) { + var rwHeaders = new Headers(); + for (var [k, v] of response.headers) { + if (!k.startsWith("x-middleware-") || k.startsWith("x-middleware-request-")) rwHeaders.append(k, v); + } + var rewritePath; + try { var parsed = new URL(rewriteUrl, request.url); rewritePath = parsed.pathname + parsed.search; } + catch { rewritePath = rewriteUrl; } + return { continue: true, rewriteUrl: rewritePath, rewriteStatus: response.status !== 200 ? response.status : undefined, responseHeaders: rwHeaders }; + } + + return { continue: false, response: response }; +} + +" +`; diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts new file mode 100644 index 00000000..25baee2e --- /dev/null +++ b/tests/entry-templates.test.ts @@ -0,0 +1,193 @@ +/** + * Snapshot tests for the entry template code generators. + * + * These tests lock down the exact generated code for all virtual entry modules + * so that future refactoring (extracting generators into separate files, etc.) + * can be verified against a known baseline. + * + * - App Router generators are standalone exported functions → imported directly. + * - Pages Router generators are closures inside the plugin → tested via + * Vite's pluginContainer.load() on the virtual module IDs. + */ +import path from "node:path"; +import { describe, it, expect, afterAll } from "vitest"; +import { createServer, type ViteDevServer } from "vite"; +import { + generateRscEntry, + generateSsrEntry, + generateBrowserEntry, +} from "../packages/vinext/src/server/app-dev-server.js"; +import type { AppRouterConfig } from "../packages/vinext/src/server/app-dev-server.js"; +import type { AppRoute } from "../packages/vinext/src/routing/app-router.js"; +import vinext from "../packages/vinext/src/index.js"; + +// Workspace root (forward-slash normalised) used to replace absolute paths +// in generated code so snapshots are machine-independent. +const ROOT = path.resolve(import.meta.dirname, "..").replace(/\\/g, "/"); + +/** Replace all occurrences of the workspace root with ``. */ +function stabilize(code: string): string { + return code.replaceAll(ROOT, ""); +} + +// ── Minimal App Router route fixtures ───────────────────────────────── +// Use stable absolute paths so snapshots don't depend on the machine. +const minimalAppRoutes: AppRoute[] = [ + { + pattern: "/", + pagePath: "/tmp/test/app/page.tsx", + routePath: null, + layouts: ["/tmp/test/app/layout.tsx"], + templates: [], + parallelSlots: [], + loadingPath: null, + errorPath: null, + layoutErrorPaths: [null], + notFoundPath: null, + notFoundPaths: [null], + forbiddenPath: null, + unauthorizedPath: null, + routeSegments: [], + layoutTreePositions: [0], + isDynamic: false, + params: [], + }, + { + pattern: "/about", + pagePath: "/tmp/test/app/about/page.tsx", + routePath: null, + layouts: ["/tmp/test/app/layout.tsx"], + templates: [], + parallelSlots: [], + loadingPath: null, + errorPath: null, + layoutErrorPaths: [null], + notFoundPath: null, + notFoundPaths: [null], + forbiddenPath: null, + unauthorizedPath: null, + routeSegments: ["about"], + layoutTreePositions: [0], + isDynamic: false, + params: [], + }, +]; + +// ── Pages Router fixture ────────────────────────────────────────────── +const PAGES_FIXTURE_DIR = path.resolve( + import.meta.dirname, + "./fixtures/pages-basic", +); + +// ── App Router entry templates ──────────────────────────────────────── + +describe("App Router entry templates", () => { + it("generateRscEntry snapshot (minimal routes)", () => { + const code = generateRscEntry( + "/tmp/test/app", + minimalAppRoutes, + null, // no middleware + [], // no metadata routes + null, // no global error + "", // no basePath + false, // no trailingSlash + ); + expect(code).toMatchSnapshot(); + }); + + it("generateRscEntry snapshot (with middleware)", () => { + const code = generateRscEntry( + "/tmp/test/app", + minimalAppRoutes, + "/tmp/test/middleware.ts", + [], + null, + "", + false, + ); + expect(code).toMatchSnapshot(); + }); + + it("generateRscEntry snapshot (with config)", () => { + const config: AppRouterConfig = { + redirects: [ + { source: "/old", destination: "/new", permanent: true }, + ], + rewrites: { + beforeFiles: [ + { source: "/api/:path*", destination: "/backend/:path*" }, + ], + afterFiles: [], + fallback: [], + }, + headers: [ + { + source: "/api/:path*", + headers: [{ key: "X-Custom", value: "test" }], + }, + ], + }; + const code = generateRscEntry( + "/tmp/test/app", + minimalAppRoutes, + null, + [], + null, + "/base", + true, + config, + ); + expect(code).toMatchSnapshot(); + }); + + it("generateSsrEntry snapshot", () => { + const code = generateSsrEntry(); + expect(code).toMatchSnapshot(); + }); + + it("generateBrowserEntry snapshot", () => { + const code = generateBrowserEntry(); + expect(code).toMatchSnapshot(); + }); +}); + +// ── Pages Router entry templates ────────────────────────────────────── +// These are closure functions inside the vinext() plugin, so we test +// them via Vite's pluginContainer.load() on the virtual module IDs. + +describe("Pages Router entry templates", () => { + let server: ViteDevServer; + + afterAll(async () => { + if (server) await server.close(); + }); + + async function getVirtualModuleCode(moduleId: string): Promise { + if (!server) { + server = await createServer({ + root: PAGES_FIXTURE_DIR, + configFile: false, + plugins: [vinext()], + server: { port: 0 }, + logLevel: "silent", + }); + } + const resolved = await server.pluginContainer.resolveId(moduleId); + expect(resolved).toBeTruthy(); + const loaded = await server.pluginContainer.load(resolved!.id); + expect(loaded).toBeTruthy(); + return typeof loaded === "string" + ? loaded + : (loaded as any)?.code ?? ""; + } + + it("server entry snapshot", async () => { + const code = await getVirtualModuleCode("virtual:vinext-server-entry"); + expect(stabilize(code)).toMatchSnapshot(); + }); + + it("client entry snapshot", async () => { + const code = await getVirtualModuleCode("virtual:vinext-client-entry"); + expect(stabilize(code)).toMatchSnapshot(); + }); +}); From c706467db6b588bc7c91f6d9d3d8b8fe09d43bc8 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 8 Mar 2026 15:33:51 +0000 Subject: [PATCH 2/8] test: update entry template snapshots after merging main --- .../entry-templates.test.ts.snap | 103 ++++++++++++------ 1 file changed, 67 insertions(+), 36 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 51a3c1c2..659233f9 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -648,11 +648,12 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // client-side tree reconciliation. const _treePositions = route?.layoutTreePositions; const _routeSegs = route?.routeSegments || []; - const _fallbackParams = opts?.params || {}; + 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 }); + element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParams }); const _tp = _treePositions ? _treePositions[i] : 0; const _cs = __resolveChildSegments(_routeSegs, _tp, _fallbackParams); element = createElement(LayoutSegmentProvider, { childSegments: _cs }, element); @@ -669,10 +670,12 @@ 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); for (let i = layouts.length - 1; i >= 0; i--) { const LayoutComponent = layouts[i]?.default; if (LayoutComponent) { - element = createElement(LayoutComponent, { children: element }); + element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParamsHtml }); } } const rscStream = renderToReadableStream(element, { onError: rscOnError }); @@ -696,8 +699,8 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req } /** Convenience: render a not-found page (404) */ -async function renderNotFoundPage(route, isRscRequest, request, params) { - return renderHTTPAccessFallbackPage(route, 404, isRscRequest, request, { params }); +async function renderNotFoundPage(route, isRscRequest, request, matchedParams) { + return renderHTTPAccessFallbackPage(route, 404, isRscRequest, request, { matchedParams }); } /** @@ -707,7 +710,7 @@ async function renderNotFoundPage(route, isRscRequest, request, params) { * Next.js returns HTTP 200 when error.tsx catches an error (the error is "handled" * by the boundary). This matches that behavior intentionally. */ -async function renderErrorBoundaryPage(route, error, isRscRequest, request, params) { +async function renderErrorBoundaryPage(route, error, isRscRequest, request, matchedParams) { // Resolve the error boundary component: leaf error.tsx first, then walk per-layout // errors from innermost to outermost (matching ancestor inheritance), then global-error.tsx. let ErrorComponent = route?.error?.default ?? null; @@ -742,11 +745,12 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, para // Same rationale as renderHTTPAccessFallbackPage — see comment there. const _errTreePositions = route?.layoutTreePositions; const _errRouteSegs = route?.routeSegments || []; - const _errParams = params || {}; + 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 }); + element = createElement(LayoutComponent, { children: element, params: _asyncErrParams }); const _etp = _errTreePositions ? _errTreePositions[i] : 0; const _ecs = __resolveChildSegments(_errRouteSegs, _etp, _errParams); element = createElement(LayoutSegmentProvider, { childSegments: _ecs }, element); @@ -762,10 +766,12 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, para }); } // For HTML (full page load) responses, wrap with layouts only. + const _errParamsHtml = matchedParams ?? route?.params ?? {}; + const _asyncErrParamsHtml = makeThenableParams(_errParamsHtml); for (let i = layouts.length - 1; i >= 0; i--) { const LayoutComponent = layouts[i]?.default; if (LayoutComponent) { - element = createElement(LayoutComponent, { children: element }); + element = createElement(LayoutComponent, { children: element, params: _asyncErrParamsHtml }); } } const rscStream = renderToReadableStream(element, { onError: rscOnError }); @@ -2276,7 +2282,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { params }); + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); if (fallbackResp) return fallbackResp; setHeadersContext(null); setNavigationContext(null); @@ -2307,7 +2313,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { params }); + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); if (fallbackResp) return fallbackResp; setHeadersContext(null); setNavigationContext(null); @@ -2372,7 +2378,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parentLayouts = route.layouts.slice(0, li); const fallbackResp = await renderHTTPAccessFallbackPage( route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, params } + { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } ); if (fallbackResp) return fallbackResp; setHeadersContext(null); @@ -2988,11 +2994,12 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // client-side tree reconciliation. const _treePositions = route?.layoutTreePositions; const _routeSegs = route?.routeSegments || []; - const _fallbackParams = opts?.params || {}; + 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 }); + element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParams }); const _tp = _treePositions ? _treePositions[i] : 0; const _cs = __resolveChildSegments(_routeSegs, _tp, _fallbackParams); element = createElement(LayoutSegmentProvider, { childSegments: _cs }, element); @@ -3009,10 +3016,12 @@ 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); for (let i = layouts.length - 1; i >= 0; i--) { const LayoutComponent = layouts[i]?.default; if (LayoutComponent) { - element = createElement(LayoutComponent, { children: element }); + element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParamsHtml }); } } const rscStream = renderToReadableStream(element, { onError: rscOnError }); @@ -3036,8 +3045,8 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req } /** Convenience: render a not-found page (404) */ -async function renderNotFoundPage(route, isRscRequest, request, params) { - return renderHTTPAccessFallbackPage(route, 404, isRscRequest, request, { params }); +async function renderNotFoundPage(route, isRscRequest, request, matchedParams) { + return renderHTTPAccessFallbackPage(route, 404, isRscRequest, request, { matchedParams }); } /** @@ -3047,7 +3056,7 @@ async function renderNotFoundPage(route, isRscRequest, request, params) { * Next.js returns HTTP 200 when error.tsx catches an error (the error is "handled" * by the boundary). This matches that behavior intentionally. */ -async function renderErrorBoundaryPage(route, error, isRscRequest, request, params) { +async function renderErrorBoundaryPage(route, error, isRscRequest, request, matchedParams) { // Resolve the error boundary component: leaf error.tsx first, then walk per-layout // errors from innermost to outermost (matching ancestor inheritance), then global-error.tsx. let ErrorComponent = route?.error?.default ?? null; @@ -3082,11 +3091,12 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, para // Same rationale as renderHTTPAccessFallbackPage — see comment there. const _errTreePositions = route?.layoutTreePositions; const _errRouteSegs = route?.routeSegments || []; - const _errParams = params || {}; + 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 }); + element = createElement(LayoutComponent, { children: element, params: _asyncErrParams }); const _etp = _errTreePositions ? _errTreePositions[i] : 0; const _ecs = __resolveChildSegments(_errRouteSegs, _etp, _errParams); element = createElement(LayoutSegmentProvider, { childSegments: _ecs }, element); @@ -3102,10 +3112,12 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, para }); } // For HTML (full page load) responses, wrap with layouts only. + const _errParamsHtml = matchedParams ?? route?.params ?? {}; + const _asyncErrParamsHtml = makeThenableParams(_errParamsHtml); for (let i = layouts.length - 1; i >= 0; i--) { const LayoutComponent = layouts[i]?.default; if (LayoutComponent) { - element = createElement(LayoutComponent, { children: element }); + element = createElement(LayoutComponent, { children: element, params: _asyncErrParamsHtml }); } } const rscStream = renderToReadableStream(element, { onError: rscOnError }); @@ -4621,7 +4633,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { params }); + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); if (fallbackResp) return fallbackResp; setHeadersContext(null); setNavigationContext(null); @@ -4652,7 +4664,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { params }); + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); if (fallbackResp) return fallbackResp; setHeadersContext(null); setNavigationContext(null); @@ -4717,7 +4729,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parentLayouts = route.layouts.slice(0, li); const fallbackResp = await renderHTTPAccessFallbackPage( route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, params } + { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } ); if (fallbackResp) return fallbackResp; setHeadersContext(null); @@ -5333,11 +5345,12 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req // client-side tree reconciliation. const _treePositions = route?.layoutTreePositions; const _routeSegs = route?.routeSegments || []; - const _fallbackParams = opts?.params || {}; + 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 }); + element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParams }); const _tp = _treePositions ? _treePositions[i] : 0; const _cs = __resolveChildSegments(_routeSegs, _tp, _fallbackParams); element = createElement(LayoutSegmentProvider, { childSegments: _cs }, element); @@ -5354,10 +5367,12 @@ 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); for (let i = layouts.length - 1; i >= 0; i--) { const LayoutComponent = layouts[i]?.default; if (LayoutComponent) { - element = createElement(LayoutComponent, { children: element }); + element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParamsHtml }); } } const rscStream = renderToReadableStream(element, { onError: rscOnError }); @@ -5381,8 +5396,8 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req } /** Convenience: render a not-found page (404) */ -async function renderNotFoundPage(route, isRscRequest, request, params) { - return renderHTTPAccessFallbackPage(route, 404, isRscRequest, request, { params }); +async function renderNotFoundPage(route, isRscRequest, request, matchedParams) { + return renderHTTPAccessFallbackPage(route, 404, isRscRequest, request, { matchedParams }); } /** @@ -5392,7 +5407,7 @@ async function renderNotFoundPage(route, isRscRequest, request, params) { * Next.js returns HTTP 200 when error.tsx catches an error (the error is "handled" * by the boundary). This matches that behavior intentionally. */ -async function renderErrorBoundaryPage(route, error, isRscRequest, request, params) { +async function renderErrorBoundaryPage(route, error, isRscRequest, request, matchedParams) { // Resolve the error boundary component: leaf error.tsx first, then walk per-layout // errors from innermost to outermost (matching ancestor inheritance), then global-error.tsx. let ErrorComponent = route?.error?.default ?? null; @@ -5427,11 +5442,12 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, para // Same rationale as renderHTTPAccessFallbackPage — see comment there. const _errTreePositions = route?.layoutTreePositions; const _errRouteSegs = route?.routeSegments || []; - const _errParams = params || {}; + 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 }); + element = createElement(LayoutComponent, { children: element, params: _asyncErrParams }); const _etp = _errTreePositions ? _errTreePositions[i] : 0; const _ecs = __resolveChildSegments(_errRouteSegs, _etp, _errParams); element = createElement(LayoutSegmentProvider, { childSegments: _ecs }, element); @@ -5447,10 +5463,12 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, para }); } // For HTML (full page load) responses, wrap with layouts only. + const _errParamsHtml = matchedParams ?? route?.params ?? {}; + const _asyncErrParamsHtml = makeThenableParams(_errParamsHtml); for (let i = layouts.length - 1; i >= 0; i--) { const LayoutComponent = layouts[i]?.default; if (LayoutComponent) { - element = createElement(LayoutComponent, { children: element }); + element = createElement(LayoutComponent, { children: element, params: _asyncErrParamsHtml }); } } const rscStream = renderToReadableStream(element, { onError: rscOnError }); @@ -7085,7 +7103,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { params }); + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); if (fallbackResp) return fallbackResp; setHeadersContext(null); setNavigationContext(null); @@ -7116,7 +7134,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10); - const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { params }); + const fallbackResp = await renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { matchedParams: params }); if (fallbackResp) return fallbackResp; setHeadersContext(null); setNavigationContext(null); @@ -7181,7 +7199,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parentLayouts = route.layouts.slice(0, li); const fallbackResp = await renderHTTPAccessFallbackPage( route, statusCode, isRscRequest, request, - { boundaryComponent: parentNotFound, layouts: parentLayouts, params } + { boundaryComponent: parentNotFound, layouts: parentLayouts, matchedParams: params } ); if (fallbackResp) return fallbackResp; setHeadersContext(null); @@ -8013,9 +8031,22 @@ import { runWithHeadState } from "vinext/head-state"; import { safeJsonStringify } from "vinext/html"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; +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"; +// Run instrumentation register() once at module evaluation time — before any +// requests are handled. Matches Next.js semantics: register() is called once +// on startup in the process that handles requests. +if (typeof _instrumentation.register === "function") { + await _instrumentation.register(); +} +// Store the onRequestError handler on globalThis so it is visible to all +// code within the Worker (same global scope). +if (typeof _instrumentation.onRequestError === "function") { + globalThis.__VINEXT_onRequestErrorHandler__ = _instrumentation.onRequestError; +} + // i18n config (embedded at build time) const i18nConfig = null; From 0f38e0f3563a53e3e8f6be25635f3d402a03deb8 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 8 Mar 2026 15:34:59 +0000 Subject: [PATCH 3/8] test: address bonk review comments on entry-template snapshot tests - Add comment on PAGES_FIXTURE_DIR warning about snapshot coupling - Add generateRscEntry snapshot test covering instrumentationPath arg --- .../entry-templates.test.ts.snap | 2361 +++++++++++++++++ tests/entry-templates.test.ts | 18 + 2 files changed, 2379 insertions(+) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 659233f9..f489c95f 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -5018,6 +5018,2367 @@ if (import.meta.hot) { " `; +exports[`App Router entry templates > generateRscEntry snapshot (with instrumentation) 1`] = ` +" +import { + renderToReadableStream, + decodeReply, + loadServerAction, + createTemporaryReferenceSet, +} from "@vitejs/plugin-rsc/rsc"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { createElement, Suspense, Fragment } from "react"; +import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; +import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, runWithHeadersContext, applyMiddlewareRequestHeaders, getHeadersContext } from "next/headers"; +import { NextRequest, NextFetchEvent } from "next/server"; +import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; +import { LayoutSegmentProvider } from "vinext/layout-segment-context"; +import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; + +import * as _instrumentation from "/tmp/test/instrumentation.ts"; + +import { _consumeRequestScopedCacheLife, _runWithCacheState } from "next/cache"; +import { runWithFetchCache } from "vinext/fetch-cache"; +import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime"; +// Import server-only state module to register ALS-backed accessors. +import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state"; +import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; +import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; +import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; +function _getSSRFontStyles() { return [..._getSSRFontStylesGoogle(), ..._getSSRFontStylesLocal()]; } +function _getSSRFontPreloads() { return [..._getSSRFontPreloadsGoogle(), ..._getSSRFontPreloadsLocal()]; } + +// ALS used to suppress the expected "Invalid hook call" dev warning when +// layout/page components are probed outside React's render cycle. Patching +// console.error once at module load (instead of per-request) avoids the +// concurrent-request issue where request A's suppression filter could +// swallow real errors from request B. +const _suppressHookWarningAls = new AsyncLocalStorage(); +const _origConsoleError = console.error; +console.error = (...args) => { + if (_suppressHookWarningAls.getStore() === true && + typeof args[0] === "string" && + args[0].includes("Invalid hook call")) return; + _origConsoleError.apply(console, args); +}; + +// Set navigation context in the ALS-backed store. "use client" components +// rendered during SSR need the pathname/searchParams/params but the SSR +// environment has a separate module instance of next/navigation. +// Use _getNavigationContext() to read the current context — never cache +// it in a module-level variable (that would leak between concurrent requests). +function setNavigationContext(ctx) { + _setNavigationContextOrig(ctx); +} + +// ISR cache is disabled in dev mode — every request re-renders fresh, +// matching Next.js dev behavior. Cache-Control headers are still emitted +// based on export const revalidate for testing purposes. +// Production ISR is handled by prod-server.ts and the Cloudflare worker entry. + +// Normalize null-prototype objects from matchPattern() into thenable objects +// that work both as Promises (for Next.js 15+ async params) and as plain +// objects with synchronous property access (for pre-15 code like params.id). +// +// matchPattern() uses Object.create(null), producing objects without +// Object.prototype. The RSC serializer rejects these. Spreading ({...obj}) +// restores a normal prototype. Object.assign onto the Promise preserves +// synchronous property access (params.id, params.slug) that existing +// components and test fixtures rely on. +function makeThenableParams(obj) { + const plain = { ...obj }; + return Object.assign(Promise.resolve(plain), plain); +} + +// 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. +function __resolveChildSegments(routeSegments, treePosition, params) { + var raw = routeSegments.slice(treePosition); + var result = []; + for (var j = 0; j < raw.length; j++) { + var seg = raw[j]; + // Optional catch-all: [[...param]] + if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") { + var pn = seg.slice(5, -2); + var v = params[pn]; + // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route) + if (Array.isArray(v) && v.length === 0) continue; + if (v == null) continue; + result.push(Array.isArray(v) ? v.join("/") : v); + // Catch-all: [...param] + } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { + var pn2 = seg.slice(4, -1); + var v2 = params[pn2]; + result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); + // Dynamic: [param] + } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { + var pn3 = seg.slice(1, -1); + result.push(params[pn3] || seg); + } else { + result.push(seg); + } + } + return result; +} + +// djb2 hash — matches Next.js's stringHash for digest generation. +// Produces a stable numeric string from error message + stack. +function __errorDigest(str) { + let hash = 5381; + for (let i = str.length - 1; i >= 0; i--) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return (hash >>> 0).toString(); +} + +// Sanitize an error for client consumption. In production, replaces the error +// with a generic Error that only carries a digest hash (matching Next.js +// behavior). In development, returns the original error for debugging. +// Navigation errors (redirect, notFound, etc.) are always passed through +// unchanged since their digests are used for client-side routing. +function __sanitizeErrorForClient(error) { + // Navigation errors must pass through with their digest intact + if (error && typeof error === "object" && "digest" in error) { + const digest = String(error.digest); + if ( + digest.startsWith("NEXT_REDIRECT;") || + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { + return error; + } + } + // In development, pass through the original error for debugging + if (process.env.NODE_ENV !== "production") { + return error; + } + // In production, create a sanitized error with only a digest hash + const msg = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? (error.stack || "") : ""; + const sanitized = new Error( + "An error occurred in the Server Components render. " + + "The specific message is omitted in production builds to avoid leaking sensitive details. " + + "A digest property is included on this error instance which may provide additional details about the nature of the error." + ); + sanitized.digest = __errorDigest(msg + stack); + return sanitized; +} + +// onError callback for renderToReadableStream — preserves the digest for +// Next.js navigation errors (redirect, notFound, forbidden, unauthorized) +// thrown during RSC streaming (e.g. inside Suspense boundaries). +// For non-navigation errors in production, generates a digest hash so the +// error can be correlated with server logs without leaking details. +function rscOnError(error) { + if (error && typeof error === "object" && "digest" in error) { + return String(error.digest); + } + + // In dev, detect the "Only plain objects" RSC serialization error and emit + // an actionable hint. This error occurs when a Server Component passes a + // class instance, ES module namespace object, or null-prototype object as a + // prop to a Client Component. + // + // Root cause: Vite bundles modules as true ESM (module namespace objects + // have a null-like internal slot), while Next.js's webpack build produces + // plain CJS-wrapped objects with __esModule:true. React's RSC serializer + // accepts the latter as plain objects but rejects the former — which means + // code that accidentally passes "import * as X" works in webpack/Next.js + // but correctly fails in vinext. + // + // Common triggers: + // - "import * as utils from './utils'" passed as a prop + // - class instances (new Foo()) passed as props + // - Date / Map / Set instances passed as props + // - Objects with Object.create(null) (null prototype) + if ( + process.env.NODE_ENV !== "production" && + error instanceof Error && + error.message.includes("Only plain objects, and a few built-ins, can be passed to Client Components") + ) { + console.error( + "[vinext] RSC serialization error: a non-plain object was passed from a Server Component to a Client Component.\\n" + + "\\n" + + "Common causes:\\n" + + " * Passing a module namespace (import * as X) directly as a prop.\\n" + + " Unlike Next.js (webpack), Vite produces real ESM module namespace objects\\n" + + " which are not serializable. Fix: pass individual values instead,\\n" + + " e.g. \\n" + + " * Passing a class instance (new Foo()) as a prop.\\n" + + " Fix: convert to a plain object, e.g. { id: foo.id, name: foo.name }\\n" + + " * Passing a Date, Map, or Set. Use .toISOString(), [...map.entries()], etc.\\n" + + " * Passing Object.create(null). Use { ...obj } to restore a prototype.\\n" + + "\\n" + + "Original error:", + error.message, + ); + return undefined; + } + + // In production, generate a digest hash for non-navigation errors + if (process.env.NODE_ENV === "production" && error) { + const msg = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? (error.stack || "") : ""; + return __errorDigest(msg + stack); + } + return undefined; +} + +import * as mod_0 from "/tmp/test/app/page.tsx"; +import * as mod_1 from "/tmp/test/app/layout.tsx"; +import * as mod_2 from "/tmp/test/app/about/page.tsx"; + +// Run instrumentation register() once at module evaluation time — before any +// requests are handled. This runs inside the Worker process (or RSC environment), +// which is exactly where request handling happens. Matches Next.js semantics: +// register() is called once on startup in the process that handles requests. +if (typeof _instrumentation.register === "function") { + await _instrumentation.register(); +} +// Store the onRequestError handler on globalThis so it is visible to +// reportRequestError() (imported as _reportRequestError above) regardless +// of which Vite environment module graph it is called from. With +// @vitejs/plugin-rsc the RSC and SSR environments run in the same Node.js +// process and share globalThis. With @cloudflare/vite-plugin everything +// runs inside the Worker so globalThis is the Worker's global — also correct. +if (typeof _instrumentation.onRequestError === "function") { + globalThis.__VINEXT_onRequestErrorHandler__ = _instrumentation.onRequestError; +} + +const routes = [ + { + pattern: "/", + isDynamic: false, + params: [], + page: mod_0, + routeHandler: null, + layouts: [mod_1], + routeSegments: [], + layoutTreePositions: [0], + templates: [], + errors: [null], + slots: { + + }, + loading: null, + error: null, + notFound: null, + notFounds: [null], + forbidden: null, + unauthorized: null, + }, + { + pattern: "/about", + isDynamic: false, + params: [], + page: mod_2, + routeHandler: null, + layouts: [mod_1], + routeSegments: ["about"], + layoutTreePositions: [0], + templates: [], + errors: [null], + slots: { + + }, + loading: null, + error: null, + notFound: null, + notFounds: [null], + forbidden: null, + unauthorized: null, + } +]; + +const metadataRoutes = [ + +]; + +const rootNotFoundModule = null; +const rootForbiddenModule = null; +const rootUnauthorizedModule = null; +const rootLayouts = [mod_1]; + +/** + * Render an HTTP access fallback page (not-found/forbidden/unauthorized) with layouts and noindex meta. + * Returns null if no matching component is available. + * + * @param opts.boundaryComponent - Override the boundary component (for layout-level notFound) + * @param opts.layouts - Override the layouts to wrap with (for layout-level notFound, excludes the throwing layout) + */ +async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, opts) { + // Determine which boundary component to use based on status code + let BoundaryComponent = opts?.boundaryComponent ?? null; + if (!BoundaryComponent) { + let boundaryModule; + if (statusCode === 403) { + boundaryModule = route?.forbidden ?? rootForbiddenModule; + } else if (statusCode === 401) { + boundaryModule = route?.unauthorized ?? rootUnauthorizedModule; + } else { + boundaryModule = route?.notFound ?? rootNotFoundModule; + } + BoundaryComponent = boundaryModule?.default ?? null; + } + const layouts = opts?.layouts ?? route?.layouts ?? rootLayouts; + if (!BoundaryComponent) return null; + + // Resolve metadata and viewport from parent layouts so that not-found/error + // pages inherit title, description, OG tags etc. — matching Next.js behavior. + const metadataList = []; + const viewportList = []; + for (const layoutMod of layouts) { + if (layoutMod) { + const meta = await resolveModuleMetadata(layoutMod); + if (meta) metadataList.push(meta); + const vp = await resolveModuleViewport(layoutMod); + if (vp) viewportList.push(vp); + } + } + const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; + const resolvedViewport = mergeViewport(viewportList); + + // Build element: metadata head + noindex meta + boundary component wrapped in layouts + // Always include charset and default viewport for parity with Next.js. + const charsetMeta = createElement("meta", { charSet: "utf-8" }); + const noindexMeta = createElement("meta", { name: "robots", content: "noindex" }); + const headElements = [charsetMeta, noindexMeta]; + if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata })); + headElements.push(createElement(ViewportHead, { viewport: resolvedViewport })); + let element = createElement(Fragment, null, ...headElements, createElement(BoundaryComponent)); + if (isRscRequest) { + // For RSC requests (client-side navigation), wrap the element with the same + // component wrappers that buildPageElement() uses. Without these wrappers, + // React's reconciliation would see a mismatched tree structure between the + // old fiber tree (ErrorBoundary > LayoutSegmentProvider > html > body > NotFoundBoundary > ...) + // and the new tree (html > body > ...), causing it to destroy and recreate + // the entire DOM tree, resulting in a blank white page. + // + // We wrap each layout with LayoutSegmentProvider and add GlobalErrorBoundary + // to match the wrapping order in buildPageElement(), ensuring smooth + // client-side tree reconciliation. + 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 _cs = __resolveChildSegments(_routeSegs, _tp, _fallbackParams); + element = createElement(LayoutSegmentProvider, { childSegments: _cs }, element); + } + } + + const rscStream = renderToReadableStream(element, { onError: rscOnError }); + setHeadersContext(null); + setNavigationContext(null); + return new Response(rscStream, { + status: statusCode, + headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, + }); + } + // 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); + for (let i = layouts.length - 1; i >= 0; i--) { + const LayoutComponent = layouts[i]?.default; + if (LayoutComponent) { + element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParamsHtml }); + } + } + const rscStream = renderToReadableStream(element, { onError: rscOnError }); + // Collect font data from RSC environment + const fontData = { + links: _getSSRFontLinks(), + styles: _getSSRFontStyles(), + preloads: _getSSRFontPreloads(), + }; + const ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData); + setHeadersContext(null); + setNavigationContext(null); + const _respHeaders = { "Content-Type": "text/html; charset=utf-8", "Vary": "RSC, Accept" }; + const _linkParts = (fontData.preloads || []).map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }); + if (_linkParts.length > 0) _respHeaders["Link"] = _linkParts.join(", "); + return new Response(htmlStream, { + status: statusCode, + headers: _respHeaders, + }); +} + +/** Convenience: render a not-found page (404) */ +async function renderNotFoundPage(route, isRscRequest, request, matchedParams) { + return renderHTTPAccessFallbackPage(route, 404, isRscRequest, request, { matchedParams }); +} + +/** + * Render an error.tsx boundary page when a server component or generateMetadata() throws. + * Returns null if no error boundary component is available for this route. + * + * Next.js returns HTTP 200 when error.tsx catches an error (the error is "handled" + * by the boundary). This matches that behavior intentionally. + */ +async function renderErrorBoundaryPage(route, error, isRscRequest, request, matchedParams) { + // Resolve the error boundary component: leaf error.tsx first, then walk per-layout + // errors from innermost to outermost (matching ancestor inheritance), then global-error.tsx. + let ErrorComponent = route?.error?.default ?? null; + if (!ErrorComponent && route?.errors) { + for (let i = route.errors.length - 1; i >= 0; i--) { + if (route.errors[i]?.default) { + ErrorComponent = route.errors[i].default; + break; + } + } + } + ErrorComponent = ErrorComponent; + if (!ErrorComponent) return null; + + const rawError = error instanceof Error ? error : new Error(String(error)); + // Sanitize the error in production to avoid leaking internal details + // (database errors, file paths, stack traces) through error.tsx to the client. + // In development, pass the original error for debugging. + const errorObj = __sanitizeErrorForClient(rawError); + // Only pass error — reset is a client-side concern (re-renders the segment) and + // can't be serialized through RSC. The error.tsx component will receive reset=undefined + // during SSR, which is fine — onClick={undefined} is harmless, and the real reset + // function is only meaningful after hydration. + let element = createElement(ErrorComponent, { + error: errorObj, + }); + const layouts = route?.layouts ?? rootLayouts; + if (isRscRequest) { + // For RSC requests (client-side navigation), wrap with the same component + // wrappers that buildPageElement() uses (LayoutSegmentProvider, GlobalErrorBoundary). + // This ensures React can reconcile the tree without destroying the DOM. + // Same rationale as renderHTTPAccessFallbackPage — see comment there. + 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 _ecs = __resolveChildSegments(_errRouteSegs, _etp, _errParams); + element = createElement(LayoutSegmentProvider, { childSegments: _ecs }, element); + } + } + + const rscStream = renderToReadableStream(element, { onError: rscOnError }); + setHeadersContext(null); + setNavigationContext(null); + return new Response(rscStream, { + status: 200, + headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, + }); + } + // For HTML (full page load) responses, wrap with layouts only. + const _errParamsHtml = matchedParams ?? route?.params ?? {}; + const _asyncErrParamsHtml = makeThenableParams(_errParamsHtml); + for (let i = layouts.length - 1; i >= 0; i--) { + const LayoutComponent = layouts[i]?.default; + if (LayoutComponent) { + element = createElement(LayoutComponent, { children: element, params: _asyncErrParamsHtml }); + } + } + const rscStream = renderToReadableStream(element, { onError: rscOnError }); + // Collect font data from RSC environment so error pages include font styles + const fontData = { + links: _getSSRFontLinks(), + styles: _getSSRFontStyles(), + preloads: _getSSRFontPreloads(), + }; + const ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + const htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData); + setHeadersContext(null); + setNavigationContext(null); + const _errHeaders = { "Content-Type": "text/html; charset=utf-8", "Vary": "RSC, Accept" }; + const _errLinkParts = (fontData.preloads || []).map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }); + if (_errLinkParts.length > 0) _errHeaders["Link"] = _errLinkParts.join(", "); + return new Response(htmlStream, { + status: 200, + headers: _errHeaders, + }); +} + +function matchRoute(url, routes) { + const pathname = url.split("?")[0]; + let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); + // NOTE: Do NOT decodeURIComponent here. The caller is responsible for decoding + // the pathname exactly once at the request entry point. Decoding again here + // would cause inconsistent path matching between middleware and routing. + for (const route of routes) { + const params = matchPattern(normalizedUrl, route.pattern); + if (params !== null) return { route, params }; + } + return null; +} + +function matchPattern(url, pattern) { + const urlParts = url.split("/").filter(Boolean); + const patternParts = pattern.split("/").filter(Boolean); + const params = Object.create(null); + for (let i = 0; i < patternParts.length; i++) { + const pp = patternParts[i]; + if (pp.endsWith("+")) { + const paramName = pp.slice(1, -1); + const remaining = urlParts.slice(i); + if (remaining.length === 0) return null; + params[paramName] = remaining; + return params; + } + if (pp.endsWith("*")) { + const paramName = pp.slice(1, -1); + params[paramName] = urlParts.slice(i); + return params; + } + if (pp.startsWith(":")) { + if (i >= urlParts.length) return null; + params[pp.slice(1)] = urlParts[i]; + continue; + } + if (i >= urlParts.length || urlParts[i] !== pp) return null; + } + if (urlParts.length !== patternParts.length) return null; + return params; +} + +// Build a global intercepting route lookup for RSC navigation. +// Maps target URL patterns to { sourceRouteIndex, slotName, interceptPage, params }. +const interceptLookup = []; +for (let ri = 0; ri < routes.length; ri++) { + const r = routes[ri]; + if (!r.slots) continue; + for (const [slotName, slotMod] of Object.entries(r.slots)) { + if (!slotMod.intercepts) continue; + for (const intercept of slotMod.intercepts) { + interceptLookup.push({ + sourceRouteIndex: ri, + slotName, + targetPattern: intercept.targetPattern, + page: intercept.page, + params: intercept.params, + }); + } + } +} + +/** + * Check if a pathname matches any intercepting route. + * Returns the match info or null. + */ +function findIntercept(pathname) { + for (const entry of interceptLookup) { + const params = matchPattern(pathname, entry.targetPattern); + if (params !== null) { + return { ...entry, matchedParams: params }; + } + } + return null; +} + +async function buildPageElement(route, params, opts, searchParams) { + const PageComponent = route.page?.default; + if (!PageComponent) { + return createElement("div", null, "Page has no default export"); + } + + // Resolve metadata and viewport from layouts and page + const metadataList = []; + const viewportList = []; + for (const layoutMod of route.layouts) { + if (layoutMod) { + const meta = await resolveModuleMetadata(layoutMod, params); + if (meta) metadataList.push(meta); + const vp = await resolveModuleViewport(layoutMod, params); + if (vp) viewportList.push(vp); + } + } + if (route.page) { + const pageMeta = await resolveModuleMetadata(route.page, params); + if (pageMeta) metadataList.push(pageMeta); + const pageVp = await resolveModuleViewport(route.page, params); + if (pageVp) viewportList.push(pageVp); + } + const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; + const resolvedViewport = mergeViewport(viewportList); + + // Build nested layout tree from outermost to innermost. + // Next.js 16 passes params/searchParams as Promises (async pattern) + // but pre-16 code accesses them as plain objects (params.id). + // makeThenableParams() normalises null-prototype + preserves both patterns. + const asyncParams = makeThenableParams(params); + const pageProps = { params: asyncParams }; + if (searchParams) { + const spObj = {}; + let hasSearchParams = false; + if (searchParams.forEach) searchParams.forEach(function(v, k) { + hasSearchParams = true; + if (k in spObj) { + // Multi-value: promote to array (Next.js returns string[] for duplicate keys) + spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v]; + } else { + spObj[k] = v; + } + }); + // 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 + // serializer accesses properties on all props (e.g. $$typeof check in + // isClientReference), triggering the Proxy even when user code doesn't + // 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(); + pageProps.searchParams = makeThenableParams(spObj); + } + let element = createElement(PageComponent, pageProps); + + // Wrap page with empty segment provider so useSelectedLayoutSegments() + // returns [] when called from inside a page component (leaf node). + element = createElement(LayoutSegmentProvider, { childSegments: [] }, element); + + // Add metadata + viewport head tags (React 19 hoists title/meta/link to ) + // Next.js always injects charset and default viewport even when no metadata/viewport + // is exported. We replicate that by always emitting these essential head elements. + { + const headElements = []; + // Always emit — Next.js includes this on every page + headElements.push(createElement("meta", { charSet: "utf-8" })); + if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata })); + headElements.push(createElement(ViewportHead, { viewport: resolvedViewport })); + element = createElement(Fragment, null, ...headElements, element); + } + + // Wrap with loading.tsx Suspense if present + if (route.loading?.default) { + element = createElement( + Suspense, + { fallback: createElement(route.loading.default) }, + element, + ); + } + + // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered + // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout). + // Per-layout error boundaries are interleaved with layouts below. + { + const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null; + if (route.error?.default && route.error !== lastLayoutError) { + element = createElement(ErrorBoundary, { + fallback: route.error.default, + children: element, + }); + } + } + + // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx + // instead of crashing the React tree. Must be above ErrorBoundary since + // ErrorBoundary re-throws notFound errors. + // Pre-render the not-found component as a React element since it may be a + // server component (not a client reference) and can't be passed as a function prop. + { + const NotFoundComponent = route.notFound?.default ?? null; + if (NotFoundComponent) { + element = createElement(NotFoundBoundary, { + fallback: createElement(NotFoundComponent), + children: element, + }); + } + } + + // Wrap with templates (innermost first, then outer) + // Templates are like layouts but re-mount on navigation (client-side concern). + // On the server, they just wrap the content like layouts do. + if (route.templates) { + for (let i = route.templates.length - 1; i >= 0; i--) { + const TemplateComponent = route.templates[i]?.default; + if (TemplateComponent) { + element = createElement(TemplateComponent, { children: element, params }); + } + } + } + + // Wrap with layouts (innermost first, then outer). + // At each layout level, first wrap with that level's error boundary (if any) + // so the boundary is inside the layout and catches errors from children. + // This matches Next.js behavior: Layout > ErrorBoundary > children. + // Parallel slots are passed as named props to the innermost layout + // (the layout at the same directory level as the page/slots) + for (let i = route.layouts.length - 1; i >= 0; i--) { + // Wrap with per-layout error boundary before wrapping with layout. + // This places the ErrorBoundary inside the layout, catching errors + // from child segments (matching Next.js per-segment error handling). + if (route.errors && route.errors[i]?.default) { + element = createElement(ErrorBoundary, { + fallback: route.errors[i].default, + children: element, + }); + } + + const LayoutComponent = route.layouts[i]?.default; + if (LayoutComponent) { + // Per-layout NotFoundBoundary: wraps this layout's children so that + // notFound() thrown from a child layout is caught here. + // Matches Next.js behavior where each segment has its own boundary. + // The boundary at level N catches errors from Layout[N+1] and below, + // but NOT from Layout[N] itself (which propagates to level N-1). + { + const LayoutNotFound = route.notFounds?.[i]?.default; + if (LayoutNotFound) { + element = createElement(NotFoundBoundary, { + fallback: createElement(LayoutNotFound), + children: element, + }); + } + } + + const layoutProps = { children: element, params: makeThenableParams(params) }; + + // Add parallel slot elements to the layout that defines them. + // Each slot has a layoutIndex indicating which layout it belongs to. + if (route.slots) { + for (const [slotName, slotMod] of Object.entries(route.slots)) { + // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1 + const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1; + if (i !== targetIdx) continue; + // Check if this slot has an intercepting route that should activate + let SlotPage = null; + let slotParams = params; + + if (opts && opts.interceptSlot === slotName && opts.interceptPage) { + // Use the intercepting route's page component + SlotPage = opts.interceptPage.default; + slotParams = opts.interceptParams || params; + } else { + SlotPage = slotMod.page?.default || slotMod.default?.default; + } + + if (SlotPage) { + let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) }); + // Wrap with slot-specific layout if present. + // In Next.js, @slot/layout.tsx wraps the slot's page content + // before it is passed as a prop to the parent layout. + const SlotLayout = slotMod.layout?.default; + if (SlotLayout) { + slotElement = createElement(SlotLayout, { + children: slotElement, + params: makeThenableParams(slotParams), + }); + } + // Wrap with slot-specific loading if present + if (slotMod.loading?.default) { + slotElement = createElement(Suspense, + { fallback: createElement(slotMod.loading.default) }, + slotElement, + ); + } + // Wrap with slot-specific error boundary if present + if (slotMod.error?.default) { + slotElement = createElement(ErrorBoundary, { + fallback: slotMod.error.default, + children: slotElement, + }); + } + layoutProps[slotName] = slotElement; + } + } + } + + element = createElement(LayoutComponent, layoutProps); + + // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments() + // called INSIDE this layout gets the correct child segments. We resolve the + // route tree segments using actual param values and pass them through context. + // We wrap the layout (not just children) because hooks are called from + // components rendered inside the layout's own JSX. + const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0; + const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params); + element = createElement(LayoutSegmentProvider, { childSegments: childSegs }, element); + } + } + + // Wrap with global error boundary if app/global-error.tsx exists. + // This catches errors in the root layout itself. + + + return element; +} + + + +const __basePath = ""; +const __trailingSlash = false; +const __configRedirects = []; +const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; +const __configHeaders = []; +const __allowedOrigins = []; + + +// ── Dev server origin verification ────────────────────────────────────── +// Block cross-origin requests to prevent data exfiltration during development. +const __allowedDevOrigins = []; +const __safeDevHosts = ["localhost", "127.0.0.1", "[::1]"]; + +function __validateDevRequestOrigin(request) { + // Check Sec-Fetch headers (catches '; - const injectHTML = paramsScript + modulePreloadHTML + insertedHTML + fontHTML; + // Embed the initial navigation context (pathname + searchParams) so the + // browser useSyncExternalStore getServerSnapshot can return the correct + // value during hydration. Without this, getServerSnapshot returns "/" and + // React detects a mismatch against the SSR-rendered HTML. + // Serialise searchParams as an array of [key, value] pairs to preserve + // duplicate keys (e.g. ?tag=a&tag=b). Object.fromEntries() would keep + // only the last value, causing a hydration mismatch for multi-value params. + const __navPayload = { pathname: navContext?.pathname ?? '/', searchParams: navContext?.searchParams ? [...navContext.searchParams.entries()] : [] }; + const navScript = ''; + const injectHTML = paramsScript + navScript + modulePreloadHTML + insertedHTML + fontHTML; // Inject the collected HTML before and progressively embed RSC // chunks as script tags throughout the HTML body stream. diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index d1c4c98e..321c62e3 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -134,7 +134,7 @@ describe("App Router entry templates", () => { "", // no basePath false, // no trailingSlash ); - expect(code).toMatchSnapshot(); + expect(stabilize(code)).toMatchSnapshot(); }); it("generateRscEntry snapshot (with middleware)", () => { @@ -147,7 +147,7 @@ describe("App Router entry templates", () => { "", false, ); - expect(code).toMatchSnapshot(); + expect(stabilize(code)).toMatchSnapshot(); }); it("generateRscEntry snapshot (with instrumentation)", () => { @@ -162,7 +162,7 @@ describe("App Router entry templates", () => { undefined, "/tmp/test/instrumentation.ts", ); - expect(code).toMatchSnapshot(); + expect(stabilize(code)).toMatchSnapshot(); }); it("generateRscEntry snapshot (with global error)", () => { @@ -175,7 +175,7 @@ describe("App Router entry templates", () => { "", false, ); - expect(code).toMatchSnapshot(); + expect(stabilize(code)).toMatchSnapshot(); }); it("generateRscEntry snapshot (with config)", () => { @@ -209,7 +209,7 @@ describe("App Router entry templates", () => { true, config, ); - expect(code).toMatchSnapshot(); + expect(stabilize(code)).toMatchSnapshot(); }); it("generateRscEntry snapshot (with metadata routes)", () => { @@ -231,17 +231,17 @@ describe("App Router entry templates", () => { "", false, ); - expect(code).toMatchSnapshot(); + expect(stabilize(code)).toMatchSnapshot(); }); it("generateSsrEntry snapshot", () => { const code = generateSsrEntry(); - expect(code).toMatchSnapshot(); + expect(stabilize(code)).toMatchSnapshot(); }); it("generateBrowserEntry snapshot", () => { const code = generateBrowserEntry(); - expect(code).toMatchSnapshot(); + expect(stabilize(code)).toMatchSnapshot(); }); });