Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
79 changes: 71 additions & 8 deletions packages/vinext/src/routing/file-matcher.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -85,19 +87,80 @@ 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,
cwd: string,
extensions: readonly string[],
exclude?: (name: string) => boolean,
): AsyncGenerator<string> {
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<string> {
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);
}
50 changes: 46 additions & 4 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />

import type { ReactNode } from "react";
import { startTransition } from "react";
import type { Root } from "react-dom/client";
import {
createFromFetch,
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading