diff --git a/examples/file-router/pages/(redirect extern).middleware.ts b/examples/file-router/pages/(redirect extern).middleware.ts
new file mode 100644
index 00000000..b862a301
--- /dev/null
+++ b/examples/file-router/pages/(redirect extern).middleware.ts
@@ -0,0 +1,22 @@
+import { redirect, usePathname } from "@lazarv/react-server";
+
+export const priority = 200;
+
+export default function RedirectMiddleware() {
+ const pathname = usePathname();
+ console.log("RedirectMiddleware - Current pathname:", pathname);
+ if (pathname === "/redirect-notfound") {
+ redirect("notexisting");
+ }
+ if (pathname === "/redirect-external") {
+ redirect("https://react-server.dev");
+ }
+ if (pathname.startsWith("/redirect-api-external")) {
+ console.log("Redirecting to /api-redirect");
+ redirect("/api-redirect");
+ }
+ if (pathname.startsWith("/redirect-exists")) {
+ console.log("Redirecting to /about");
+ redirect("/about");
+ }
+}
diff --git a/examples/file-router/pages/GET.api-redirect.server.ts b/examples/file-router/pages/GET.api-redirect.server.ts
new file mode 100644
index 00000000..4559622f
--- /dev/null
+++ b/examples/file-router/pages/GET.api-redirect.server.ts
@@ -0,0 +1,3 @@
+export default async function GetRedirect() {
+ return Response.redirect("https://react-server.dev", 302);
+}
diff --git a/examples/file-router/pages/index.tsx b/examples/file-router/pages/index.tsx
index f246077f..fae82e8e 100644
--- a/examples/file-router/pages/index.tsx
+++ b/examples/file-router/pages/index.tsx
@@ -1,3 +1,5 @@
+import { Link } from "@lazarv/react-server/navigation";
+
export default function IndexPage() {
return (
@@ -10,6 +12,28 @@ export default function IndexPage() {
Go to Forms Page Go to Simple Forms Page
+
+
+ 404 Route not found
+
+
+ Redirect by Middleware:
+
+
+ 404 Route not found
+
+
+
+ External
+
+
+
+ External with API
+
+
+
+ Internal redirect to existing about page
+
);
}
diff --git a/packages/react-server/client/ClientProvider.jsx b/packages/react-server/client/ClientProvider.jsx
index 2c9a8504..09e7d2af 100644
--- a/packages/react-server/client/ClientProvider.jsx
+++ b/packages/react-server/client/ClientProvider.jsx
@@ -686,6 +686,7 @@ function getFlightResponse(url, options = {}) {
try {
response = await fetch(srcString, {
...options.request,
+ redirect: "manual",
method: options.method ?? (options.body ? "POST" : "GET"),
body: options.body,
headers: {
@@ -698,6 +699,23 @@ function getFlightResponse(url, options = {}) {
credentials: "include",
signal: abortController?.signal,
});
+
+ if (response.status < 200 || response.status >= 300) {
+ // this does not work properly - @lazarv: I need your help to handle this properly
+ const error = new Error(
+ `Failed to load RSC component at ${srcString} - ${response.status} ${response.statusText}`
+ );
+ window.dispatchEvent(
+ new CustomEvent(
+ `__react_server_flight_error_${options.outlet}__`,
+ {
+ detail: { error, options, url },
+ }
+ )
+ );
+ options.onError?.(error, response);
+ throw error;
+ }
const { body } = response;
window.dispatchEvent(
diff --git a/packages/react-server/client/ErrorBoundary.jsx b/packages/react-server/client/ErrorBoundary.jsx
index 5d2f9fc8..4d4c3f83 100644
--- a/packages/react-server/client/ErrorBoundary.jsx
+++ b/packages/react-server/client/ErrorBoundary.jsx
@@ -6,7 +6,6 @@ import {
createElement,
isValidElement,
useContext,
- useEffect,
useMemo,
useState,
} from "react";
@@ -15,7 +14,6 @@ import {
FlightContext,
FlightComponentContext,
useClient,
- PAGE_ROOT,
} from "./context.mjs";
const ErrorBoundaryContext = createContext(null);
@@ -127,10 +125,6 @@ export class ErrorBoundary extends Component {
this.props;
const { didCatch, error } = this.state;
- if (error?.digest.startsWith("Location=")) {
- error.redirectTo = error.digest.slice(9);
- }
-
let childToRender = children;
if (didCatch) {
@@ -185,22 +179,6 @@ function FallbackRenderComponent({
fallbackRender,
...props
}) {
- const { outlet } = useContext(FlightContext);
- const client = useClient();
- const { navigate } = client;
- const { error } = props;
- const { redirectTo } = error;
-
- useEffect(() => {
- if (redirectTo) {
- navigate(redirectTo, { outlet, external: outlet !== PAGE_ROOT });
- }
- }, [redirectTo, navigate, outlet]);
-
- if (redirectTo) {
- return null;
- }
-
return (
<>
{FallbackComponent && typeof FallbackComponent === "function" ? (
diff --git a/packages/react-server/client/RedirectHandler.jsx b/packages/react-server/client/RedirectHandler.jsx
new file mode 100644
index 00000000..5a24b902
--- /dev/null
+++ b/packages/react-server/client/RedirectHandler.jsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+export default function RedirectHandler({ url }) {
+ const isRedirectingRef = useRef(false);
+
+ useEffect(() => {
+ // Prevent double reload in Strict Mode
+ if (isRedirectingRef.current) {
+ return;
+ }
+ if (!url) {
+ return;
+ }
+ isRedirectingRef.current = true;
+ window.location.assign(url);
+ }, [url]);
+
+ return null;
+}
diff --git a/packages/react-server/client/ReloadHandler.jsx b/packages/react-server/client/ReloadHandler.jsx
new file mode 100644
index 00000000..a9b9815f
--- /dev/null
+++ b/packages/react-server/client/ReloadHandler.jsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+export default function ReloadHandler() {
+ const isReloadingRef = useRef(false);
+
+ useEffect(() => {
+ // Prevent double reload in Strict Mode
+ if (isReloadingRef.current) {
+ return;
+ }
+
+ isReloadingRef.current = true;
+ // Reload the page to get the full HTML response with error component
+ window.location.reload();
+ }, []);
+
+ return null;
+}
diff --git a/packages/react-server/lib/dev/ssr-handler.mjs b/packages/react-server/lib/dev/ssr-handler.mjs
index 5c791f26..77a29226 100644
--- a/packages/react-server/lib/dev/ssr-handler.mjs
+++ b/packages/react-server/lib/dev/ssr-handler.mjs
@@ -2,6 +2,7 @@ import { context$, ContextStorage, getContext } from "../../server/context.mjs";
import { createWorker } from "../../server/create-worker.mjs";
import { useErrorComponent } from "../../server/error-handler.mjs";
import { init$ as module_loader_init$ } from "../../server/module-loader.mjs";
+import { isRedirectError } from "../../server/redirects.mjs";
import {
createRenderContext,
RENDER_TYPE,
@@ -134,8 +135,23 @@ export default async function ssrHandler(root) {
}
} catch (e) {
const redirect = getContext(REDIRECT_CONTEXT);
- if (redirect?.response) {
+ const request = httpContext.request;
+ const accept = request.headers.get("accept") || "";
+ const isComponentRequest =
+ accept.includes("text/x-component");
+
+ // Check if this is a redirect error
+ const isRedirect = isRedirectError(e);
+
+ if (isRedirect && redirect?.response) {
+ // Redirect with HTTP response (non-RSC request) - return it directly
return redirect.response;
+ } else if (isComponentRequest && isRedirect) {
+ // RSC component request with redirect (no response created)
+ // Pass as middlewareError to serialize in RSC stream with RedirectHandler
+ middlewareError = e;
+ } else if (e.message === "Page not found") {
+ return;
} else {
middlewareError = new Error(
e?.message ?? "Internal Server Error",
@@ -146,7 +162,12 @@ export default async function ssrHandler(root) {
}
}
- if (renderContext.type === RENDER_TYPE.Unknown) {
+ // If no component found and no middleware error, just return (let it 404 normally)
+ // But if there's a middleware error (e.g., redirect), we need to render to handle it
+ if (
+ renderContext.type === RENDER_TYPE.Unknown &&
+ !middlewareError
+ ) {
return;
}
diff --git a/packages/react-server/lib/http/middleware.mjs b/packages/react-server/lib/http/middleware.mjs
index d2ae5bda..b9edfab5 100644
--- a/packages/react-server/lib/http/middleware.mjs
+++ b/packages/react-server/lib/http/middleware.mjs
@@ -110,22 +110,32 @@ export function createMiddleware(handler, options = {}) {
const nodeReadable = Readable.fromWeb(response.body);
const abortController = new AbortController();
const { signal } = abortController;
+ let aborted = false;
- // Destroy stream when aborted (client disconnect or error)
+ // Gracefully end stream & response on abort (client disconnect)
signal.addEventListener(
"abort",
() => {
+ aborted = true;
try {
- nodeReadable.destroy(new Error("aborted"));
+ // destroy silently (no error object to avoid noisy logs)
+ if (!nodeReadable.destroyed) nodeReadable.destroy();
} catch {
- // no-op
+ /* ignore abort destroy error */
+ }
+ try {
+ if (!res.writableEnded) res.end();
+ } catch {
+ /* ignore abort end error */
}
},
{ once: true }
);
// Abort on client disconnect
- const onDisconnect = () => abortController.abort();
+ const onDisconnect = () => {
+ if (!signal.aborted) abortController.abort();
+ };
res.once("close", onDisconnect);
req.once("aborted", onDisconnect);
@@ -133,8 +143,8 @@ export function createMiddleware(handler, options = {}) {
await new Promise((resolve, reject) => {
// Use { once: true } for auto-cleanup
const onFinish = () => resolve();
- const onReadableError = (err) => reject(err);
- const onResError = (err) => reject(err);
+ const onReadableError = (err) => (aborted ? resolve() : reject(err));
+ const onResError = (err) => (aborted ? resolve() : reject(err));
nodeReadable.once("error", onReadableError);
res.once("error", onResError);
@@ -144,9 +154,8 @@ export function createMiddleware(handler, options = {}) {
nodeReadable.pipe(res);
});
} finally {
- // Trigger abort to clean up the signal listener
- abortController.abort();
- // Remove disconnect listeners
+ // Trigger abort only if not already aborted (cleanup listeners)
+ if (!signal.aborted) abortController.abort();
res.off("close", onDisconnect);
req.off("aborted", onDisconnect);
}
diff --git a/packages/react-server/lib/plugins/file-router/entrypoint.jsx b/packages/react-server/lib/plugins/file-router/entrypoint.jsx
index 4f890593..15272646 100644
--- a/packages/react-server/lib/plugins/file-router/entrypoint.jsx
+++ b/packages/react-server/lib/plugins/file-router/entrypoint.jsx
@@ -8,6 +8,7 @@ import {
usePathname,
useResponseCache,
} from "@lazarv/react-server";
+import ReloadHandler from "@lazarv/react-server/client/ReloadHandler.jsx";
import {
middlewares,
pages,
@@ -21,7 +22,6 @@ import {
RENDER_CONTEXT,
ROUTE_MATCH,
} from "@lazarv/react-server/server/symbols.mjs";
-import ErrorBoundary from "@lazarv/react-server/error-boundary";
import { RENDER_TYPE } from "../../../server/render-context.mjs";
const PAGE_PATH = Symbol("PAGE_PATH");
@@ -111,20 +111,11 @@ export async function init$() {
for (const [path, , , , , lazy] of outlets) {
const match = useMatch(path, { exact: true });
if (match) {
- let errorBoundary =
- pages.find(
- ([errorPath, type, outlet]) =>
- type === "error" &&
- outlet === reactServerOutlet &&
- errorPath === path
- )?.[5] ??
- pages.find(
- ([errorPath, type, outlet]) =>
- type === "error" &&
- outlet === reactServerOutlet &&
- useMatch(errorPath)
- )?.[5] ??
- (() => ({ default: null }));
+ // Note: Error boundary components cannot be used in file-router because
+ // they are server components that would need to be passed as props to
+ // the client ErrorBoundary component, which violates RSC rules.
+ // Errors will propagate to default error handling instead.
+
let fallback =
pages.find(
([fallbackPath, type, outlet]) =>
@@ -156,15 +147,9 @@ export async function init$() {
const [
{ default: Component, ttl, init$: page_init$ },
- { default: ErrorComponent },
- { default: FallbackComponent },
+ { default: _FallbackComponent },
{ default: LoadingComponent },
- ] = await Promise.all([
- lazy(),
- errorBoundary(),
- fallback(),
- loading(),
- ]);
+ ] = await Promise.all([lazy(), fallback(), loading()]);
await page_init$?.();
if (typeof ttl === "number") {
@@ -173,22 +158,7 @@ export async function init$() {
context$(PAGE_MATCH, match);
- if (ErrorComponent) {
- context$(PAGE_COMPONENT, (match) => (
-
- ) : LoadingComponent ? (
-
- ) : null
- }
- >
-
-
- ));
- } else if (LoadingComponent) {
+ if (LoadingComponent) {
context$(PAGE_COMPONENT, (match) => (
}>
@@ -242,7 +212,6 @@ export async function init$() {
},
() => {
if (!getContext(PAGE_COMPONENT)) {
- status(404);
const renderContext = getContext(RENDER_CONTEXT);
if (
import.meta.env.DEV &&
@@ -250,7 +219,15 @@ export async function init$() {
) {
throw new Error("Page not found");
}
- return new Response(null, { status: 404 });
+ // For RSC requests, return ReloadHandler to trigger full page reload
+ // This allows the HTML request to render custom error components with HTTP 404
+ if (renderContext?.flags?.isRSC) {
+ context$(PAGE_COMPONENT, () => );
+ return;
+ }
+ // For HTML requests, set 404 status and let rendering continue
+ // The error will be caught by root error boundary if it exists
+ status(404);
}
},
];
@@ -260,7 +237,7 @@ export default async function App() {
const prevPathname = getContext(PAGE_PATH);
const currentPathname = usePathname();
- // If the pathname has changed, we need to re-run the selector to load the new page
+ // If the pathname has changed (or this is first render), run the selector
if (prevPathname !== currentPathname) {
const selector = getContext(PAGE_SELECTOR);
if (typeof selector === "function") {
@@ -274,10 +251,15 @@ export default async function App() {
getContext(PAGE_COMPONENT) ??
(() => {
status(404);
- if (import.meta.env.DEV) {
- throw new Error("Page not found");
- }
- return null;
+ // Return a simple 404 message
+ // Note: Custom error components from (root).error.tsx won't work here
+ // because the error boundary is set up in plugin-generated code per route
+ return (
+
+
404 - Page Not Found
+
The page you're looking for doesn't exist.
+
+ );
});
return ;
diff --git a/packages/react-server/server/redirects.mjs b/packages/react-server/server/redirects.mjs
index 6d6db370..39c8255d 100644
--- a/packages/react-server/server/redirects.mjs
+++ b/packages/react-server/server/redirects.mjs
@@ -12,10 +12,18 @@ export class RedirectError extends Error {
super("Redirect");
this.url = url;
this.status = status;
- this.digest = `${status} ${url}`;
+ // Use Location= prefix for client error boundary compatibility
+ this.digest = `Location=${url}`;
}
}
+// Helper to detect redirect errors across module realms (Vite dev)
+export function isRedirectError(error) {
+ return (
+ error?.message === "Redirect" && error?.digest?.startsWith("Location=")
+ );
+}
+
export function redirect(url, status = 302) {
usePostpone(dynamicHookError("redirect"));
@@ -23,24 +31,41 @@ export function redirect(url, status = 302) {
if (store) {
const request = getContext(HTTP_CONTEXT).request;
store.location = url;
- store.response =
- request.method !== "GET"
- ? new Response(
- ``,
- {
+
+ // Check if this is an RSC component request
+ const accept = request.headers.get("accept") || "";
+ const isComponentRequest = accept.includes("text/x-component");
+
+ // For RSC component requests (both absolute and relative URLs), don't create redirect.response
+ // This allows them to be handled by RedirectHandler component on the client
+ // For non-RSC requests, always create the HTTP 302 response
+ const shouldCreateResponse = !isComponentRequest;
+
+ if (shouldCreateResponse) {
+ const escapedUrl = url
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+ store.response =
+ request.method !== "GET"
+ ? new Response(
+ ``,
+ {
+ status,
+ headers: {
+ "content-type": "text/html; charset=utf-8",
+ Location: url,
+ },
+ }
+ )
+ : new Response(null, {
status,
headers: {
- "content-type": "text/html; charset=utf-8",
Location: url,
},
- }
- )
- : new Response(null, {
- status,
- headers: {
- Location: url,
- },
- });
+ });
+ }
}
throw new RedirectError(url, status);
diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx
index f5873aa4..8cafd330 100644
--- a/packages/react-server/server/render-rsc.jsx
+++ b/packages/react-server/server/render-rsc.jsx
@@ -51,6 +51,8 @@ import {
} from "@lazarv/react-server/server/symbols.mjs";
import { ServerFunctionNotFoundError } from "./action-state.mjs";
import { cwd } from "../lib/sys.mjs";
+import { isRedirectError } from "./redirects.mjs";
+import RedirectHandler from "../client/RedirectHandler.jsx";
const serverReferenceMap = new Proxy(
{},
@@ -73,9 +75,47 @@ export async function render(Component, props = {}, options = {}) {
const logger = getContext(LOGGER_CONTEXT);
const renderStream = getContext(RENDER_STREAM);
const config = getContext(CONFIG_CONTEXT)?.[CONFIG_ROOT];
+
+ let actionRedirectUrl = null;
+
+ // Save request body BEFORE any rewrite/middleware processing
+ // This prevents "Body has already been read" errors
+ const context = getContext(HTTP_CONTEXT);
+ let savedBodyText = null;
+ let savedFormData = null;
+
+ const isFormData = context.request.headers
+ .get("content-type")
+ ?.includes("multipart/form-data");
+
+ if (
+ context.request.body &&
+ context.request.body instanceof ReadableStream &&
+ !context.request.body.locked
+ ) {
+ if (isFormData) {
+ // Save form data before any processing
+ savedFormData = await context.request.formData();
+ } else {
+ // Save text body before any processing
+ savedBodyText = await context.request.text();
+ }
+ }
+
+ // Handle middleware errors
+ if (options.middlewareError) {
+ if (isRedirectError(options.middlewareError)) {
+ // For all RSC redirects, keep the middleware error to render RedirectHandler
+ // This will trigger a client-side navigation to the redirect URL
+ // Don't try to rewrite here because component resolution has already happened
+ } else {
+ // Throw non-redirect middleware errors early
+ throw options.middlewareError;
+ }
+ }
+
try {
const streaming = new Promise(async (resolve, reject) => {
- const context = getContext(HTTP_CONTEXT);
try {
revalidate$();
@@ -88,14 +128,16 @@ export async function render(Component, props = {}, options = {}) {
const remote = renderContext.flags.isRemote;
const outlet = useOutlet();
let body = "";
+
+ // Use saved body instead of trying to read it again
+ if (savedBodyText !== null) {
+ body = savedBodyText || "{}";
+ }
+
let serverFunctionResult,
callServer,
callServerHeaders,
callServerComponent;
-
- const isFormData = context.request.headers
- .get("content-type")
- ?.includes("multipart/form-data");
let formState;
const serverActionHeader = decodeURIComponent(
context.request.headers.get("react-server-action") ?? null
@@ -111,11 +153,10 @@ export async function render(Component, props = {}, options = {}) {
};
let input = [];
try {
- if (options.middlewareError) {
- throw options.middlewareError;
- }
if (isFormData) {
- const multipartFormData = await context.request.formData();
+ // Use saved form data instead of reading it again
+ const multipartFormData =
+ savedFormData || (await context.request.formData());
const formData = new FormData();
for (const [key, value] of multipartFormData.entries()) {
formData.append(key.replace(/^remote:\/\//, ""), value);
@@ -126,10 +167,10 @@ export async function render(Component, props = {}, options = {}) {
input = formData;
}
} else {
- input = await server.decodeReply(
- await context.request.text(),
- serverReferenceMap
- );
+ // Use saved text body instead of calling context.request.text() again
+ const requestText =
+ savedBodyText || (await context.request.text());
+ input = await server.decodeReply(requestText, serverReferenceMap);
}
} catch (error) {
logger?.error(error);
@@ -213,11 +254,33 @@ export async function render(Component, props = {}, options = {}) {
throw e;
}
+ // Handle redirect errors from actions
+ let redirectHandled = false;
+ if (renderContext.flags.isRSC && isRedirectError(error)) {
+ const redirectUrl = error.url || error.digest?.slice(9); // Extract URL from digest "Location=..."
+ // Check if it's a relative URL (starts with /)
+ if (
+ redirectUrl &&
+ redirectUrl.startsWith("/") &&
+ !redirectUrl.startsWith("//")
+ ) {
+ // For relative URLs, use rewrite to internally redirect and continue rendering
+ rewrite(redirectUrl);
+ redirectHandled = true;
+ // Store redirect URL to set headers later
+ actionRedirectUrl = redirectUrl;
+ // Don't throw - let rendering continue with the new path
+ } else {
+ // For absolute/external URLs, throw to trigger client-side redirect
+ throw error;
+ }
+ }
+
if (!callServer) {
callServer = true;
if (!isFormData) {
serverFunctionResult =
- renderContext.flags.isRSC && error
+ renderContext.flags.isRSC && error && !redirectHandled
? Promise.reject(error)
: data instanceof Buffer
? data.buffer.slice(
@@ -237,13 +300,15 @@ export async function render(Component, props = {}, options = {}) {
callServerHeaders = {
"React-Server-Action-Key": encodeURIComponent(key),
};
- if (renderContext.flags.isRSC && error) {
+ if (renderContext.flags.isRSC && error && !redirectHandled) {
serverFunctionResult = Promise.reject(error);
} else {
serverFunctionResult = result;
}
} else {
- if (renderContext.flags.isRSC && error) {
+ // Initialize callServerHeaders even when there's no formState
+ callServerHeaders = {};
+ if (renderContext.flags.isRSC && error && !redirectHandled) {
callServerComponent = true;
serverFunctionResult = Promise.reject(error);
} else {
@@ -269,7 +334,9 @@ export async function render(Component, props = {}, options = {}) {
}
}
- if (!(input instanceof Error)) {
+ // Only set ACTION_CONTEXT if we didn't handle a redirect via rewrite
+ // When redirectHandled=true, we're rendering a different page that doesn't need action context
+ if (!(input instanceof Error) && !redirectHandled) {
context$(ACTION_CONTEXT, {
formData: input[input.length - 1] ?? input,
data,
@@ -277,25 +344,11 @@ export async function render(Component, props = {}, options = {}) {
actionId,
});
}
- } else if (options.middlewareError) {
- throw options.middlewareError;
}
const temporaryReferences = createTemporaryReferenceSet();
context$(RENDER_TEMPORARY_REFERENCES, temporaryReferences);
- if (
- !options.middlewareError &&
- context.request.body &&
- context.request.body instanceof ReadableStream &&
- !context.request.body.locked
- ) {
- const decoder = new TextDecoder();
- for await (const chunk of context.request.body) {
- body += decoder.decode(chunk);
- }
- body = body || "{}";
- }
if (body) {
const remoteProps = await decodeReply(body, serverReferenceMap, {
temporaryReferences,
@@ -406,7 +459,12 @@ export async function render(Component, props = {}, options = {}) {
)}
-
+ {isRedirectError(options.middlewareError) &&
+ options.middlewareError?.url ? (
+
+ ) : (
+
+ )}
>
);
@@ -419,6 +477,15 @@ export async function render(Component, props = {}, options = {}) {
};
}
+ // Set headers for action redirect (if rewrite was used)
+ if (actionRedirectUrl) {
+ callServerHeaders = {
+ ...callServerHeaders,
+ "React-Server-Render": actionRedirectUrl,
+ "React-Server-Outlet": "PAGE_ROOT",
+ };
+ }
+
const redirect = getContext(REDIRECT_CONTEXT);
if (redirect?.response) {
callServerHeaders = {
@@ -440,7 +507,10 @@ export async function render(Component, props = {}, options = {}) {
"React-Server-Data": "rsc",
};
app =
- reload || redirect?.response || callServerComponent ? (
+ reload ||
+ redirect?.response ||
+ callServerComponent ||
+ actionRedirectUrl ? (
<>
{ComponentWithStyles}
{serverFunctionResult}
@@ -560,6 +630,13 @@ export async function render(Component, props = {}, options = {}) {
}
}
+ // Check for redirect after initial render
+ const redirect = getContext(REDIRECT_CONTEXT);
+ if (redirect?.response) {
+ controller.close();
+ return resolve(redirect.response);
+ }
+
controller.enqueue(new Uint8Array(concat(payload)));
const httpStatus = getContext(HTTP_STATUS) ?? {
@@ -814,12 +891,9 @@ export async function render(Component, props = {}, options = {}) {
},
});
} else {
- return resolve(
- new Response(null, {
- status: 404,
- statusText: "Not Found",
- })
- );
+ const notFoundError = new Error("Not Found");
+ notFoundError.digest = "404";
+ throw notFoundError;
}
} catch (e) {
logger.error(e);
diff --git a/test/__test__/apps/file-router.spec.mjs b/test/__test__/apps/file-router.spec.mjs
index 63e14868..21bc7dc3 100644
--- a/test/__test__/apps/file-router.spec.mjs
+++ b/test/__test__/apps/file-router.spec.mjs
@@ -1,34 +1,150 @@
import { join } from "node:path";
import { hostname, page, server, waitForChange } from "playground/utils";
-import { expect, test } from "vitest";
+import { beforeAll, describe, expect, it } from "vitest";
process.chdir(join(process.cwd(), "../examples/file-router"));
-test("file-router plugin", async () => {
- await server(null);
-
- await page.goto(`${hostname}/forms`);
- await page.waitForLoadState("networkidle");
- expect(await page.textContent("body")).toContain("Layout (forms)");
- expect(await page.textContent("body")).not.toContain("Layout (forms simple)");
-
- await page.goto(`${hostname}/forms-simple`);
- await page.waitForLoadState("networkidle");
- expect(await page.textContent("body")).not.toContain("Layout (forms)");
- expect(await page.textContent("body")).toContain("Layout (forms simple)");
-
- await page.goto(`${hostname}/forms`);
- await page.waitForLoadState("networkidle");
- const titleInput = await page.$('input[name="title"]');
- const noteInput = await page.$('textarea[name="note"]');
- await titleInput.fill("Test Title");
- await noteInput.fill("This is a test note.");
- const prevBody = await page.textContent("body");
- await page.click('button[type="submit"]');
- await page.waitForLoadState("networkidle");
- await waitForChange(null, () => page.textContent("body"), prevBody);
- expect(await page.textContent("body")).toContain(
- "Welcome to the File Router Example"
- );
+describe("file-router plugin", () => {
+ beforeAll(async () => {
+ await server(null);
+ });
+ it("forms action redirect", async () => {
+ await page.goto(`${hostname}/forms`);
+ await page.waitForLoadState("networkidle");
+ expect(await page.textContent("body")).toContain("Layout (forms)");
+ expect(await page.textContent("body")).not.toContain(
+ "Layout (forms simple)"
+ );
+
+ await page.goto(`${hostname}/forms-simple`);
+ await page.waitForLoadState("networkidle");
+ expect(await page.textContent("body")).not.toContain("Layout (forms)");
+ expect(await page.textContent("body")).toContain("Layout (forms simple)");
+
+ await page.goto(`${hostname}/forms`);
+ await page.waitForLoadState("networkidle");
+ const titleInput = await page.$('input[name="title"]');
+ const noteInput = await page.$('textarea[name="note"]');
+ await titleInput.fill("Test Title");
+ await noteInput.fill("This is a test note.");
+ const prevBody = await page.textContent("body");
+ await page.click('button[type="submit"]');
+ await page.waitForLoadState("networkidle");
+ await waitForChange(null, () => page.textContent("body"), prevBody);
+ expect(await page.textContent("body")).toContain(
+ "Welcome to the File Router Example"
+ );
+ });
+
+ it("rsc redirects to 404 page on route not found", async () => {
+ await page.goto(`${hostname}/`);
+ await page.waitForLoadState("networkidle");
+ // Track network responses (the real page load will appear here)
+ const [response] = await Promise.all([
+ page.waitForResponse(
+ (res) => {
+ const url = res.url();
+ return (
+ !url.includes("rsc.x-component") &&
+ url.includes("/notexisting") &&
+ res.status() === 404
+ );
+ },
+ { timeout: 1500 }
+ ),
+ page.click("#notexisting"),
+ ]);
+
+ expect(response.status()).toBe(404);
+ expect(await response.text()).toContain("404 - Page Not Found");
+ });
+
+ it("rsc redirects from middleware to 404 page on route not found", async () => {
+ await page.goto(`${hostname}/`);
+ await page.waitForLoadState("networkidle");
+ // Track network responses (the real page load will appear here)
+ const [response] = await Promise.all([
+ page.waitForResponse(
+ (res) => {
+ const url = res.url();
+ return (
+ !url.includes("rsc.x-component") &&
+ url.includes("/notexisting") &&
+ res.status() === 404
+ );
+ },
+ { timeout: 1500 }
+ ),
+ page.click("#redirect-notfound"),
+ ]);
+
+ expect(response.status()).toBe(404);
+ expect(await response.text()).toContain("404 - Page Not Found");
+ });
+
+ it("rsc redirects from middleware to external url", async () => {
+ await page.goto(`${hostname}/`);
+ await page.waitForLoadState("networkidle");
+ // Track network responses (the real page load will appear here)
+ const [response] = await Promise.all([
+ page.waitForResponse(
+ (res) => {
+ const url = res.url();
+ return (
+ !url.includes("rsc.x-component") &&
+ url.includes("https://react-server.dev") &&
+ res.status() === 200
+ );
+ },
+ { timeout: 1500 }
+ ),
+ page.click("#redirect-external"),
+ ]);
+
+ expect(response.status()).toBe(200);
+ expect(await response.text()).toContain("react-server");
+ });
+
+ it("rsc redirects from middleware to api to external url", async () => {
+ await page.goto(`${hostname}/`);
+ await page.waitForLoadState("networkidle");
+ // Track network responses (the real page load will appear here)
+ const [response] = await Promise.all([
+ page.waitForResponse(
+ (res) => {
+ const url = res.url();
+ return (
+ !url.includes("rsc.x-component") &&
+ url.includes("https://react-server.dev") &&
+ res.status() === 200
+ );
+ },
+ { timeout: 1500 }
+ ),
+ page.click("#redirect-api-external"),
+ ]);
+
+ expect(response.status()).toBe(200);
+ expect(await response.text()).toContain("react-server");
+ });
+
+ it("rsc redirects to existing internal route", async () => {
+ await page.goto(`${hostname}/`);
+ await page.waitForLoadState("networkidle");
+ // Track network responses (the real page load will appear here)
+ const [response] = await Promise.all([
+ page.waitForResponse(
+ (res) => {
+ const url = res.url();
+ return url.includes("/about") && res.status() === 200;
+ },
+ { timeout: 1500 }
+ ),
+ page.click("#redirect-exists"),
+ ]);
+
+ expect(response.status()).toBe(200);
+ expect(await response.text()).toContain("About");
+ });
});