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
22 changes: 22 additions & 0 deletions examples/file-router/pages/(redirect extern).middleware.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}
3 changes: 3 additions & 0 deletions examples/file-router/pages/GET.api-redirect.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default async function GetRedirect() {
return Response.redirect("https://react-server.dev", 302);
}
24 changes: 24 additions & 0 deletions examples/file-router/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Link } from "@lazarv/react-server/navigation";

export default function IndexPage() {
return (
<div>
Expand All @@ -10,6 +12,28 @@ export default function IndexPage() {
<a href="/forms">Go to Forms Page</a>
<br />
<a href="/forms-simple">Go to Simple Forms Page</a>
<br />
<Link to="/notexisting" id="notexisting">
404 Route not found
</Link>
<br />
<b>Redirect by Middleware:</b>
<br />
<Link to="/redirect-notfound" id="redirect-notfound">
404 Route not found
</Link>
<br />
<Link to="/redirect-external" id="redirect-external">
External
</Link>
<br />
<Link to="/redirect-api-external" id="redirect-api-external">
External with API
</Link>
<br />
<Link to="/redirect-exists" id="redirect-exists">
Internal redirect to existing about page
</Link>
</div>
);
}
18 changes: 18 additions & 0 deletions packages/react-server/client/ClientProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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(
Expand Down
22 changes: 0 additions & 22 deletions packages/react-server/client/ErrorBoundary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
createElement,
isValidElement,
useContext,
useEffect,
useMemo,
useState,
} from "react";
Expand All @@ -15,7 +14,6 @@ import {
FlightContext,
FlightComponentContext,
useClient,
PAGE_ROOT,
} from "./context.mjs";

const ErrorBoundaryContext = createContext(null);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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" ? (
Expand Down
21 changes: 21 additions & 0 deletions packages/react-server/client/RedirectHandler.jsx
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions packages/react-server/client/ReloadHandler.jsx
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 23 additions & 2 deletions packages/react-server/lib/dev/ssr-handler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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;
}

Expand Down
27 changes: 18 additions & 9 deletions packages/react-server/lib/http/middleware.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,31 +110,41 @@ 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);

try {
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);
Expand All @@ -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);
}
Expand Down
Loading