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"); + }); });