From 8c5b8e4040da34c8cfc48a43ddc19e3834cb9161 Mon Sep 17 00:00:00 2001 From: Md Yunus Date: Sat, 21 Mar 2026 14:43:22 +0530 Subject: [PATCH] fix: prevent useActionState state becoming undefined when redirect() is called Fixes issue #589 where useActionState receives undefined state when a Server Action calls redirect() from next/navigation. Root Cause: When a server action calls redirect(), the server was sending redirect headers with empty body. The client tried to use RSC navigation but received empty body, causing navigation to fail and leaving useActionState with undefined state. Solution: Use hard redirect (window.location.assign/replace) for server action redirects. This ensures navigation completes and the new page loads with fresh initial state. Server-side (app-rsc-entry.ts): - Return 303 See Other response with Location header (Next.js behavior) - Include Set-Cookie headers for cookies set during action Client-side (app-browser-entry.ts): - Detect x-action-redirect header from response - Use window.location.assign/replace for hard redirect - Respect push/replace type from redirect Changes: - packages/vinext/src/entries/app-rsc-entry.ts: Return 303 redirect response - packages/vinext/src/server/app-browser-entry.ts: Use hard redirect for action redirects - tests/e2e/app-router/server-actions.spec.ts: Add waitForHydration to test - tests/fixtures/app-basic/app/actions/actions.ts: Add redirectWithActionState test action - tests/fixtures/app-basic/app/action-state-redirect/page.tsx: Add test page - tests/__snapshots__/entry-templates.test.ts.snap: Update snapshots This matches Next.js behavior where server action redirects use 303 status code and the browser performs a full page load to the redirect target. Signed-off-by: Md Yunus Co-authored-by: Qwen-Coder --- packages/vinext/src/entries/app-rsc-entry.ts | 19 +- .../vinext/src/server/app-browser-entry.ts | 14 +- .../entry-templates.test.ts.snap | 1891 +++-------------- tests/e2e/app-router/server-actions.spec.ts | 19 + .../app/action-state-redirect/page.tsx | 22 + .../fixtures/app-basic/app/actions/actions.ts | 15 + 6 files changed, 346 insertions(+), 1634 deletions(-) create mode 100644 tests/fixtures/app-basic/app/action-state-redirect/page.tsx diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index fcceaaa60..433df40eb 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2036,28 +2036,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), return a 303 redirect response. + // This matches Next.js behavior for server action redirects. + // The client will handle the redirect and navigate to the target URL. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ - "Content-Type": "text/x-component; charset=utf-8", - "Vary": "RSC, Accept", - "x-action-redirect": actionRedirect.url, - "x-action-redirect-type": actionRedirect.type, - "x-action-redirect-status": String(actionRedirect.status), + Location: actionRedirect.url, }); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) - return new Response("", { status: 200, headers: redirectHeaders }); + + // Return 303 See Other for server action redirects (Next.js behavior) + return new Response(null, { status: 303, headers: redirectHeaders }); } // After the action, re-render the current page so the client diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 27589b830..eb2ca059f 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -133,6 +133,7 @@ function registerServerActionCallback(): void { const actionRedirect = fetchResponse.headers.get("x-action-redirect"); if (actionRedirect) { + // Check for external URLs that need a hard redirect. try { const redirectUrl = new URL(actionRedirect, window.location.origin); if (redirectUrl.origin !== window.location.origin) { @@ -140,20 +141,17 @@ function registerServerActionCallback(): void { return undefined; } } catch { - // Fall through to client-side navigation if URL parsing fails. + // Fall through to hard redirect below if URL parsing fails. } + // For same-origin redirects, use hard redirect to ensure navigation completes. + // This matches Next.js behavior for server action redirects with useActionState. const redirectType = fetchResponse.headers.get("x-action-redirect-type") ?? "replace"; if (redirectType === "push") { - window.history.pushState(null, "", actionRedirect); + window.location.assign(actionRedirect); } else { - window.history.replaceState(null, "", actionRedirect); + window.location.replace(actionRedirect); } - - if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { - await window.__VINEXT_RSC_NAVIGATE__(actionRedirect); - } - return undefined; } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 771476fb9..f7594387f 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1748,13 +1748,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -1768,7 +1810,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -4718,13 +4759,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -4738,7 +4821,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -7715,13 +7797,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -7735,7 +7859,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -10723,13 +10846,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -10743,7 +10908,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -13697,13 +13861,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -13717,7 +13923,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -17024,13 +17229,55 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setHeadersAccessPhase(previousHeadersPhase); } - // If the action called redirect(), signal the client to navigate. - // We can't use a real HTTP redirect (the fetch would follow it automatically - // and receive a page HTML instead of RSC stream). Instead, we return a 200 - // with x-action-redirect header that the client entry detects and handles. + // If the action called redirect(), render the redirect target's RSC payload + // and signal the client to navigate. This matches Next.js behavior where + // the redirect response includes the target page's RSC payload for soft navigation. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); + + // Match the redirect target to a route and render its RSC payload + const redirectPathname = actionRedirect.url.startsWith("/") + ? actionRedirect.url.split("?")[0] + : actionRedirect.url; + const redirectMatch = matchRoute(redirectPathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + setNavigationContext({ + pathname: redirectPathname, + searchParams: new URLSearchParams(actionRedirect.url.split("?")[1] || ""), + params: redirectParams, + }); + const redirectElement = buildPageElement(redirectRoute, redirectParams, undefined, new URLSearchParams(actionRedirect.url.split("?")[1] || "")); + + const onRenderError = createRscOnErrorHandler( + request, + redirectPathname, + redirectMatch.route.pattern, + ); + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue: undefined }, + { temporaryReferences, onError: onRenderError }, + ); + + setHeadersContext(null); + const redirectHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + "x-action-redirect": actionRedirect.url, + "x-action-redirect-type": actionRedirect.type, + "x-action-redirect-status": String(actionRedirect.status), + }); + for (const cookie of actionPendingCookies) { + redirectHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); + + return new Response(rscStream, { status: 200, headers: redirectHeaders }); + } + + // Fallback: redirect target not found, send headers only setHeadersContext(null); setNavigationContext(null); const redirectHeaders = new Headers({ @@ -17044,7 +17291,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { redirectHeaders.append("Set-Cookie", cookie); } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); - // Send an empty RSC-like body (client will navigate instead of parsing) return new Response("", { status: 200, headers: redirectHeaders }); } @@ -18251,1588 +18497,3 @@ export * from "/packages/vinext/src/server/app-ssr-entry.ts"; export { default } from "/packages/vinext/src/server/app-ssr-entry.ts"; " `; - -exports[`Pages Router entry templates > client entry snapshot 1`] = ` -" -import React from "react"; -import { hydrateRoot } from "react-dom/client"; -// Eagerly import the router shim so its module-level popstate listener is -// registered. Without this, browser back/forward buttons do nothing because -// navigateClient() is never invoked on history changes. -import "next/router"; - -const pageLoaders = { - "/": () => import("/tests/fixtures/pages-basic/pages/index.tsx"), - "/404": () => import("/tests/fixtures/pages-basic/pages/404.tsx"), - "/about": () => import("/tests/fixtures/pages-basic/pages/about.tsx"), - "/alias-test": () => import("/tests/fixtures/pages-basic/pages/alias-test.tsx"), - "/before-pop-state-destination": () => import("/tests/fixtures/pages-basic/pages/before-pop-state-destination.tsx"), - "/before-pop-state-test": () => import("/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx"), - "/cjs/basic": () => import("/tests/fixtures/pages-basic/pages/cjs/basic.tsx"), - "/cjs/random": () => import("/tests/fixtures/pages-basic/pages/cjs/random.ts"), - "/compat-router-test": () => import("/tests/fixtures/pages-basic/pages/compat-router-test.tsx"), - "/concurrent-head": () => import("/tests/fixtures/pages-basic/pages/concurrent-head.tsx"), - "/concurrent-router": () => import("/tests/fixtures/pages-basic/pages/concurrent-router.tsx"), - "/config-test": () => import("/tests/fixtures/pages-basic/pages/config-test.tsx"), - "/counter": () => import("/tests/fixtures/pages-basic/pages/counter.tsx"), - "/dynamic-page": () => import("/tests/fixtures/pages-basic/pages/dynamic-page.tsx"), - "/dynamic-ssr-false": () => import("/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"), - "/header-override-delete": () => import("/tests/fixtures/pages-basic/pages/header-override-delete.tsx"), - "/isr-second-render-state": () => import("/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"), - "/isr-test": () => import("/tests/fixtures/pages-basic/pages/isr-test.tsx"), - "/link-test": () => import("/tests/fixtures/pages-basic/pages/link-test.tsx"), - "/mw-object-gated": () => import("/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"), - "/nav-test": () => import("/tests/fixtures/pages-basic/pages/nav-test.tsx"), - "/posts/missing": () => import("/tests/fixtures/pages-basic/pages/posts/missing.tsx"), - "/redirect-xss": () => import("/tests/fixtures/pages-basic/pages/redirect-xss.tsx"), - "/router-events-test": () => import("/tests/fixtures/pages-basic/pages/router-events-test.tsx"), - "/script-test": () => import("/tests/fixtures/pages-basic/pages/script-test.tsx"), - "/shallow-test": () => import("/tests/fixtures/pages-basic/pages/shallow-test.tsx"), - "/ssr": () => import("/tests/fixtures/pages-basic/pages/ssr.tsx"), - "/ssr-headers": () => import("/tests/fixtures/pages-basic/pages/ssr-headers.tsx"), - "/ssr-res-end": () => import("/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"), - "/suspense-test": () => import("/tests/fixtures/pages-basic/pages/suspense-test.tsx"), - "/articles/[id]": () => import("/tests/fixtures/pages-basic/pages/articles/[id].tsx"), - "/blog/[slug]": () => import("/tests/fixtures/pages-basic/pages/blog/[slug].tsx"), - "/posts/[id]": () => import("/tests/fixtures/pages-basic/pages/posts/[id].tsx"), - "/products/[pid]": () => import("/tests/fixtures/pages-basic/pages/products/[pid].tsx"), - "/docs/[...slug]": () => import("/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"), - "/sign-up/[[...sign-up]]": () => import("/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx") -}; - -async function hydrate() { - const nextData = window.__NEXT_DATA__; - if (!nextData) { - console.error("[vinext] No __NEXT_DATA__ found"); - return; - } - - const { pageProps } = nextData.props; - const loader = pageLoaders[nextData.page]; - if (!loader) { - console.error("[vinext] No page loader for route:", nextData.page); - return; - } - - const pageModule = await loader(); - const PageComponent = pageModule.default; - if (!PageComponent) { - console.error("[vinext] Page module has no default export"); - return; - } - - let element; - - try { - const appModule = await import("/tests/fixtures/pages-basic/pages/_app.tsx"); - const AppComponent = appModule.default; - window.__VINEXT_APP__ = AppComponent; - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); - } catch { - element = React.createElement(PageComponent, pageProps); - } - - - // Wrap with RouterContext.Provider so next/compat/router works during hydration - const { wrapWithRouterContext } = await import("next/router"); - element = wrapWithRouterContext(element); - - const container = document.getElementById("__next"); - if (!container) { - console.error("[vinext] No #__next element found"); - return; - } - - const root = hydrateRoot(container, element); - window.__VINEXT_ROOT__ = root; -} - -hydrate(); -" -`; - -exports[`Pages Router entry templates > server entry snapshot 1`] = ` -" -import React from "react"; -import { renderToReadableStream } from "react-dom/server.edge"; -import { resetSSRHead, getSSRHeadHTML } from "next/head"; -import { flushPreloads } from "next/dynamic"; -import { setSSRContext, wrapWithRouterContext } from "next/router"; -import { getCacheHandler, _runWithCacheState } from "next/cache"; -import { runWithPrivateCache } from "vinext/cache-runtime"; -import { ensureFetchPatch, runWithFetchCache } from "vinext/fetch-cache"; -import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; -import "vinext/router-state"; -import { runWithServerInsertedHTMLState } from "vinext/navigation-state"; -import { runWithHeadState } from "vinext/head-state"; -import "vinext/i18n-state"; -import { setI18nContext } from "vinext/i18n-context"; -import { safeJsonStringify } from "vinext/html"; -import { decode as decodeQueryString } from "node:querystring"; -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 { parseCookies, sanitizeDestination as sanitizeDestinationLocal } from "/packages/vinext/src/config/config-matchers.js"; -import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; -import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; -import { resolvePagesI18nRequest } from "/packages/vinext/src/server/pages-i18n.js"; -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; - -// Build ID (embedded at build time) -const buildId = "test-build-id"; - -// Full resolved config for production server (embedded at build time) -export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false},{"source":"/redirect-before-middleware-rewrite","destination":"/about","permanent":false},{"source":"/redirect-before-middleware-response","destination":"/about","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]},{"source":"/headers-before-middleware-rewrite","headers":[{"key":"X-Rewrite-Source-Header","value":"1"}]}],"i18n":null,"images":{}}; - -class ApiBodyParseError extends Error { - constructor(message, statusCode) { - super(message); - this.statusCode = statusCode; - this.name = "ApiBodyParseError"; - } -} - -// ISR cache helpers (inlined for the server entry) -async function isrGet(key) { - const handler = getCacheHandler(); - const result = await handler.get(key); - if (!result || !result.value) return null; - return { value: result, isStale: result.cacheState === "stale" }; -} -async function isrSet(key, data, revalidateSeconds, tags) { - const handler = getCacheHandler(); - await handler.set(key, data, { revalidate: revalidateSeconds, tags: tags || [] }); -} -const pendingRegenerations = new Map(); -function triggerBackgroundRegeneration(key, renderFn) { - if (pendingRegenerations.has(key)) return; - const promise = renderFn() - .catch((err) => console.error("[vinext] ISR regen failed for " + key + ":", err)) - .finally(() => pendingRegenerations.delete(key)); - pendingRegenerations.set(key, promise); - // Register with the Workers ExecutionContext so the isolate is kept alive - // until the regeneration finishes, even after the Response has been sent. - const ctx = _getRequestExecutionContext(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(promise); -} - -function fnv1a64(input) { - let h1 = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - h1 ^= input.charCodeAt(i); - h1 = (h1 * 0x01000193) >>> 0; - } - let h2 = 0x050c5d1f; - for (let i = 0; i < input.length; i++) { - h2 ^= input.charCodeAt(i); - h2 = (h2 * 0x01000193) >>> 0; - } - return h1.toString(36) + h2.toString(36); -} -// Keep prefix construction and hashing logic in sync with isrCacheKey() in server/isr-cache.ts. -// buildId is a top-level const in the generated entry (see "const buildId = ..." above). -function isrCacheKey(router, pathname) { - const normalized = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); - const prefix = buildId ? router + ":" + buildId : router; - const key = prefix + ":" + normalized; - if (key.length <= 200) return key; - return prefix + ":__hash:" + fnv1a64(normalized); -} - -function getMediaType(contentType) { - var type = (contentType || "text/plain").split(";")[0]; - type = type && type.trim().toLowerCase(); - return type || "text/plain"; -} - -function isJsonMediaType(mediaType) { - return mediaType === "application/json" || mediaType === "application/ld+json"; -} - -async function renderToStringAsync(element) { - const stream = await renderToReadableStream(element); - await stream.allReady; - return new Response(stream).text(); -} - -async function renderIsrPassToStringAsync(element) { - // The cache-fill render is a second render pass for the same request. - // Reset render-scoped state so it cannot leak from the streamed response - // render or affect async work that is still draining from that stream. - // Keep request identity state (pathname/query/locale/executionContext) - // intact: this second pass still belongs to the same request. - return await runWithServerInsertedHTMLState(() => - runWithHeadState(() => - _runWithCacheState(() => - runWithPrivateCache(() => runWithFetchCache(async () => renderToStringAsync(element))), - ), - ), - ); -} - -import * as page_0 from "/tests/fixtures/pages-basic/pages/index.tsx"; -import * as page_1 from "/tests/fixtures/pages-basic/pages/404.tsx"; -import * as page_2 from "/tests/fixtures/pages-basic/pages/about.tsx"; -import * as page_3 from "/tests/fixtures/pages-basic/pages/alias-test.tsx"; -import * as page_4 from "/tests/fixtures/pages-basic/pages/before-pop-state-destination.tsx"; -import * as page_5 from "/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx"; -import * as page_6 from "/tests/fixtures/pages-basic/pages/cjs/basic.tsx"; -import * as page_7 from "/tests/fixtures/pages-basic/pages/cjs/random.ts"; -import * as page_8 from "/tests/fixtures/pages-basic/pages/compat-router-test.tsx"; -import * as page_9 from "/tests/fixtures/pages-basic/pages/concurrent-head.tsx"; -import * as page_10 from "/tests/fixtures/pages-basic/pages/concurrent-router.tsx"; -import * as page_11 from "/tests/fixtures/pages-basic/pages/config-test.tsx"; -import * as page_12 from "/tests/fixtures/pages-basic/pages/counter.tsx"; -import * as page_13 from "/tests/fixtures/pages-basic/pages/dynamic-page.tsx"; -import * as page_14 from "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"; -import * as page_15 from "/tests/fixtures/pages-basic/pages/header-override-delete.tsx"; -import * as page_16 from "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"; -import * as page_17 from "/tests/fixtures/pages-basic/pages/isr-test.tsx"; -import * as page_18 from "/tests/fixtures/pages-basic/pages/link-test.tsx"; -import * as page_19 from "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"; -import * as page_20 from "/tests/fixtures/pages-basic/pages/nav-test.tsx"; -import * as page_21 from "/tests/fixtures/pages-basic/pages/posts/missing.tsx"; -import * as page_22 from "/tests/fixtures/pages-basic/pages/redirect-xss.tsx"; -import * as page_23 from "/tests/fixtures/pages-basic/pages/router-events-test.tsx"; -import * as page_24 from "/tests/fixtures/pages-basic/pages/script-test.tsx"; -import * as page_25 from "/tests/fixtures/pages-basic/pages/shallow-test.tsx"; -import * as page_26 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; -import * as page_27 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; -import * as page_28 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; -import * as page_29 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; -import * as page_30 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; -import * as page_31 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; -import * as page_32 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; -import * as page_33 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; -import * as page_34 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; -import * as page_35 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; -import * as api_0 from "/tests/fixtures/pages-basic/pages/api/binary.ts"; -import * as api_1 from "/tests/fixtures/pages-basic/pages/api/echo-body.ts"; -import * as api_2 from "/tests/fixtures/pages-basic/pages/api/error-route.ts"; -import * as api_3 from "/tests/fixtures/pages-basic/pages/api/hello.ts"; -import * as api_4 from "/tests/fixtures/pages-basic/pages/api/instrumentation-test.ts"; -import * as api_5 from "/tests/fixtures/pages-basic/pages/api/middleware-test.ts"; -import * as api_6 from "/tests/fixtures/pages-basic/pages/api/no-content-type.ts"; -import * as api_7 from "/tests/fixtures/pages-basic/pages/api/parse.ts"; -import * as api_8 from "/tests/fixtures/pages-basic/pages/api/send-buffer.ts"; -import * as api_9 from "/tests/fixtures/pages-basic/pages/api/users/[id].ts"; - -import { default as AppComponent } from "/tests/fixtures/pages-basic/pages/_app.tsx"; -import { default as DocumentComponent } from "/tests/fixtures/pages-basic/pages/_document.tsx"; - -export const pageRoutes = [ - { pattern: "/", patternParts: [], isDynamic: false, params: [], module: page_0, filePath: "/tests/fixtures/pages-basic/pages/index.tsx" }, - { pattern: "/404", patternParts: ["404"], isDynamic: false, params: [], module: page_1, filePath: "/tests/fixtures/pages-basic/pages/404.tsx" }, - { pattern: "/about", patternParts: ["about"], isDynamic: false, params: [], module: page_2, filePath: "/tests/fixtures/pages-basic/pages/about.tsx" }, - { pattern: "/alias-test", patternParts: ["alias-test"], isDynamic: false, params: [], module: page_3, filePath: "/tests/fixtures/pages-basic/pages/alias-test.tsx" }, - { pattern: "/before-pop-state-destination", patternParts: ["before-pop-state-destination"], isDynamic: false, params: [], module: page_4, filePath: "/tests/fixtures/pages-basic/pages/before-pop-state-destination.tsx" }, - { pattern: "/before-pop-state-test", patternParts: ["before-pop-state-test"], isDynamic: false, params: [], module: page_5, filePath: "/tests/fixtures/pages-basic/pages/before-pop-state-test.tsx" }, - { pattern: "/cjs/basic", patternParts: ["cjs","basic"], isDynamic: false, params: [], module: page_6, filePath: "/tests/fixtures/pages-basic/pages/cjs/basic.tsx" }, - { pattern: "/cjs/random", patternParts: ["cjs","random"], isDynamic: false, params: [], module: page_7, filePath: "/tests/fixtures/pages-basic/pages/cjs/random.ts" }, - { pattern: "/compat-router-test", patternParts: ["compat-router-test"], isDynamic: false, params: [], module: page_8, filePath: "/tests/fixtures/pages-basic/pages/compat-router-test.tsx" }, - { pattern: "/concurrent-head", patternParts: ["concurrent-head"], isDynamic: false, params: [], module: page_9, filePath: "/tests/fixtures/pages-basic/pages/concurrent-head.tsx" }, - { pattern: "/concurrent-router", patternParts: ["concurrent-router"], isDynamic: false, params: [], module: page_10, filePath: "/tests/fixtures/pages-basic/pages/concurrent-router.tsx" }, - { pattern: "/config-test", patternParts: ["config-test"], isDynamic: false, params: [], module: page_11, filePath: "/tests/fixtures/pages-basic/pages/config-test.tsx" }, - { pattern: "/counter", patternParts: ["counter"], isDynamic: false, params: [], module: page_12, filePath: "/tests/fixtures/pages-basic/pages/counter.tsx" }, - { pattern: "/dynamic-page", patternParts: ["dynamic-page"], isDynamic: false, params: [], module: page_13, filePath: "/tests/fixtures/pages-basic/pages/dynamic-page.tsx" }, - { pattern: "/dynamic-ssr-false", patternParts: ["dynamic-ssr-false"], isDynamic: false, params: [], module: page_14, filePath: "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx" }, - { pattern: "/header-override-delete", patternParts: ["header-override-delete"], isDynamic: false, params: [], module: page_15, filePath: "/tests/fixtures/pages-basic/pages/header-override-delete.tsx" }, - { pattern: "/isr-second-render-state", patternParts: ["isr-second-render-state"], isDynamic: false, params: [], module: page_16, filePath: "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx" }, - { pattern: "/isr-test", patternParts: ["isr-test"], isDynamic: false, params: [], module: page_17, filePath: "/tests/fixtures/pages-basic/pages/isr-test.tsx" }, - { pattern: "/link-test", patternParts: ["link-test"], isDynamic: false, params: [], module: page_18, filePath: "/tests/fixtures/pages-basic/pages/link-test.tsx" }, - { pattern: "/mw-object-gated", patternParts: ["mw-object-gated"], isDynamic: false, params: [], module: page_19, filePath: "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx" }, - { pattern: "/nav-test", patternParts: ["nav-test"], isDynamic: false, params: [], module: page_20, filePath: "/tests/fixtures/pages-basic/pages/nav-test.tsx" }, - { pattern: "/posts/missing", patternParts: ["posts","missing"], isDynamic: false, params: [], module: page_21, filePath: "/tests/fixtures/pages-basic/pages/posts/missing.tsx" }, - { pattern: "/redirect-xss", patternParts: ["redirect-xss"], isDynamic: false, params: [], module: page_22, filePath: "/tests/fixtures/pages-basic/pages/redirect-xss.tsx" }, - { pattern: "/router-events-test", patternParts: ["router-events-test"], isDynamic: false, params: [], module: page_23, filePath: "/tests/fixtures/pages-basic/pages/router-events-test.tsx" }, - { pattern: "/script-test", patternParts: ["script-test"], isDynamic: false, params: [], module: page_24, filePath: "/tests/fixtures/pages-basic/pages/script-test.tsx" }, - { pattern: "/shallow-test", patternParts: ["shallow-test"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/shallow-test.tsx" }, - { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, - { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, - { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, - { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, - { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, - { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, - { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_32, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, - { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_33, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, - { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_34, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, - { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_35, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } -]; -const _pageRouteTrie = _buildRouteTrie(pageRoutes); - -const apiRoutes = [ - { pattern: "/api/binary", patternParts: ["api","binary"], isDynamic: false, params: [], module: api_0 }, - { pattern: "/api/echo-body", patternParts: ["api","echo-body"], isDynamic: false, params: [], module: api_1 }, - { pattern: "/api/error-route", patternParts: ["api","error-route"], isDynamic: false, params: [], module: api_2 }, - { pattern: "/api/hello", patternParts: ["api","hello"], isDynamic: false, params: [], module: api_3 }, - { pattern: "/api/instrumentation-test", patternParts: ["api","instrumentation-test"], isDynamic: false, params: [], module: api_4 }, - { pattern: "/api/middleware-test", patternParts: ["api","middleware-test"], isDynamic: false, params: [], module: api_5 }, - { pattern: "/api/no-content-type", patternParts: ["api","no-content-type"], isDynamic: false, params: [], module: api_6 }, - { pattern: "/api/parse", patternParts: ["api","parse"], isDynamic: false, params: [], module: api_7 }, - { pattern: "/api/send-buffer", patternParts: ["api","send-buffer"], isDynamic: false, params: [], module: api_8 }, - { pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_9 } -]; -const _apiRouteTrie = _buildRouteTrie(apiRoutes); - -function matchRoute(url, routes) { - const pathname = url.split("?")[0]; - let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); - // NOTE: Do NOT decodeURIComponent here. The pathname is already decoded at - // the entry point. Decoding again would create a double-decode vector. - const urlParts = normalizedUrl.split("/").filter(Boolean); - const trie = routes === pageRoutes ? _pageRouteTrie : _apiRouteTrie; - return _trieMatch(trie, urlParts); -} - -function parseQuery(url) { - const qs = url.split("?")[1]; - if (!qs) return {}; - const p = new URLSearchParams(qs); - const q = {}; - for (const [k, v] of p) { - if (k in q) { - q[k] = Array.isArray(q[k]) ? q[k].concat(v) : [q[k], v]; - } else { - q[k] = v; - } - } - return q; -} - -function patternToNextFormat(pattern) { - return pattern - .replace(/:([\\w]+)\\*/g, "[[...$1]]") - .replace(/:([\\w]+)\\+/g, "[...$1]") - .replace(/:([\\w]+)/g, "[$1]"); -} - -function collectAssetTags(manifest, moduleIds) { - // Fall back to embedded manifest (set by vinext:cloudflare-build for Workers) - const m = (manifest && Object.keys(manifest).length > 0) - ? manifest - : (typeof globalThis !== "undefined" && globalThis.__VINEXT_SSR_MANIFEST__) || null; - const tags = []; - const seen = new Set(); - - // Load the set of lazy chunk filenames (only reachable via dynamic imports). - // These should NOT get or '); - } - 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; -} - -// 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 (Buffer.isBuffer(data)) { - if (!resHeaders["content-type"]) resHeaders["content-type"] = "application/octet-stream"; - resHeaders["content-length"] = String(data.length); - res.end(data); - } else 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, ctx) { - if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest)); - return _renderPage(request, url, manifest); -} - -async function _renderPage(request, url, manifest) { - const localeInfo = i18nConfig - ? resolvePagesI18nRequest( - url, - i18nConfig, - request.headers, - new URL(request.url).hostname, - vinextConfig.basePath, - vinextConfig.trailingSlash, - ) - : { locale: undefined, url, hadPrefix: false, domainLocale: undefined, redirectUrl: undefined }; - const locale = localeInfo.locale; - const routeUrl = localeInfo.url; - const currentDefaultLocale = i18nConfig - ? (localeInfo.domainLocale ? localeInfo.domainLocale.defaultLocale : i18nConfig.defaultLocale) - : undefined; - const domainLocales = i18nConfig ? i18nConfig.domains : undefined; - - if (localeInfo.redirectUrl) { - return new Response(null, { status: 307, headers: { Location: localeInfo.redirectUrl } }); - } - - 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; - const __uCtx = _createUnifiedCtx({ - executionContext: _getRequestExecutionContext(), - }); - return _runWithUnifiedCtx(__uCtx, async () => { - ensureFetchPatch(); - try { - if (typeof setSSRContext === "function") { - setSSRContext({ - pathname: patternToNextFormat(route.pattern), - query: { ...params, ...parseQuery(routeUrl) }, - asPath: routeUrl, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - }); - } - - if (i18nConfig) { - setI18nContext({ - locale: locale, - locales: i18nConfig.locales, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - hostname: new URL(request.url).hostname, - }); - } - - 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: currentDefaultLocale || "", - }); - 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: currentDefaultLocale, - }; - 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 = isrCacheKey("pages", pathname); - 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() { - var revalCtx = _createUnifiedCtx({ - executionContext: _getRequestExecutionContext(), - }); - return _runWithUnifiedCtx(revalCtx, async () => { - ensureFetchPatch(); - var freshResult = await pageModule.getStaticProps({ - params: params, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, - }); - if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { - var _fp = freshResult.props; - if (typeof setSSRContext === "function") { - setSSRContext({ - pathname: patternToNextFormat(route.pattern), - query: { ...params, ...parseQuery(routeUrl) }, - asPath: routeUrl, - locale: locale, - locales: i18nConfig ? i18nConfig.locales : undefined, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - }); - } - if (i18nConfig) { - setI18nContext({ - locale: locale, - locales: i18nConfig.locales, - defaultLocale: currentDefaultLocale, - domainLocales: domainLocales, - hostname: new URL(request.url).hostname, - }); - } - // Re-render the page with fresh props inside fresh render sub-scopes - // so head/cache state cannot leak across passes. - var _el = AppComponent - ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp }) - : React.createElement(PageComponent, _fp); - _el = wrapWithRouterContext(_el); - var _freshBody = await renderIsrPassToStringAsync(_el); - // Rebuild __NEXT_DATA__ with fresh props - var _regenPayload = { - props: { pageProps: _fp }, page: patternToNextFormat(route.pattern), - query: params, buildId: buildId, isFallback: false, - }; - if (i18nConfig) { - _regenPayload.locale = locale; - _regenPayload.locales = i18nConfig.locales; - _regenPayload.defaultLocale = currentDefaultLocale; - _regenPayload.domainLocales = domainLocales; - } - var _lGlobals = i18nConfig - ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + - ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + - ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(currentDefaultLocale) - : ""; - var _freshNDS = ""; - // Reconstruct ISR HTML preserving the document shell from the - // cached entry (head, fonts, assets, custom _document markup). - var _cachedStr = cached.value.value.html; - var _btag = '
'; - var _bstart = _cachedStr.indexOf(_btag); - var _bodyStart = _bstart >= 0 ? _bstart + _btag.length : -1; - // Locate __NEXT_DATA__ script to split body from suffix - var _ndMarker = ' - var _ndEnd = _cachedStr.indexOf('', _ndStart) + 9; - var _tail = _cachedStr.slice(_ndEnd); - _freshHtml = _cachedStr.slice(0, _bodyStart) + _freshBody + '
' + _gap + _freshNDS + _tail; - } else { - _freshHtml = '\\n\\n\\n\\n\\n
' + _freshBody + '
\\n ' + _freshNDS + '\\n\\n'; - } - await isrSet(cacheKey, { kind: "PAGES", html: _freshHtml, pageData: _fp, 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: currentDefaultLocale, - }; - 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, buildId, isFallback: false, - }; - if (i18nConfig) { - nextDataPayload.locale = locale; - nextDataPayload.locales = i18nConfig.locales; - nextDataPayload.defaultLocale = currentDefaultLocale; - nextDataPayload.domainLocales = domainLocales; - } - const localeGlobals = i18nConfig - ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + - ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + - ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(currentDefaultLocale) - : ""; - 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 renderIsrPassToStringAsync(isrElement); - var fullHtml = shellPrefix + isrHtml + shellSuffix; - var isrPathname = url.split("?")[0]; - var _cacheKey = isrCacheKey("pages", isrPathname); - await isrSet(_cacheKey, { 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); - _reportRequestError( - e instanceof Error ? e : new Error(String(e)), - { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, - { routerKind: "Pages Router", routePath: route.pattern, routeType: "render" }, - ).catch(() => { /* ignore reporting errors */ }); - return new Response("Internal Server Error", { status: 500 }); - } - }); -} - -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 }); - } - try { - let body; - const mediaType = getMediaType(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 = isJsonMediaType(mediaType) - ? {} - : mediaType === "application/x-www-form-urlencoded" - ? decodeQueryString(rawBody) - : undefined; - } else if (isJsonMediaType(mediaType)) { - try { body = JSON.parse(rawBody); } - catch { throw new ApiBodyParseError("Invalid JSON", 400); } - } else if (mediaType === "application/x-www-form-urlencoded") { - body = decodeQueryString(rawBody); - } else { - body = rawBody; - } - - const { req, res, responsePromise } = createReqRes(request, url, query, body); - 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) { - if (e instanceof ApiBodyParseError) { - return new Response(e.message, { status: e.statusCode, statusText: e.message }); - } - console.error("[vinext] API error:", e); - _reportRequestError( - e instanceof Error ? e : new Error(String(e)), - { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, - { routerKind: "Pages Router", routePath: route.pattern, routeType: "route" }, - ); - 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("/"); -} - -var __pathDelimiterRegex = /([/#?\\\\]|%(2f|23|3f|5c))/gi; -function __decodeRouteSegment(segment) { - return decodeURIComponent(segment).replace(__pathDelimiterRegex, function (char) { - return encodeURIComponent(char); - }); -} -function __decodeRouteSegmentSafe(segment) { - try { return __decodeRouteSegment(segment); } catch (e) { return segment; } -} -function __normalizePathnameForRouteMatch(pathname) { - var segments = pathname.split("/"); - var normalized = []; - for (var i = 0; i < segments.length; i++) { - normalized.push(__decodeRouteSegmentSafe(segments[i])); - } - return normalized.join("/"); -} -function __normalizePathnameForRouteMatchStrict(pathname) { - var segments = pathname.split("/"); - var normalized = []; - for (var i = 0; i < segments.length; i++) { - normalized.push(__decodeRouteSegment(segments[i])); - } - return normalized.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; } -} - -var __mwPatternCache = new Map(); -function __extractConstraint(str, re) { - if (str[re.lastIndex] !== "(") return null; - var start = re.lastIndex + 1; - var depth = 1; - var i = start; - while (i < str.length && depth > 0) { - if (str[i] === "(") depth++; - else if (str[i] === ")") depth--; - i++; - } - if (depth !== 0) return null; - re.lastIndex = i; - return str.slice(start, i - 1); -} -function __compileMwPattern(pattern) { - var hasConstraints = /:[\\w-]+[*+]?\\(/.test(pattern); - if (!hasConstraints && (pattern.includes("(") || pattern.includes("\\\\"))) { - return __safeRegExp("^" + pattern + "$"); - } - var regexStr = ""; - var tokenRe = /\\/:([\\w-]+)\\*|\\/:([\\w-]+)\\+|:([\\w-]+)|[.]|[^/:.]+|./g; - var tok; - while ((tok = tokenRe.exec(pattern)) !== null) { - if (tok[1] !== undefined) { - var c1 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - regexStr += c1 !== null ? "(?:/(" + c1 + "))?" : "(?:/.*)?"; - } - else if (tok[2] !== undefined) { - var c2 = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - regexStr += c2 !== null ? "(?:/(" + c2 + "))" : "(?:/.+)"; - } - else if (tok[3] !== undefined) { - var constraint = hasConstraints ? __extractConstraint(pattern, tokenRe) : null; - var isOptional = pattern[tokenRe.lastIndex] === "?"; - if (isOptional) tokenRe.lastIndex += 1; - var group = constraint !== null ? "(" + constraint + ")" : "([^/]+)"; - if (isOptional && regexStr.endsWith("/")) { - regexStr = regexStr.slice(0, -1) + "(?:/" + group + ")?"; - } else if (isOptional) { - regexStr += group + "?"; - } else { - regexStr += group; - } - } - else if (tok[0] === ".") { regexStr += "\\\\."; } - else { regexStr += tok[0]; } - } - return __safeRegExp("^" + regexStr + "$"); -} -function matchMiddlewarePattern(pathname, pattern) { - var cached = __mwPatternCache.get(pattern); - if (cached === undefined) { - cached = __compileMwPattern(pattern); - __mwPatternCache.set(pattern, cached); - } - return cached ? cached.test(pathname) : pathname === pattern; -} - -var __middlewareConditionRegexCache = new Map(); -// Requestless matcher checks reuse this singleton. Treat it as immutable. -var __emptyMiddlewareRequestContext = { - headers: new Headers(), - cookies: {}, - query: new URLSearchParams(), - host: "", -}; - -function __normalizeMiddlewareHost(hostHeader, fallbackHostname) { - var host = hostHeader ?? fallbackHostname; - return host.split(":", 1)[0].toLowerCase(); -} - -function __parseMiddlewareCookies(cookieHeader) { - if (!cookieHeader) return {}; - var cookies = {}; - for (var part of cookieHeader.split(";")) { - var eq = part.indexOf("="); - if (eq === -1) continue; - var key = part.slice(0, eq).trim(); - var value = part.slice(eq + 1).trim(); - if (key) cookies[key] = value; - } - return cookies; -} - -function __middlewareRequestContextFromRequest(request) { - if (!request) return __emptyMiddlewareRequestContext; - var url = new URL(request.url); - return { - headers: request.headers, - cookies: __parseMiddlewareCookies(request.headers.get("cookie")), - query: url.searchParams, - host: __normalizeMiddlewareHost(request.headers.get("host"), url.hostname), - }; -} - -function __stripMiddlewareLocalePrefix(pathname, i18nConfig) { - if (pathname === "/") return null; - var segments = pathname.split("/"); - var firstSegment = segments[1]; - if (!firstSegment || !i18nConfig || !i18nConfig.locales.includes(firstSegment)) { - return null; - } - var stripped = "/" + segments.slice(2).join("/"); - return stripped === "/" ? "/" : stripped.replace(/\\/+$/, "") || "/"; -} - -function __matchMiddlewareMatcherPattern(pathname, pattern, i18nConfig) { - if (!i18nConfig) return matchMiddlewarePattern(pathname, pattern); - var localeStrippedPathname = __stripMiddlewareLocalePrefix(pathname, i18nConfig); - return matchMiddlewarePattern(localeStrippedPathname ?? pathname, pattern); -} - -function __middlewareConditionRegex(value) { - if (__middlewareConditionRegexCache.has(value)) { - return __middlewareConditionRegexCache.get(value); - } - var re = __safeRegExp(value); - __middlewareConditionRegexCache.set(value, re); - return re; -} - -function __checkMiddlewareCondition(condition, ctx) { - switch (condition.type) { - case "header": { - var headerValue = ctx.headers.get(condition.key); - if (headerValue === null) return false; - if (condition.value !== undefined) { - var re = __middlewareConditionRegex(condition.value); - if (re) return re.test(headerValue); - return headerValue === condition.value; - } - return true; - } - case "cookie": { - var cookieValue = ctx.cookies[condition.key]; - if (cookieValue === undefined) return false; - if (condition.value !== undefined) { - var re = __middlewareConditionRegex(condition.value); - if (re) return re.test(cookieValue); - return cookieValue === condition.value; - } - return true; - } - case "query": { - var queryValue = ctx.query.get(condition.key); - if (queryValue === null) return false; - if (condition.value !== undefined) { - var re = __middlewareConditionRegex(condition.value); - if (re) return re.test(queryValue); - return queryValue === condition.value; - } - return true; - } - case "host": { - if (condition.value !== undefined) { - var re = __middlewareConditionRegex(condition.value); - if (re) return re.test(ctx.host); - return ctx.host === condition.value; - } - return ctx.host === condition.key; - } - default: - return false; - } -} - -function __checkMiddlewareHasConditions(has, missing, ctx) { - if (has) { - for (var condition of has) { - if (!__checkMiddlewareCondition(condition, ctx)) return false; - } - } - if (missing) { - for (var condition of missing) { - if (__checkMiddlewareCondition(condition, ctx)) return false; - } - } - return true; -} - -// Keep this in sync with isValidMiddlewareMatcherObject in middleware.ts. -function __isValidMiddlewareMatcherObject(matcher) { - if (!matcher || typeof matcher !== "object" || Array.isArray(matcher)) return false; - if (typeof matcher.source !== "string") return false; - for (var key of Object.keys(matcher)) { - if (key !== "source" && key !== "locale" && key !== "has" && key !== "missing") { - return false; - } - } - if ("locale" in matcher && matcher.locale !== undefined && matcher.locale !== false) return false; - if ("has" in matcher && matcher.has !== undefined && !Array.isArray(matcher.has)) return false; - if ("missing" in matcher && matcher.missing !== undefined && !Array.isArray(matcher.missing)) { - return false; - } - return true; -} - -function __matchMiddlewareObject(pathname, matcher, i18nConfig) { - return matcher.locale === false - ? matchMiddlewarePattern(pathname, matcher.source) - : __matchMiddlewareMatcherPattern(pathname, matcher.source, i18nConfig); -} - -function matchesMiddleware(pathname, matcher, request, i18nConfig) { - if (!matcher) { - return true; - } - if (typeof matcher === "string") { - return __matchMiddlewareMatcherPattern(pathname, matcher, i18nConfig); - } - if (!Array.isArray(matcher)) { - return false; - } - var requestContext = __middlewareRequestContextFromRequest(request); - for (var m of matcher) { - if (typeof m === "string") { - if (__matchMiddlewareMatcherPattern(pathname, m, i18nConfig)) return true; - continue; - } - if (__isValidMiddlewareMatcherObject(m)) { - if (!__matchMiddlewareObject(pathname, m, i18nConfig)) continue; - if (!__checkMiddlewareHasConditions(m.has, m.missing, requestContext)) continue; - return true; - } - } - return false; -} - -export async function runMiddleware(request, ctx) { - if (ctx) return _runWithExecutionContext(ctx, () => _runMiddleware(request)); - return _runMiddleware(request); -} - -async function _runMiddleware(request) { - 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 = __normalizePathnameForRouteMatchStrict(url.pathname); } catch (e) { - return { continue: false, response: new Response("Bad Request", { status: 400 }) }; - } - var normalizedPathname = __normalizePath(decodedPathname); - - if (!matchesMiddleware(normalizedPathname, matcher, request, i18nConfig)) 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 __mwNextConfig = (vinextConfig.basePath || i18nConfig) ? { basePath: vinextConfig.basePath, i18n: i18nConfig || undefined } : undefined; - var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined); - 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 }) }; - } - var _mwCtx = _getRequestExecutionContext(); - if (_mwCtx && typeof _mwCtx.waitUntil === "function") { _mwCtx.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 === "x-middleware-override-headers" || - 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) { - var rdHeaders = new Headers(); - for (var [rk, rv] of response.headers) { - if (!rk.startsWith("x-middleware-") && rk.toLowerCase() !== "location") rdHeaders.append(rk, rv); - } - return { continue: false, redirectUrl: location, redirectStatus: response.status, responseHeaders: rdHeaders }; - } - } - - 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 === "x-middleware-override-headers" || 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/e2e/app-router/server-actions.spec.ts b/tests/e2e/app-router/server-actions.spec.ts index 90616ca1e..335aa61dd 100644 --- a/tests/e2e/app-router/server-actions.spec.ts +++ b/tests/e2e/app-router/server-actions.spec.ts @@ -170,4 +170,23 @@ test.describe("useActionState", () => { await expect(page.locator("#count")).toHaveText("Count: -1", { timeout: 3_000 }); }).toPass({ timeout: 15_000 }); }); + + test("useActionState: redirect does not cause undefined state (issue #589)", async ({ page }) => { + await page.goto(`${BASE}/action-state-redirect`); + await expect(page.locator("h1")).toHaveText("useActionState Redirect Test"); + await waitForHydration(page); + + // Initial state should be { success: false } + await expect(async () => { + const stateText = await page.locator("#state").textContent(); + expect(stateText).toContain('"success":false'); + }).toPass({ timeout: 5_000 }); + + // Click the redirect button — should navigate without state becoming undefined + await page.click("#redirect-btn"); + + // Should navigate to /action-state-test without crashing + await expect(page).toHaveURL(/\/action-state-test$/); + await expect(page.locator("h1")).toHaveText("useActionState Test"); + }); }); diff --git a/tests/fixtures/app-basic/app/action-state-redirect/page.tsx b/tests/fixtures/app-basic/app/action-state-redirect/page.tsx new file mode 100644 index 000000000..2d00492a0 --- /dev/null +++ b/tests/fixtures/app-basic/app/action-state-redirect/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useActionState } from "react"; +import { redirectWithActionState } from "../actions/actions"; + +const initialState = { success: false, error: undefined as string | undefined }; + +export default function ActionStateRedirectTest() { + const [state, formAction] = useActionState(redirectWithActionState, initialState); + + return ( +
+

useActionState Redirect Test

+
{JSON.stringify(state)}
+
+ +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/actions/actions.ts b/tests/fixtures/app-basic/app/actions/actions.ts index 8243a08fd..6ec68df24 100644 --- a/tests/fixtures/app-basic/app/actions/actions.ts +++ b/tests/fixtures/app-basic/app/actions/actions.ts @@ -65,3 +65,18 @@ export async function counterAction( } return prevState; } + +/** + * Server action for useActionState that calls redirect() on success. + * This tests issue #589 — redirect() should not cause state to become undefined. + */ +export async function redirectWithActionState( + _prevState: { success: boolean; error?: string }, + formData: FormData, +): Promise<{ success: boolean; error?: string }> { + const shouldRedirect = formData.get("redirect") === "true"; + if (shouldRedirect) { + redirect("/action-state-test"); + } + return { success: true }; +}