diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index c5a39bcb..0709ead5 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1877,6 +1877,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // 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. + // + // For same-origin routes, we pre-render the redirect target's RSC payload + // so the client can perform a soft RSC navigation (SPA-style) instead of + // a hard page reload. This matches Next.js behavior. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); @@ -1893,8 +1897,71 @@ 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 }); + + // Try to pre-render the redirect target for soft RSC navigation. + // This is the Next.js parity fix for issue #654. + try { + const redirectUrl = new URL(actionRedirect.url, request.url); + + // Only pre-render same-origin URLs. External URLs fall through to + // the empty-body response, which triggers a hard redirect on the client. + if (redirectUrl.origin === new URL(request.url).origin) { + const redirectMatch = matchRoute(redirectUrl.pathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + + // Set navigation context for the redirect target + setNavigationContext({ + pathname: redirectUrl.pathname, + searchParams: redirectUrl.searchParams, + params: redirectParams, + }); + + // Build and render the redirect target page + const redirectElement = buildPageElement( + redirectRoute, + redirectParams, + undefined, + redirectUrl.searchParams, + ); + + const redirectOnError = createRscOnErrorHandler( + request, + redirectUrl.pathname, + redirectRoute.pattern, + ); + + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue }, + { temporaryReferences, onError: redirectOnError }, + ); + + const redirectResponse = new Response(rscStream, { + status: 200, + headers: redirectHeaders, + }); + + // Append cookies to the response + if (actionPendingCookies.length > 0 || actionDraftCookie) { + for (const cookie of actionPendingCookies) { + redirectResponse.headers.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie); + } + + return redirectResponse; + } + } + } catch (preRenderErr) { + // If pre-rendering fails (e.g., auth guard, missing data, unmatched route), + // fall through to the empty-body response below. This ensures graceful + // degradation to hard redirect rather than a 500 error. + console.error("[vinext] Failed to pre-render redirect target:", preRenderErr); + } + + // Fallback: external URL or unmatched route — client will hard-navigate. + return new Response(null, { status: 200, headers: redirectHeaders }); } // After the action, re-render the current page so the client diff --git a/packages/vinext/src/routing/file-matcher.ts b/packages/vinext/src/routing/file-matcher.ts index 7d03e22b..1581e4ac 100644 --- a/packages/vinext/src/routing/file-matcher.ts +++ b/packages/vinext/src/routing/file-matcher.ts @@ -1,4 +1,6 @@ -import { glob } from "node:fs/promises"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import type { Dirent } from "node:fs"; export const DEFAULT_PAGE_EXTENSIONS = ["tsx", "ts", "jsx", "js"] as const; @@ -85,7 +87,9 @@ export function createValidFileMatcher( } /** - * Use function-form exclude for Node < 22.14 compatibility. + * Use function-form exclude for Node 22.14+ compatibility. + * Scans for files matching stem with extensions recursively under cwd. + * Supports glob patterns in stem. */ export async function* scanWithExtensions( stem: string, @@ -93,11 +97,70 @@ export async function* scanWithExtensions( extensions: readonly string[], exclude?: (name: string) => boolean, ): AsyncGenerator { - const pattern = buildExtensionGlob(stem, extensions); - for await (const file of glob(pattern, { - cwd, - ...(exclude ? { exclude } : {}), - })) { - yield file; + const dir = cwd; + + // Check if stem contains glob patterns + const isGlob = stem.includes("**") || stem.includes("*"); + + // Extract the base name from stem (e.g., "**/page" -> "page", "page" -> "page") + // For "**/*", baseName will be "*" which means match all files + const baseName = stem.split("/").pop() || stem; + const matchAllFiles = baseName === "*"; + + async function* scanDir(currentDir: string, relativeBase: string): AsyncGenerator { + let entries: Dirent[]; + try { + entries = (await readdir(currentDir, { withFileTypes: true })) as Dirent[]; + } catch { + return; + } + + for (const entry of entries) { + if (exclude && exclude(entry.name)) continue; + if (entry.name.startsWith(".")) continue; + + const fullPath = join(currentDir, entry.name); + const relativePath = fullPath.startsWith(dir) ? fullPath.slice(dir.length + 1) : fullPath; + + if (entry.isDirectory()) { + // Recurse into subdirectories + yield* scanDir(fullPath, relativePath); + } else if (entry.isFile()) { + if (matchAllFiles) { + // For "**/*" pattern, match any file with the given extensions + for (const ext of extensions) { + if (entry.name.endsWith(`.${ext}`)) { + yield relativePath; + break; + } + } + } else { + // Check if file matches baseName.{extension} + for (const ext of extensions) { + const expectedName = `${baseName}.${ext}`; + if (entry.name === expectedName) { + // For glob patterns like **/page, match any path ending with page.tsx + if (isGlob) { + if (relativePath.endsWith(`${baseName}.${ext}`)) { + yield relativePath; + } + } else { + // For non-glob stems, the path should start with the stem + if ( + relativePath === `${relativeBase}.${ext}` || + relativePath.startsWith(`${relativeBase}/`) || + relativePath === `${baseName}.${ext}` + ) { + yield relativePath; + } + } + break; + } + } + } + } + } } + + yield* scanDir(dir, stem); } diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 3713228b..332ae266 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1,6 +1,7 @@ /// import type { ReactNode } from "react"; +import { startTransition } from "react"; import type { Root } from "react-dom/client"; import { createFromFetch, @@ -144,11 +145,52 @@ function registerServerActionCallback(): void { // Fall through to hard redirect below if URL parsing fails. } - // Use hard redirect for all action redirects because vinext's server - // currently returns an empty body for redirect responses. RSC navigation - // requires a valid RSC payload. This is a known parity gap with Next.js, - // which pre-renders the redirect target's RSC payload. + // Check if the server pre-rendered the redirect target's RSC payload. + // If so, we can perform a soft RSC navigation (SPA-style) instead of + // a hard page reload. This is the fix for issue #654. + const contentType = fetchResponse.headers.get("content-type") ?? ""; + const hasRscPayload = contentType.includes("text/x-component"); const redirectType = fetchResponse.headers.get("x-action-redirect-type") ?? "replace"; + + if (hasRscPayload && fetchResponse.body) { + // Server pre-rendered the redirect target — apply it as a soft SPA navigation. + // This matches how Next.js handles action redirects internally. + try { + const result = await createFromFetch(Promise.resolve(fetchResponse), { + temporaryReferences, + }); + + if (isServerActionResult(result)) { + // Update the React tree with the redirect target's RSC payload + startTransition(() => { + getReactRoot().render(result.root); + }); + + // Update the browser URL without a reload + if (redirectType === "push") { + window.history.pushState(null, "", actionRedirect); + } else { + window.history.replaceState(null, "", actionRedirect); + } + + // Handle return value if present + if (result.returnValue) { + if (!result.returnValue.ok) throw result.returnValue.data; + return result.returnValue.data; + } + return undefined; + } + } catch (rscParseErr) { + // RSC parse failed — fall through to hard redirect below. + console.error( + "[vinext] RSC navigation failed, falling back to hard redirect:", + rscParseErr, + ); + } + } + + // Fallback: empty body (external URL, unmatched route, or parse error). + // Use hard redirect to ensure the navigation still completes. if (redirectType === "push") { window.location.assign(actionRedirect); } else { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 28f58516..2714b07a 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1595,6 +1595,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // 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. + // + // For same-origin routes, we pre-render the redirect target's RSC payload + // so the client can perform a soft RSC navigation (SPA-style) instead of + // a hard page reload. This matches Next.js behavior. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); @@ -1611,8 +1615,71 @@ 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 }); + + // Try to pre-render the redirect target for soft RSC navigation. + // This is the Next.js parity fix for issue #654. + try { + const redirectUrl = new URL(actionRedirect.url, request.url); + + // Only pre-render same-origin URLs. External URLs fall through to + // the empty-body response, which triggers a hard redirect on the client. + if (redirectUrl.origin === new URL(request.url).origin) { + const redirectMatch = matchRoute(redirectUrl.pathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + + // Set navigation context for the redirect target + setNavigationContext({ + pathname: redirectUrl.pathname, + searchParams: redirectUrl.searchParams, + params: redirectParams, + }); + + // Build and render the redirect target page + const redirectElement = buildPageElement( + redirectRoute, + redirectParams, + undefined, + redirectUrl.searchParams, + ); + + const redirectOnError = createRscOnErrorHandler( + request, + redirectUrl.pathname, + redirectRoute.pattern, + ); + + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue }, + { temporaryReferences, onError: redirectOnError }, + ); + + const redirectResponse = new Response(rscStream, { + status: 200, + headers: redirectHeaders, + }); + + // Append cookies to the response + if (actionPendingCookies.length > 0 || actionDraftCookie) { + for (const cookie of actionPendingCookies) { + redirectResponse.headers.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie); + } + + return redirectResponse; + } + } + } catch (preRenderErr) { + // If pre-rendering fails (e.g., auth guard, missing data, unmatched route), + // fall through to the empty-body response below. This ensures graceful + // degradation to hard redirect rather than a 500 error. + console.error("[vinext] Failed to pre-render redirect target:", preRenderErr); + } + + // Fallback: external URL or unmatched route — client will hard-navigate. + return new Response(null, { status: 200, headers: redirectHeaders }); } // After the action, re-render the current page so the client @@ -3792,6 +3859,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // 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. + // + // For same-origin routes, we pre-render the redirect target's RSC payload + // so the client can perform a soft RSC navigation (SPA-style) instead of + // a hard page reload. This matches Next.js behavior. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); @@ -3808,8 +3879,71 @@ 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 }); + + // Try to pre-render the redirect target for soft RSC navigation. + // This is the Next.js parity fix for issue #654. + try { + const redirectUrl = new URL(actionRedirect.url, request.url); + + // Only pre-render same-origin URLs. External URLs fall through to + // the empty-body response, which triggers a hard redirect on the client. + if (redirectUrl.origin === new URL(request.url).origin) { + const redirectMatch = matchRoute(redirectUrl.pathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + + // Set navigation context for the redirect target + setNavigationContext({ + pathname: redirectUrl.pathname, + searchParams: redirectUrl.searchParams, + params: redirectParams, + }); + + // Build and render the redirect target page + const redirectElement = buildPageElement( + redirectRoute, + redirectParams, + undefined, + redirectUrl.searchParams, + ); + + const redirectOnError = createRscOnErrorHandler( + request, + redirectUrl.pathname, + redirectRoute.pattern, + ); + + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue }, + { temporaryReferences, onError: redirectOnError }, + ); + + const redirectResponse = new Response(rscStream, { + status: 200, + headers: redirectHeaders, + }); + + // Append cookies to the response + if (actionPendingCookies.length > 0 || actionDraftCookie) { + for (const cookie of actionPendingCookies) { + redirectResponse.headers.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie); + } + + return redirectResponse; + } + } + } catch (preRenderErr) { + // If pre-rendering fails (e.g., auth guard, missing data, unmatched route), + // fall through to the empty-body response below. This ensures graceful + // degradation to hard redirect rather than a 500 error. + console.error("[vinext] Failed to pre-render redirect target:", preRenderErr); + } + + // Fallback: external URL or unmatched route — client will hard-navigate. + return new Response(null, { status: 200, headers: redirectHeaders }); } // After the action, re-render the current page so the client @@ -5995,6 +6129,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // 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. + // + // For same-origin routes, we pre-render the redirect target's RSC payload + // so the client can perform a soft RSC navigation (SPA-style) instead of + // a hard page reload. This matches Next.js behavior. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); @@ -6011,8 +6149,71 @@ 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 }); + + // Try to pre-render the redirect target for soft RSC navigation. + // This is the Next.js parity fix for issue #654. + try { + const redirectUrl = new URL(actionRedirect.url, request.url); + + // Only pre-render same-origin URLs. External URLs fall through to + // the empty-body response, which triggers a hard redirect on the client. + if (redirectUrl.origin === new URL(request.url).origin) { + const redirectMatch = matchRoute(redirectUrl.pathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + + // Set navigation context for the redirect target + setNavigationContext({ + pathname: redirectUrl.pathname, + searchParams: redirectUrl.searchParams, + params: redirectParams, + }); + + // Build and render the redirect target page + const redirectElement = buildPageElement( + redirectRoute, + redirectParams, + undefined, + redirectUrl.searchParams, + ); + + const redirectOnError = createRscOnErrorHandler( + request, + redirectUrl.pathname, + redirectRoute.pattern, + ); + + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue }, + { temporaryReferences, onError: redirectOnError }, + ); + + const redirectResponse = new Response(rscStream, { + status: 200, + headers: redirectHeaders, + }); + + // Append cookies to the response + if (actionPendingCookies.length > 0 || actionDraftCookie) { + for (const cookie of actionPendingCookies) { + redirectResponse.headers.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie); + } + + return redirectResponse; + } + } + } catch (preRenderErr) { + // If pre-rendering fails (e.g., auth guard, missing data, unmatched route), + // fall through to the empty-body response below. This ensures graceful + // degradation to hard redirect rather than a 500 error. + console.error("[vinext] Failed to pre-render redirect target:", preRenderErr); + } + + // Fallback: external URL or unmatched route — client will hard-navigate. + return new Response(null, { status: 200, headers: redirectHeaders }); } // After the action, re-render the current page so the client @@ -8222,6 +8423,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // 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. + // + // For same-origin routes, we pre-render the redirect target's RSC payload + // so the client can perform a soft RSC navigation (SPA-style) instead of + // a hard page reload. This matches Next.js behavior. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); @@ -8238,8 +8443,71 @@ 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 }); + + // Try to pre-render the redirect target for soft RSC navigation. + // This is the Next.js parity fix for issue #654. + try { + const redirectUrl = new URL(actionRedirect.url, request.url); + + // Only pre-render same-origin URLs. External URLs fall through to + // the empty-body response, which triggers a hard redirect on the client. + if (redirectUrl.origin === new URL(request.url).origin) { + const redirectMatch = matchRoute(redirectUrl.pathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + + // Set navigation context for the redirect target + setNavigationContext({ + pathname: redirectUrl.pathname, + searchParams: redirectUrl.searchParams, + params: redirectParams, + }); + + // Build and render the redirect target page + const redirectElement = buildPageElement( + redirectRoute, + redirectParams, + undefined, + redirectUrl.searchParams, + ); + + const redirectOnError = createRscOnErrorHandler( + request, + redirectUrl.pathname, + redirectRoute.pattern, + ); + + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue }, + { temporaryReferences, onError: redirectOnError }, + ); + + const redirectResponse = new Response(rscStream, { + status: 200, + headers: redirectHeaders, + }); + + // Append cookies to the response + if (actionPendingCookies.length > 0 || actionDraftCookie) { + for (const cookie of actionPendingCookies) { + redirectResponse.headers.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie); + } + + return redirectResponse; + } + } + } catch (preRenderErr) { + // If pre-rendering fails (e.g., auth guard, missing data, unmatched route), + // fall through to the empty-body response below. This ensures graceful + // degradation to hard redirect rather than a 500 error. + console.error("[vinext] Failed to pre-render redirect target:", preRenderErr); + } + + // Fallback: external URL or unmatched route — client will hard-navigate. + return new Response(null, { status: 200, headers: redirectHeaders }); } // After the action, re-render the current page so the client @@ -10423,6 +10691,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // 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. + // + // For same-origin routes, we pre-render the redirect target's RSC payload + // so the client can perform a soft RSC navigation (SPA-style) instead of + // a hard page reload. This matches Next.js behavior. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); @@ -10439,8 +10711,71 @@ 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 }); + + // Try to pre-render the redirect target for soft RSC navigation. + // This is the Next.js parity fix for issue #654. + try { + const redirectUrl = new URL(actionRedirect.url, request.url); + + // Only pre-render same-origin URLs. External URLs fall through to + // the empty-body response, which triggers a hard redirect on the client. + if (redirectUrl.origin === new URL(request.url).origin) { + const redirectMatch = matchRoute(redirectUrl.pathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + + // Set navigation context for the redirect target + setNavigationContext({ + pathname: redirectUrl.pathname, + searchParams: redirectUrl.searchParams, + params: redirectParams, + }); + + // Build and render the redirect target page + const redirectElement = buildPageElement( + redirectRoute, + redirectParams, + undefined, + redirectUrl.searchParams, + ); + + const redirectOnError = createRscOnErrorHandler( + request, + redirectUrl.pathname, + redirectRoute.pattern, + ); + + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue }, + { temporaryReferences, onError: redirectOnError }, + ); + + const redirectResponse = new Response(rscStream, { + status: 200, + headers: redirectHeaders, + }); + + // Append cookies to the response + if (actionPendingCookies.length > 0 || actionDraftCookie) { + for (const cookie of actionPendingCookies) { + redirectResponse.headers.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie); + } + + return redirectResponse; + } + } + } catch (preRenderErr) { + // If pre-rendering fails (e.g., auth guard, missing data, unmatched route), + // fall through to the empty-body response below. This ensures graceful + // degradation to hard redirect rather than a 500 error. + console.error("[vinext] Failed to pre-render redirect target:", preRenderErr); + } + + // Fallback: external URL or unmatched route — client will hard-navigate. + return new Response(null, { status: 200, headers: redirectHeaders }); } // After the action, re-render the current page so the client @@ -12977,6 +13312,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // 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. + // + // For same-origin routes, we pre-render the redirect target's RSC payload + // so the client can perform a soft RSC navigation (SPA-style) instead of + // a hard page reload. This matches Next.js behavior. if (actionRedirect) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); @@ -12993,8 +13332,71 @@ 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 }); + + // Try to pre-render the redirect target for soft RSC navigation. + // This is the Next.js parity fix for issue #654. + try { + const redirectUrl = new URL(actionRedirect.url, request.url); + + // Only pre-render same-origin URLs. External URLs fall through to + // the empty-body response, which triggers a hard redirect on the client. + if (redirectUrl.origin === new URL(request.url).origin) { + const redirectMatch = matchRoute(redirectUrl.pathname); + + if (redirectMatch) { + const { route: redirectRoute, params: redirectParams } = redirectMatch; + + // Set navigation context for the redirect target + setNavigationContext({ + pathname: redirectUrl.pathname, + searchParams: redirectUrl.searchParams, + params: redirectParams, + }); + + // Build and render the redirect target page + const redirectElement = buildPageElement( + redirectRoute, + redirectParams, + undefined, + redirectUrl.searchParams, + ); + + const redirectOnError = createRscOnErrorHandler( + request, + redirectUrl.pathname, + redirectRoute.pattern, + ); + + const rscStream = renderToReadableStream( + { root: redirectElement, returnValue }, + { temporaryReferences, onError: redirectOnError }, + ); + + const redirectResponse = new Response(rscStream, { + status: 200, + headers: redirectHeaders, + }); + + // Append cookies to the response + if (actionPendingCookies.length > 0 || actionDraftCookie) { + for (const cookie of actionPendingCookies) { + redirectResponse.headers.append("Set-Cookie", cookie); + } + if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie); + } + + return redirectResponse; + } + } + } catch (preRenderErr) { + // If pre-rendering fails (e.g., auth guard, missing data, unmatched route), + // fall through to the empty-body response below. This ensures graceful + // degradation to hard redirect rather than a 500 error. + console.error("[vinext] Failed to pre-render redirect target:", preRenderErr); + } + + // Fallback: external URL or unmatched route — client will hard-navigate. + return new Response(null, { status: 200, headers: redirectHeaders }); } // After the action, re-render the current page so the client diff --git a/tests/e2e/app-router/server-actions.spec.ts b/tests/e2e/app-router/server-actions.spec.ts index 335aa61d..096ede05 100644 --- a/tests/e2e/app-router/server-actions.spec.ts +++ b/tests/e2e/app-router/server-actions.spec.ts @@ -189,4 +189,31 @@ test.describe("useActionState", () => { await expect(page).toHaveURL(/\/action-state-test$/); await expect(page.locator("h1")).toHaveText("useActionState Test"); }); + + test("server action redirect performs soft RSC navigation (issue #654)", async ({ page }) => { + // Track page load events BEFORE navigating — a hard reload triggers a full 'load' event. + // Soft RSC navigation uses history.pushState which does NOT trigger 'load'. + let pageLoads = 0; + page.on("load", () => { + pageLoads++; + }); + + await page.goto(`${BASE}/action-redirect-test`); + await expect(page.locator("h1")).toHaveText("Action Redirect Test"); + await waitForHydration(page); + + // Initial page load should have been counted + expect(pageLoads).toBe(1); + + // Click the redirect button — should invoke redirectAction() which calls redirect("/about") + await page.click('[data-testid="redirect-btn"]'); + + // Should navigate to /about + await expect(page).toHaveURL(/\/about/, { timeout: 10_000 }); + await expect(page.locator("h1")).toHaveText("About"); + + // Soft navigation = no additional page load after the initial one + // If it was a hard redirect, pageLoads would be 2 (initial + redirect) + expect(pageLoads).toBe(1); + }); });